Compare commits
3 Commits
9b8b382925
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| f72b775379 | |||
| 88ce38a49e | |||
| 99c113b6f5 |
@@ -3,6 +3,5 @@ node_modules
|
||||
.gitignore
|
||||
*.md
|
||||
.env
|
||||
.env.local
|
||||
.next
|
||||
out
|
||||
|
||||
@@ -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
|
||||
@@ -1,3 +1,7 @@
|
||||
# Tool artifacts
|
||||
.omo/
|
||||
graphify-out/
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
# IPFS Portal Feature Plan
|
||||
|
||||
## A. Payment / Blockchain Features
|
||||
|
||||
### A1. Multi-chain Support
|
||||
- **Status**: `getWalletClient()` en `getPublicClient()` hardcoden naar `zkSyncLocal`
|
||||
- **Wijzigingen**:
|
||||
- `src/lib/payment/read-service.ts`: `getPublicClient()` chain parameter toevoegen
|
||||
- `src/lib/payment/write-service.ts`: `getWalletClient()` chain configurable maken
|
||||
- `src/lib/payment/abi.ts`: `ChainConfig[]` type, default chains (zkSync Local + optioneel Polygon/Arbitrum)
|
||||
- `src/lib/payment/service.ts`: chain selector in constructor
|
||||
- `src/components/PaymentPanel.tsx`: chain switcher UI
|
||||
- `src/app/admin/payment/page.tsx`: multi-chain admin overzicht
|
||||
- **Bestanden**: `src/lib/payment/read-service.ts`, `write-service.ts`, `abi.ts`, `service.ts`, `src/components/PaymentPanel.tsx`, `src/app/admin/payment/page.tsx`
|
||||
|
||||
### A2. Gas Estimator
|
||||
- **Status**: Geen indicatie van transactiekosten voor gebruiker
|
||||
- **Wijzigingen**:
|
||||
- `src/lib/payment/read-service.ts`: `estimateGas()` methode
|
||||
- `src/lib/payment/write-service.ts`: gas estimation voor `payWithETH`, `approveAndPayWithToken`
|
||||
- `src/components/PaymentPanel.tsx`: gas fee display naast prijs
|
||||
- **Bestanden**: `src/lib/payment/read-service.ts`, `write-service.ts`, `src/components/PaymentPanel.tsx`
|
||||
|
||||
### A3. Batch Upload + Single Payment
|
||||
- **Status**: 1 file → 1 upload → 1 tx (sequentieel via `startUpload()`)
|
||||
- **Wijzigingen**:
|
||||
- `src/lib/payment/write-service.ts`: `payWithBatch(provider, cids[], totalBytes, priceWei)` — encodet meerdere CIDs in 1 tx
|
||||
- `src/app/upload/page.tsx`: batch selectie in crypto tab
|
||||
- `src/components/PaymentPanel.tsx`: multiple files support
|
||||
- **Bestanden**: `src/lib/payment/write-service.ts`, `src/app/upload/page.tsx`, `src/components/PaymentPanel.tsx`
|
||||
|
||||
### A4. Storage Quota UI
|
||||
- **Status**: `getUserStats()` returned `totalBytes/uploadCount/remainingFree` maar nergens getoond als overzicht
|
||||
- **Wijzigingen**:
|
||||
- Nieuw component: `src/components/StorageQuota.tsx` — balkje met used/free/total
|
||||
- `src/app/dashboard/page.tsx`: storage quota card toevoegen
|
||||
- `src/components/PaymentPanel.tsx`: quota status in payment panel
|
||||
- **Bestanden**: NIEUW `src/components/StorageQuota.tsx`, `src/app/dashboard/page.tsx`, `src/components/PaymentPanel.tsx`
|
||||
|
||||
### A5. On-chain Transaction History
|
||||
- **Status**: History page gebruikt alleen localStorage; contract heeft `getUserUploads(address)` met records
|
||||
- **Wijzigingen**:
|
||||
- `src/lib/payment/read-service.ts`: `getUserUploads()` bestaat al — wordt gebruikt in UsersPage
|
||||
- `src/app/history/page.tsx`: "On-chain History" tab toevoegen naast localStorage tab
|
||||
- Nieuw component: `src/components/ChainHistory.tsx`
|
||||
- **Bestanden**: `src/app/history/page.tsx`, NIEUW `src/components/ChainHistory.tsx`
|
||||
|
||||
### A6. Token Auto-router
|
||||
- **Status**: Gebruiker kiest handmatig token in PaymentPanel
|
||||
- **Wijzigingen**:
|
||||
- `src/components/PaymentPanel.tsx`: auto-select goedkoopste token op basis van `finalWei` / `weiPerToken`
|
||||
- Optioneel: "Auto" button naast token lijst
|
||||
- **Bestanden**: `src/components/PaymentPanel.tsx`
|
||||
|
||||
### A7. Subscription / Pre-paid Tegoed
|
||||
- **Status**: Per-file betaling; contract heeft `getUserStats()` met `remainingFree`
|
||||
- **Wijzigingen**:
|
||||
- Nieuw component: `src/components/DepositPanel.tsx` — stort ETH/tokens op contract
|
||||
- Nieuw component: `src/components/BalanceDisplay.tsx` — toon tegoed
|
||||
- `src/app/dashboard/page.tsx`: balance card
|
||||
- `src/lib/payment/write-service.ts`: `deposit(provider, amount)` methode
|
||||
- **Bestanden**: NIEUW `src/components/DepositPanel.tsx`, NIEUW `src/components/BalanceDisplay.tsx`, `src/app/dashboard/page.tsx`, `src/lib/payment/write-service.ts`
|
||||
|
||||
---
|
||||
|
||||
## B. IPFS Explorer — Content Viewing
|
||||
|
||||
### B1. Video Preview (.mp4, .webm, .mov)
|
||||
- **Status**: valt onder `'other'` → "Preview not available"
|
||||
- **Wijzigingen**:
|
||||
- `src/app/explorer/components/FilePreview.tsx`: `detectFileType()` uitbreiden met `'video'` type
|
||||
- `src/app/explorer/components/FilePreview.tsx`: `<video>` element met lokale gateway URL
|
||||
- `src/lib/gateway.ts`: `gatewayUrl(cid)` helper (gebruik `maos.dedyn.io/ipfs/` ipv `ipfs.io`)
|
||||
- **Bestanden**: `src/app/explorer/components/FilePreview.tsx`, `src/lib/gateway.ts` (NIEUW of bestaande helper)
|
||||
|
||||
### B2. Audio Preview (.mp3, .wav, .ogg)
|
||||
- **Status**: valt onder `'other'` → "Preview not available"
|
||||
- **Wijzigingen**:
|
||||
- `src/app/explorer/components/FilePreview.tsx`: `detectFileType()` uitbreiden met `'audio'` type
|
||||
- `<audio>` element met controls
|
||||
- **Bestanden**: `src/app/explorer/components/FilePreview.tsx`
|
||||
|
||||
### B3. PDF Inline Viewer
|
||||
- **Status**: valt onder `'other'` → "Preview not available"
|
||||
- **Wijzigingen**:
|
||||
- `src/app/explorer/components/FilePreview.tsx`: `detectFileType()` uitbreiden met `'pdf'` type
|
||||
- `<iframe>` of `<embed>` met gateway URL
|
||||
- Fallback naar download als browser geen PDF ondersteunt
|
||||
- **Bestanden**: `src/app/explorer/components/FilePreview.tsx`
|
||||
|
||||
### B4. HTML Preview
|
||||
- **Status**: `.html` valt onder `'text'` → raw content tonen
|
||||
- **Wijzigingen**:
|
||||
- `src/app/explorer/components/FilePreview.tsx`: `detectFileType()` uitbreiden met `'html'` type
|
||||
- `<iframe>` met `srcdoc` om HTML veilig te renderen (sandboxed)
|
||||
- **Bestanden**: `src/app/explorer/components/FilePreview.tsx`
|
||||
|
||||
### B5. Eigen Gateway voor Afbeeldingen
|
||||
- **Status**: `FilePreview.tsx` gebruikt hardcoded `https://ipfs.io/ipfs/${cid}` voor images
|
||||
- **Wijzigingen**:
|
||||
- `src/app/explorer/components/FilePreview.tsx`: vervang door `settings.gatewayUrl` uit localStorage
|
||||
- Of `src/lib/gateway.ts`: `imageUrl(cid)` die `maos.dedyn.io/ipfs/${cid}` gebruikt
|
||||
- **Bestanden**: `src/app/explorer/components/FilePreview.tsx`, `src/lib/storage.ts` (gateway setting)
|
||||
|
||||
### B6. Full-Screen Preview Mode
|
||||
- **Status**: Preview is inline card onder directory listing
|
||||
- **Wijzigingen**:
|
||||
- `src/app/explorer/components/FilePreview.tsx`: full-screen toggle
|
||||
- NIEUW `src/app/explorer/components/FullScreenPreview.tsx`: overlay modal met grotere view
|
||||
- Keyboard shortcuts: Escape, pijltjes voor navigatie
|
||||
- **Bestanden**: `src/app/explorer/components/FilePreview.tsx`, NIEUW `src/app/explorer/components/FullScreenPreview.tsx`, `src/app/explorer/page.tsx`
|
||||
|
||||
### B7. Directory Tree View
|
||||
- **Status**: Alleen flat listing, 1 level diep
|
||||
- **Wijzigingen**:
|
||||
- `src/app/explorer/components/DirectoryListing.tsx`: recursive toggle — klik op dir opent inline ipv nieuwe pagina
|
||||
- `src/app/explorer/page.tsx`: tree mode / flat mode toggle
|
||||
- **Bestanden**: `src/app/explorer/components/DirectoryListing.tsx`, `src/app/explorer/page.tsx`
|
||||
|
||||
### B8. Content Type Icons
|
||||
- **Status**: `FileIcon.tsx` toont alleen folder/file icoon
|
||||
- **Wijzigingen**:
|
||||
- `src/components/FileIcon.tsx`: specifieke iconen voor image/video/audio/pdf/code per extensie
|
||||
- Gebruik Lucide icons: `FileImage`, `FileVideo`, `FileAudio`, `FileText`
|
||||
- **Bestanden**: `src/components/FileIcon.tsx`
|
||||
|
||||
---
|
||||
|
||||
## C. Prioriteit & Volgorde
|
||||
|
||||
| Prio | Feature | Impact | Effort |
|
||||
|------|---------|--------|--------|
|
||||
| P0 | **B5** Eigen gateway voor media | Direct zichtbare verbetering, laag risico | 1 file, ~5 min |
|
||||
| P0 | **B1-B4** Video/Audio/PDF/HTML preview | Meeste gebruikerswaarde Explorer | ~2 files, ~30 min |
|
||||
| P0 | **B6** Full-screen preview | UX upgrade | ~2 files, ~20 min |
|
||||
| P1 | **A5** On-chain history | Nuttig voor betalende gebruikers | ~2 files, ~30 min |
|
||||
| P1 | **A4** Storage quota UI | Inzicht voor gebruikers | ~2 files, ~20 min |
|
||||
| P1 | **B7** Directory tree | Explorer power feature | ~2 files, ~30 min |
|
||||
| P1 | **B8** Content type icons | Polijsting | 1 file, ~10 min |
|
||||
| P2 | **A1** Multi-chain | Uitbreiding naar andere chains | ~5 files, ~1-2 uur |
|
||||
| P2 | **A6** Token auto-router | Gemak voor betalende gebruikers | 1 file, ~15 min |
|
||||
| P2 | **A2** Gas estimator | Transparantie | ~2 files, ~20 min |
|
||||
| P3 | **A3** Batch upload + betaling | Nieuwe feature | ~3 files, ~1 uur |
|
||||
| P3 | **A7** Subscription / tegoed | Grote nieuwe feature | ~4 files, ~2+ uur |
|
||||
|
||||
---
|
||||
|
||||
## D. Graph Impact
|
||||
|
||||
Deze features veranderen de knowledge graph:
|
||||
|
||||
```
|
||||
HUIDIGE COMMUNITIES:
|
||||
WalletProvider ── PaymentService ── Smart Contract
|
||||
│ │
|
||||
│ ExplorerPage ── FilePreview ── Kubo API
|
||||
│ │
|
||||
│ UploadPage ── PaymentPanel
|
||||
|
||||
NA DEZE FEATURES:
|
||||
ChainManager ──► PaymentService ──► SmartContract (multi-chain)
|
||||
StorageQuota ──► Dashboard ──► PaymentService
|
||||
ChainHistory ──► HistoryPage ──► PaymentReadService
|
||||
TreeView ──► ExplorerPage ──► DirectoryListing ──► FilePreview
|
||||
FullScreenPreview ──► FilePreview ──► Gateway
|
||||
```
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"sessionID": "ses_0dccaec9affeokDqxzoESEknqc",
|
||||
"updatedAt": "2026-07-02T15:56:27.603Z",
|
||||
"sources": {
|
||||
"background-task": {
|
||||
"state": "idle",
|
||||
"updatedAt": "2026-07-02T15:56:27.603Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"sessionID": "ses_0f6f91d66ffexLf0P0aWM1urQ2",
|
||||
"updatedAt": "2026-06-27T20:06:35.253Z",
|
||||
"sources": {
|
||||
"background-task": {
|
||||
"state": "idle",
|
||||
"updatedAt": "2026-06-27T20:06:35.253Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
+306
@@ -0,0 +1,306 @@
|
||||
# IPFS Portal — Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
IPFS Portal is a **Next.js SPA** (Single Page Application) that provides a browser-based interface for managing an IPFS node, uploading files, and handling crypto payments via a smart contract. All pages are `'use client'` — no server-side rendering.
|
||||
|
||||
### Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Frontend | Next.js 14+ (React, `'use client'` SPA) |
|
||||
| Styling | Tailwind CSS + custom `glass`/`surface` design tokens |
|
||||
| Icons | Lucide React |
|
||||
| Wallet | EIP-6963 + viem |
|
||||
| Blockchain | zkSync Local (chain 270) |
|
||||
| IPFS | Kubo RPC API (port 5001) |
|
||||
| User Mgmt | Python backend (port 8444) |
|
||||
| IPFS Gateway | `https://maos.dedyn.io/ipfs/<CID>` |
|
||||
|
||||
---
|
||||
|
||||
## System Architecture
|
||||
|
||||
```
|
||||
BROWSER (React SPA)
|
||||
│
|
||||
┌─────────────────────┼──────────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
11 Pages (SPA) WalletProvider PaymentService
|
||||
┌──────────────┐ (EIP-6963) (viem, chain 270)
|
||||
│ apiFetch() │ │ │
|
||||
│ wrapper │ │ │
|
||||
└──────┬───────┘ │ │
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Next.js API Route: [...path]/route.ts │
|
||||
│ │
|
||||
│ matchRoute(path) → │
|
||||
│ 'health' → inline JSON │
|
||||
│ 'users' → proxyUserAPI() → :8444 (Python) │
|
||||
│ default → proxyIPFS() → :5001 (Kubo) │
|
||||
│ │
|
||||
│ mapToKuboAPI(path) → /api/v0/* translation │
|
||||
└─────────────────────┬───────────────────────────────┘
|
||||
│
|
||||
┌─────────────┴─────────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ Kubo IPFS │ │ Python User API │
|
||||
│ Daemon │ │ (port 8444) │
|
||||
│ (port 5001) │ │ │
|
||||
│ │ │ htpasswd mgmt │
|
||||
│ /api/v0/id │ │ Auth: X-Admin- │
|
||||
│ /api/v0/swarm │ │ Key header │
|
||||
│ /api/v0/pin/* │ │ │
|
||||
│ /api/v0/add │ └──────────────────┘
|
||||
│ /api/v0/ls │
|
||||
│ /api/v0/name/ │
|
||||
│ /api/v0/repo │ CHAIN 270
|
||||
│ /api/v0/bw │ ┌──────────────────┐
|
||||
└─────────────────┘ │ Smart Contract │
|
||||
│ 0xCBc6... │
|
||||
│ zkSync Local │
|
||||
│ RPC :3050 │
|
||||
│ │
|
||||
│ payWithETH() │
|
||||
│ payWithToken() │
|
||||
│ getPrice() │
|
||||
│ configureToken()│
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pages
|
||||
|
||||
| Page | Route | Lines | Backend Dependencies | Description |
|
||||
|---|---|---|---|---|
|
||||
| Landing | `/` | ~130 | Kubo (health) | Hero + connect wallet + quick stats |
|
||||
| Dashboard | `/dashboard` | 253 | Kubo (peers, pins, health, repo, bw) | Node overview, stats, bandwidth |
|
||||
| Upload | `/upload` | **590** | Kubo + Smart Contract | Quick + crypto file upload |
|
||||
| Explorer | `/explorer` | 244 | Kubo (ls, cat) | IPFS content browser |
|
||||
| IPNS | `/ipns` | 285 | Kubo (keys, publish, resolve) | IPNS name management |
|
||||
| Users | `/users` | 275 | Python API + Smart Contract | htpasswd users + wallet lookup |
|
||||
| Peers | `/peers` | 63 | Kubo (swarm/peers) | Connected peers list |
|
||||
| Pins | `/pins` | 167 | Kubo (pin/ls, add, rm) | Pin management |
|
||||
| Admin/Payment | `/admin/payment` | **536** | Smart Contract | Owner payment dashboard |
|
||||
| Settings | `/settings` | 319 | localStorage only | App configuration |
|
||||
| History | `/history` | 393 | localStorage only | Upload history viewer |
|
||||
|
||||
**Note**: IPNS page does NOT wrap in `PortalLayout` — appears standalone. All other pages use the shared layout.
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
### 1. API Flow (all pages except Settings, History)
|
||||
|
||||
```
|
||||
Page Component
|
||||
→ apiFetch('/api/endpoint')
|
||||
→ fetch('/api/endpoint', { credentials: 'include' })
|
||||
→ Next.js [...path]/route.ts
|
||||
→ matchRoute(path)
|
||||
→ proxyIPFS() → Kubo RPC :5001
|
||||
→ proxyUserAPI() → Python :8444
|
||||
```
|
||||
|
||||
### 2. Upload Flow (Quick)
|
||||
|
||||
```
|
||||
User drags file
|
||||
→ UploadPage state: FileEntry[]
|
||||
→ startUpload() — sequential per file
|
||||
→ uploadFile(file) → apiFetch POST /api/files/upload (FormData)
|
||||
→ proxyIPFS → Kubo /api/v0/add
|
||||
→ addToHistory({ cid, name, size }) → localStorage
|
||||
→ Show results (CID, gateway link, copy)
|
||||
```
|
||||
|
||||
### 3. Upload Flow (Crypto)
|
||||
|
||||
```
|
||||
User selects file
|
||||
→ PaymentPanel shown
|
||||
→ WalletProvider.connectWallet() → get address
|
||||
→ PaymentService.getPrice(bytes, addr) → { finalWei, isFree }
|
||||
→ PaymentService.getSupportedTokens() → [ETH, USDC, MAOS]
|
||||
→ PaymentService.getMAOSDiscountTiers() → discount %
|
||||
→ User pays
|
||||
→ Step 1: uploadFile(file) → Kubo → get CID
|
||||
→ Step 2a (free): onPaid({ method: 'free' })
|
||||
→ Step 2b (ETH): payWithETH(provider, cid, size, wei)
|
||||
→ sendTransaction → contract.payWithETH(cid, bytesSize)
|
||||
→ Step 2c (token): approveAndPayWithToken(...)
|
||||
→ check allowance → approve if needed → contract.payWithToken(...)
|
||||
→ addToHistory({ method: 'eth' }) → localStorage
|
||||
```
|
||||
|
||||
### 4. Payment Admin Flow
|
||||
|
||||
```
|
||||
Admin connects wallet
|
||||
→ Check isOwner (compare connected address to contract.owner)
|
||||
→ Load: getOwner, getBasePricePerMB, getFreeTierBytes,
|
||||
getSupportedTokens, getMAOSDiscountTiers, getTotalUploads
|
||||
→ Per token: getTotalRevenue, getBalance (ETH) / balanceOf (ERC20)
|
||||
→ Owner can: setPrice, setFreeTier, configureToken,
|
||||
add/remove discount tiers, withdraw funds
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Modules
|
||||
|
||||
### `src/lib/api.ts` (217 lines)
|
||||
|
||||
Central API layer. Exports:
|
||||
|
||||
| Function | HTTP | Endpoint | Category |
|
||||
|---|---|---|---|
|
||||
| `getNodeInfo()` | GET | `/api/node/info` | Node |
|
||||
| `getPeers()` | GET | `/api/peers` | Peers |
|
||||
| `listPins()` / `addPin()` / `removePin()` | GET/POST/DELETE | `/api/pins` | Pins |
|
||||
| `uploadFile(file)` | POST (FormData) | `/api/files/upload` | Files |
|
||||
| `listUsers()` / `createUser()` / `deleteUser()` | GET/POST/DELETE | `/api/users` | Users |
|
||||
| `explorerLs()` / `explorerCat()` / `explorerStat()` | POST | `/api/explorer/*` | Explorer |
|
||||
| `listIPNSKeys()` / `ipnsPublish()` / `ipnsResolve()` / `ipnsGenKey()` | GET/POST | `/api/ipns/*` | IPNS |
|
||||
| `getRepoStats()` / `getBWStats()` | GET | `/api/repo` / `/api/bw` | Stats |
|
||||
| `checkHealth()` | GET | `/api/health` | Health |
|
||||
|
||||
### `src/lib/wallet.ts` (196 lines)
|
||||
|
||||
Wallet connection layer.
|
||||
|
||||
- **EIP-6963** event-based provider discovery (`discoverWallets`)
|
||||
- Legacy `window.ethereum` fallback
|
||||
- MAOS Wallet preferred (`window.maosv6`)
|
||||
- Chain 270 (zkSync Local) switch + add
|
||||
- Helpers: `formatWeiToETH`, `formatBytes`, `signMessage`, `getBalance`
|
||||
|
||||
### `src/lib/payment.ts` (539 lines)
|
||||
|
||||
Smart contract interaction via viem.
|
||||
|
||||
- 28 methods wrapping ABI functions
|
||||
- Supports ETH and ERC20 token payments
|
||||
- Read: `getPrice`, `getUserStats`, `getSupportedTokens`, `getMAOSDiscountTiers`
|
||||
- Write: `payWithETH`, `approveAndPayWithToken`
|
||||
- Admin: `adminSetPricePerMB`, `adminConfigureToken`, `adminWithdrawETH`, etc.
|
||||
- Contract: `0xCBc6b8aeea129c206F4836799621C833Bf8B9BDe` on chain 270
|
||||
|
||||
### `src/lib/storage.ts` (98 lines)
|
||||
|
||||
localStorage persistence.
|
||||
|
||||
- `PortalSettings`: gateway URL, API endpoint, storage max, refresh, theme
|
||||
- `UploadRecord`: history with dedup by CID+name, capped at 500 entries
|
||||
|
||||
### `src/app/api/[...path]/route.ts` (~320 lines)
|
||||
|
||||
Catch-all API proxy. Routes:
|
||||
|
||||
| Path | Handler | Backend |
|
||||
|---|---|---|
|
||||
| `/api/health` | `handleHealth()` | Inline JSON |
|
||||
| `/api/users*` | `proxyUserAPI()` | Python :8444 |
|
||||
| `/api/explorer/*` / `/api/ipns/*` / default | `proxyIPFS()` | Kubo :5001 |
|
||||
|
||||
**`mapToKuboAPI(path)`** translates Portal API paths to Kubo RPC endpoints:
|
||||
- `/node/info` → `/api/v0/id`
|
||||
- `/peers` → `/api/v0/swarm/peers`
|
||||
- `/pins` → `/api/v0/pin/ls|add|rm`
|
||||
- `/files/upload` → `/api/v0/add`
|
||||
- `/explorer/ls` → `/api/v0/ls`
|
||||
- `/ipns/publish` → `/api/v0/name/publish`
|
||||
- `/repo/stats` → `/api/v0/repo/stat`
|
||||
- `/bw/stats` → `/api/v0/bw`
|
||||
|
||||
---
|
||||
|
||||
## Smart Contract (Chain 270 — zkSync Local)
|
||||
|
||||
**Address**: `0xCBc6b8aeea129c206F4836799621C833Bf8B9BDe`
|
||||
**RPC**: `http://tom1687.no-ip.org:3050`
|
||||
|
||||
### Functions
|
||||
|
||||
| Category | Functions |
|
||||
|---|---|
|
||||
| **Payment** | `payWithETH(cid, bytesSize)` (payable), `payWithToken(cid, bytes, symbol)` |
|
||||
| **Pricing** | `getPrice(bytes, user)` → `(totalWei, discountBps, finalWei, isFree)` |
|
||||
| **Free Tier** | `freeTierBytes()`, `userTotalBytes(user)` |
|
||||
| **Token Mgmt** | `configureToken(symbol, addr, decimals, weiPerToken, enabled)` |
|
||||
| **Discounts** | `getMAOSDiscountTiers()`, `setMAOSDiscountTier(minBal, bps)`, `removeMAOSDiscountTier(index)` |
|
||||
| **Admin** | `setPricePerMB(wei)`, `setFreeTierBytes(bytes)`, `withdrawETH(to)`, `withdrawToken(symbol, to)`, `transferOwnership(to)` |
|
||||
| **Stats** | `totalRevenue(symbol)`, `totalUploads()`, `getUserStats(user)` |
|
||||
| **Events** | `UploadPaidETH`, `UploadPaidToken`, `FreeUploadUsed` |
|
||||
|
||||
### Known Bug
|
||||
Contract returns duplicate token entries — admin page deduplicates on symbol.
|
||||
|
||||
---
|
||||
|
||||
## Graph Analysis
|
||||
|
||||
### Static (AST) View
|
||||
- **251 nodes**, **401 edges**, **17 communities**
|
||||
- Graphify knowledge graph at `graphify-out/graph.json`
|
||||
- **79 isolated nodes** (type definitions, interfaces, scripts)
|
||||
|
||||
### Bridge Nodes
|
||||
|
||||
| Node | Edges | Role |
|
||||
|---|---|---|
|
||||
| `PaymentService` | 31 | Bridges Smart Contract ↔ UI (Payments) |
|
||||
| `WalletProvider` | 15 | Bridges Wallet ↔ PaymentService |
|
||||
|
||||
### Graph Gap
|
||||
The AST parser cannot trace URL-routed connections. The runtime flow:
|
||||
|
||||
```
|
||||
apiFetch('/api/node/info') → fetch('/api/node/info')
|
||||
→ [...path]/route.ts → proxyIPFS() → Kubo :5001
|
||||
```
|
||||
|
||||
Appears as 3 disconnected communities in the graph but is one continuous flow at runtime.
|
||||
|
||||
---
|
||||
|
||||
## Code Quality Summary
|
||||
|
||||
### Issues Found (24 total)
|
||||
|
||||
| Severity | Count | Key Items |
|
||||
|---|---|---|
|
||||
| 🔴 Critical | 4 | XSS, unhandled promises, silent catch blocks, oversized files |
|
||||
| 🟡 Medium | 5 | 15× `@ts-expect-error`, 8× `as any`, duplicate helpers, precision loss |
|
||||
| 🟢 Low | 3 | Unused imports, console.* logging, IPNS layout inconsistency |
|
||||
|
||||
### Priority Fixes
|
||||
|
||||
1. **Window type augmentation** — Declare `Window` interface in `wallet.ts` to eliminate 15 `@ts-expect-error`
|
||||
2. **Extract duplicate patterns** — Consolidate `formatBytes`, `gatewayLink`, `truncateCid` into `lib/helpers.ts`
|
||||
3. **Add error logging** — Every empty catch block gets `console.error` or structured logging
|
||||
4. **Split oversized files** — `upload/page.tsx` (590→2 components), `admin/payment/page.tsx` (536→4 views), `payment.ts` (539→abi/types/service)
|
||||
5. **Fix XSS** — Add DOMPurify or replace `dangerouslySetInnerHTML` in `FilePreview.tsx`
|
||||
6. **Fix unhandled promises** — Add `.catch()` to `isDeployed().then()`
|
||||
7. **Replace `as any` casts** — Use proper viem `parseAbi` types instead of `as unknown as X`
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
- **API_BASE**: `''` (empty — same origin; nginx routes `/api/*` to backend)
|
||||
- **Kubo RPC**: `http://127.0.0.1:5001`
|
||||
- **Python API**: `http://192.168.1.176:8444` (htpasswd + admin key)
|
||||
- **Smart Contract**: `0xCBc6...` on chain 270 (zkSync Local)
|
||||
- **IPFS Gateway**: `https://maos.dedyn.io/ipfs/`
|
||||
- **Refresh Interval**: 15s (Dashboard)
|
||||
- **Max Uploads**: 20 files, 100MB each
|
||||
- **Storage Max**: 30 GB (localStorage setting)
|
||||
- **Admin Key**: `maos-admin-2024` (X-Admin-Key header)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
# ── IPFS Portal Deploy Script ──
|
||||
# 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)
|
||||
#
|
||||
# Builds Docker image locally, pushes to Docker Hub or saves as tar,
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REMOTE_HOST="${1:-maik@192.168.1.176}"
|
||||
REMOTE_HOST="${1:?Usage: $0 <user@host> [port]}"
|
||||
REMOTE_PORT="${2:-22}"
|
||||
IMAGE_NAME="maos/ipfs-portal"
|
||||
TAG="$(date +%Y%m%d-%H%M%S)"
|
||||
@@ -81,10 +81,12 @@ services:
|
||||
- NODE_ENV=production
|
||||
- PORT=3445
|
||||
- NEXT_TELEMETRY_DISABLED=1
|
||||
- USER_API_BASE=http://192.168.1.176:8444
|
||||
- USER_API_ADMIN_KEY=maos-admin-2024
|
||||
- KUBO_API_URL=http://192.168.1.176:8443
|
||||
- KUBO_BASIC_AUTH=portal:ipfs-portal-2024
|
||||
# ── Set these via .env.production or environment ──
|
||||
- USER_API_BASE=${USER_API_BASE:-http://localhost:8444}
|
||||
# - USER_API_ADMIN_KEY= # ← REQUIRED: set this!
|
||||
# - KUBO_API_URL= # ← Kubo RPC if behind proxy
|
||||
# - KUBO_BASIC_AUTH= # ← Kubo basic auth credentials
|
||||
# - JWT_SECRET= # ← REQUIRED: generate a random string
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3445/api/health"]
|
||||
interval: 30s
|
||||
@@ -111,4 +113,4 @@ REMOTESHELL
|
||||
|
||||
echo ""
|
||||
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,8 +1,33 @@
|
||||
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 = {
|
||||
images: { unoptimized: true },
|
||||
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;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"description": "IPFS Portal — Next.js frontend for remote IPFS node management",
|
||||
"scripts": {
|
||||
"dev": "next dev --port 3445",
|
||||
"dev:webpack": "next dev --port 3445 --webpack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"export": "next build && npx serve out"
|
||||
|
||||
+1
-10
@@ -1,10 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
|
Before Width: | Height: | Size: 446 B After Width: | Height: | Size: 235 B |
Binary file not shown.
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 13 KiB |
+3
-22
@@ -1,31 +1,12 @@
|
||||
{
|
||||
"name": "MAOS IPFS Portal",
|
||||
"short_name": "IPFS Portal",
|
||||
"description": "Decentralized storage management for your IPFS node",
|
||||
"description": "Decentralized storage management for IPFS",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0a0e17",
|
||||
"background_color": "#0f172a",
|
||||
"theme_color": "#0891b2",
|
||||
"orientation": "any",
|
||||
"categories": ["utilities", "productivity"],
|
||||
"icons": [
|
||||
{
|
||||
"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"
|
||||
}
|
||||
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" }
|
||||
]
|
||||
}
|
||||
|
||||
+21
-140
@@ -1,150 +1,31 @@
|
||||
/* ── IPFS Portal — Service Worker v3 ──
|
||||
*
|
||||
* Cache strategieën per route type:
|
||||
* - Precache: app shell (alle pages)
|
||||
* - Cache-first: static assets (fonts, images, CSS, JS)
|
||||
* - Network-first: API calls (/_next/data, /api/*)
|
||||
* - Stale-while-revalidate: navigatie requests
|
||||
/* ── IPFS Portal Service Worker ──
|
||||
* Minimal offline cache for static assets.
|
||||
* Auto-generated — do not edit manually.
|
||||
*/
|
||||
const CACHE = 'ipfs-portal-v1';
|
||||
const PRECACHE = ['/', '/manifest.json', '/favicon.svg'];
|
||||
|
||||
const CACHE = 'ipfs-portal-v3';
|
||||
const STATIC_CACHE = 'ipfs-portal-static-v3';
|
||||
const DATA_CACHE = 'ipfs-portal-data-v3';
|
||||
|
||||
const PRECACHE_URLS = [
|
||||
'/',
|
||||
'/dashboard',
|
||||
'/upload',
|
||||
'/explorer',
|
||||
'/pins',
|
||||
'/history',
|
||||
'/usage',
|
||||
'/peers',
|
||||
'/users',
|
||||
'/ipns',
|
||||
'/login',
|
||||
'/settings',
|
||||
'/profile',
|
||||
];
|
||||
|
||||
/* ── Install: precache app shell ── */
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE).then((cache) => cache.addAll(PRECACHE_URLS))
|
||||
self.addEventListener('install', (e) => {
|
||||
e.waitUntil(
|
||||
caches.open(CACHE).then((c) => c.addAll(PRECACHE)).then(() => self.skipWaiting()),
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
/* ── Activate: clean old caches ── */
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
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.addEventListener('activate', (e) => {
|
||||
e.waitUntil(
|
||||
caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))),
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
/* ── Helper: is this a navigation request? ── */
|
||||
|
||||
function isNavigation(req) {
|
||||
return req.mode === 'navigate';
|
||||
self.addEventListener('fetch', (e) => {
|
||||
if (e.request.method !== 'GET') return;
|
||||
e.respondWith(
|
||||
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();
|
||||
caches.open(CACHE).then((c) => c.put(e.request, clone));
|
||||
}
|
||||
|
||||
function isStaticAsset(url) {
|
||||
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
|
||||
}
|
||||
|
||||
// 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 → stale-while-revalidate
|
||||
if (isNavigation(request)) {
|
||||
event.respondWith(staleWhileRevalidate(request, CACHE));
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. All other requests → network-first with cache fallback
|
||||
event.respondWith(networkFirst(request, CACHE));
|
||||
return res;
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
/* ── 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// Generate PWA icons
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const sizes = [192, 512];
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const sharp = require('sharp');
|
||||
for (const size of sizes) {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
|
||||
<rect width="${size}" height="${size}" rx="${size * 0.1667}" fill="#0891b2"/>
|
||||
<text x="${size / 2}" y="${size * 0.7}" font-family="system-ui, sans-serif" font-size="${size * 0.6}" font-weight="bold" fill="white" text-anchor="middle">M</text>
|
||||
</svg>`;
|
||||
await sharp(Buffer.from(svg)).png().toFile(path.join(__dirname, '..', 'public', `icon-${size}.png`));
|
||||
console.log(`Created icon-${size}.png`);
|
||||
}
|
||||
} catch {
|
||||
// sharp not available, create minimal valid PNG manually
|
||||
console.log('sharp not available, creating minimal PNGs');
|
||||
// Minimal 1x1 blue PNG
|
||||
for (const size of [192, 512]) {
|
||||
// Create a minimal valid PNG - will just be a solid blue square
|
||||
// PNG signature
|
||||
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
// IHDR chunk
|
||||
const ihdrData = Buffer.alloc(13);
|
||||
ihdrData.writeUInt32BE(size, 0); // width
|
||||
ihdrData.writeUInt32BE(size, 4); // height
|
||||
ihdrData[8] = 8; // bit depth
|
||||
ihdrData[9] = 2; // color type (RGB)
|
||||
ihdrData[10] = 0; // compression
|
||||
ihdrData[11] = 0; // filter
|
||||
ihdrData[12] = 0; // interlace
|
||||
|
||||
const ihdrLen = Buffer.alloc(4);
|
||||
ihdrLen.writeUInt32BE(13);
|
||||
const ihdrType = Buffer.from('IHDR');
|
||||
const ihdrCRC = crc32(Buffer.concat([ihdrType, ihdrData]));
|
||||
const ihdrCRCbuf = Buffer.alloc(4);
|
||||
ihdrCRCbuf.writeUInt32BE(ihdrCRC);
|
||||
|
||||
// IDAT - raw filtered image data (each row: filter byte 0 + RGB pixels)
|
||||
const rowSize = 1 + size * 3; // filter byte + RGB per pixel
|
||||
const rawData = Buffer.alloc(rowSize * size);
|
||||
for (let y = 0; y < size; y++) {
|
||||
rawData[y * rowSize] = 0; // no filter
|
||||
for (let x = 0; x < size; x++) {
|
||||
const offset = y * rowSize + 1 + x * 3;
|
||||
rawData[offset] = 8; // R
|
||||
rawData[offset+1] = 145; // G
|
||||
rawData[offset+2] = 178; // B
|
||||
}
|
||||
}
|
||||
|
||||
// Zlib compress
|
||||
const zlib = require('zlib');
|
||||
const compressed = zlib.deflateSync(rawData);
|
||||
|
||||
const idatLen = Buffer.alloc(4);
|
||||
idatLen.writeUInt32BE(compressed.length);
|
||||
const idatType = Buffer.from('IDAT');
|
||||
const idatCRC = crc32(Buffer.concat([idatType, compressed]));
|
||||
const idatCRCbuf = Buffer.alloc(4);
|
||||
idatCRCbuf.writeUInt32BE(idatCRC);
|
||||
|
||||
// IEND
|
||||
const iendLen = Buffer.alloc(4);
|
||||
iendLen.writeUInt32BE(0);
|
||||
const iendType = Buffer.from('IEND');
|
||||
const iendCRC = crc32(iendType);
|
||||
const iendCRCbuf = Buffer.alloc(4);
|
||||
iendCRCbuf.writeUInt32BE(iendCRC);
|
||||
|
||||
const png = Buffer.concat([
|
||||
sig, ihdrLen, ihdrType, ihdrData, ihdrCRCbuf,
|
||||
idatLen, idatType, compressed, idatCRCbuf,
|
||||
iendLen, iendType, iendCRCbuf
|
||||
]);
|
||||
|
||||
fs.writeFileSync(path.join(__dirname, '..', 'public', `icon-${size}.png`), png);
|
||||
console.log(`Created icon-${size}.png (minimal)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function crc32(buf) {
|
||||
let crc = 0xFFFFFFFF;
|
||||
const table = new Uint32Array(256);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let c = i;
|
||||
for (let j = 0; j < 8; j++) {
|
||||
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
||||
}
|
||||
table[i] = c;
|
||||
}
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
crc = table[(crc ^ buf[i]) & 0xFF] ^ (crc >>> 8);
|
||||
}
|
||||
return (crc ^ 0xFFFFFFFF) >>> 0;
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -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:);
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import PortalLayout from '@/app/layout-portal';
|
||||
import AuthGuard from '@/components/AuthGuard';
|
||||
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 Link from 'next/link';
|
||||
import {
|
||||
Shield, Users, HardDrive, Server, Activity, Wallet,
|
||||
ArrowUpRight, CreditCard, RefreshCw, Wifi, Database,
|
||||
} from 'lucide-react';
|
||||
|
||||
/* ════════════════════════════ Admin Page ════════════════════════════ */
|
||||
|
||||
export default function AdminPage() {
|
||||
const [users, setUsers] = useState<{ username: string; created: string; active: boolean }[]>([]);
|
||||
const [loadingUsers, setLoadingUsers] = useState(true);
|
||||
const [health, setHealth] = useState<{ status: string; userApi: string; kuboApi: string; mode: string } | null>(null);
|
||||
const [loadingHealth, setLoadingHealth] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
listUsers()
|
||||
.then(setUsers)
|
||||
.catch((err) => { console.error('[Admin] listUsers failed:', err); })
|
||||
.finally(() => setLoadingUsers(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkHealth()
|
||||
.then((h: any) => setHealth(h))
|
||||
.catch((err) => { console.error('[Admin] checkHealth failed:', err); })
|
||||
.finally(() => setLoadingHealth(false));
|
||||
}, []);
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: 'Gebruikers',
|
||||
value: loadingUsers ? '…' : users.length.toString(),
|
||||
icon: Users,
|
||||
color: 'text-accent-cyan',
|
||||
bg: 'bg-accent-cyan/10',
|
||||
href: '/users',
|
||||
},
|
||||
{
|
||||
label: 'Node Status',
|
||||
value: health?.status ?? '…',
|
||||
icon: Server,
|
||||
color: health?.status === 'ok' ? 'text-accent-green' : 'text-accent-rose',
|
||||
bg: health?.status === 'ok' ? 'bg-accent-green/10' : 'bg-accent-rose/10',
|
||||
href: null,
|
||||
},
|
||||
{
|
||||
label: 'User API',
|
||||
value: health?.userApi ?? '…',
|
||||
icon: Database,
|
||||
color: 'text-accent-amber',
|
||||
bg: 'bg-accent-amber/10',
|
||||
href: null,
|
||||
},
|
||||
{
|
||||
label: 'Kubo API',
|
||||
value: health?.kuboApi ?? '…',
|
||||
icon: Wifi,
|
||||
color: 'text-accent-purple',
|
||||
bg: 'bg-accent-purple/10',
|
||||
href: null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<AuthGuard requireAdmin>
|
||||
<PortalLayout>
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||
<Shield className="w-6 h-6 text-accent-rose" />
|
||||
Admin
|
||||
</h1>
|
||||
<p className="text-sm text-surface-400 mt-1">System overview and management</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-1.5 text-xs text-surface-500">
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${health?.status === 'ok' ? 'bg-accent-green' : 'bg-accent-rose'}`} />
|
||||
{health?.mode === 'proxy' ? 'Live' : 'Minimal'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
{stats.map((s) => (
|
||||
<div key={s.label} className="glass rounded-xl p-5 animate-fade-in">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className={`p-2 rounded-lg ${s.bg}`}>
|
||||
<s.icon className={`w-4 h-4 ${s.color}`} />
|
||||
</div>
|
||||
{s.href && (
|
||||
<Link href={s.href}>
|
||||
<ArrowUpRight className="w-3.5 h-3.5 text-surface-500 hover:text-surface-300 transition-colors" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">{s.value}</div>
|
||||
<div className="text-xs text-surface-400 mt-1">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<Link
|
||||
href="/admin/payment"
|
||||
className="glass rounded-xl p-5 border border-surface-800 hover:border-surface-700 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 rounded-lg bg-brand-500/10">
|
||||
<CreditCard className="w-5 h-5 text-brand-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white group-hover:text-brand-400 transition-colors">
|
||||
Payment Config
|
||||
</h3>
|
||||
<p className="text-xs text-surface-500">Smart contract pricing, tokens, tiers</p>
|
||||
</div>
|
||||
<ArrowUpRight className="w-4 h-4 text-surface-500 ml-auto group-hover:text-brand-400 transition-colors" />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/users"
|
||||
className="glass rounded-xl p-5 border border-surface-800 hover:border-surface-700 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 rounded-lg bg-accent-cyan/10">
|
||||
<Users className="w-5 h-5 text-accent-cyan" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white group-hover:text-accent-cyan transition-colors">
|
||||
User Management
|
||||
</h3>
|
||||
<p className="text-xs text-surface-500">Create, delete, and manage users</p>
|
||||
</div>
|
||||
<ArrowUpRight className="w-4 h-4 text-surface-500 ml-auto group-hover:text-accent-cyan transition-colors" />
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Users table */}
|
||||
<div className="glass rounded-xl p-5 animate-fade-in">
|
||||
<h2 className="text-sm font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-surface-400" />
|
||||
Registered Users
|
||||
</h2>
|
||||
{loadingUsers ? (
|
||||
<SkeletonTable rows={4} cols={3} />
|
||||
) : users.length === 0 ? (
|
||||
<p className="text-sm text-surface-500">No users registered yet.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-800">
|
||||
<th className="text-left py-2 px-2 text-surface-500 font-medium">Username</th>
|
||||
<th className="text-left py-2 px-2 text-surface-500 font-medium">Created</th>
|
||||
<th className="text-right py-2 px-2 text-surface-500 font-medium">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.slice(0, 10).map((u) => (
|
||||
<tr key={u.username} className="border-b border-surface-800/50 hover:bg-surface-800/30 transition-colors">
|
||||
<td className="py-2.5 px-2 text-surface-200 font-mono text-xs">{u.username}</td>
|
||||
<td className="py-2.5 px-2 text-surface-400 text-xs">
|
||||
{u.created ? new Date(u.created).toLocaleDateString() : '—'}
|
||||
</td>
|
||||
<td className="py-2.5 px-2 text-right">
|
||||
<span className={`inline-flex items-center gap-1 text-xs ${u.active ? 'text-accent-green' : 'text-surface-500'}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${u.active ? 'bg-accent-green' : 'bg-surface-600'}`} />
|
||||
{u.active ? 'active' : 'inactive'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{users.length > 10 && (
|
||||
<p className="text-xs text-surface-500 text-center mt-3">
|
||||
Showing 10 of {users.length} users
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PortalLayout>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
@@ -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 { 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 { formatWeiToETH, formatBytes } from '@/lib/wallet';
|
||||
import { formatWeiToETH } from '@/lib/wallet';
|
||||
|
||||
/* ── Props ── */
|
||||
interface PricingViewProps {
|
||||
@@ -29,6 +30,7 @@ export default function PricingView({
|
||||
priceInput, setPriceInput, freeInput, setFreeInput,
|
||||
doTx, provider, address,
|
||||
}: PricingViewProps) {
|
||||
const paymentService = usePaymentService();
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-4">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { type Dispatch, type SetStateAction } from '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 { formatWeiToETH } from '@/lib/wallet';
|
||||
|
||||
@@ -25,6 +25,7 @@ export default function TiersView({
|
||||
tierBalance, setTierBalance, tierBps, setTierBps,
|
||||
doTx, provider,
|
||||
}: TiersViewProps) {
|
||||
const paymentService = usePaymentService();
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-4">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { type Dispatch, type SetStateAction } from '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 { formatWeiToETH } from '@/lib/wallet';
|
||||
|
||||
@@ -34,6 +34,7 @@ export default function TokensView({
|
||||
tokenDec, setTokenDec, tokenWei, setTokenWei,
|
||||
tokenEnabled, setTokenEnabled, doTx, provider, address,
|
||||
}: TokensViewProps) {
|
||||
const paymentService = usePaymentService();
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-4">
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -3,22 +3,20 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import PortalLayout from '@/app/layout-portal';
|
||||
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 {
|
||||
connectWallet, switchToChain270, getInjectedProvider,
|
||||
formatWeiToETH, formatBytes, type WalletProvider,
|
||||
connectWallet, switchToChain270, getInjectedProvider, addWalletListener, disconnectWallet,
|
||||
type WalletProvider,
|
||||
} from '@/lib/wallet';
|
||||
import {
|
||||
Wallet, Loader2, CheckCircle, XCircle,
|
||||
RefreshCw, AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { SkeletonCard } from '@/components/Skeleton';
|
||||
import { useNotify } from '@/lib/notifications';
|
||||
import Overview from './components/Overview';
|
||||
import PricingView from './components/PricingView';
|
||||
import TokensView from './components/TokensView';
|
||||
import TiersView from './components/TiersView';
|
||||
|
||||
type ViewMode = 'overview' | 'pricing' | 'tokens' | 'tiers';
|
||||
type TxStatus = { ok: boolean; msg: string } | null;
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
import type { ViewMode, TxStatus } from './helpers';
|
||||
import { WalletConnect, WrongChainWarning, WalletInfo, NavTabs, TxStatusToast } from './components';
|
||||
|
||||
export default function AdminPaymentPage() {
|
||||
const [address, setAddress] = useState<string | null>(null);
|
||||
@@ -42,13 +40,14 @@ export default function AdminPaymentPage() {
|
||||
const [contractBalance, setContractBalance] = useState<Record<string, string>>({});
|
||||
|
||||
const isOwner = !!(address && owner && address.toLowerCase() === owner.toLowerCase());
|
||||
const { notify } = useNotify();
|
||||
|
||||
// ── Load all contract state ──
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setTxStatus(null);
|
||||
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([
|
||||
paymentService.getOwner(),
|
||||
paymentService.getBasePricePerMB(),
|
||||
@@ -62,7 +61,6 @@ export default function AdminPaymentPage() {
|
||||
setFreeTier(free);
|
||||
setTiers(tiersData);
|
||||
|
||||
// Deduplicate by symbol
|
||||
const seen = new Set<string>();
|
||||
const uniqueTokens = t.filter(tk => {
|
||||
if (seen.has(tk.symbol)) return false;
|
||||
@@ -72,32 +70,29 @@ export default function AdminPaymentPage() {
|
||||
setTokens(uniqueTokens);
|
||||
|
||||
const symbols = uniqueTokens.map(tk => tk.symbol);
|
||||
const revEntries = await Promise.all(
|
||||
symbols.map(s => paymentService.getTotalRevenue(s))
|
||||
);
|
||||
const revEntries = await Promise.all(symbols.map(s => paymentService.getTotalRevenue(s)));
|
||||
const revMap: Record<string, bigint> = {};
|
||||
symbols.forEach((s, i) => { revMap[s] = revEntries[i]; });
|
||||
setRevenues(revMap);
|
||||
|
||||
// Balances via RPC
|
||||
const balMap: Record<string, string> = {};
|
||||
for (const tk of t) {
|
||||
try {
|
||||
const client = paymentService['getPublicClient']();
|
||||
const client = (paymentService as any)['getPublicClient']();
|
||||
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();
|
||||
} 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({
|
||||
address: tk.address as `0x${string}`,
|
||||
abi: ERC20_BAL_ABI,
|
||||
functionName: 'balanceOf',
|
||||
args: [paymentService['contractAddress']],
|
||||
args: [(paymentService as any)['contractAddress']],
|
||||
});
|
||||
balMap[tk.symbol] = (bal as bigint).toString();
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
} catch (err) { console.error('[AdminPayment] Failed to fetch balance for token:', err); }
|
||||
}
|
||||
setContractBalance(balMap);
|
||||
setTotalUploads(uploads);
|
||||
@@ -111,6 +106,22 @@ export default function AdminPaymentPage() {
|
||||
|
||||
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 ──
|
||||
async function handleConnect() {
|
||||
setConnecting(true);
|
||||
@@ -129,14 +140,20 @@ export default function AdminPaymentPage() {
|
||||
else if (window.ethereum?.isRabby) setWalletType('Rabby');
|
||||
else if (window.ethereum?.isMetaMask) setWalletType('MetaMask');
|
||||
else setWalletType('Wallet');
|
||||
} catch (e: any) {
|
||||
setTxStatus({ ok: false, msg: e.message || 'Connect failed' });
|
||||
} catch (e: unknown) {
|
||||
setTxStatus({ ok: false, msg: e instanceof Error ? e.message : 'Connect failed' });
|
||||
} finally {
|
||||
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>) {
|
||||
if (!provider) return;
|
||||
setTxStatus(null);
|
||||
@@ -144,8 +161,8 @@ export default function AdminPaymentPage() {
|
||||
const hash = await fn();
|
||||
setTxStatus({ ok: true, msg: `${label} success: ${hash.slice(0, 10)}...` });
|
||||
await refresh();
|
||||
} catch (e: any) {
|
||||
setTxStatus({ ok: false, msg: `${label} failed: ${e.message || e}` });
|
||||
} catch (e: unknown) {
|
||||
setTxStatus({ ok: false, msg: `${label} failed: ${e instanceof Error ? e.message : String(e)}` });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,64 +181,33 @@ export default function AdminPaymentPage() {
|
||||
return (
|
||||
<AuthGuard requireAdmin redirectTo="/dashboard">
|
||||
<PortalLayout>
|
||||
{/* ── Header ── */}
|
||||
<ErrorBoundary label="Betaalbeheer">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Payment Admin</h1>
|
||||
<p className="text-sm text-surface-400 mt-1">Manage IPFS Portal payment contract</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{address && (
|
||||
<span className="text-xs text-surface-400 flex items-center gap-1.5">
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${isOwner ? 'bg-accent-green' : 'bg-surface-500'}`} />
|
||||
{isOwner ? 'Owner' : 'Viewer'}
|
||||
</span>
|
||||
<WalletInfo
|
||||
address={address}
|
||||
isOwner={isOwner}
|
||||
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>
|
||||
|
||||
{/* ── Connect wallet ── */}
|
||||
{!address && (
|
||||
<div className="glass rounded-xl p-6 text-center space-y-4">
|
||||
<Wallet className="w-8 h-8 text-surface-500 mx-auto" />
|
||||
<p className="text-sm text-surface-400">Connect wallet to view admin panel</p>
|
||||
<button onClick={handleConnect} disabled={connecting}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-500 hover:bg-brand-400 disabled:opacity-50 text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
{connecting ? <><Loader2 className="w-4 h-4 animate-spin" /> Connecting...</> : <><Wallet className="w-4 h-4" /> Connect Wallet</>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wrongChain && (
|
||||
<div className="flex items-center gap-2 px-4 py-3 rounded-lg bg-amber-500/10 border border-amber-500/20 text-amber-400 text-sm mb-4">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
Please switch to zkSync Local (chain 270)
|
||||
</div>
|
||||
)}
|
||||
{!address && <WalletConnect connecting={connecting} onConnect={handleConnect} />}
|
||||
{wrongChain && <WrongChainWarning />}
|
||||
|
||||
{address && (
|
||||
<>
|
||||
{/* ── Nav tabs ── */}
|
||||
<div className="flex gap-1 mb-6 p-1 rounded-lg bg-surface-800/50 w-fit">
|
||||
{(['overview', 'pricing', 'tokens', 'tiers'] as ViewMode[]).map(v => (
|
||||
<button key={v} onClick={() => setView(v)}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-medium capitalize transition-colors ${
|
||||
view === v ? 'bg-surface-700 text-white' : 'text-surface-400 hover:text-surface-200'
|
||||
}`}
|
||||
>
|
||||
{v === 'overview' ? 'Overview' : v === 'pricing' ? 'Pricing' : v === 'tokens' ? 'Tokens' : 'Discount Tiers'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<PaymentProvider>
|
||||
<NavTabs view={view} onChange={setView} />
|
||||
|
||||
{/* ── Loading ── */}
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-surface-400 text-sm py-12 justify-center">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Loading contract state...
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 animate-fade-in">
|
||||
{Array.from({ length: 6 }).map((_, i) => <SkeletonCard key={i} />)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -231,7 +217,7 @@ export default function AdminPaymentPage() {
|
||||
tokens={tokens}
|
||||
revenues={revenues}
|
||||
contractBalance={contractBalance}
|
||||
contractAddress={paymentService['contractAddress']}
|
||||
contractAddress={(paymentService as any)['contractAddress']}
|
||||
owner={owner}
|
||||
isOwner={isOwner}
|
||||
address={address}
|
||||
@@ -239,7 +225,6 @@ export default function AdminPaymentPage() {
|
||||
onNavigate={(v: string) => setView(v as ViewMode)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === 'pricing' && (
|
||||
<PricingView
|
||||
basePrice={basePrice}
|
||||
@@ -256,7 +241,6 @@ export default function AdminPaymentPage() {
|
||||
address={address}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === 'tokens' && (
|
||||
<TokensView
|
||||
tokens={tokens}
|
||||
@@ -277,7 +261,6 @@ export default function AdminPaymentPage() {
|
||||
address={address}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === 'tiers' && (
|
||||
<TiersView
|
||||
tiers={tiers}
|
||||
@@ -293,19 +276,10 @@ export default function AdminPaymentPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── TX Status ── */}
|
||||
{txStatus && (
|
||||
<div className={`fixed bottom-6 right-6 flex items-center gap-2 px-4 py-3 rounded-xl shadow-lg text-sm animate-fade-in ${
|
||||
txStatus.ok
|
||||
? 'bg-accent-green/10 border border-accent-green/20 text-accent-green'
|
||||
: 'bg-accent-rose/10 border border-accent-rose/20 text-accent-rose'
|
||||
}`}>
|
||||
{txStatus.ok ? <CheckCircle className="w-4 h-4 shrink-0" /> : <XCircle className="w-4 h-4 shrink-0" />}
|
||||
{txStatus.msg}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<TxStatusToast status={txStatus} />
|
||||
</PaymentProvider>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
</PortalLayout>
|
||||
</AuthGuard>
|
||||
);
|
||||
|
||||
@@ -3,85 +3,9 @@ import { describe, it, expect } from 'vitest';
|
||||
/* ── Route matching & Kubo mapping tests ──
|
||||
*
|
||||
* These test the pure functions from route.ts in isolation.
|
||||
* We import the source directly since these functions have no
|
||||
* external dependencies (no fetch, no cookies, no process.env).
|
||||
* They're now exported so we import directly instead of duplicating logic.
|
||||
*/
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
import { matchRoute, mapToKuboAPI } from '../route';
|
||||
|
||||
/* ════════════════════════ matchRoute ════════════════════════ */
|
||||
|
||||
@@ -96,37 +20,24 @@ describe('matchRoute', () => {
|
||||
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', () => {
|
||||
const r = matchRoute(['users'], 'GET');
|
||||
expect(r?.target).toBe('users');
|
||||
expect(r?.target).toBe('user');
|
||||
expect(r?.path).toBe('/users');
|
||||
});
|
||||
|
||||
it('routes users with subpath', () => {
|
||||
const r = matchRoute(['users', 'create'], 'POST');
|
||||
expect(r?.target).toBe('users');
|
||||
expect(r?.target).toBe('user');
|
||||
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', () => {
|
||||
const r = matchRoute(['explorer', 'ls', 'QmTest'], 'GET');
|
||||
expect(r?.target).toBe('ipfs');
|
||||
@@ -212,7 +123,7 @@ describe('mapToKuboAPI', () => {
|
||||
});
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -11,24 +11,18 @@
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
/* ── Backend targets ── */
|
||||
|
||||
const USER_API_BASE = process.env.USER_API_BASE || 'http://192.168.1.176:8444';
|
||||
const ADMIN_KEY = process.env.USER_API_ADMIN_KEY || 'maos-admin-2024';
|
||||
|
||||
/* ── Route matching ── */
|
||||
|
||||
interface RouteMatch {
|
||||
target: 'user' | 'ipfs' | 'health';
|
||||
path: string; // path relative to the backend
|
||||
method: string;
|
||||
}
|
||||
|
||||
function matchRoute(path: string[], method: string): RouteMatch | null {
|
||||
export function matchRoute(path: string[], method: string): RouteMatch | null {
|
||||
if (!path || path.length === 0) return null;
|
||||
|
||||
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 ── */
|
||||
|
||||
export async function GET(
|
||||
@@ -282,74 +107,3 @@ export async function DELETE(
|
||||
return NextResponse.json({ error: 'Method not allowed' }, { status: 405 });
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Health check ── */
|
||||
|
||||
async function handleHealth(req: NextRequest): Promise<NextResponse> {
|
||||
// Check User API reachability
|
||||
let userApiOk = false;
|
||||
let userApiMsg = 'unknown';
|
||||
try {
|
||||
const r = await fetch(`${USER_API_BASE}/users`, {
|
||||
headers: { 'X-Admin-Key': ADMIN_KEY },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (r.ok) {
|
||||
userApiOk = true;
|
||||
userApiMsg = 'connected';
|
||||
} else {
|
||||
userApiMsg = `status ${r.status}`;
|
||||
}
|
||||
} catch (e: any) {
|
||||
userApiMsg = e.message?.substring(0, 60) || 'error';
|
||||
}
|
||||
|
||||
// Check Kubo API reachability
|
||||
let kuboApiOk = false;
|
||||
let kuboApiMsg = 'not configured';
|
||||
const kuboSvc = process.env.KUBO_API_URL;
|
||||
const kuboAuth = process.env.KUBO_BASIC_AUTH;
|
||||
if (kuboSvc) {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (kuboAuth) {
|
||||
headers['Authorization'] = 'Basic ' + Buffer.from(kuboAuth).toString('base64');
|
||||
}
|
||||
const r = await fetch(kuboSvc.replace(/\/+$/, '') + '/api/v0/version', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (r.ok) {
|
||||
const data = await r.json();
|
||||
kuboApiOk = true;
|
||||
kuboApiMsg = `v${data.Version || 'unknown'}`;
|
||||
} else {
|
||||
kuboApiMsg = `status ${r.status}`;
|
||||
}
|
||||
} catch (e: any) {
|
||||
kuboApiMsg = e.message?.substring(0, 60) || 'error';
|
||||
}
|
||||
}
|
||||
|
||||
const overall = userApiOk || kuboApiOk ? 'ok' : 'degraded';
|
||||
|
||||
return NextResponse.json({
|
||||
status: overall,
|
||||
userApi: userApiMsg,
|
||||
kuboApi: kuboApiMsg,
|
||||
mode: process.env.KUBO_API_URL ? 'proxy' : 'minimal',
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Helpers ── */
|
||||
|
||||
async function proxyResult(res: Response): Promise<NextResponse> {
|
||||
const text = await res.text();
|
||||
return new NextResponse(text, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface RouteMatch {
|
||||
target: 'user' | 'ipfs' | 'health';
|
||||
path: string; // path relative to the backend
|
||||
method: string;
|
||||
}
|
||||
@@ -5,11 +5,10 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { userApiBase } from '@/lib/config';
|
||||
|
||||
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) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
@@ -19,7 +18,7 @@ export async function POST(req: NextRequest) {
|
||||
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',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ address: address.toLowerCase() }),
|
||||
@@ -28,9 +27,10 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const data = await res.json();
|
||||
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(
|
||||
{ error: e.message || 'Challenge failed' },
|
||||
{ error: msg || 'Challenge failed' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,11 +6,10 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createSessionJWT, cookieOptions } from '@/lib/auth-server';
|
||||
import { userApiBase } from '@/lib/config';
|
||||
|
||||
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) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
@@ -24,7 +23,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
// Proxy login to Python User API
|
||||
const res = await fetch(`${USER_API_BASE}/auth/login`, {
|
||||
const res = await fetch(`${userApiBase()}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -48,7 +47,7 @@ export async function POST(req: NextRequest) {
|
||||
// Determine role — Python backend returns role info via /auth/verify
|
||||
let role: 'admin' | 'user' = 'user';
|
||||
try {
|
||||
const verifyRes = await fetch(`${USER_API_BASE}/auth/verify`, {
|
||||
const verifyRes = await fetch(`${userApiBase()}/auth/verify`, {
|
||||
headers: { 'X-Session-Token': pythonSessionToken },
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
@@ -76,9 +75,10 @@ export async function POST(req: NextRequest) {
|
||||
response.cookies.set('ipfs-portal-session', jwt, cookieOptions());
|
||||
|
||||
return response;
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return NextResponse.json(
|
||||
{ error: e.message || 'Login failed' },
|
||||
{ error: msg || 'Login failed' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
/* ── POST /api/auth/logout ──
|
||||
*
|
||||
* 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 { NextResponse } from 'next/server';
|
||||
import { verifySessionJWT, SESSION_COOKIE } from '@/lib/auth-server';
|
||||
import { userApiBase } from '@/lib/config';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const USER_API_BASE = process.env.USER_API_BASE || 'http://192.168.1.176:8444';
|
||||
|
||||
export async function POST() {
|
||||
const cookieStore = await cookies();
|
||||
const jwt = cookieStore.get(SESSION_COOKIE)?.value;
|
||||
@@ -27,16 +27,17 @@ export async function POST() {
|
||||
// Notify Python backend (best effort)
|
||||
if (sessionToken) {
|
||||
try {
|
||||
await fetch(`${USER_API_BASE}/auth/logout`, {
|
||||
await fetch(`${userApiBase()}/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Session-Token': sessionToken },
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
} catch {
|
||||
// Non-critical
|
||||
} catch (e) {
|
||||
console.warn('[logout] Python backend unreachable:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Wipe cookie regardless
|
||||
const response = NextResponse.json({ authenticated: false });
|
||||
response.cookies.set(SESSION_COOKIE, '', {
|
||||
httpOnly: true,
|
||||
|
||||
@@ -6,9 +6,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { verifySessionJWT, SESSION_COOKIE } from '@/lib/auth-server';
|
||||
|
||||
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';
|
||||
import { userApiBase, userApiAdminKey } from '@/lib/config';
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
const res = await fetch(`${USER_API_BASE}/auth/password`, {
|
||||
const res = await fetch(`${userApiBase()}/auth/password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Admin-Key': ADMIN_KEY },
|
||||
headers: { 'Content-Type': 'application/json', 'X-Admin-Key': userApiAdminKey() },
|
||||
body: JSON.stringify({
|
||||
address: session.address,
|
||||
currentPassword,
|
||||
@@ -52,7 +50,8 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ error: e.message || 'Password change failed' }, { status: 500 });
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return NextResponse.json({ error: msg || 'Password change failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
* Alternatief voor WebSocket (werkt door proxy heen).
|
||||
*/
|
||||
|
||||
import { kuboApiUrl, kuboBasicAuth } from '@/lib/config';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
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
|
||||
|
||||
/* ── Helpers ── */
|
||||
@@ -23,13 +23,15 @@ function encodeSSE(event: string, data: unknown): string {
|
||||
}
|
||||
|
||||
async function kuboFetch(path: string): Promise<any> {
|
||||
if (!KUBO_API) return null;
|
||||
const api = kuboApiUrl();
|
||||
if (!api) return null;
|
||||
try {
|
||||
const url = new URL(KUBO_API);
|
||||
const url = new URL(api);
|
||||
url.pathname = path;
|
||||
const headers: Record<string, string> = {};
|
||||
if (KUBO_AUTH) {
|
||||
headers['Authorization'] = 'Basic ' + Buffer.from(KUBO_AUTH).toString('base64');
|
||||
const auth = kuboBasicAuth();
|
||||
if (auth) {
|
||||
headers['Authorization'] = 'Basic ' + Buffer.from(auth).toString('base64');
|
||||
}
|
||||
const res = await fetch(url.toString(), {
|
||||
method: 'POST',
|
||||
@@ -43,17 +45,6 @@ async function kuboFetch(path: string): Promise<any> {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBWStats() {
|
||||
const raw = await kuboFetch('/api/v0/bw/stats');
|
||||
if (!raw) return null;
|
||||
return {
|
||||
totalIn: raw.TotalIn ?? 0,
|
||||
totalOut: raw.TotalOut ?? 0,
|
||||
rateIn: raw.RateIn ?? 0,
|
||||
rateOut: raw.RateOut ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchPeerCount() {
|
||||
const raw = await kuboFetch('/api/v0/swarm/peers');
|
||||
if (!raw || !raw.Peers) return 0;
|
||||
@@ -81,7 +72,6 @@ export async function GET(req: Request): Promise<Response> {
|
||||
// Send initial connection event
|
||||
controller.enqueue(new TextEncoder().encode(encodeSSE('connected', { ts: Date.now() })));
|
||||
|
||||
let bwPrev = { totalIn: 0, totalOut: 0 };
|
||||
let lastRepoPoll = 0;
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
@@ -91,25 +81,6 @@ export async function GET(req: Request): Promise<Response> {
|
||||
}
|
||||
|
||||
try {
|
||||
// Bandwidth (elke poll)
|
||||
const bw = await fetchBWStats();
|
||||
if (bw) {
|
||||
const deltaIn = bw.totalIn - bwPrev.totalIn;
|
||||
const deltaOut = bw.totalOut - bwPrev.totalOut;
|
||||
bwPrev = { totalIn: bw.totalIn, totalOut: bw.totalOut };
|
||||
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(encodeSSE('bandwidth', {
|
||||
totalIn: bw.totalIn,
|
||||
totalOut: bw.totalOut,
|
||||
rateIn: bw.rateIn,
|
||||
rateOut: bw.rateOut,
|
||||
deltaIn: Math.max(0, deltaIn),
|
||||
deltaOut: Math.max(0, deltaOut),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// Peer count (elke poll)
|
||||
const peerCount = await fetchPeerCount();
|
||||
controller.enqueue(
|
||||
|
||||
@@ -14,18 +14,12 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createPublicClient, http, type Address, type Hash, type Chain } from 'viem';
|
||||
import { IPFS_PORTAL_PAYMENT_ABI } from '@/lib/payment';
|
||||
import { createPublicClient, http, type Address, type Hash } from 'viem';
|
||||
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 CONTRACT_ADDRESS = (process.env.PAYMENT_CONTRACT_ADDRESS || '0xCBc6b8aeea129c206F4836799621C833Bf8B9BDe') as Address;
|
||||
|
||||
const zkSyncLocal: Chain = {
|
||||
id: 270,
|
||||
name: 'zkSync Local',
|
||||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||
rpcUrls: { default: { http: [RPC_URL] } },
|
||||
};
|
||||
const RPC_URL = zkSyncRpcUrl();
|
||||
const CONTRACT_ADDRESS = paymentContractAddress() as Address;
|
||||
|
||||
const client = createPublicClient({
|
||||
chain: zkSyncLocal,
|
||||
@@ -117,11 +111,12 @@ export async function GET(req: NextRequest) {
|
||||
})),
|
||||
verified: matchingUpload !== null,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('[payment/verify] Error:', err.message);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error('[payment/verify] Error:', msg);
|
||||
return NextResponse.json({
|
||||
error: 'Verification failed',
|
||||
detail: err.message?.substring(0, 200),
|
||||
detail: msg.substring(0, 200),
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -159,12 +154,13 @@ export async function POST(req: NextRequest) {
|
||||
gasUsed: receipt.gasUsed?.toString(),
|
||||
effectiveGasPrice: receipt.effectiveGasPrice?.toString(),
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('[payment/verify/tx] Error:', err.message);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error('[payment/verify/tx] Error:', msg);
|
||||
return NextResponse.json({
|
||||
confirmed: false,
|
||||
error: 'Verification failed',
|
||||
detail: err.message?.substring(0, 200),
|
||||
detail: msg.substring(0, 200),
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
Fingerprint,
|
||||
} from 'lucide-react';
|
||||
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
import { formatRepoSize, formatBandwidthMb } from './helpers';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { data, isValidating } = useDashboard();
|
||||
const realtime = useRealtime();
|
||||
@@ -39,9 +42,9 @@ export default function DashboardPage() {
|
||||
|
||||
const loading = isValidating && !data;
|
||||
|
||||
const repoSizeGb = repo ? (repo.repoSize / 1e9).toFixed(2) : '—';
|
||||
const bwIn = bw ? (bw.totalIn / 1e6).toFixed(1) : '—';
|
||||
const bwOut = bw ? (bw.totalOut / 1e6).toFixed(1) : '—';
|
||||
const repoSizeGb = repo ? formatRepoSize(repo.repoSize) : '—';
|
||||
const bwIn = bw ? formatBandwidthMb(bw.totalIn) : '—';
|
||||
const bwOut = bw ? formatBandwidthMb(bw.totalOut) : '—';
|
||||
|
||||
// Peer count from SSE (live) or SWR
|
||||
const peerCount = realtime.peers?.count ?? peers.length;
|
||||
@@ -57,6 +60,7 @@ export default function DashboardPage() {
|
||||
|
||||
return (
|
||||
<PortalLayout>
|
||||
<ErrorBoundary label="Dashboard">
|
||||
{/* Page header */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
@@ -258,6 +262,7 @@ export default function DashboardPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
</PortalLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import type { IPFSEntry } from '@/lib/api';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { explorerLs, type IPFSEntry } from '@/lib/api/explorer';
|
||||
import FileIcon from './FileIcon';
|
||||
import { CheckCircle, Download } from 'lucide-react';
|
||||
import { ChevronRight, CheckCircle, Download, Loader2 } from 'lucide-react';
|
||||
import { truncateCid } from '@/lib/helpers';
|
||||
import { downloadFile } from '@/lib/download';
|
||||
|
||||
@@ -34,6 +35,160 @@ function SkeletonRow() {
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Recursive tree row ── */
|
||||
|
||||
interface TreeRowProps {
|
||||
entry: IPFSEntry;
|
||||
depth: number;
|
||||
onNavigate: (cid: string, name: string) => void;
|
||||
onPreview: (cid: string) => void;
|
||||
pins: string[];
|
||||
subdirEntries: Record<string, IPFSEntry[]>;
|
||||
loadingSubdirs: Record<string, boolean>;
|
||||
expandedDirs: Set<string>;
|
||||
onToggle: (cid: string) => void;
|
||||
}
|
||||
|
||||
function TreeRow({
|
||||
entry,
|
||||
depth,
|
||||
onNavigate,
|
||||
onPreview,
|
||||
pins,
|
||||
subdirEntries,
|
||||
loadingSubdirs,
|
||||
expandedDirs,
|
||||
onToggle,
|
||||
}: TreeRowProps) {
|
||||
const isDir = entry.type === 'dir';
|
||||
const isExpanded = expandedDirs.has(entry.hash);
|
||||
const isLoading = loadingSubdirs[entry.hash] ?? false;
|
||||
const children = subdirEntries[entry.hash];
|
||||
const isPinned = pins.includes(entry.hash);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-800/30 transition-colors cursor-pointer"
|
||||
style={{ paddingLeft: `${16 + depth * 20}px` }}
|
||||
onClick={() =>
|
||||
isDir
|
||||
? onNavigate(entry.hash, entry.name)
|
||||
: onPreview(entry.hash)
|
||||
}
|
||||
>
|
||||
{/* Chevron / spacer column */}
|
||||
{isDir ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggle(entry.hash);
|
||||
}}
|
||||
className="p-0.5 rounded hover:bg-surface-700/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-blue shrink-0"
|
||||
aria-label={isExpanded ? `Collapse ${entry.name}` : `Expand ${entry.name}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-3.5 h-3.5 text-surface-500 animate-spin" />
|
||||
) : (
|
||||
<ChevronRight
|
||||
className={`w-3.5 h-3.5 text-surface-500 transition-transform duration-200 ${
|
||||
isExpanded ? 'rotate-90' : ''
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-4 shrink-0" />
|
||||
)}
|
||||
|
||||
<FileIcon name={entry.name} type={entry.type} className="w-4 h-4 shrink-0" />
|
||||
|
||||
<span className="flex-1 text-sm text-surface-200 truncate min-w-0">
|
||||
{entry.name}
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-surface-500 w-12 text-center shrink-0 hidden sm:block">
|
||||
{entry.type}
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-surface-500 w-16 text-right shrink-0 hidden md:block">
|
||||
{formatSize(entry.size)}
|
||||
</span>
|
||||
|
||||
<code className="text-xs text-surface-500 w-20 text-right shrink-0 hidden lg:block font-mono">
|
||||
{truncateCid(entry.hash, 6)}
|
||||
</code>
|
||||
|
||||
<div className="w-20 flex items-center justify-center gap-1 shrink-0">
|
||||
{entry.type === 'file' && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
downloadFile(entry.hash, entry.name);
|
||||
}}
|
||||
className="p-1 rounded-md hover:bg-surface-700/50 text-surface-500 hover:text-accent-blue transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-blue"
|
||||
aria-label={`Download ${entry.name}`}
|
||||
title={`Download ${entry.name}`}
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{isPinned && (
|
||||
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded sub-tree */}
|
||||
{isExpanded && children !== undefined && (
|
||||
<div className="divide-y divide-surface-800/50">
|
||||
{children.length === 0 ? (
|
||||
<div
|
||||
className="flex items-center px-5 py-2 text-xs text-surface-500 italic"
|
||||
style={{ paddingLeft: `${20 + (depth + 1) * 20}px` }}
|
||||
>
|
||||
empty
|
||||
</div>
|
||||
) : (
|
||||
[...children]
|
||||
.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
.map((child) => (
|
||||
<TreeRow
|
||||
key={child.hash}
|
||||
entry={child}
|
||||
depth={depth + 1}
|
||||
onNavigate={onNavigate}
|
||||
onPreview={onPreview}
|
||||
pins={pins}
|
||||
subdirEntries={subdirEntries}
|
||||
loadingSubdirs={loadingSubdirs}
|
||||
expandedDirs={expandedDirs}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading indicator */}
|
||||
{isExpanded && isLoading && children === undefined && (
|
||||
<div
|
||||
className="flex items-center gap-2 px-5 py-2 text-xs text-surface-500"
|
||||
style={{ paddingLeft: `${20 + (depth + 1) * 20}px` }}
|
||||
>
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main component ── */
|
||||
|
||||
export default function DirectoryListing({
|
||||
entries,
|
||||
loading,
|
||||
@@ -41,6 +196,41 @@ export default function DirectoryListing({
|
||||
onPreview,
|
||||
pins,
|
||||
}: DirectoryListingProps) {
|
||||
const [subdirEntries, setSubdirEntries] = useState<Record<string, IPFSEntry[]>>({});
|
||||
const [loadingSubdirs, setLoadingSubdirs] = useState<Record<string, boolean>>({});
|
||||
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
|
||||
|
||||
const handleToggle = useCallback(async (cid: string) => {
|
||||
setExpandedDirs((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (prev.has(cid)) {
|
||||
next.delete(cid);
|
||||
} else {
|
||||
next.add(cid);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
// Only fetch if not already cached
|
||||
setSubdirEntries((prev) => {
|
||||
if (prev[cid] !== undefined) return prev; // already cached
|
||||
// Trigger async fetch — use a microtask to avoid set-in-set
|
||||
void (async () => {
|
||||
setLoadingSubdirs((l) => ({ ...l, [cid]: true }));
|
||||
try {
|
||||
const result = await explorerLs(cid);
|
||||
setSubdirEntries((e) => ({ ...e, [cid]: result }));
|
||||
} catch (err) {
|
||||
console.error('[DirectoryListing] Failed to list subdirectory:', err);
|
||||
setSubdirEntries((e) => ({ ...e, [cid]: [] }));
|
||||
} finally {
|
||||
setLoadingSubdirs((l) => ({ ...l, [cid]: false }));
|
||||
}
|
||||
})();
|
||||
return prev; // unchanged here, effect will update
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="glass rounded-xl overflow-hidden divide-y divide-surface-800/50">
|
||||
@@ -83,57 +273,20 @@ export default function DirectoryListing({
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-surface-800/50">
|
||||
{sorted.map((entry) => {
|
||||
const isPinned = pins.includes(entry.hash);
|
||||
return (
|
||||
<div
|
||||
{sorted.map((entry) => (
|
||||
<TreeRow
|
||||
key={entry.hash}
|
||||
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-800/30 transition-colors cursor-pointer"
|
||||
onClick={() =>
|
||||
entry.type === 'dir'
|
||||
? onNavigate(entry.hash, entry.name)
|
||||
: onPreview(entry.hash)
|
||||
}
|
||||
>
|
||||
<FileIcon name={entry.name} type={entry.type} className="w-4 h-4 shrink-0" />
|
||||
|
||||
<span className="flex-1 text-sm text-surface-200 truncate min-w-0">
|
||||
{entry.name}
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-surface-500 w-12 text-center shrink-0 hidden sm:block">
|
||||
{entry.type}
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-surface-500 w-16 text-right shrink-0 hidden md:block">
|
||||
{formatSize(entry.size)}
|
||||
</span>
|
||||
|
||||
<code className="text-xs text-surface-500 w-20 text-right shrink-0 hidden lg:block font-mono">
|
||||
{truncateCid(entry.hash, 6)}
|
||||
</code>
|
||||
|
||||
<div className="w-20 flex items-center justify-center gap-1 shrink-0">
|
||||
{entry.type === 'file' && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
downloadFile(entry.hash, entry.name);
|
||||
}}
|
||||
className="p-1 rounded-md hover:bg-surface-700/50 text-surface-500 hover:text-accent-blue transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-blue"
|
||||
aria-label={`Download ${entry.name}`}
|
||||
title={`Download ${entry.name}`}
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{isPinned && (
|
||||
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
entry={entry}
|
||||
depth={0}
|
||||
onNavigate={onNavigate}
|
||||
onPreview={onPreview}
|
||||
pins={pins}
|
||||
subdirEntries={subdirEntries}
|
||||
loadingSubdirs={loadingSubdirs}
|
||||
expandedDirs={expandedDirs}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
FileText,
|
||||
Code,
|
||||
Code2,
|
||||
Image,
|
||||
File,
|
||||
FileArchive,
|
||||
FileAudio,
|
||||
FileCode,
|
||||
FileImage,
|
||||
FileJson,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
FileVideo,
|
||||
Folder,
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -15,34 +19,38 @@ interface FileIconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ext = (name: string): string =>
|
||||
name.split('.').pop()?.toLowerCase() ?? '';
|
||||
|
||||
const IMAGE_EXTS = new Set([
|
||||
'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico',
|
||||
]);
|
||||
const VIDEO_EXTS = new Set(['mp4', 'webm', 'mov', 'avi', 'mkv']);
|
||||
const AUDIO_EXTS = new Set(['mp3', 'wav', 'ogg', 'flac', 'm4a']);
|
||||
const CODE_EXTS = new Set([
|
||||
'js', 'ts', 'jsx', 'tsx', 'py', 'go', 'rs', 'css', 'html',
|
||||
'xml', 'yaml', 'yml', 'sh', 'bash',
|
||||
]);
|
||||
const ARCHIVE_EXTS = new Set(['zip', 'tar', 'gz', 'rar', '7z']);
|
||||
const SHEET_EXTS = new Set(['csv', 'xls', 'xlsx']);
|
||||
const TEXT_EXTS = new Set(['txt', 'md', 'log']);
|
||||
|
||||
export default function FileIcon({ name, type, className = 'w-4 h-4' }: FileIconProps) {
|
||||
if (type === 'dir') {
|
||||
return <Folder className={`${className} text-accent-cyan`} />;
|
||||
}
|
||||
|
||||
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
||||
const e = ext(name);
|
||||
|
||||
if (IMAGE_EXTS.has(e)) return <FileImage className={`${className} text-surface-400`} />;
|
||||
if (VIDEO_EXTS.has(e)) return <FileVideo className={`${className} text-surface-400`} />;
|
||||
if (AUDIO_EXTS.has(e)) return <FileAudio className={`${className} text-surface-400`} />;
|
||||
if (e === 'pdf') return <FileText className={`${className} text-accent-rose`} />; // FilePdf niet beschikbaar in deze versie
|
||||
if (e === 'json') return <FileJson className={`${className} text-surface-400`} />;
|
||||
if (CODE_EXTS.has(e)) return <FileCode className={`${className} text-surface-400`} />;
|
||||
if (ARCHIVE_EXTS.has(e)) return <FileArchive className={`${className} text-surface-400`} />;
|
||||
if (SHEET_EXTS.has(e)) return <FileSpreadsheet className={`${className} text-surface-400`} />;
|
||||
if (TEXT_EXTS.has(e)) return <FileText className={`${className} text-surface-400`} />;
|
||||
|
||||
switch (ext) {
|
||||
case 'md':
|
||||
return <FileText className={`${className} text-surface-400`} />;
|
||||
case 'json':
|
||||
return <Code className={`${className} text-surface-400`} />;
|
||||
case 'js':
|
||||
case 'ts':
|
||||
case 'py':
|
||||
case 'css':
|
||||
case 'html':
|
||||
return <Code2 className={`${className} text-surface-400`} />;
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
case 'png':
|
||||
case 'gif':
|
||||
case 'webp':
|
||||
case 'svg':
|
||||
return <Image className={`${className} text-surface-400`} />;
|
||||
case 'pdf':
|
||||
return <File className={`${className} text-surface-400`} />;
|
||||
default:
|
||||
return <File className={`${className} text-surface-400`} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { X, Download } from 'lucide-react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { X, Download, Maximize2 } from 'lucide-react';
|
||||
import { getSettings } from '@/lib/storage';
|
||||
import { gatewayUrl } from '@/lib/helpers';
|
||||
|
||||
interface FilePreviewProps {
|
||||
cid: string;
|
||||
@@ -10,12 +13,18 @@ interface FilePreviewProps {
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
function detectFileType(filename: string | undefined): 'image' | 'markdown' | 'json' | 'text' | 'other' {
|
||||
function detectFileType(
|
||||
filename: string | undefined,
|
||||
): 'image' | 'markdown' | 'json' | 'text' | 'video' | 'audio' | 'pdf' | 'html' | 'other' {
|
||||
const ext = filename?.split('.').pop()?.toLowerCase() ?? '';
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) return 'image';
|
||||
if (ext === 'md') return 'markdown';
|
||||
if (ext === 'json') return 'json';
|
||||
if (['txt', 'js', 'ts', 'py', 'css', 'html', 'sh', 'yaml', 'yml', 'toml', 'xml'].includes(ext)) return 'text';
|
||||
if (['txt', 'js', 'ts', 'py', 'css', 'sh', 'yaml', 'yml', 'toml', 'xml'].includes(ext)) return 'text';
|
||||
if (['mp4', 'webm', 'mov', 'avi', 'mkv'].includes(ext)) return 'video';
|
||||
if (['mp3', 'wav', 'ogg', 'flac', 'm4a'].includes(ext)) return 'audio';
|
||||
if (ext === 'pdf') return 'pdf';
|
||||
if (['html', 'htm'].includes(ext)) return 'html';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
@@ -47,7 +56,8 @@ function renderJson(text: string): string {
|
||||
const parsed = JSON.parse(text);
|
||||
const syntax = JSON.stringify(parsed, null, 2);
|
||||
return syntax;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error('[FilePreview] JSON parse failed in renderJson:', err);
|
||||
return text;
|
||||
}
|
||||
}
|
||||
@@ -56,13 +66,18 @@ function JsonDisplay({ text }: { text: string }) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
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>;
|
||||
}
|
||||
|
||||
const formatted = JSON.stringify(parsed, null, 2);
|
||||
const escaped = formatted
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
const colored = formatted.replace(
|
||||
const colored = escaped.replace(
|
||||
/("(?:\\.|[^"\\])*")\s*:/g,
|
||||
'<span class="text-accent-cyan">$1</span>:',
|
||||
).replace(
|
||||
@@ -89,8 +104,146 @@ function JsonDisplay({ text }: { text: string }) {
|
||||
|
||||
export default function FilePreview({ cid, content, loading, onClose, filename }: FilePreviewProps) {
|
||||
const fileType = detectFileType(filename);
|
||||
const [fullScreen, setFullScreen] = useState(false);
|
||||
|
||||
const handleEscape = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setFullScreen(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (fullScreen) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}
|
||||
}, [fullScreen, handleEscape]);
|
||||
|
||||
const renderPreview = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="animate-pulse space-y-2">
|
||||
<div className="h-3 rounded bg-surface-700 w-full" />
|
||||
<div className="h-3 rounded bg-surface-700 w-5/6" />
|
||||
<div className="h-3 rounded bg-surface-700 w-4/6" />
|
||||
<div className="h-3 rounded bg-surface-700 w-3/6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
switch (fileType) {
|
||||
case 'image':
|
||||
return (
|
||||
<div className="flex items-center justify-center bg-surface-900 rounded-lg p-2">
|
||||
<img
|
||||
src={gatewayUrl(cid)}
|
||||
alt={filename ?? 'IPFS content'}
|
||||
className={`max-w-full ${fullScreen ? 'max-h-[85vh]' : 'max-h-96'} rounded object-contain`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case 'markdown':
|
||||
if (!content) return null;
|
||||
return (
|
||||
<div
|
||||
className={`prose prose-invert max-w-none text-sm text-surface-300 ${fullScreen ? 'h-[75vh]' : 'max-h-96'} overflow-y-auto whitespace-pre-wrap`}
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }}
|
||||
/>
|
||||
);
|
||||
case 'json':
|
||||
if (!content) return null;
|
||||
return (
|
||||
<div className={`${fullScreen ? 'h-[75vh]' : 'max-h-96'} overflow-y-auto`}>
|
||||
<JsonDisplay text={content} />
|
||||
</div>
|
||||
);
|
||||
case 'text':
|
||||
if (!content) return null;
|
||||
return (
|
||||
<pre className={`text-sm font-mono whitespace-pre-wrap text-surface-300 ${fullScreen ? 'h-[75vh]' : 'max-h-96'} overflow-y-auto`}>
|
||||
{content}
|
||||
</pre>
|
||||
);
|
||||
case 'video':
|
||||
return (
|
||||
<div className="flex items-center justify-center bg-surface-900 rounded-lg p-2">
|
||||
<video
|
||||
controls
|
||||
className={`max-w-full ${fullScreen ? 'max-h-[85vh]' : 'max-h-96'} rounded-lg`}
|
||||
src={gatewayUrl(cid)}
|
||||
>
|
||||
Your browser does not support the video element.
|
||||
</video>
|
||||
</div>
|
||||
);
|
||||
case 'audio':
|
||||
return (
|
||||
<div className="flex items-center justify-center bg-surface-900 rounded-lg p-4">
|
||||
<audio controls className="w-full" src={gatewayUrl(cid)}>
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</div>
|
||||
);
|
||||
case 'pdf':
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<embed
|
||||
src={gatewayUrl(cid)}
|
||||
type="application/pdf"
|
||||
className={`w-full ${fullScreen ? 'h-[80vh]' : 'h-96'} rounded-lg`}
|
||||
/>
|
||||
<p className="text-xs text-surface-500">
|
||||
PDF viewer not available?{' '}
|
||||
<a
|
||||
href={gatewayUrl(cid)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-brand-400 underline hover:text-brand-300"
|
||||
>
|
||||
Download PDF
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
case 'html':
|
||||
if (content) {
|
||||
return (
|
||||
<iframe
|
||||
sandbox="allow-scripts"
|
||||
className={`w-full ${fullScreen ? 'h-[80vh]' : 'h-96'} rounded-lg bg-white`}
|
||||
srcDoc={content}
|
||||
title="HTML preview"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<iframe
|
||||
sandbox="allow-scripts"
|
||||
className={`w-full ${fullScreen ? 'h-[80vh]' : 'h-96'} rounded-lg bg-white`}
|
||||
src={gatewayUrl(cid)}
|
||||
title="HTML preview"
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<p className="text-sm text-surface-500 mb-4">
|
||||
Preview not available for this file type
|
||||
</p>
|
||||
<a
|
||||
href={gatewayUrl(cid)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-surface-800 text-sm text-surface-200 hover:bg-surface-700 transition-colors"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Download file
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="glass rounded-xl p-5 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@@ -102,6 +255,16 @@ export default function FilePreview({ cid, content, loading, onClose, filename }
|
||||
{fileType}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{fileType !== 'other' && (
|
||||
<button
|
||||
onClick={() => setFullScreen((v) => !v)}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors"
|
||||
title="Full screen"
|
||||
>
|
||||
<Maximize2 className="w-4 h-4 text-surface-400" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors"
|
||||
@@ -109,52 +272,29 @@ export default function FilePreview({ cid, content, loading, onClose, filename }
|
||||
<X className="w-4 h-4 text-surface-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<div className="animate-pulse space-y-2">
|
||||
<div className="h-3 rounded bg-surface-700 w-full" />
|
||||
<div className="h-3 rounded bg-surface-700 w-5/6" />
|
||||
<div className="h-3 rounded bg-surface-700 w-4/6" />
|
||||
<div className="h-3 rounded bg-surface-700 w-3/6" />
|
||||
{/* Preview content */}
|
||||
{renderPreview()}
|
||||
</div>
|
||||
) : fileType === 'image' ? (
|
||||
<div className="flex items-center justify-center bg-surface-900 rounded-lg p-2">
|
||||
<img
|
||||
src={`https://ipfs.io/ipfs/${cid}`}
|
||||
alt={filename ?? 'IPFS content'}
|
||||
className="max-w-full max-h-96 rounded object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : fileType === 'markdown' && content ? (
|
||||
<div
|
||||
className="prose prose-invert max-w-none text-sm text-surface-300 max-h-96 overflow-y-auto whitespace-pre-wrap"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }}
|
||||
/>
|
||||
) : fileType === 'json' && content ? (
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
<JsonDisplay text={content} />
|
||||
</div>
|
||||
) : fileType === 'text' && content ? (
|
||||
<pre className="text-sm font-mono whitespace-pre-wrap text-surface-300 max-h-96 overflow-y-auto">
|
||||
{content}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<p className="text-sm text-surface-500 mb-4">
|
||||
Preview not available for this file type
|
||||
</p>
|
||||
<a
|
||||
href={`https://ipfs.io/ipfs/${cid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-surface-800 text-sm text-surface-200 hover:bg-surface-700 transition-colors"
|
||||
|
||||
{/* Full-screen overlay */}
|
||||
{fullScreen && (
|
||||
<div className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center p-8 animate-fade-in">
|
||||
<div className="absolute top-4 right-4 z-10">
|
||||
<button
|
||||
onClick={() => setFullScreen(false)}
|
||||
className="p-2 rounded-lg bg-surface-800/80 hover:bg-surface-700 transition-colors"
|
||||
title="Exit full screen"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Download file
|
||||
</a>
|
||||
<X className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="w-full max-w-5xl max-h-full">
|
||||
{renderPreview()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@ export default function GatewayLink({ cid, filename }: GatewayLinkProps) {
|
||||
await navigator.clipboard.writeText(gatewayUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// clipboard write failed silently
|
||||
} catch (err) {
|
||||
console.error('[GatewayLink] Clipboard write failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { CheckCircle, Pin } from 'lucide-react';
|
||||
import { addPin } from '@/lib/api';
|
||||
import { addPin } from '@/lib/api/pins';
|
||||
|
||||
interface PinBadgeProps {
|
||||
cid: string;
|
||||
@@ -18,8 +18,8 @@ export default function PinBadge({ cid, isPinned, onPin }: PinBadgeProps) {
|
||||
try {
|
||||
await addPin(cid);
|
||||
onPin(cid);
|
||||
} catch {
|
||||
// pin failed silently
|
||||
} catch (err) {
|
||||
console.error('[PinBadge] Failed to pin CID:', err);
|
||||
} finally {
|
||||
setPinning(false);
|
||||
}
|
||||
|
||||
@@ -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
@@ -2,47 +2,23 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import PortalLayout from '@/app/layout-portal';
|
||||
import { explorerLs, explorerCat, listPins } from '@/lib/api';
|
||||
import type { IPFSEntry } from '@/lib/api';
|
||||
import { explorerLs, explorerCat, type IPFSEntry } from '@/lib/api/explorer';
|
||||
import { listPins } from '@/lib/api/pins';
|
||||
import CIDInput from './components/CIDInput';
|
||||
import DirectoryListing from './components/DirectoryListing';
|
||||
import FilePreview from './components/FilePreview';
|
||||
import Breadcrumbs from './components/Breadcrumbs';
|
||||
import PinBadge from './components/PinBadge';
|
||||
import GatewayLink from './components/GatewayLink';
|
||||
import SearchBar from '@/components/SearchBar';
|
||||
import SearchBar from '@/components/search';
|
||||
import type { SearchResult } from '@/lib/search-index';
|
||||
import { isTextFile, extractText, addToIndex, clearIndex, getIndexStats } from '@/lib/search-index';
|
||||
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 {
|
||||
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';
|
||||
import { loadRecentCids, saveRecentCid, findFilename, type ExplorerState, type BreadcrumbSegment } from './helpers';
|
||||
|
||||
export default function ExplorerPage() {
|
||||
const [cid, setCid] = useState('');
|
||||
@@ -111,7 +87,8 @@ export default function ExplorerPage() {
|
||||
try {
|
||||
const content = await explorerCat(hash);
|
||||
setPreviewContent(content);
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error('[ExplorerPage] Failed to fetch preview content:', err);
|
||||
setPreviewContent(null);
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
@@ -156,7 +133,8 @@ export default function ExplorerPage() {
|
||||
});
|
||||
indexed++;
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error('[ExplorerPage] Failed to index pin:', err);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
@@ -171,6 +149,7 @@ export default function ExplorerPage() {
|
||||
|
||||
return (
|
||||
<PortalLayout>
|
||||
<ErrorBoundary label="Verkenner">
|
||||
{/* Page header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-white">IPFS Explorer</h1>
|
||||
@@ -234,11 +213,22 @@ export default function ExplorerPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resolving IPNS */}
|
||||
{/* Resolving IPNS — skeleton */}
|
||||
{state === 'resolving' && (
|
||||
<div className="glass rounded-xl p-8 flex flex-col items-center justify-center text-center animate-fade-in">
|
||||
<RefreshCw className="w-5 h-5 text-accent-cyan animate-spin mb-3" />
|
||||
<p className="text-sm text-surface-400">Resolving IPNS name...</p>
|
||||
<div className="glass rounded-xl p-8 animate-fade-in">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -300,10 +290,9 @@ export default function ExplorerPage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
</PortalLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function findFilename(entries: IPFSEntry[], hash: string): string | undefined {
|
||||
return entries.find((e) => e.hash === hash)?.name;
|
||||
}
|
||||
|
||||
|
||||
@@ -138,3 +138,18 @@ html.theme-transitioning *::after {
|
||||
.animate-fade-in { animation: fade-in 0.4s ease-out both; }
|
||||
.animate-pulse-glow { animation: pulse-glow 2s ease-in-out infinite; }
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -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 “{search}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
+52
-355
@@ -2,27 +2,22 @@
|
||||
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
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 { SkeletonTable } from '@/components/Skeleton';
|
||||
import BatchBar from '@/components/BatchBar';
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
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 {
|
||||
Clock, HardDrive, Copy, ExternalLink, Trash2, Search, Upload,
|
||||
Database, Calendar, Filter, X, CheckCircle, Download,
|
||||
Link as LinkIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
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' };
|
||||
}
|
||||
}
|
||||
HistoryHeader,
|
||||
HistorySearch,
|
||||
HistorySkeleton,
|
||||
HistoryEmpty,
|
||||
HistoryTable,
|
||||
HistoryMobileCard,
|
||||
HistoryNoResults,
|
||||
HistoryResultsCount,
|
||||
} from './components';
|
||||
|
||||
/* ════════════════════════════ Page ════════════════════════════ */
|
||||
|
||||
@@ -33,8 +28,6 @@ export default function HistoryPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
const recordKey = (r: UploadRecord) => `${r.cid}||${r.date}`;
|
||||
|
||||
function load() {
|
||||
setHistory(getHistory());
|
||||
setLoading(false);
|
||||
@@ -72,7 +65,7 @@ export default function HistoryPage() {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(key);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
} catch { /* ignore */ }
|
||||
} catch (err) { console.error('[HistoryPage] Clipboard write failed:', err); }
|
||||
}
|
||||
|
||||
/* ── Selection ── */
|
||||
@@ -99,12 +92,7 @@ export default function HistoryPage() {
|
||||
function handleBatchDelete() {
|
||||
const count = selected.size;
|
||||
if (!confirm(`Delete ${count} upload${count !== 1 ? 's' : ''} from history?`)) return;
|
||||
|
||||
const keys = Array.from(selected).map((key) => {
|
||||
const idx = key.lastIndexOf('||');
|
||||
return { cid: key.slice(0, idx), date: key.slice(idx + 2) };
|
||||
});
|
||||
|
||||
const keys = Array.from(selected).map(parseRecordKey);
|
||||
removeMultipleFromHistory(keys);
|
||||
setHistory((prev) => prev.filter((r) => !selected.has(recordKey(r))));
|
||||
setSelected(new Set());
|
||||
@@ -114,354 +102,62 @@ export default function HistoryPage() {
|
||||
/* ── Render ── */
|
||||
return (
|
||||
<PortalLayout>
|
||||
<ErrorBoundary label="HistoryPage">
|
||||
<div className="animate-fade-in">
|
||||
{/* ── Header ── */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Upload History</h1>
|
||||
<p className="text-sm text-surface-400 mt-1">
|
||||
{stats.totalUploads > 0
|
||||
? `${stats.totalUploads} upload${stats.totalUploads !== 1 ? 's' : ''} · ${formatBytes(stats.totalSize)} total · ${stats.uniqueCids} unique file${stats.uniqueCids !== 1 ? 's' : ''}`
|
||||
: 'Track your past uploads'}
|
||||
</p>
|
||||
</div>
|
||||
{history.length > 0 && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-accent-rose/10 text-accent-rose text-sm font-medium hover:bg-accent-rose/20 transition-colors self-start"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Clear History
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Search ── */}
|
||||
{history.length > 0 && (
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-surface-500" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search by file name or CID…"
|
||||
className="w-full pl-10 pr-10 py-2 rounded-lg bg-surface-900 border border-surface-700 text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none focus:border-brand-500"
|
||||
<HistoryHeader
|
||||
totalUploads={stats.totalUploads}
|
||||
totalSize={stats.totalSize}
|
||||
uniqueCids={stats.uniqueCids}
|
||||
onClear={handleClear}
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-surface-700 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4 text-surface-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{history.length > 0 && (
|
||||
<HistorySearch search={search} onChange={setSearch} />
|
||||
)}
|
||||
|
||||
{/* ── Loading skeleton ── */}
|
||||
{loading && (
|
||||
<div className="glass rounded-xl overflow-hidden">
|
||||
<div className="px-1">
|
||||
<SkeletonTable rows={8} cols={3} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{loading && <HistorySkeleton />}
|
||||
|
||||
{/* ── Empty state ── */}
|
||||
{!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>
|
||||
)}
|
||||
{!loading && history.length === 0 && <HistoryEmpty />}
|
||||
|
||||
{/* ── Desktop table ── */}
|
||||
{filtered.length > 0 && (
|
||||
<>
|
||||
{/* Desktop table — hidden on small screens */}
|
||||
<div className="hidden sm:block glass rounded-xl overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-800 text-left text-xs font-medium text-surface-500 uppercase tracking-wider">
|
||||
<th className="px-5 py-3 w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
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"
|
||||
<HistoryTable
|
||||
records={filtered}
|
||||
selected={selected}
|
||||
copied={copied}
|
||||
onToggle={toggleSelection}
|
||||
onToggleAll={toggleAll}
|
||||
allSelected={selected.size === filtered.length}
|
||||
onCopy={copyToClipboard}
|
||||
/>
|
||||
</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">
|
||||
{filtered.map((rec) => {
|
||||
const badge = getMethodBadge(rec.method);
|
||||
const gwLink = gatewayLink(rec.cid);
|
||||
return (
|
||||
<div key={rec.cid + rec.date} className="glass rounded-xl p-4 space-y-3">
|
||||
{/* Checkbox + Name + method badge */}
|
||||
<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={() => toggleSelection(recordKey(rec))}
|
||||
className="rounded border-surface-600 bg-surface-800 text-brand-500 focus:ring-brand-500 shrink-0 mt-0.5"
|
||||
{filtered.map((rec) => (
|
||||
<HistoryMobileCard
|
||||
key={rec.cid + rec.date}
|
||||
rec={rec}
|
||||
selected={selected}
|
||||
copied={copied}
|
||||
onToggle={toggleSelection}
|
||||
onCopy={copyToClipboard}
|
||||
/>
|
||||
<HardDrive className="w-4 h-4 text-surface-500 shrink-0" />
|
||||
<span className="text-sm text-surface-200 truncate" title={rec.name}>
|
||||
{rec.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`shrink-0 px-1.5 py-0.5 rounded text-[10px] font-medium ${badge.bg} ${badge.text}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CID */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="text-xs font-mono text-surface-400 truncate"
|
||||
title={rec.cid}
|
||||
>
|
||||
{truncateCid(rec.cid)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => copyToClipboard(rec.cid, `cid-${rec.cid}`)}
|
||||
className="p-1 rounded-lg hover:bg-surface-700 transition-colors shrink-0"
|
||||
>
|
||||
{copied === `cid-${rec.cid}` ? (
|
||||
<CheckCircle className="w-3 h-3 text-accent-green" />
|
||||
) : (
|
||||
<Copy className="w-3 h-3 text-surface-500" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Size + date */}
|
||||
<div className="flex items-center gap-4 text-xs text-surface-500">
|
||||
<span>{formatBytes(rec.size)}</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{new Date(rec.date).toLocaleDateString()}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{new Date(rec.date).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions row */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
{/* Copy CID */}
|
||||
<button
|
||||
onClick={() => copyToClipboard(rec.cid, `cid-m-${rec.cid}`)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-surface-800/50 hover:bg-surface-700 text-xs text-surface-400 hover:text-surface-200 transition-colors"
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
Copy CID
|
||||
</button>
|
||||
|
||||
{/* Copy link */}
|
||||
<button
|
||||
onClick={() => copyToClipboard(gwLink, `gw-m-${rec.cid}`)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-surface-800/50 hover:bg-surface-700 text-xs text-surface-400 hover:text-surface-200 transition-colors"
|
||||
>
|
||||
<LinkIcon className="w-3 h-3" />
|
||||
Copy Link
|
||||
</button>
|
||||
|
||||
{/* Download */}
|
||||
<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 && (
|
||||
<div className="mt-3 text-center text-xs text-surface-500">
|
||||
Showing {filtered.length} of {history.length} uploads
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
className="ml-2 text-brand-400 hover:text-brand-300 underline transition-colors"
|
||||
>
|
||||
Clear filter
|
||||
</button>
|
||||
</div>
|
||||
<HistoryResultsCount
|
||||
filtered={filtered.length}
|
||||
total={history.length}
|
||||
onClear={() => setSearch('')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── No search results ── */}
|
||||
{!loading && history.length > 0 && filtered.length === 0 && (
|
||||
<div className="glass rounded-xl p-12 text-center">
|
||||
<div className="flex justify-center mb-3">
|
||||
<Search className="w-8 h-8 text-surface-500" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-surface-300 mb-1">No results found</h3>
|
||||
<p className="text-sm text-surface-500">
|
||||
No uploads match “{search}”
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
className="mt-4 text-sm text-brand-400 hover:text-brand-300 underline transition-colors"
|
||||
>
|
||||
Clear search
|
||||
</button>
|
||||
</div>
|
||||
<HistoryNoResults search={search} onClear={() => setSearch('')} />
|
||||
)}
|
||||
{/* ── Batch bar ── */}
|
||||
|
||||
<BatchBar
|
||||
count={selected.size}
|
||||
onClear={() => setSelected(new Set())}
|
||||
@@ -470,12 +166,13 @@ export default function HistoryPage() {
|
||||
key: 'delete',
|
||||
label: 'Delete selected',
|
||||
icon: <Trash2 className="w-3.5 h-3.5" />,
|
||||
variant: 'danger',
|
||||
variant: 'danger' as const,
|
||||
onClick: handleBatchDelete,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
</PortalLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
/* ── IPNS Page Helpers ── */
|
||||
|
||||
export type Status = "idle" | "loading" | "success" | "error";
|
||||
@@ -1,11 +1,13 @@
|
||||
'use client';
|
||||
|
||||
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 { 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() {
|
||||
const [keys, setKeys] = useState<IPNSKey[]>([]);
|
||||
@@ -95,6 +97,7 @@ export default function IPNSPage() {
|
||||
|
||||
return (
|
||||
<PortalLayout>
|
||||
<ErrorBoundary label="IPNS">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
@@ -123,9 +126,8 @@ export default function IPNSPage() {
|
||||
</div>
|
||||
|
||||
{status === 'loading' && (
|
||||
<div className="flex items-center gap-2 text-sm text-surface-400 py-4">
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
Loading keys...
|
||||
<div className="overflow-hidden">
|
||||
<SkeletonTable rows={4} cols={3} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -281,6 +283,7 @@ export default function IPNSPage() {
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
</PortalLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,12 +3,20 @@
|
||||
import PortalSidebar from '@/components/PortalSidebar';
|
||||
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 (
|
||||
<div className="flex min-h-screen">
|
||||
<PortalSidebar />
|
||||
{sidebar ?? <PortalSidebar />}
|
||||
<div className="flex-1 flex flex-col ml-56">
|
||||
<PortalHeader />
|
||||
{header ?? <PortalHeader />}
|
||||
<main className="flex-1 p-6">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,8 +29,8 @@ export default function LoginPage() {
|
||||
await loginWithWallet(address, dw.provider);
|
||||
notify({ type: 'success', title: 'Ingelogd', message: `Wallet ${address.slice(0, 6)}…${address.slice(-4)}` });
|
||||
router.push('/dashboard');
|
||||
} catch (err: any) {
|
||||
notify({ type: 'error', title: 'Login mislukt', message: err.message || 'Onbekende fout' });
|
||||
} catch (err: unknown) {
|
||||
notify({ type: 'error', title: 'Login mislukt', message: err instanceof Error ? err.message : 'Onbekende fout' });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setBusyWallet(null);
|
||||
|
||||
+3
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { connectWallet, signMessage } from '@/lib/wallet';
|
||||
import { checkHealth } from '@/lib/api';
|
||||
import { checkHealth } from '@/lib/api/gateway';
|
||||
import { useRouter } from 'next/navigation';
|
||||
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
|
||||
checkHealth().catch(() => {});
|
||||
router.push('/dashboard');
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Connection failed');
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Connection failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getPeers } from '@/lib/api';
|
||||
import { getPeers } from '@/lib/api/gateway';
|
||||
import PortalLayout from '@/app/layout-portal';
|
||||
import { SkeletonTable } from '@/components/Skeleton';
|
||||
import { Wifi, Globe, Clock } from 'lucide-react';
|
||||
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
|
||||
export default function PeersPage() {
|
||||
const [peers, setPeers] = useState<{ id: string; addr: string; latency: string }[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -15,7 +17,7 @@ export default function PeersPage() {
|
||||
try {
|
||||
const items = await getPeers();
|
||||
setPeers(items);
|
||||
} catch { /* ignore */ }
|
||||
} catch (err) { console.error('[PeersPage] Failed to load peers:', err); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
load();
|
||||
@@ -25,6 +27,7 @@ export default function PeersPage() {
|
||||
|
||||
return (
|
||||
<PortalLayout>
|
||||
<ErrorBoundary label="Peers">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-white">Peers</h1>
|
||||
<p className="text-sm text-surface-400 mt-1">{peers.length} connected peers</p>
|
||||
@@ -59,6 +62,7 @@ export default function PeersPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
</PortalLayout>
|
||||
);
|
||||
}
|
||||
|
||||
+10
-6
@@ -1,13 +1,14 @@
|
||||
'use client';
|
||||
|
||||
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 Link from 'next/link';
|
||||
import { HardDrive, Trash2, Plus, Search, Copy, CheckCircle, ExternalLink, Download } from 'lucide-react';
|
||||
import Skeleton, { SkeletonRow, SkeletonTable } from '@/components/Skeleton';
|
||||
import BatchBar, { type BatchAction } from '@/components/BatchBar';
|
||||
import { useNotify } from '@/lib/notifications';
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
|
||||
export default function PinsPage() {
|
||||
const [pins, setPins] = useState<{ cid: string; name: string; size: number; created: string }[]>([]);
|
||||
@@ -25,7 +26,7 @@ export default function PinsPage() {
|
||||
try {
|
||||
const items = await listPins();
|
||||
setPins(items);
|
||||
} catch { /* ignore */ }
|
||||
} catch (err) { console.error('[PinsPage] Failed to load pins:', err); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
|
||||
@@ -39,14 +40,14 @@ export default function PinsPage() {
|
||||
setNewName('');
|
||||
setShowAdd(false);
|
||||
await load();
|
||||
} catch { /* ignore */ }
|
||||
} catch (err) { console.error('[PinsPage] Failed to add pin:', err); }
|
||||
}
|
||||
|
||||
async function handleRemove(cid: string) {
|
||||
try {
|
||||
await removePin(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) {
|
||||
@@ -54,7 +55,7 @@ export default function PinsPage() {
|
||||
await navigator.clipboard.writeText(cid);
|
||||
setCopied(cid);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
} catch { /* ignore */ }
|
||||
} catch (err) { console.error('[PinsPage] Clipboard write failed:', err); }
|
||||
}
|
||||
|
||||
function toggleSelect(cid: string) {
|
||||
@@ -78,7 +79,8 @@ export default function PinsPage() {
|
||||
try {
|
||||
await removePin(cid);
|
||||
success++;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error('[PinsPage] Batch unpin failed for CID:', err);
|
||||
fail++;
|
||||
}
|
||||
}
|
||||
@@ -98,6 +100,7 @@ export default function PinsPage() {
|
||||
|
||||
return (
|
||||
<PortalLayout>
|
||||
<ErrorBoundary label="Pinnen">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Pins</h1>
|
||||
@@ -225,6 +228,7 @@ export default function PinsPage() {
|
||||
onClear={clearSelection}
|
||||
label="selected"
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</PortalLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,8 +53,8 @@ export default function ProfilePage() {
|
||||
setCurrentPass('');
|
||||
setNewPass('');
|
||||
setConfirmPass('');
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -52,8 +52,8 @@ export default function RegisterPage() {
|
||||
message: `Welkom, ${username.trim()}! Je kunt nu inloggen.`,
|
||||
});
|
||||
router.push('/login');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Registratie mislukt');
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Registratie mislukt');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 1–102400 MB";
|
||||
valid = false;
|
||||
}
|
||||
if (form.refreshInterval < 5 || form.refreshInterval > 300) {
|
||||
errors.refreshInterval = "Must be 5–300 seconds";
|
||||
valid = false;
|
||||
}
|
||||
|
||||
return { valid, errors };
|
||||
}
|
||||
+108
-105
@@ -3,18 +3,16 @@
|
||||
import { useState } from 'react';
|
||||
import { getSettings, saveSettings, resetSettings, type PortalSettings } from '@/lib/storage';
|
||||
import { useTheme } from '@/lib/theme';
|
||||
import { formatBytes } from '@/lib/helpers';
|
||||
import PortalLayout from '@/app/layout-portal';
|
||||
import {
|
||||
Settings, Globe, Server, HardDrive, RefreshCw,
|
||||
Sun, Moon, Save, RotateCcw, Check, AlertTriangle,
|
||||
Globe, Server, HardDrive, RefreshCw, Shield,
|
||||
Sun, Moon,
|
||||
} from 'lucide-react';
|
||||
|
||||
/* ── Helpers ── */
|
||||
|
||||
function formatStorageMB(mb: number): string {
|
||||
if (mb >= 1024) return (mb / 1024).toFixed(0) + ' GB';
|
||||
return mb + ' MB';
|
||||
}
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
import { formatStorageMB, inputCls, validateSettings } from './helpers';
|
||||
import { errorMsg } from './components';
|
||||
import { SettingsActions } from './components';
|
||||
|
||||
/* ════════════════════════════ Page ════════════════════════════ */
|
||||
|
||||
@@ -33,47 +31,19 @@ export default function SettingsPage() {
|
||||
setForm(prev => ({ ...prev, [key]: value }));
|
||||
setSaved(false);
|
||||
setError(null);
|
||||
|
||||
// Clear validation error on change
|
||||
if (validation[key]) {
|
||||
setValidation(prev => ({ ...prev, [key]: null }));
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Validate ── */
|
||||
function validate(): boolean {
|
||||
const errs: Record<string, string | null> = {};
|
||||
let valid = true;
|
||||
|
||||
if (!form.gatewayUrl.startsWith('http://') && !form.gatewayUrl.startsWith('https://')) {
|
||||
errs.gatewayUrl = 'Must start with http:// or https://';
|
||||
valid = false;
|
||||
}
|
||||
if (form.gatewayUrl.length > 500) {
|
||||
errs.gatewayUrl = 'Too long (max 500 chars)';
|
||||
valid = false;
|
||||
}
|
||||
if (form.apiEndpoint && !form.apiEndpoint.startsWith('/') && !form.apiEndpoint.startsWith('http')) {
|
||||
errs.apiEndpoint = 'Must be empty, a path (/…), or a URL';
|
||||
valid = false;
|
||||
}
|
||||
if (form.storageMax < 1 || form.storageMax > 102400) {
|
||||
errs.storageMax = 'Must be 1–102400 MB';
|
||||
valid = false;
|
||||
}
|
||||
if (form.refreshInterval < 5 || form.refreshInterval > 300) {
|
||||
errs.refreshInterval = 'Must be 5–300 seconds';
|
||||
valid = false;
|
||||
}
|
||||
|
||||
setValidation(errs);
|
||||
if (!valid) setError('Fix validation errors before saving');
|
||||
return valid;
|
||||
}
|
||||
|
||||
/* ── Save ── */
|
||||
function handleSave() {
|
||||
if (!validate()) return;
|
||||
const result = validateSettings(form);
|
||||
setValidation(result.errors);
|
||||
if (!result.valid) {
|
||||
setError('Fix validation errors before saving');
|
||||
return;
|
||||
}
|
||||
const merged = saveSettings(form);
|
||||
setForm(merged);
|
||||
setOriginal({ ...merged });
|
||||
@@ -93,18 +63,6 @@ export default function SettingsPage() {
|
||||
setValidation({});
|
||||
}
|
||||
|
||||
/* ── Render helpers ── */
|
||||
|
||||
function inputCls(field: string): string {
|
||||
return `w-full rounded-lg bg-surface-900 border ${validation[field] ? 'border-accent-rose/50' : 'border-surface-700'} px-3 py-2 text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none focus:border-brand-500 focus:ring-1 focus:ring-brand-500 transition-colors`;
|
||||
}
|
||||
|
||||
function errorMsg(field: string) {
|
||||
return validation[field] ? (
|
||||
<p className="mt-1 text-xs text-accent-rose">{validation[field]}</p>
|
||||
) : null;
|
||||
}
|
||||
|
||||
/* ════════════ Sections ════════════ */
|
||||
|
||||
const sections = [
|
||||
@@ -119,9 +77,9 @@ export default function SettingsPage() {
|
||||
value={form.gatewayUrl}
|
||||
onChange={e => update('gatewayUrl', e.target.value)}
|
||||
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">
|
||||
Base URL for IPFS gateway links. Used in dashboard, history, and share links.
|
||||
</p>
|
||||
@@ -139,9 +97,9 @@ export default function SettingsPage() {
|
||||
value={form.apiEndpoint}
|
||||
onChange={e => update('apiEndpoint', e.target.value)}
|
||||
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">
|
||||
Leave empty to use the same origin. Set to a custom URL to proxy through another server.
|
||||
</p>
|
||||
@@ -178,13 +136,93 @@ export default function SettingsPage() {
|
||||
/>
|
||||
<span className="text-xs text-surface-500">MB</span>
|
||||
</div>
|
||||
{errorMsg('storageMax')}
|
||||
{errorMsg('storageMax', validation)}
|
||||
<p className="mt-1.5 text-[11px] text-surface-500">
|
||||
Maximum storage the IPFS node should use. Applied server-side.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Upload Restrictions',
|
||||
icon: Shield,
|
||||
iconColor: 'text-accent-rose',
|
||||
fields: (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-surface-400 mb-1.5 block">Allowed File Extensions</label>
|
||||
<input
|
||||
value={form.allowedFileTypes}
|
||||
onChange={e => update('allowedFileTypes', e.target.value)}
|
||||
placeholder=".jpg,.png,.pdf,.mp4 (leave empty for all)"
|
||||
className={inputCls('allowedFileTypes', validation)}
|
||||
/>
|
||||
{errorMsg('allowedFileTypes', validation)}
|
||||
<p className="mt-1.5 text-[11px] text-surface-500">
|
||||
Comma-separated extensions. Empty = all file types accepted. Extension check is case-insensitive.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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>
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
min={1024}
|
||||
max={1024 * 1024 * 1024}
|
||||
step={1024 * 1024}
|
||||
value={Math.max(1024, form.maxFileSize)}
|
||||
onChange={e => update('maxFileSize', Number(e.target.value))}
|
||||
className="flex-1 accent-brand-500 h-1.5 rounded-full appearance-none bg-surface-700 cursor-pointer"
|
||||
style={{ accentColor: '#14b8a6' }}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1024 * 1024 * 1024}
|
||||
value={form.maxFileSize}
|
||||
onChange={e => update('maxFileSize', Math.max(0, Number(e.target.value) || 0))}
|
||||
className="w-24 rounded-lg bg-surface-900 border border-surface-700 px-2 py-1.5 text-xs text-surface-200 text-center focus:outline-none focus:border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] text-surface-500">
|
||||
Maximum file size in bytes. 0 = unlimited.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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>
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
value={form.maxFiles}
|
||||
onChange={e => update('maxFiles', Number(e.target.value))}
|
||||
className="flex-1 accent-brand-500 h-1.5 rounded-full appearance-none bg-surface-700 cursor-pointer"
|
||||
style={{ accentColor: '#14b8a6' }}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={form.maxFiles}
|
||||
onChange={e => update('maxFiles', Math.max(1, Math.min(100, Number(e.target.value) || 0)))}
|
||||
className="w-16 rounded-lg bg-surface-900 border border-surface-700 px-2 py-1.5 text-xs text-surface-200 text-center focus:outline-none focus:border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] text-surface-500">
|
||||
Maximum number of files allowed in a single upload batch.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Auto-Refresh',
|
||||
icon: RefreshCw,
|
||||
@@ -215,7 +253,7 @@ export default function SettingsPage() {
|
||||
/>
|
||||
<span className="text-xs text-surface-500">sec</span>
|
||||
</div>
|
||||
{errorMsg('refreshInterval')}
|
||||
{errorMsg('refreshInterval', validation)}
|
||||
<p className="mt-1.5 text-[11px] text-surface-500">
|
||||
How often the dashboard auto-refreshes peer/bandwidth data.
|
||||
</p>
|
||||
@@ -255,13 +293,12 @@ export default function SettingsPage() {
|
||||
|
||||
return (
|
||||
<PortalLayout>
|
||||
{/* ── Header ── */}
|
||||
<ErrorBoundary label="Instellingen">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-white">Settings</h1>
|
||||
<p className="text-sm text-surface-400 mt-1">IPFS Portal configuration</p>
|
||||
</div>
|
||||
|
||||
{/* ── Settings grid ── */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
{sections.map((s) => (
|
||||
<div key={s.title} className="glass rounded-xl p-5 animate-fade-in">
|
||||
@@ -274,48 +311,14 @@ export default function SettingsPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Actions + status ── */}
|
||||
<div className="glass rounded-xl p-5 animate-fade-in">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Save */}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!dirty && !error}
|
||||
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-brand-600 hover:bg-brand-500 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
{saved ? (
|
||||
<><Check className="w-4 h-4 text-accent-green" /> Saved!</>
|
||||
) : (
|
||||
<><Save className="w-4 h-4" /> Save Settings</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Reset */}
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-surface-800 hover:bg-surface-700 text-surface-300 text-sm font-medium transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Reset Defaults
|
||||
</button>
|
||||
|
||||
{/* Dirty indicator */}
|
||||
{dirty && !saved && (
|
||||
<span className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-accent-amber/10 text-accent-amber text-xs">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
Unsaved changes
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<span className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-accent-rose/10 text-accent-rose text-xs">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SettingsActions
|
||||
dirty={dirty}
|
||||
saved={saved}
|
||||
error={error}
|
||||
onSave={handleSave}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</PortalLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,16 +4,8 @@ import { type RefObject } from 'react';
|
||||
import {
|
||||
File, CheckCircle, Copy, ExternalLink, Trash2, ArrowUp,
|
||||
} from 'lucide-react';
|
||||
import PaymentPanel from '@/components/PaymentPanel';
|
||||
|
||||
/* ── 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];
|
||||
}
|
||||
import { formatBytes } from '@/lib/helpers';
|
||||
import PaymentPanel from '@/components/payment';
|
||||
|
||||
/* ── Props ── */
|
||||
interface CryptoUploadProps {
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { type DragEvent, type RefObject } from 'react';
|
||||
import { useState, type DragEvent, type RefObject } from 'react';
|
||||
import {
|
||||
Upload, File, CheckCircle, XCircle, Loader2,
|
||||
Copy, ExternalLink, Trash2, Globe,
|
||||
Copy, ExternalLink, Trash2, Globe, AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
|
||||
const MAX_FILES = 20;
|
||||
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB
|
||||
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
||||
|
||||
/* ── Helpers ── */
|
||||
function formatBytes(b: number): string {
|
||||
if (b === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.min(Math.floor(Math.log(b) / Math.log(1024)), units.length - 1);
|
||||
const val = b / Math.pow(1024, i);
|
||||
return (val < 10 ? val.toFixed(1) : Math.round(val)) + ' ' + units[i];
|
||||
}
|
||||
import { formatBytes } from '@/lib/helpers';
|
||||
import { getSettings } from '@/lib/storage';
|
||||
|
||||
export interface FileEntry {
|
||||
id: string;
|
||||
@@ -57,20 +46,80 @@ export default function QuickUpload({
|
||||
onBrowse, onInputChange, onStartUpload, onCopyCid, onCopyShareLink, onClearAll, gatewayLink,
|
||||
}: QuickUploadProps) {
|
||||
|
||||
const s = getSettings();
|
||||
const maxFiles = s.maxFiles;
|
||||
const maxFileSize = s.maxFileSize;
|
||||
|
||||
const allowedExts = s.allowedFileTypes
|
||||
? s.allowedFileTypes.split(',').map(e => e.trim().toLowerCase()).filter(Boolean)
|
||||
: null;
|
||||
|
||||
const [fileTypeError, setFileTypeError] = useState<string | null>(null);
|
||||
|
||||
const doneCount = files.filter(f => f.status === 'done').length;
|
||||
const errorCount = files.filter(f => f.status === 'error').length;
|
||||
const totalCount = files.length;
|
||||
const allDone = files.every(f => f.status === 'done' || f.status === 'error');
|
||||
|
||||
function filterAllowedFiles(fileList: File[]): { accepted: File[]; rejected: string[] } {
|
||||
if (!allowedExts) return { accepted: fileList, rejected: [] };
|
||||
const accepted: File[] = [];
|
||||
const rejected: string[] = [];
|
||||
for (const f of fileList) {
|
||||
const ext = f.name.split('.').pop()?.toLowerCase();
|
||||
if (ext && allowedExts.includes(ext)) {
|
||||
accepted.push(f);
|
||||
} else {
|
||||
rejected.push(f.name);
|
||||
}
|
||||
}
|
||||
return { accepted, rejected };
|
||||
}
|
||||
|
||||
function handleLocalDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
onDragLeave();
|
||||
if (e.dataTransfer.files.length > 0) {
|
||||
const fileArr = Array.from(e.dataTransfer.files);
|
||||
const { accepted, rejected } = filterAllowedFiles(fileArr);
|
||||
if (rejected.length > 0) {
|
||||
setFileTypeError(`${rejected.length} bestand(en) overgeslagen (type niet toegestaan): ${rejected.join(', ')}`);
|
||||
setTimeout(() => setFileTypeError(null), 5000);
|
||||
}
|
||||
if (accepted.length > 0) onAddFiles(accepted);
|
||||
}
|
||||
}
|
||||
|
||||
function handleLocalInputChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
const fileArr = Array.from(e.target.files);
|
||||
const { accepted, rejected } = filterAllowedFiles(fileArr);
|
||||
if (rejected.length > 0) {
|
||||
setFileTypeError(`${rejected.length} bestand(en) overgeslagen (type niet toegestaan): ${rejected.join(', ')}`);
|
||||
setTimeout(() => setFileTypeError(null), 5000);
|
||||
}
|
||||
if (accepted.length > 0) onAddFiles(accepted);
|
||||
}
|
||||
e.target.value = '';
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* File type error warning */}
|
||||
{fileTypeError && (
|
||||
<div className="mb-4 flex items-center gap-2 p-3 rounded-xl bg-accent-rose/10 border border-accent-rose/30 text-sm text-accent-rose">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
<span>{fileTypeError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drop zone */}
|
||||
{!uploading && !uploadDone && (
|
||||
<div
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={files.length < MAX_FILES ? onDrop : undefined}
|
||||
onClick={files.length < MAX_FILES ? onBrowse : undefined}
|
||||
onDrop={files.length < maxFiles ? handleLocalDrop : undefined}
|
||||
onClick={files.length < maxFiles ? onBrowse : undefined}
|
||||
className={`relative border-2 border-dashed rounded-xl p-10 text-center transition-all duration-200 cursor-pointer ${
|
||||
dragOver
|
||||
? 'border-brand-400 bg-brand-500/10'
|
||||
@@ -82,8 +131,8 @@ export default function QuickUpload({
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={onInputChange}
|
||||
disabled={uploading || files.length >= MAX_FILES}
|
||||
onChange={files.length < maxFiles ? handleLocalInputChange : undefined}
|
||||
disabled={uploading || files.length >= maxFiles}
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Upload className="w-10 h-10 text-surface-500" />
|
||||
@@ -92,7 +141,7 @@ export default function QuickUpload({
|
||||
<span className="text-brand-400 font-medium">Click to browse</span> or drag & drop files
|
||||
</p>
|
||||
<p className="text-xs text-surface-500 mt-1">
|
||||
Up to {MAX_FILES} files, max {formatBytes(MAX_FILE_SIZE)} each
|
||||
Up to {maxFiles} files, max {formatBytes(maxFileSize)} each
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,7 +211,7 @@ export default function QuickUpload({
|
||||
Uploading…
|
||||
</span>
|
||||
)}
|
||||
{entry.status === 'done' && entry.result && (
|
||||
{entry.status === 'done' && entry.result?.cid && (
|
||||
<span className="text-xs font-mono text-surface-400 truncate max-w-[160px]">
|
||||
{entry.result.cid.slice(0, 16)}…
|
||||
</span>
|
||||
@@ -257,12 +306,12 @@ export default function QuickUpload({
|
||||
</div>
|
||||
|
||||
{/* Per-file result details */}
|
||||
{files.filter(f => f.status === 'done').map(entry => entry.result && (
|
||||
{files.filter(f => f.status === 'done').map(entry => entry.result?.cid && (
|
||||
<div key={entry.id} className="flex items-center justify-between py-2 px-3 rounded-lg bg-surface-800/50 mb-1.5">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<span className="text-sm text-surface-300 truncate max-w-[150px]">{entry.file.name}</span>
|
||||
<span className="text-xs font-mono text-surface-500 truncate max-w-[120px]">{entry.result.cid.slice(0, 16)}…</span>
|
||||
<span className="text-xs text-surface-500">{formatBytes(entry.result.size)}</span>
|
||||
<span className="text-xs text-surface-500">{formatBytes(entry.result.size ?? 0)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
/* ── Upload Page Helpers ── */
|
||||
|
||||
export type TabMode = "quick" | "crypto";
|
||||
+42
-22
@@ -1,26 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, type DragEvent, useCallback } from 'react';
|
||||
import { uploadFile } from '@/lib/api';
|
||||
import { addToHistory } from '@/lib/storage';
|
||||
import { useState, useRef, type DragEvent, useCallback, useMemo } from 'react';
|
||||
import { uploadFile } from '@/lib/api/files';
|
||||
import { addToHistory, getSettings } from '@/lib/storage';
|
||||
import PortalLayout from '@/app/layout-portal';
|
||||
import { checkUploadAllowed } from '@/lib/limits';
|
||||
import { useNotify } from '@/lib/notifications';
|
||||
import { formatBytes, gatewayLink } from '@/lib/helpers';
|
||||
import { Zap, ArrowUp } from 'lucide-react';
|
||||
import QuickUpload, { type FileEntry } from './components/QuickUpload';
|
||||
import { PaymentProvider } from '@/lib/payment';
|
||||
import CryptoUpload from './components/CryptoUpload';
|
||||
|
||||
/* ── Constants ── */
|
||||
const MAX_FILES = 20;
|
||||
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB
|
||||
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
||||
|
||||
type TabMode = 'quick' | 'crypto';
|
||||
import type { TabMode } from './helpers';
|
||||
|
||||
/* ════════════════════════════ Page ════════════════════════════ */
|
||||
|
||||
export default function UploadPage() {
|
||||
const { notify } = useNotify();
|
||||
const settings = useMemo(() => getSettings(), []);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const cryptoInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -41,24 +38,49 @@ export default function UploadPage() {
|
||||
|
||||
/* ── File management ── */
|
||||
const addFiles = useCallback((incoming: FileList | File[]) => {
|
||||
const s = getSettings();
|
||||
const arr = Array.from(incoming);
|
||||
const remaining = MAX_FILES - files.length;
|
||||
const remaining = s.maxFiles - files.length;
|
||||
const toAdd = arr.slice(0, remaining);
|
||||
|
||||
const allowedExts = s.allowedFileTypes
|
||||
? s.allowedFileTypes.split(',').map(e => e.trim().toLowerCase()).filter(Boolean)
|
||||
: null;
|
||||
|
||||
const rejected: string[] = [];
|
||||
|
||||
const entries: FileEntry[] = toAdd
|
||||
.filter(f => f.size <= MAX_FILE_SIZE)
|
||||
.filter(f => s.maxFileSize === 0 || f.size <= s.maxFileSize)
|
||||
.filter(f => !files.some(ex => ex.file.name === f.name && ex.file.size === f.size))
|
||||
.filter(f => {
|
||||
if (!allowedExts) return true;
|
||||
const ext = f.name.split('.').pop()?.toLowerCase();
|
||||
if (ext && allowedExts.includes(ext)) return true;
|
||||
rejected.push(f.name);
|
||||
return false;
|
||||
})
|
||||
.map(f => ({
|
||||
id: Math.random().toString(36).slice(2, 9),
|
||||
file: f,
|
||||
preview: ALLOWED_TYPES.includes(f.type) ? URL.createObjectURL(f) : undefined,
|
||||
preview: !allowedExts || allowedExts.some(ext => f.name.toLowerCase().endsWith('.' + ext))
|
||||
? URL.createObjectURL(f)
|
||||
: undefined,
|
||||
status: 'pending' as const,
|
||||
}));
|
||||
|
||||
if (rejected.length > 0) {
|
||||
notify({
|
||||
type: 'warning',
|
||||
title: 'Bestandstype niet toegestaan',
|
||||
message: `De volgende bestanden zijn overgeslagen: ${rejected.join(', ')}`,
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
if (entries.length === 0) return;
|
||||
setFiles(prev => [...prev, ...entries]);
|
||||
setUploadDone(false);
|
||||
}, [files]);
|
||||
}, [files, notify]);
|
||||
|
||||
function removeFile(id: string) {
|
||||
setFiles(prev => {
|
||||
@@ -124,9 +146,9 @@ export default function UploadPage() {
|
||||
setFiles(prev => prev.map(f =>
|
||||
f.id === entry.id ? { ...f, status: 'done' as const, result, date } : f
|
||||
));
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
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
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -163,22 +185,18 @@ export default function UploadPage() {
|
||||
|
||||
/* ── Clipboard ── */
|
||||
function copyCid(cid: string) {
|
||||
navigator.clipboard.writeText(cid).catch(() => {});
|
||||
navigator.clipboard.writeText(cid).catch((err) => { console.error('[UploadPage] Clipboard CID failed:', err); });
|
||||
setCopied(cid);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
}
|
||||
|
||||
function copyShareLink(cid: string) {
|
||||
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}`);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
}
|
||||
|
||||
function gatewayLink(cid: string) {
|
||||
return `https://maos.dedyn.io/ipfs/${cid}`;
|
||||
}
|
||||
|
||||
/* ════════════ Render ════════════ */
|
||||
|
||||
return (
|
||||
@@ -241,6 +259,7 @@ export default function UploadPage() {
|
||||
|
||||
{/* Crypto Payment tab */}
|
||||
{tab === 'crypto' && (
|
||||
<PaymentProvider>
|
||||
<CryptoUpload
|
||||
cryptoFile={cryptoFile}
|
||||
cryptoResult={cryptoResult}
|
||||
@@ -254,6 +273,7 @@ export default function UploadPage() {
|
||||
onCopyCid={copyCid}
|
||||
gatewayLink={gatewayLink}
|
||||
/>
|
||||
</PaymentProvider>
|
||||
)}
|
||||
</PortalLayout>
|
||||
);
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import PortalLayout from '@/app/layout-portal';
|
||||
import { getRepoStats, getBWStats, getPeers, listPins } from '@/lib/api';
|
||||
import type { RepoStats, BWStats } from '@/lib/api';
|
||||
import { getRepoStats, getPeers } from '@/lib/api/gateway';
|
||||
import { listPins } from '@/lib/api/pins';
|
||||
import type { RepoStats } from '@/lib/api/client';
|
||||
import { checkUploadAllowed, type UsageCheckResult } from '@/lib/limits';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import StorageGauge from '@/app/dashboard/components/StorageGauge';
|
||||
@@ -17,7 +18,6 @@ export default function UsagePage() {
|
||||
const { user } = useAuth();
|
||||
const [usage, setUsage] = useState<UsageCheckResult | null>(null);
|
||||
const [repo, setRepo] = useState<RepoStats | null>(null);
|
||||
const [bw, setBW] = useState<BWStats | null>(null);
|
||||
const [peerCount, setPeerCount] = useState(0);
|
||||
const [pinCount, setPinCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -25,16 +25,14 @@ export default function UsagePage() {
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const [u, r, b, p, pins] = await Promise.all([
|
||||
const [u, r, p, pins] = await Promise.all([
|
||||
checkUploadAllowed().catch(() => null),
|
||||
getRepoStats().catch(() => null),
|
||||
getBWStats().catch(() => null),
|
||||
getPeers().catch(() => []),
|
||||
listPins().catch(() => []),
|
||||
]);
|
||||
setUsage(u);
|
||||
setRepo(r);
|
||||
setBW(b);
|
||||
setPeerCount(p.length);
|
||||
setPinCount(pins.length);
|
||||
} finally {
|
||||
@@ -122,7 +120,7 @@ export default function UsagePage() {
|
||||
<span className="text-xs text-surface-400">In</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-surface-200">
|
||||
{bw ? (bw.totalIn / 1e6).toFixed(1) : '—'} MB
|
||||
— MB
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-surface-900/50 border border-surface-800">
|
||||
@@ -131,7 +129,7 @@ export default function UsagePage() {
|
||||
<span className="text-xs text-surface-400">Uit</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-surface-200">
|
||||
{bw ? (bw.totalOut / 1e6).toFixed(1) : '—'} MB
|
||||
— MB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
@@ -1,18 +1,23 @@
|
||||
'use client';
|
||||
|
||||
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 { SkeletonTable } from '@/components/Skeleton';
|
||||
import { paymentService, type UserFullStats, type UploadRecord } from '@/lib/payment';
|
||||
import { formatWeiToETH, formatBytes } from '@/lib/wallet';
|
||||
import { usePaymentService, type UserFullStats, type UploadRecord } from '@/lib/payment';
|
||||
import { formatBytes } from '@/lib/helpers';
|
||||
import { formatWeiToETH } from '@/lib/wallet';
|
||||
import { formatTimestamp } from './helpers';
|
||||
import {
|
||||
Users, Plus, Trash2, UserPlus, Search, Wallet,
|
||||
HardDrive, Upload, Clock, ExternalLink, Copy,
|
||||
CheckCircle, Loader2, XCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
|
||||
export default function UsersPage() {
|
||||
const paymentService = usePaymentService();
|
||||
// ── htpasswd users ──
|
||||
const [users, setUsers] = useState<{ username: string; created: string; active: boolean }[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -31,7 +36,7 @@ export default function UsersPage() {
|
||||
try {
|
||||
const items = await listUsers();
|
||||
setUsers(items);
|
||||
} catch { /* ignore */ }
|
||||
} catch (err) { console.error('[UsersPage] Failed to load users:', err); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
|
||||
@@ -45,14 +50,14 @@ export default function UsersPage() {
|
||||
setNewPass('');
|
||||
setShowAdd(false);
|
||||
await load();
|
||||
} catch { /* ignore */ }
|
||||
} catch (err) { console.error('[UsersPage] Failed to create user:', err); }
|
||||
}
|
||||
|
||||
async function handleDelete(username: string) {
|
||||
try {
|
||||
await deleteUser(username);
|
||||
setUsers((u) => u.filter((x) => x.username !== username));
|
||||
} catch { /* ignore */ }
|
||||
} catch (err) { console.error('[UsersPage] Failed to delete user:', err); }
|
||||
}
|
||||
|
||||
// ── Wallet lookup ──
|
||||
@@ -68,8 +73,8 @@ export default function UsersPage() {
|
||||
try {
|
||||
const stats = await paymentService.getUserUploads(addr as `0x${string}`);
|
||||
setWalletStats(stats);
|
||||
} catch (e: any) {
|
||||
setWalletError(e.message || 'Failed to fetch wallet stats');
|
||||
} catch (e: unknown) {
|
||||
setWalletError(e instanceof Error ? e.message : 'Failed to fetch wallet stats');
|
||||
} finally {
|
||||
setWalletLoading(false);
|
||||
}
|
||||
@@ -80,16 +85,12 @@ export default function UsersPage() {
|
||||
await navigator.clipboard.writeText(cid);
|
||||
setCopiedCid(cid);
|
||||
setTimeout(() => setCopiedCid(null), 2000);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: bigint): string {
|
||||
const d = new Date(Number(ts) * 1000);
|
||||
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString();
|
||||
} catch (err) { console.error('[UsersPage] Clipboard write failed:', err); }
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalLayout>
|
||||
<ErrorBoundary label="Gebruikers">
|
||||
{/* ── Header with count badge ── */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
@@ -271,6 +272,7 @@ export default function UsersPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
</PortalLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { checkHealth } from '@/lib/api';
|
||||
import { checkHealth } from '@/lib/api/gateway';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
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() {
|
||||
const { user, logout } = useAuth();
|
||||
const [nodeStatus, setNodeStatus] = useState<'online' | 'offline' | 'checking'>('checking');
|
||||
@@ -18,7 +21,8 @@ export default function PortalHeader() {
|
||||
if (!mounted) return;
|
||||
setNodeStatus('online');
|
||||
setNodeVersion(h.node);
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error('[PortalHeader] Health check failed:', err);
|
||||
if (!mounted) return;
|
||||
setNodeStatus('offline');
|
||||
}
|
||||
|
||||
@@ -20,7 +20,22 @@ import {
|
||||
Moon,
|
||||
} 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: '/usage', label: 'Usage', icon: BarChart3 },
|
||||
{ href: '/upload', label: 'Upload', icon: Upload },
|
||||
@@ -34,13 +49,13 @@ const NAV_ITEMS = [
|
||||
{ href: '/settings', label: 'Settings', icon: Settings },
|
||||
];
|
||||
|
||||
export default function PortalSidebar() {
|
||||
export default function PortalSidebar({ navItems, onThemeToggle, onDisconnect }: PortalSidebarProps = {}) {
|
||||
const pathname = usePathname();
|
||||
const { theme, toggle: toggleTheme } = useTheme();
|
||||
|
||||
function handleDisconnect() {
|
||||
window.location.href = '/';
|
||||
}
|
||||
const items = navItems ?? DEFAULT_NAV_ITEMS;
|
||||
const handleThemeToggle = onThemeToggle ?? toggleTheme;
|
||||
const handleDisconnect = onDisconnect ?? (() => { window.location.href = '/'; });
|
||||
|
||||
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">
|
||||
@@ -54,7 +69,7 @@ export default function PortalSidebar() {
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 p-3 space-y-1 overflow-y-auto">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
{items.map((item) => {
|
||||
const active = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
@@ -76,7 +91,7 @@ export default function PortalSidebar() {
|
||||
{/* Theme toggle + Disconnect */}
|
||||
<div className="p-3 border-t border-surface-800 space-y-1">
|
||||
<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"
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="w-4 h-4 shrink-0" /> : <Moon className="w-4 h-4 shrink-0" />}
|
||||
|
||||
@@ -9,8 +9,8 @@ import { useEffect } from 'react';
|
||||
export default function SWRegister() {
|
||||
useEffect(() => {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {
|
||||
// SW registratie mislukt — geen offline, app werkt gewoon
|
||||
navigator.serviceWorker.register('/sw.js').catch((err) => {
|
||||
console.error('[SWRegister] Registration failed:', err);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import PaymentPanel from './PaymentPanel';
|
||||
|
||||
export default PaymentPanel;
|
||||
export { PaymentPanel };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatBytes, gatewayLink, truncateCid } from '../helpers';
|
||||
import { formatBytes, gatewayLink, gatewayUrl, truncateCid } from '../helpers';
|
||||
|
||||
describe('formatBytes', () => {
|
||||
it('formats 0 bytes', () => {
|
||||
it('formats 0 as "0 B"', () => {
|
||||
expect(formatBytes(0)).toBe('0 B');
|
||||
});
|
||||
|
||||
it('formats bytes without decimal', () => {
|
||||
expect(formatBytes(512)).toBe('512 B');
|
||||
it('formats bigint 0n as "0 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', () => {
|
||||
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', () => {
|
||||
@@ -22,34 +34,63 @@ describe('formatBytes', () => {
|
||||
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', () => {
|
||||
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', () => {
|
||||
expect(formatBytes(1099511627776)).toBe('1.0 TB');
|
||||
});
|
||||
|
||||
it('formats large value as integer when >= 10', () => {
|
||||
// 10+ MB → integer, no decimal
|
||||
expect(formatBytes(11534336)).toBe('11 MB');
|
||||
it('handles bigint input', () => {
|
||||
expect(formatBytes(500n)).toBe('500 B');
|
||||
});
|
||||
|
||||
it('handles negative values gracefully', () => {
|
||||
const result = formatBytes(-100);
|
||||
expect(result).toBeTruthy();
|
||||
expect(typeof result).toBe('string');
|
||||
it('handles bigint zero', () => {
|
||||
expect(formatBytes(0n)).toBe('0 B');
|
||||
});
|
||||
|
||||
it('handles bigint large value', () => {
|
||||
expect(formatBytes(1073741824n)).toBe('1.0 GB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gatewayLink', () => {
|
||||
it('returns correct gateway URL for valid CID', () => {
|
||||
const cid = 'QmTest123';
|
||||
expect(gatewayLink(cid)).toBe('https://maos.dedyn.io/ipfs/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', () => {
|
||||
expect(gatewayLink('')).toBe('https://maos.dedyn.io/ipfs/');
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,6 +113,10 @@ describe('truncateCid', () => {
|
||||
expect(truncateCid('abcdef', 6)).toBe('abcdef');
|
||||
});
|
||||
|
||||
it('uses default of 12 chars', () => {
|
||||
expect(truncateCid('abcdefghijklm')).toBe('abcdefghijkl...');
|
||||
});
|
||||
|
||||
it('handles empty string', () => {
|
||||
expect(truncateCid('')).toBe('');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
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', () => {
|
||||
it('formats 0 wei', () => {
|
||||
@@ -31,7 +32,7 @@ describe('formatWeiToETH', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatBytes (wallet)', () => {
|
||||
describe('formatBytes (helpers)', () => {
|
||||
it('formats 0 bytes', () => {
|
||||
expect(formatBytes(0n)).toBe('0 B');
|
||||
});
|
||||
@@ -49,7 +50,7 @@ describe('formatBytes (wallet)', () => {
|
||||
});
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -84,8 +84,17 @@ export async function uploadFile(file: File): Promise<{ cid: string; size: numbe
|
||||
body: form,
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error(`Upload failed: ${res.statusText}`);
|
||||
return res.json();
|
||||
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 }[]> {
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/* ── IPFS Portal API — Users (admin) ── */
|
||||
|
||||
import { apiFetch } from './client';
|
||||
|
||||
export interface IPFSUser {
|
||||
username: string;
|
||||
created: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export async function listUsers(): Promise<IPFSUser[]> {
|
||||
const raw = await apiFetch<IPFSUser[] | { users: IPFSUser[] }>('/users');
|
||||
// Handle both [{...}] and {users: [{...}]} response formats
|
||||
if (Array.isArray(raw)) return raw;
|
||||
if (raw && Array.isArray(raw.users)) return raw.users;
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function createUser(username: string, password: string): Promise<{ username: string }> {
|
||||
return apiFetch('/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteUser(username: string): Promise<void> {
|
||||
await apiFetch(`/users/${encodeURIComponent(username)}`, { method: 'DELETE' });
|
||||
}
|
||||
@@ -13,8 +13,14 @@ export const SESSION_COOKIE = 'ipfs-portal-session';
|
||||
export const SESSION_MAX_AGE_SEC = 24 * 60 * 60; // 24 uur
|
||||
|
||||
function getSecret(): Uint8Array {
|
||||
const secret = process.env.JWT_SECRET || 'ipfs-portal-dev-secret-change-in-production';
|
||||
return new TextEncoder().encode(secret);
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret) {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error('JWT_SECRET environment variable is required in production');
|
||||
}
|
||||
console.warn('[auth-server] ⚠️ Using dev-only JWT secret. Set JWT_SECRET for production.');
|
||||
}
|
||||
return new TextEncoder().encode(secret || 'ipfs-portal-dev-secret-change-in-production');
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Types ════════════════════════════ */
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||
import type { WalletProvider } from '@/lib/wallet';
|
||||
|
||||
/* ── Types ── */
|
||||
|
||||
export interface AuthUser {
|
||||
address: string;
|
||||
role: 'admin' | 'user';
|
||||
}
|
||||
|
||||
export interface WalletOption {
|
||||
name: string;
|
||||
icon?: string;
|
||||
provider: WalletProvider;
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
loading: boolean;
|
||||
loginWithWallet: (address: string, provider: WalletProvider) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
isAdmin: boolean;
|
||||
connectError: string | null;
|
||||
}
|
||||
|
||||
/* ── Context ── */
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
/* ── Provider ── */
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [connectError, setConnectError] = useState<string | null>(null);
|
||||
|
||||
/* Check session on mount */
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me')
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => {
|
||||
if (data?.authenticated) {
|
||||
setUser({ address: data.address, role: data.role });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* server unreachable — stay logged out */
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const loginWithWallet = useCallback(async (address: string, provider: WalletProvider) => {
|
||||
setConnectError(null);
|
||||
|
||||
// 1. Get SIWE challenge message
|
||||
let challengeRes: Response;
|
||||
try {
|
||||
challengeRes = await fetch('/api/auth/challenge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ address }),
|
||||
});
|
||||
} catch {
|
||||
setConnectError('Kan geen verbinding maken met de server');
|
||||
throw new Error('Network error');
|
||||
}
|
||||
|
||||
if (!challengeRes.ok) {
|
||||
const data = await challengeRes.json().catch(() => ({}));
|
||||
const msg = data.error || 'Challenge mislukt';
|
||||
setConnectError(msg);
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
const { message, nonce } = await challengeRes.json();
|
||||
|
||||
// 2. Sign the message with the wallet
|
||||
let signature: string;
|
||||
try {
|
||||
signature = await provider.request({
|
||||
method: 'personal_sign',
|
||||
params: [message, address],
|
||||
});
|
||||
} catch {
|
||||
setConnectError('Signatuur geweigerd in wallet');
|
||||
throw new Error('User rejected signature');
|
||||
}
|
||||
|
||||
// 3. Send signed message to backend for verification
|
||||
let loginRes: Response;
|
||||
try {
|
||||
loginRes = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ address, signature, nonce }),
|
||||
});
|
||||
} catch {
|
||||
setConnectError('Login request mislukt');
|
||||
throw new Error('Login network error');
|
||||
}
|
||||
|
||||
if (!loginRes.ok) {
|
||||
const data = await loginRes.json().catch(() => ({}));
|
||||
const msg = data.error || 'Verificatie mislukt';
|
||||
setConnectError(msg);
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
const data = await loginRes.json();
|
||||
setUser({ address: data.address, role: data.role });
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await fetch('/api/auth/logout', { method: 'POST' }).catch(() => {});
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, loginWithWallet, logout, isAdmin: user?.role === 'admin', connectError }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Hook ── */
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within <AuthProvider>');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useState, useEffect, useCallback, type ReactNode } from "react";
|
||||
import type { WalletProvider } from "@/lib/wallet";
|
||||
import type { AuthUser, AuthContextValue } from "./types";
|
||||
|
||||
export const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [connectError, setConnectError] = useState<string | null>(null);
|
||||
|
||||
/* Check session on mount */
|
||||
useEffect(() => {
|
||||
fetch("/api/auth/me")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => {
|
||||
if (data?.authenticated) {
|
||||
setUser({ address: data.address, role: data.role });
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("[AuthProvider] Session check failed:", err);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const loginWithWallet = useCallback(async (address: string, provider: WalletProvider) => {
|
||||
setConnectError(null);
|
||||
|
||||
// 1. Get SIWE challenge message
|
||||
let challengeRes: Response;
|
||||
try {
|
||||
challengeRes = await fetch("/api/auth/challenge", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ address }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[AuthProvider] Challenge request failed:", err);
|
||||
setConnectError("Kan geen verbinding maken met de server");
|
||||
throw new Error("Network error");
|
||||
}
|
||||
|
||||
if (!challengeRes.ok) {
|
||||
const data = await challengeRes.json().catch(() => ({}));
|
||||
const msg = data.error || "Challenge mislukt";
|
||||
setConnectError(msg);
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
const { message, nonce } = await challengeRes.json();
|
||||
|
||||
// 2. Sign the message with the wallet
|
||||
let signature: string;
|
||||
try {
|
||||
signature = await provider.request({
|
||||
method: "personal_sign",
|
||||
params: [message, address],
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[AuthProvider] Signature request failed:", err);
|
||||
setConnectError("Signatuur geweigerd in wallet");
|
||||
throw new Error("User rejected signature");
|
||||
}
|
||||
|
||||
// 3. Send signed message to backend for verification
|
||||
let loginRes: Response;
|
||||
try {
|
||||
loginRes = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ address, signature, nonce }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[AuthProvider] Login request failed:", err);
|
||||
setConnectError("Login request mislukt");
|
||||
throw new Error("Login network error");
|
||||
}
|
||||
|
||||
if (!loginRes.ok) {
|
||||
const data = await loginRes.json().catch(() => ({}));
|
||||
const msg = data.error || "Verificatie mislukt";
|
||||
setConnectError(msg);
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
const data = await loginRes.json();
|
||||
setUser({ address: data.address, role: data.role });
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await fetch("/api/auth/logout", { method: "POST" }).catch((err) => {
|
||||
console.error("[AuthProvider] Logout request failed:", err);
|
||||
});
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, loginWithWallet, logout, isAdmin: user?.role === "admin", connectError }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user