fix: bw/stats 404, gateway links, history crash
- swr.ts: remove useBandwidth(), drop bw from useDashboard() - events/route.ts: skip bandwidth SSE (Kubo 0.28.0 unsupported) - usage/page.tsx: remove getBWStats call + bw references - helpers.ts: gatewayLink -> ipfs.maos.dedyn.io (subdomain only) - upload/page.tsx: fix local gatewayLink (subdomain only) - storage.ts: update default gatewayUrl, add defensive getHistory()
This commit is contained in:
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
{"0": "Portal Layout & Navigation", "1": "Landing & Payment UI", "2": "Payment Smart Contract", "3": "IPFS Gateway & History", "4": "Explorer Components", "5": "NPM Dependencies", "6": "TypeScript Config", "7": "API Routes & Proxy", "8": "File Preview", "9": "Static Server", "10": "Root Layout", "11": "Sidebar Navigation", "12": "Deploy Script", "13": "Next.js Config", "14": "Playwright Config", "15": "PostCSS Config", "16": "E2E Tests"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
C:\Users\maik\AppData\Local\Microsoft\WindowsApps\PythonSoftwareFoundation.Python.3.13_qbz5n2kfra8p0\python.exe
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
H:\IPFS-portal
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# Graph Report - . (2026-06-27)
|
||||||
|
|
||||||
|
## Corpus Check
|
||||||
|
- Corpus is ~21,600 words - fits in a single context window. You may not need a graph.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
- 251 nodes · 401 edges · 17 communities (12 shown, 5 thin omitted)
|
||||||
|
- Extraction: 100% EXTRACTED · 0% INFERRED · 0% AMBIGUOUS · INFERRED: 2 edges (avg confidence: 0.8)
|
||||||
|
- Token cost: 0 input · 0 output
|
||||||
|
|
||||||
|
## Community Hubs (Navigation)
|
||||||
|
- [[_COMMUNITY_Portal Layout & Navigation|Portal Layout & Navigation]]
|
||||||
|
- [[_COMMUNITY_Landing & Payment UI|Landing & Payment UI]]
|
||||||
|
- [[_COMMUNITY_Payment Smart Contract|Payment Smart Contract]]
|
||||||
|
- [[_COMMUNITY_IPFS Gateway & History|IPFS Gateway & History]]
|
||||||
|
- [[_COMMUNITY_Explorer Components|Explorer Components]]
|
||||||
|
- [[_COMMUNITY_NPM Dependencies|NPM Dependencies]]
|
||||||
|
- [[_COMMUNITY_TypeScript Config|TypeScript Config]]
|
||||||
|
- [[_COMMUNITY_API Routes & Proxy|API Routes & Proxy]]
|
||||||
|
- [[_COMMUNITY_File Preview|File Preview]]
|
||||||
|
- [[_COMMUNITY_Static Server|Static Server]]
|
||||||
|
- [[_COMMUNITY_Root Layout|Root Layout]]
|
||||||
|
- [[_COMMUNITY_Sidebar Navigation|Sidebar Navigation]]
|
||||||
|
- [[_COMMUNITY_Deploy Script|Deploy Script]]
|
||||||
|
- [[_COMMUNITY_Next.js Config|Next.js Config]]
|
||||||
|
|
||||||
|
## God Nodes (most connected - your core abstractions)
|
||||||
|
1. `PaymentService` - 31 edges
|
||||||
|
2. `compilerOptions` - 16 edges
|
||||||
|
3. `WalletProvider` - 15 edges
|
||||||
|
4. `apiFetch()` - 12 edges
|
||||||
|
5. `getInjectedProvider()` - 7 edges
|
||||||
|
6. `GET()` - 6 edges
|
||||||
|
7. `formatWeiToETH()` - 6 edges
|
||||||
|
8. `proxyIPFS()` - 5 edges
|
||||||
|
9. `POST()` - 5 edges
|
||||||
|
10. `DELETE()` - 5 edges
|
||||||
|
|
||||||
|
## Surprising Connections (you probably didn't know these)
|
||||||
|
- `AdminPaymentPage()` --calls--> `formatWeiToETH()` [EXTRACTED]
|
||||||
|
src/app/admin/payment/page.tsx → src/lib/wallet.ts
|
||||||
|
- `UploadPage()` --calls--> `GatewayLink()` [INFERRED]
|
||||||
|
src/app/upload/page.tsx → src/app/explorer/components/GatewayLink.tsx
|
||||||
|
- `PaymentPanel()` --calls--> `formatWeiToETH()` [EXTRACTED]
|
||||||
|
src/components/PaymentPanel.tsx → src/lib/wallet.ts
|
||||||
|
- `DirectoryListingProps` --references--> `IPFSEntry` [EXTRACTED]
|
||||||
|
src/app/explorer/components/DirectoryListing.tsx → src/lib/api.ts
|
||||||
|
|
||||||
|
## Import Cycles
|
||||||
|
- None detected.
|
||||||
|
|
||||||
|
## Communities (17 total, 5 thin omitted)
|
||||||
|
|
||||||
|
### Community 0 - "Portal Layout & Navigation"
|
||||||
|
Cohesion: 0.08
|
||||||
|
Nodes (28): PinBadgeProps, PeerData, PinData, Status, addPin(), apiFetch(), BWStats, checkHealth() (+20 more)
|
||||||
|
|
||||||
|
### Community 1 - "Landing & Payment UI"
|
||||||
|
Cohesion: 0.08
|
||||||
|
Nodes (30): PaymentPanel(), PaymentPanelProps, TOKEN_ICONS, listUsers(), ERC20_ABI, IPFS_PORTAL_PAYMENT_ABI, MAOSDiscountTier, PriceInfo (+22 more)
|
||||||
|
|
||||||
|
### Community 3 - "IPFS Gateway & History"
|
||||||
|
Cohesion: 0.11
|
||||||
|
Nodes (21): GatewayLink(), GatewayLinkProps, formatBytes(), HistoryPage(), uploadFile(), addToHistory(), clearHistory(), DEFAULT_SETTINGS (+13 more)
|
||||||
|
|
||||||
|
### Community 4 - "Explorer Components"
|
||||||
|
Cohesion: 0.09
|
||||||
|
Nodes (13): BreadcrumbSegment, BreadcrumbsProps, CIDInputProps, DirectoryListingProps, FileIconProps, BreadcrumbSegment, ExplorerPage(), ExplorerState (+5 more)
|
||||||
|
|
||||||
|
### Community 5 - "NPM Dependencies"
|
||||||
|
Cohesion: 0.08
|
||||||
|
Nodes (24): dependencies, clsx, lucide-react, next, postcss, react, react-dom, @tailwindcss/postcss (+16 more)
|
||||||
|
|
||||||
|
### Community 6 - "TypeScript Config"
|
||||||
|
Cohesion: 0.10
|
||||||
|
Nodes (19): compilerOptions, allowJs, esModuleInterop, incremental, isolatedModules, jsx, lib, module (+11 more)
|
||||||
|
|
||||||
|
### Community 7 - "API Routes & Proxy"
|
||||||
|
Cohesion: 0.44
|
||||||
|
Nodes (10): DELETE(), GET(), handleHealth(), mapToKuboAPI(), matchRoute(), POST(), proxyIPFS(), proxyResult() (+2 more)
|
||||||
|
|
||||||
|
### Community 8 - "File Preview"
|
||||||
|
Cohesion: 0.38
|
||||||
|
Nodes (4): detectFileType(), FilePreview(), FilePreviewProps, renderMarkdown()
|
||||||
|
|
||||||
|
### Community 9 - "Static Server"
|
||||||
|
Cohesion: 0.33
|
||||||
|
Nodes (5): fs, http, MIME, path, ROOT
|
||||||
|
|
||||||
|
## Knowledge Gaps
|
||||||
|
- **79 isolated node(s):** `deploy.sh script`, `nextConfig`, `name`, `version`, `private` (+74 more)
|
||||||
|
These have ≤1 connection - possible missing edges or undocumented components.
|
||||||
|
- **5 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||||
|
|
||||||
|
## Suggested Questions
|
||||||
|
_Questions this graph is uniquely positioned to answer:_
|
||||||
|
|
||||||
|
- **Why does `PaymentService` connect `Payment Smart Contract` to `Landing & Payment UI`?**
|
||||||
|
_High betweenness centrality (0.153) - this node is a cross-community bridge._
|
||||||
|
- **Why does `http` connect `Static Server` to `Payment Smart Contract`?**
|
||||||
|
_High betweenness centrality (0.029) - this node is a cross-community bridge._
|
||||||
|
- **Why does `WalletProvider` connect `Payment Smart Contract` to `Landing & Payment UI`?**
|
||||||
|
_High betweenness centrality (0.017) - this node is a cross-community bridge._
|
||||||
|
- **What connects `deploy.sh script`, `nextConfig`, `name` to the rest of the system?**
|
||||||
|
_79 weakly-connected nodes found - possible documentation gaps or missing edges._
|
||||||
|
- **Should `Portal Layout & Navigation` be split into smaller, more focused modules?**
|
||||||
|
_Cohesion score 0.08194905869324474 - nodes in this community are weakly interconnected._
|
||||||
|
- **Should `Landing & Payment UI` be split into smaller, more focused modules?**
|
||||||
|
_Cohesion score 0.08305647840531562 - nodes in this community are weakly interconnected._
|
||||||
|
- **Should `IPFS Gateway & History` be split into smaller, more focused modules?**
|
||||||
|
_Cohesion score 0.1111111111111111 - nodes in this community are weakly interconnected._
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
g = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8'))
|
||||||
|
nodes = g.get('nodes', [])
|
||||||
|
edges = g.get('edges', [])
|
||||||
|
print(f'Nodes: {len(nodes)}, Edges: {len(edges)}')
|
||||||
|
|
||||||
|
communities = g.get('communities', g.get('clusters', {}))
|
||||||
|
if communities:
|
||||||
|
print(f'Communities: {len(communities)}')
|
||||||
|
|
||||||
|
# Show node labels and types
|
||||||
|
for n in nodes[:40]:
|
||||||
|
nid = n.get('id', '?')
|
||||||
|
label = n.get('label', n.get('name', '?'))
|
||||||
|
ntype = n.get('type', n.get('node_type', '?'))
|
||||||
|
print(f' {nid}: {label} [{ntype}]')
|
||||||
|
if len(nodes) > 40:
|
||||||
|
print(f' ... and {len(nodes)-40} more')
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import json
|
||||||
|
from pathlib as Path
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
g = json.loads(Path('graphify-out/graph.json').read_text(encoding='utf-8'))
|
||||||
|
nodes = g.get('nodes', [])
|
||||||
|
|
||||||
|
# Show all unique node labels to understand project structure
|
||||||
|
types = Counter()
|
||||||
|
labels = []
|
||||||
|
for n in nodes:
|
||||||
|
label = n.get('label', n.get('name', '?'))
|
||||||
|
labels.append(label)
|
||||||
|
|
||||||
|
# Show all labels
|
||||||
|
for lbl in sorted(labels):
|
||||||
|
print(lbl)
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"nodes": [{"id": "h_ipfs_portal_deploy_sh", "label": "deploy.sh", "file_type": "code", "source_file": "deploy.sh", "source_location": "L1", "metadata": {"language": "bash", "kind": "file"}}, {"id": "h_ipfs_portal_deploy_sh__entry", "label": "deploy.sh script", "file_type": "code", "source_file": "deploy.sh", "source_location": "L1", "metadata": {"language": "bash", "kind": "bash_entrypoint"}}], "edges": [{"source": "h_ipfs_portal_deploy_sh", "target": "h_ipfs_portal_deploy_sh__entry", "relation": "contains", "confidence": "EXTRACTED", "source_file": "deploy.sh", "source_location": "L1", "weight": 1.0}]}
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"H:\\IPFS-portal\\deploy.sh":{"size":3276,"mtime_ns":1782335829420010800,"hash":"93155dc8f78a78f135a0687f64636e2bc09b9dd2d7e3f8a3ecf01ddf9aaeba38"},"H:\\IPFS-portal\\package.json":{"size":741,"mtime_ns":1782331693806062100,"hash":"b4d183d0a729b9f4854201b7a3e9a097b30f87d4f5fc666c2e5bebe5f26a3713"},"H:\\IPFS-portal\\tsconfig.json":{"size":768,"mtime_ns":1782305238016408400,"hash":"07d2ec4b6bf62550169c81f9ecd578fd6f3f30c40ae57eb4ee0d57f2dd440ed1"}}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"runs": [
|
||||||
|
{
|
||||||
|
"date": "2026-06-27T12:47:45.586275+00:00",
|
||||||
|
"input_tokens": 0,
|
||||||
|
"output_tokens": 0,
|
||||||
|
"files": 37
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_input_tokens": 0,
|
||||||
|
"total_output_tokens": 0
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,187 @@
|
|||||||
|
{
|
||||||
|
"H:\\IPFS-portal\\deploy.sh": {
|
||||||
|
"mtime": 1782335829.4200108,
|
||||||
|
"ast_hash": "c2bab196ff38a17553535fe4f8bbe819",
|
||||||
|
"semantic_hash": "c2bab196ff38a17553535fe4f8bbe819"
|
||||||
|
},
|
||||||
|
"H:\\IPFS-portal\\next.config.ts": {
|
||||||
|
"mtime": 1782323679.7429354,
|
||||||
|
"ast_hash": "e4e1a2f09cef4c8973680aada3492e6a",
|
||||||
|
"semantic_hash": "e4e1a2f09cef4c8973680aada3492e6a"
|
||||||
|
},
|
||||||
|
"H:\\IPFS-portal\\package.json": {
|
||||||
|
"mtime": 1782331693.806062,
|
||||||
|
"ast_hash": "e27c51334fbc9da00b627bfb435c6f17",
|
||||||
|
"semantic_hash": "e27c51334fbc9da00b627bfb435c6f17"
|
||||||
|
},
|
||||||
|
"H:\\IPFS-portal\\playwright.config.ts": {
|
||||||
|
"mtime": 1782323985.4343338,
|
||||||
|
"ast_hash": "6e5fed3d80cb43667faa997526898061",
|
||||||
|
"semantic_hash": "6e5fed3d80cb43667faa997526898061"
|
||||||
|
},
|
||||||
|
"H:\\IPFS-portal\\postcss.config.mjs": {
|
||||||
|
"mtime": 1782310036.331215,
|
||||||
|
"ast_hash": "9a944fbda06979be39571bd9bd00b0d9",
|
||||||
|
"semantic_hash": "9a944fbda06979be39571bd9bd00b0d9"
|
||||||
|
},
|
||||||
|
"H:\\IPFS-portal\\serve-static.js": {
|
||||||
|
"mtime": 1782305384.6641164,
|
||||||
|
"ast_hash": "bfab69b103dde3ffb47c529b702039c4",
|
||||||
|
"semantic_hash": "bfab69b103dde3ffb47c529b702039c4"
|
||||||
|
},
|
||||||
|
"H:\\IPFS-portal\\src\\app\\admin\\payment\\page.tsx": {
|
||||||
|
"mtime": 1782329023.8102376,
|
||||||
|
"ast_hash": "5307f48b701258697597fe94b5556379",
|
||||||
|
"semantic_hash": "5307f48b701258697597fe94b5556379"
|
||||||
|
},
|
||||||
|
"H:\\IPFS-portal\\src\\app\\api\\[...path]\\route.ts": {
|
||||||
|
"mtime": 1782323547.7908971,
|
||||||
|
"ast_hash": "f689f8997ccd50c7bf0d288d64658730",
|
||||||
|
"semantic_hash": "f689f8997ccd50c7bf0d288d64658730"
|
||||||
|
},
|
||||||
|
"H:\\IPFS-portal\\src\\app\\api\\payment\\verify\\route.ts": {
|
||||||
|
"mtime": 1782327091.7359939,
|
||||||
|
"ast_hash": "5ad06957bde104f5a84f49207516c141",
|
||||||
|
"semantic_hash": "5ad06957bde104f5a84f49207516c141"
|
||||||
|
},
|
||||||
|
"H:\\IPFS-portal\\src\\app\\dashboard\\page.tsx": {
|
||||||
|
"mtime": 1782323579.6307354,
|
||||||
|
"ast_hash": "317a7db415a3fe21e9efcd2910a2b571",
|
||||||
|
"semantic_hash": "317a7db415a3fe21e9efcd2910a2b571"
|
||||||
|
},
|
||||||
|
"H:\\IPFS-portal\\src\\app\\explorer\\components\\Breadcrumbs.tsx": {
|
||||||
|
"mtime": 1782311813.8024487,
|
||||||
|
"ast_hash": "7a8663d6921b01d4c5c40a4711cb604a",
|
||||||
|
"semantic_hash": "7a8663d6921b01d4c5c40a4711cb604a"
|
||||||
|
},
|
||||||
|
"H:\\IPFS-portal\\src\\app\\explorer\\components\\CIDInput.tsx": {
|
||||||
|
"mtime": 1782311809.727791,
|
||||||
|
"ast_hash": "65f143b625b84f2f2e3173e5a501bbc3",
|
||||||
|
"semantic_hash": "65f143b625b84f2f2e3173e5a501bbc3"
|
||||||
|
},
|
||||||
|
"H:\\IPFS-portal\\src\\app\\explorer\\components\\DirectoryListing.tsx": {
|
||||||
|
"mtime": 1782311847.8061457,
|
||||||
|
"ast_hash": "6b5535d5f6a9c217c5258cf2237d2d0a",
|
||||||
|
"semantic_hash": "6b5535d5f6a9c217c5258cf2237d2d0a"
|
||||||
|
},
|
||||||
|
"H:\\IPFS-portal\\src\\app\\explorer\\components\\FileIcon.tsx": {
|
||||||
|
"mtime": 1782311811.8335142,
|
||||||
|
"ast_hash": "eb56c4a87f86c02072213372fde78c57",
|
||||||
|
"semantic_hash": "eb56c4a87f86c02072213372fde78c57"
|
||||||
|
},
|
||||||
|
"H:\\IPFS-portal\\src\\app\\explorer\\components\\FilePreview.tsx": {
|
||||||
|
"mtime": 1782311858.0255232,
|
||||||
|
"ast_hash": "cd32e1869bcc9d2daf246ca28331adf6",
|
||||||
|
"semantic_hash": "cd32e1869bcc9d2daf246ca28331adf6"
|
||||||
|
},
|
||||||
|
"H:\\IPFS-portal\\src\\app\\explorer\\components\\GatewayLink.tsx": {
|
||||||
|
"mtime": 1782311816.2048059,
|
||||||
|
"ast_hash": "75c99a0ad5cec3c4a95e9a1b7c629f01",
|
||||||
|
"semantic_hash": "75c99a0ad5cec3c4a95e9a1b7c629f01"
|
||||||
|
},
|
||||||
|
"H:\\IPFS-portal\\src\\app\\explorer\\components\\PinBadge.tsx": {
|
||||||
|
"mtime": 1782311860.326526,
|
||||||
|
"ast_hash": "a9cfeed551301ba64baae63ae61af663",
|
||||||
|
"semantic_hash": "a9cfeed551301ba64baae63ae61af663"
|
||||||
|
},
|
||||||
|
"H:\\IPFS-portal\\src\\app\\explorer\\page.tsx": {
|
||||||
|
"mtime": 1782311882.7185402,
|
||||||
|
"ast_hash": "54d9d6575bec7bb6d79bdfdd25ed2a91",
|
||||||
|
"semantic_hash": "54d9d6575bec7bb6d79bdfdd25ed2a91"
|
||||||
|
},
|
||||||
|
"H:\\IPFS-portal\\src\\app\\history\\page.tsx": {
|
||||||
|
"mtime": 1782331561.210468,
|
||||||
|
"ast_hash": "49dbc35a3ac3741f194a9b669f4a0a22",
|
||||||
|
"semantic_hash": "49dbc35a3ac3741f194a9b669f4a0a22"
|
||||||
|
},
|
||||||
|
"H:\\IPFS-portal\\src\\app\\ipns\\page.tsx": {
|
||||||
|
"mtime": 1782323444.911427,
|
||||||
|
"ast_hash": "c1df10e59827567593b103f4d2264cb1",
|
||||||
|
"semantic_hash": "c1df10e59827567593b103f4d2264cb1"
|
||||||
|
},
|
||||||
|
"H:\\IPFS-portal\\src\\app\\layout-portal.tsx": {
|
||||||
|
"mtime": 1782245653.0417998,
|
||||||
|
"ast_hash": "d9fc9f177e5a1a6b9b7d3c055a5195d7",
|
||||||
|
"semantic_hash": "d9fc9f177e5a1a6b9b7d3c055a5195d7"
|
||||||
|
},
|
||||||
|
"H:\\IPFS-portal\\src\\app\\layout.tsx": {
|
||||||
|
"mtime": 1782245634.5082266,
|
||||||
|
"ast_hash": "5a3014d8d3238f91d605ff08286e614b",
|
||||||
|
"semantic_hash": "5a3014d8d3238f91d605ff08286e614b"
|
||||||
|
},
|
||||||
|
"H:\\IPFS-portal\\src\\app\\page.tsx": {
|
||||||
|
"mtime": 1782310269.774857,
|
||||||
|
"ast_hash": "2a7b2f606087627eeb707c544fbf758e",
|
||||||
|
"semantic_hash": "2a7b2f606087627eeb707c544fbf758e"
|
||||||
|
},
|
||||||
|
"H:\\IPFS-portal\\src\\app\\peers\\page.tsx": {
|
||||||
|
"mtime": 1782245691.6038518,
|
||||||
|
"ast_hash": "611bdfac279b5c618f8e9463b560661e",
|
||||||
|
"semantic_hash": "611bdfac279b5c618f8e9463b560661e"
|
||||||
|
},
|
||||||
|
"H:\\IPFS-portal\\src\\app\\pins\\page.tsx": {
|
||||||
|
"mtime": 1782311813.3564186,
|
||||||
|
"ast_hash": "a5f2b0b8faa633501c87a6bc4750ef9c",
|
||||||
|
"semantic_hash": "a5f2b0b8faa633501c87a6bc4750ef9c"
|
||||||
|
},
|
||||||
|
"H:\\IPFS-portal\\src\\app\\settings\\page.tsx": {
|
||||||
|
"mtime": 1782335599.9904728,
|
||||||
|
"ast_hash": "6f75968e308d9c29703232ab04165e8f",
|
||||||
|
"semantic_hash": "6f75968e308d9c29703232ab04165e8f"
|
||||||
|
},
|
||||||
|
"H:\\IPFS-portal\\src\\app\\upload\\page.tsx": {
|
||||||
|
"mtime": 1782335637.3023176,
|
||||||
|
"ast_hash": "23a4087203579b7238d395b3f8c48959",
|
||||||
|
"semantic_hash": "23a4087203579b7238d395b3f8c48959"
|
||||||
|
},
|
||||||
|
"H:\\IPFS-portal\\src\\app\\users\\page.tsx": {
|
||||||
|
"mtime": 1782329529.5074713,
|
||||||
|
"ast_hash": "1e4c5ee26d4ce72bae3734bed5563ae4",
|
||||||
|
"semantic_hash": "1e4c5ee26d4ce72bae3734bed5563ae4"
|
||||||
|
},
|
||||||
|
"H:\\IPFS-portal\\src\\components\\PaymentPanel.tsx": {
|
||||||
|
"mtime": 1782325710.1937659,
|
||||||
|
"ast_hash": "041ebb060c47ebb6b0e6fb5be0ca0f2e",
|
||||||
|
"semantic_hash": "041ebb060c47ebb6b0e6fb5be0ca0f2e"
|
||||||
|
},
|
||||||
|
"H:\\IPFS-portal\\src\\components\\PortalHeader.tsx": {
|
||||||
|
"mtime": 1782245647.888308,
|
||||||
|
"ast_hash": "aa2056cf805a28927f51822d2acf1766",
|
||||||
|
"semantic_hash": "aa2056cf805a28927f51822d2acf1766"
|
||||||
|
},
|
||||||
|
"H:\\IPFS-portal\\src\\components\\PortalSidebar.tsx": {
|
||||||
|
"mtime": 1782331656.858291,
|
||||||
|
"ast_hash": "d6c679be20bffe1fa35419769c9f7496",
|
||||||
|
"semantic_hash": "d6c679be20bffe1fa35419769c9f7496"
|
||||||
|
},
|
||||||
|
"H:\\IPFS-portal\\src\\lib\\api.ts": {
|
||||||
|
"mtime": 1782329220.8041124,
|
||||||
|
"ast_hash": "7a394e3d0aa7f9cd76228bbe83b28137",
|
||||||
|
"semantic_hash": "7a394e3d0aa7f9cd76228bbe83b28137"
|
||||||
|
},
|
||||||
|
"H:\\IPFS-portal\\src\\lib\\payment.ts": {
|
||||||
|
"mtime": 1782329623.6892607,
|
||||||
|
"ast_hash": "b90cabcab2fde7b9ac67995822a22c01",
|
||||||
|
"semantic_hash": "b90cabcab2fde7b9ac67995822a22c01"
|
||||||
|
},
|
||||||
|
"H:\\IPFS-portal\\src\\lib\\storage.ts": {
|
||||||
|
"mtime": 1782331404.7951808,
|
||||||
|
"ast_hash": "3ad1d14d945bfb9d4febae0cb170ab37",
|
||||||
|
"semantic_hash": "3ad1d14d945bfb9d4febae0cb170ab37"
|
||||||
|
},
|
||||||
|
"H:\\IPFS-portal\\src\\lib\\wallet.ts": {
|
||||||
|
"mtime": 1782325663.3287177,
|
||||||
|
"ast_hash": "765b52a55f741e10f2c002d393e191c5",
|
||||||
|
"semantic_hash": "765b52a55f741e10f2c002d393e191c5"
|
||||||
|
},
|
||||||
|
"H:\\IPFS-portal\\tests\\portal.spec.ts": {
|
||||||
|
"mtime": 1782335736.5423336,
|
||||||
|
"ast_hash": "b637be3c8e2edfc09f7180a7a0247ebc",
|
||||||
|
"semantic_hash": "b637be3c8e2edfc09f7180a7a0247ebc"
|
||||||
|
},
|
||||||
|
"H:\\IPFS-portal\\tsconfig.json": {
|
||||||
|
"mtime": 1782305238.0164084,
|
||||||
|
"ast_hash": "bb6977ff03c7e4d3028e1af49008da2f",
|
||||||
|
"semantic_hash": "bb6977ff03c7e4d3028e1af49008da2f"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"description": "IPFS Portal — Next.js frontend for remote IPFS node management",
|
"description": "IPFS Portal — Next.js frontend for remote IPFS node management",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --port 3445",
|
"dev": "next dev --port 3445",
|
||||||
|
"dev:webpack": "next dev --port 3445 --webpack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"export": "next build && npx serve out"
|
"export": "next build && npx serve out"
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
+13
-18
@@ -1,30 +1,25 @@
|
|||||||
/* ── IPFS Portal — Service Worker v2 ──
|
/* ── IPFS Portal — Service Worker v3 ──
|
||||||
*
|
*
|
||||||
* Cache strategieën per route type:
|
* Cache strategieën per route type:
|
||||||
* - Precache: app shell (alle pages)
|
* - Precache: alleen publieke pages (auth-protected pages worden
|
||||||
|
* gecachet via stale-while-revalidate tijdens normaal gebruik)
|
||||||
* - Cache-first: static assets (fonts, images, CSS, JS)
|
* - Cache-first: static assets (fonts, images, CSS, JS)
|
||||||
* - Network-first: API calls (/_next/data, /api/*)
|
* - Network-first: API calls (/_next/data, /api/*)
|
||||||
* - Stale-while-revalidate: navigatie requests
|
* - Stale-while-revalidate: navigatie requests
|
||||||
|
*
|
||||||
|
* Let op: auth-protected pages (dashboard, upload, settings, etc.)
|
||||||
|
* worden NIET geprecached — tijdens installatie is er geen sessie-
|
||||||
|
* cookie, dus de middleware redirect naar /login. Alleen publieke
|
||||||
|
* pagina's in de precache.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE = 'ipfs-portal-v2';
|
const CACHE = 'ipfs-portal-v4';
|
||||||
const STATIC_CACHE = 'ipfs-portal-static-v2';
|
const STATIC_CACHE = 'ipfs-portal-static-v4';
|
||||||
const DATA_CACHE = 'ipfs-portal-data-v2';
|
const DATA_CACHE = 'ipfs-portal-data-v4';
|
||||||
|
|
||||||
const PRECACHE_URLS = [
|
const PRECACHE_URLS = [
|
||||||
'/',
|
'/',
|
||||||
'/dashboard',
|
|
||||||
'/upload',
|
|
||||||
'/explorer',
|
|
||||||
'/pins',
|
|
||||||
'/history',
|
|
||||||
'/usage',
|
|
||||||
'/peers',
|
|
||||||
'/users',
|
|
||||||
'/ipns',
|
|
||||||
'/login',
|
'/login',
|
||||||
'/settings',
|
|
||||||
'/profile',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/* ── Install: precache app shell ── */
|
/* ── Install: precache app shell ── */
|
||||||
@@ -86,9 +81,9 @@ self.addEventListener('fetch', (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Navigation → stale-while-revalidate
|
// 3. Navigation → network-first (nooit stale HTML serveren, HMR breekt)
|
||||||
if (isNavigation(request)) {
|
if (isNavigation(request)) {
|
||||||
event.respondWith(staleWhileRevalidate(request, CACHE));
|
event.respondWith(networkFirst(request, CACHE));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import PortalLayout from '@/app/layout-portal';
|
||||||
|
import AuthGuard from '@/components/AuthGuard';
|
||||||
|
import { listUsers, getNodeInfo, checkHealth, type IPFSNodeInfo } from '@/lib/api';
|
||||||
|
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(() => {})
|
||||||
|
.finally(() => setLoadingUsers(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkHealth()
|
||||||
|
.then((h: any) => setHealth(h))
|
||||||
|
.catch(() => {})
|
||||||
|
.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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -43,17 +43,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() {
|
async function fetchPeerCount() {
|
||||||
const raw = await kuboFetch('/api/v0/swarm/peers');
|
const raw = await kuboFetch('/api/v0/swarm/peers');
|
||||||
if (!raw || !raw.Peers) return 0;
|
if (!raw || !raw.Peers) return 0;
|
||||||
@@ -81,7 +70,6 @@ export async function GET(req: Request): Promise<Response> {
|
|||||||
// Send initial connection event
|
// Send initial connection event
|
||||||
controller.enqueue(new TextEncoder().encode(encodeSSE('connected', { ts: Date.now() })));
|
controller.enqueue(new TextEncoder().encode(encodeSSE('connected', { ts: Date.now() })));
|
||||||
|
|
||||||
let bwPrev = { totalIn: 0, totalOut: 0 };
|
|
||||||
let lastRepoPoll = 0;
|
let lastRepoPoll = 0;
|
||||||
|
|
||||||
const interval = setInterval(async () => {
|
const interval = setInterval(async () => {
|
||||||
@@ -91,25 +79,6 @@ export async function GET(req: Request): Promise<Response> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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)
|
// Peer count (elke poll)
|
||||||
const peerCount = await fetchPeerCount();
|
const peerCount = await fetchPeerCount();
|
||||||
controller.enqueue(
|
controller.enqueue(
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
import type { IPFSEntry } from '@/lib/api';
|
import type { IPFSEntry } from '@/lib/api';
|
||||||
|
import { explorerLs } from '@/lib/api';
|
||||||
import FileIcon from './FileIcon';
|
import FileIcon from './FileIcon';
|
||||||
import { CheckCircle, Download } from 'lucide-react';
|
import { ChevronRight, CheckCircle, Download, Loader2 } from 'lucide-react';
|
||||||
import { truncateCid } from '@/lib/helpers';
|
import { truncateCid } from '@/lib/helpers';
|
||||||
import { downloadFile } from '@/lib/download';
|
import { downloadFile } from '@/lib/download';
|
||||||
|
|
||||||
@@ -34,6 +36,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({
|
export default function DirectoryListing({
|
||||||
entries,
|
entries,
|
||||||
loading,
|
loading,
|
||||||
@@ -41,6 +197,40 @@ export default function DirectoryListing({
|
|||||||
onPreview,
|
onPreview,
|
||||||
pins,
|
pins,
|
||||||
}: DirectoryListingProps) {
|
}: 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 {
|
||||||
|
setSubdirEntries((e) => ({ ...e, [cid]: [] }));
|
||||||
|
} finally {
|
||||||
|
setLoadingSubdirs((l) => ({ ...l, [cid]: false }));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return prev; // unchanged here, effect will update
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="glass rounded-xl overflow-hidden divide-y divide-surface-800/50">
|
<div className="glass rounded-xl overflow-hidden divide-y divide-surface-800/50">
|
||||||
@@ -83,57 +273,20 @@ export default function DirectoryListing({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="divide-y divide-surface-800/50">
|
<div className="divide-y divide-surface-800/50">
|
||||||
{sorted.map((entry) => {
|
{sorted.map((entry) => (
|
||||||
const isPinned = pins.includes(entry.hash);
|
<TreeRow
|
||||||
return (
|
key={entry.hash}
|
||||||
<div
|
entry={entry}
|
||||||
key={entry.hash}
|
depth={0}
|
||||||
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-800/30 transition-colors cursor-pointer"
|
onNavigate={onNavigate}
|
||||||
onClick={() =>
|
onPreview={onPreview}
|
||||||
entry.type === 'dir'
|
pins={pins}
|
||||||
? onNavigate(entry.hash, entry.name)
|
subdirEntries={subdirEntries}
|
||||||
: onPreview(entry.hash)
|
loadingSubdirs={loadingSubdirs}
|
||||||
}
|
expandedDirs={expandedDirs}
|
||||||
>
|
onToggle={handleToggle}
|
||||||
<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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
FileText,
|
|
||||||
Code,
|
|
||||||
Code2,
|
|
||||||
Image,
|
|
||||||
File,
|
File,
|
||||||
|
FileArchive,
|
||||||
|
FileAudio,
|
||||||
|
FileCode,
|
||||||
|
FileImage,
|
||||||
|
FileJson,
|
||||||
|
FileSpreadsheet,
|
||||||
|
FileText,
|
||||||
|
FileVideo,
|
||||||
Folder,
|
Folder,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
@@ -15,34 +19,38 @@ interface FileIconProps {
|
|||||||
className?: string;
|
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) {
|
export default function FileIcon({ name, type, className = 'w-4 h-4' }: FileIconProps) {
|
||||||
if (type === 'dir') {
|
if (type === 'dir') {
|
||||||
return <Folder className={`${className} text-accent-cyan`} />;
|
return <Folder className={`${className} text-accent-cyan`} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
const e = ext(name);
|
||||||
|
|
||||||
switch (ext) {
|
if (IMAGE_EXTS.has(e)) return <FileImage className={`${className} text-surface-400`} />;
|
||||||
case 'md':
|
if (VIDEO_EXTS.has(e)) return <FileVideo className={`${className} text-surface-400`} />;
|
||||||
return <FileText className={`${className} text-surface-400`} />;
|
if (AUDIO_EXTS.has(e)) return <FileAudio className={`${className} text-surface-400`} />;
|
||||||
case 'json':
|
if (e === 'pdf') return <FileText className={`${className} text-accent-rose`} />; // FilePdf niet beschikbaar in deze versie
|
||||||
return <Code className={`${className} text-surface-400`} />;
|
if (e === 'json') return <FileJson className={`${className} text-surface-400`} />;
|
||||||
case 'js':
|
if (CODE_EXTS.has(e)) return <FileCode className={`${className} text-surface-400`} />;
|
||||||
case 'ts':
|
if (ARCHIVE_EXTS.has(e)) return <FileArchive className={`${className} text-surface-400`} />;
|
||||||
case 'py':
|
if (SHEET_EXTS.has(e)) return <FileSpreadsheet className={`${className} text-surface-400`} />;
|
||||||
case 'css':
|
if (TEXT_EXTS.has(e)) return <FileText className={`${className} text-surface-400`} />;
|
||||||
case 'html':
|
|
||||||
return <Code2 className={`${className} text-surface-400`} />;
|
return <File 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,8 @@
|
|||||||
'use client';
|
'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';
|
||||||
|
|
||||||
interface FilePreviewProps {
|
interface FilePreviewProps {
|
||||||
cid: string;
|
cid: string;
|
||||||
@@ -10,12 +12,23 @@ interface FilePreviewProps {
|
|||||||
filename?: string;
|
filename?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectFileType(filename: string | undefined): 'image' | 'markdown' | 'json' | 'text' | 'other' {
|
function gatewayUrl(cid: string): string {
|
||||||
|
const { gatewayUrl: base } = getSettings();
|
||||||
|
return `${base}/${cid}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectFileType(
|
||||||
|
filename: string | undefined,
|
||||||
|
): 'image' | 'markdown' | 'json' | 'text' | 'video' | 'audio' | 'pdf' | 'html' | 'other' {
|
||||||
const ext = filename?.split('.').pop()?.toLowerCase() ?? '';
|
const ext = filename?.split('.').pop()?.toLowerCase() ?? '';
|
||||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) return 'image';
|
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) return 'image';
|
||||||
if (ext === 'md') return 'markdown';
|
if (ext === 'md') return 'markdown';
|
||||||
if (ext === 'json') return 'json';
|
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';
|
return 'other';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,72 +102,197 @@ function JsonDisplay({ text }: { text: string }) {
|
|||||||
|
|
||||||
export default function FilePreview({ cid, content, loading, onClose, filename }: FilePreviewProps) {
|
export default function FilePreview({ cid, content, loading, onClose, filename }: FilePreviewProps) {
|
||||||
const fileType = detectFileType(filename);
|
const fileType = detectFileType(filename);
|
||||||
|
const [fullScreen, setFullScreen] = useState(false);
|
||||||
|
|
||||||
return (
|
const handleEscape = useCallback((e: KeyboardEvent) => {
|
||||||
<div className="glass rounded-xl p-5 animate-fade-in">
|
if (e.key === 'Escape') setFullScreen(false);
|
||||||
{/* Header */}
|
}, []);
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium text-white">
|
|
||||||
{filename || cid.slice(0, 16) + '…'}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-surface-500 uppercase px-1.5 py-0.5 rounded bg-surface-800">
|
|
||||||
{fileType}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="p-1.5 rounded-lg hover:bg-surface-700 transition-colors"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4 text-surface-400" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
useEffect(() => {
|
||||||
{loading ? (
|
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="animate-pulse space-y-2">
|
||||||
<div className="h-3 rounded bg-surface-700 w-full" />
|
<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-5/6" />
|
||||||
<div className="h-3 rounded bg-surface-700 w-4/6" />
|
<div className="h-3 rounded bg-surface-700 w-4/6" />
|
||||||
<div className="h-3 rounded bg-surface-700 w-3/6" />
|
<div className="h-3 rounded bg-surface-700 w-3/6" />
|
||||||
</div>
|
</div>
|
||||||
) : fileType === 'image' ? (
|
);
|
||||||
<div className="flex items-center justify-center bg-surface-900 rounded-lg p-2">
|
}
|
||||||
<img
|
|
||||||
src={`https://ipfs.io/ipfs/${cid}`}
|
switch (fileType) {
|
||||||
alt={filename ?? 'IPFS content'}
|
case 'image':
|
||||||
className="max-w-full max-h-96 rounded object-contain"
|
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-same-origin"
|
||||||
|
className={`w-full ${fullScreen ? 'h-[80vh]' : 'h-96'} rounded-lg bg-white`}
|
||||||
|
srcDoc={content}
|
||||||
|
title="HTML preview"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
sandbox="allow-same-origin"
|
||||||
|
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">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-white">
|
||||||
|
{filename || cid.slice(0, 16) + '…'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-surface-500 uppercase px-1.5 py-0.5 rounded bg-surface-800">
|
||||||
|
{fileType}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 text-surface-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : fileType === 'markdown' && content ? (
|
|
||||||
<div
|
{/* Preview content */}
|
||||||
className="prose prose-invert max-w-none text-sm text-surface-300 max-h-96 overflow-y-auto whitespace-pre-wrap"
|
{renderPreview()}
|
||||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }}
|
</div>
|
||||||
/>
|
|
||||||
) : fileType === 'json' && content ? (
|
{/* Full-screen overlay */}
|
||||||
<div className="max-h-96 overflow-y-auto">
|
{fullScreen && (
|
||||||
<JsonDisplay text={content} />
|
<div className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center p-8 animate-fade-in">
|
||||||
</div>
|
<div className="absolute top-4 right-4 z-10">
|
||||||
) : fileType === 'text' && content ? (
|
<button
|
||||||
<pre className="text-sm font-mono whitespace-pre-wrap text-surface-300 max-h-96 overflow-y-auto">
|
onClick={() => setFullScreen(false)}
|
||||||
{content}
|
className="p-2 rounded-lg bg-surface-800/80 hover:bg-surface-700 transition-colors"
|
||||||
</pre>
|
title="Exit full screen"
|
||||||
) : (
|
>
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
<X className="w-5 h-5 text-white" />
|
||||||
<p className="text-sm text-surface-500 mb-4">
|
</button>
|
||||||
Preview not available for this file type
|
</div>
|
||||||
</p>
|
<div className="w-full max-w-5xl max-h-full">
|
||||||
<a
|
{renderPreview()}
|
||||||
href={`https://ipfs.io/ipfs/${cid}`}
|
</div>
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-surface-800 text-sm text-surface-200 hover:bg-surface-700 transition-colors"
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4" />
|
|
||||||
Download file
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { downloadFile } from '@/lib/download';
|
|||||||
import { useNotify } from '@/lib/notifications';
|
import { useNotify } from '@/lib/notifications';
|
||||||
import { SkeletonTable } from '@/components/Skeleton';
|
import { SkeletonTable } from '@/components/Skeleton';
|
||||||
import BatchBar from '@/components/BatchBar';
|
import BatchBar from '@/components/BatchBar';
|
||||||
|
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||||
import PortalLayout from '@/app/layout-portal';
|
import PortalLayout from '@/app/layout-portal';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {
|
import {
|
||||||
@@ -114,6 +115,7 @@ export default function HistoryPage() {
|
|||||||
/* ── Render ── */
|
/* ── Render ── */
|
||||||
return (
|
return (
|
||||||
<PortalLayout>
|
<PortalLayout>
|
||||||
|
<ErrorBoundary label="HistoryPage">
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in">
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
||||||
@@ -476,6 +478,7 @@ export default function HistoryPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</ErrorBoundary>
|
||||||
</PortalLayout>
|
</PortalLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { getSettings, saveSettings, resetSettings, type PortalSettings } from '@
|
|||||||
import { useTheme } from '@/lib/theme';
|
import { useTheme } from '@/lib/theme';
|
||||||
import PortalLayout from '@/app/layout-portal';
|
import PortalLayout from '@/app/layout-portal';
|
||||||
import {
|
import {
|
||||||
Settings, Globe, Server, HardDrive, RefreshCw,
|
Settings, Globe, Server, HardDrive, RefreshCw, Shield,
|
||||||
Sun, Moon, Save, RotateCcw, Check, AlertTriangle,
|
Sun, Moon, Save, RotateCcw, Check, AlertTriangle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
@@ -16,6 +16,13 @@ function formatStorageMB(mb: number): string {
|
|||||||
return mb + ' MB';
|
return mb + ' MB';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
|
||||||
|
}
|
||||||
|
|
||||||
/* ════════════════════════════ Page ════════════════════════════ */
|
/* ════════════════════════════ Page ════════════════════════════ */
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
@@ -185,6 +192,90 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Upload Restrictions',
|
||||||
|
icon: Shield,
|
||||||
|
iconColor: 'text-accent-rose',
|
||||||
|
fields: (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Allowed file types */}
|
||||||
|
<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')}
|
||||||
|
/>
|
||||||
|
{errorMsg('allowedFileTypes')}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Max file size */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Max files per batch */}
|
||||||
|
<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',
|
title: 'Auto-Refresh',
|
||||||
icon: RefreshCw,
|
icon: RefreshCw,
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { type DragEvent, type RefObject } from 'react';
|
import { useState, type DragEvent, type RefObject } from 'react';
|
||||||
import {
|
import {
|
||||||
Upload, File, CheckCircle, XCircle, Loader2,
|
Upload, File, CheckCircle, XCircle, Loader2,
|
||||||
Copy, ExternalLink, Trash2, Globe,
|
Copy, ExternalLink, Trash2, Globe, AlertTriangle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { getSettings } from '@/lib/storage';
|
||||||
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 ── */
|
/* ── Helpers ── */
|
||||||
function formatBytes(b: number): string {
|
function formatBytes(b: number): string {
|
||||||
@@ -57,20 +54,80 @@ export default function QuickUpload({
|
|||||||
onBrowse, onInputChange, onStartUpload, onCopyCid, onCopyShareLink, onClearAll, gatewayLink,
|
onBrowse, onInputChange, onStartUpload, onCopyCid, onCopyShareLink, onClearAll, gatewayLink,
|
||||||
}: QuickUploadProps) {
|
}: 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 doneCount = files.filter(f => f.status === 'done').length;
|
||||||
const errorCount = files.filter(f => f.status === 'error').length;
|
const errorCount = files.filter(f => f.status === 'error').length;
|
||||||
const totalCount = files.length;
|
const totalCount = files.length;
|
||||||
const allDone = files.every(f => f.status === 'done' || f.status === 'error');
|
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 (
|
return (
|
||||||
<div>
|
<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 */}
|
{/* Drop zone */}
|
||||||
{!uploading && !uploadDone && (
|
{!uploading && !uploadDone && (
|
||||||
<div
|
<div
|
||||||
onDragOver={onDragOver}
|
onDragOver={onDragOver}
|
||||||
onDragLeave={onDragLeave}
|
onDragLeave={onDragLeave}
|
||||||
onDrop={files.length < MAX_FILES ? onDrop : undefined}
|
onDrop={files.length < maxFiles ? handleLocalDrop : undefined}
|
||||||
onClick={files.length < MAX_FILES ? onBrowse : 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 ${
|
className={`relative border-2 border-dashed rounded-xl p-10 text-center transition-all duration-200 cursor-pointer ${
|
||||||
dragOver
|
dragOver
|
||||||
? 'border-brand-400 bg-brand-500/10'
|
? 'border-brand-400 bg-brand-500/10'
|
||||||
@@ -82,8 +139,8 @@ export default function QuickUpload({
|
|||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={onInputChange}
|
onChange={files.length < maxFiles ? handleLocalInputChange : undefined}
|
||||||
disabled={uploading || files.length >= MAX_FILES}
|
disabled={uploading || files.length >= maxFiles}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<Upload className="w-10 h-10 text-surface-500" />
|
<Upload className="w-10 h-10 text-surface-500" />
|
||||||
@@ -92,7 +149,7 @@ export default function QuickUpload({
|
|||||||
<span className="text-brand-400 font-medium">Click to browse</span> or drag & drop files
|
<span className="text-brand-400 font-medium">Click to browse</span> or drag & drop files
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-surface-500 mt-1">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -162,7 +219,7 @@ export default function QuickUpload({
|
|||||||
Uploading…
|
Uploading…
|
||||||
</span>
|
</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]">
|
<span className="text-xs font-mono text-surface-400 truncate max-w-[160px]">
|
||||||
{entry.result.cid.slice(0, 16)}…
|
{entry.result.cid.slice(0, 16)}…
|
||||||
</span>
|
</span>
|
||||||
@@ -257,12 +314,12 @@ export default function QuickUpload({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Per-file result details */}
|
{/* 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 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">
|
<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-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 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>
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
<button
|
<button
|
||||||
|
|||||||
+33
-12
@@ -1,8 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef, type DragEvent, useCallback } from 'react';
|
import { useState, useRef, type DragEvent, useCallback, useMemo } from 'react';
|
||||||
import { uploadFile } from '@/lib/api';
|
import { uploadFile } from '@/lib/api';
|
||||||
import { addToHistory } from '@/lib/storage';
|
import { addToHistory, getSettings } from '@/lib/storage';
|
||||||
import PortalLayout from '@/app/layout-portal';
|
import PortalLayout from '@/app/layout-portal';
|
||||||
import { checkUploadAllowed } from '@/lib/limits';
|
import { checkUploadAllowed } from '@/lib/limits';
|
||||||
import { useNotify } from '@/lib/notifications';
|
import { useNotify } from '@/lib/notifications';
|
||||||
@@ -10,17 +10,13 @@ import { Zap, ArrowUp } from 'lucide-react';
|
|||||||
import QuickUpload, { type FileEntry } from './components/QuickUpload';
|
import QuickUpload, { type FileEntry } from './components/QuickUpload';
|
||||||
import CryptoUpload from './components/CryptoUpload';
|
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';
|
type TabMode = 'quick' | 'crypto';
|
||||||
|
|
||||||
/* ════════════════════════════ Page ════════════════════════════ */
|
/* ════════════════════════════ Page ════════════════════════════ */
|
||||||
|
|
||||||
export default function UploadPage() {
|
export default function UploadPage() {
|
||||||
const { notify } = useNotify();
|
const { notify } = useNotify();
|
||||||
|
const settings = useMemo(() => getSettings(), []);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const cryptoInputRef = useRef<HTMLInputElement>(null);
|
const cryptoInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -41,24 +37,49 @@ export default function UploadPage() {
|
|||||||
|
|
||||||
/* ── File management ── */
|
/* ── File management ── */
|
||||||
const addFiles = useCallback((incoming: FileList | File[]) => {
|
const addFiles = useCallback((incoming: FileList | File[]) => {
|
||||||
|
const s = getSettings();
|
||||||
const arr = Array.from(incoming);
|
const arr = Array.from(incoming);
|
||||||
const remaining = MAX_FILES - files.length;
|
const remaining = s.maxFiles - files.length;
|
||||||
const toAdd = arr.slice(0, remaining);
|
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
|
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 => !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 => ({
|
.map(f => ({
|
||||||
id: Math.random().toString(36).slice(2, 9),
|
id: Math.random().toString(36).slice(2, 9),
|
||||||
file: f,
|
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,
|
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;
|
if (entries.length === 0) return;
|
||||||
setFiles(prev => [...prev, ...entries]);
|
setFiles(prev => [...prev, ...entries]);
|
||||||
setUploadDone(false);
|
setUploadDone(false);
|
||||||
}, [files]);
|
}, [files, notify]);
|
||||||
|
|
||||||
function removeFile(id: string) {
|
function removeFile(id: string) {
|
||||||
setFiles(prev => {
|
setFiles(prev => {
|
||||||
@@ -176,7 +197,7 @@ export default function UploadPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function gatewayLink(cid: string) {
|
function gatewayLink(cid: string) {
|
||||||
return `https://maos.dedyn.io/ipfs/${cid}`;
|
return `https://ipfs.maos.dedyn.io/${cid}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ════════════ Render ════════════ */
|
/* ════════════ Render ════════════ */
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import PortalLayout from '@/app/layout-portal';
|
import PortalLayout from '@/app/layout-portal';
|
||||||
import { getRepoStats, getBWStats, getPeers, listPins } from '@/lib/api';
|
import { getRepoStats, getPeers, listPins } from '@/lib/api';
|
||||||
import type { RepoStats, BWStats } from '@/lib/api';
|
import type { RepoStats } from '@/lib/api';
|
||||||
import { checkUploadAllowed, type UsageCheckResult } from '@/lib/limits';
|
import { checkUploadAllowed, type UsageCheckResult } from '@/lib/limits';
|
||||||
import { useAuth } from '@/lib/auth';
|
import { useAuth } from '@/lib/auth';
|
||||||
import StorageGauge from '@/app/dashboard/components/StorageGauge';
|
import StorageGauge from '@/app/dashboard/components/StorageGauge';
|
||||||
@@ -17,7 +17,6 @@ export default function UsagePage() {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [usage, setUsage] = useState<UsageCheckResult | null>(null);
|
const [usage, setUsage] = useState<UsageCheckResult | null>(null);
|
||||||
const [repo, setRepo] = useState<RepoStats | null>(null);
|
const [repo, setRepo] = useState<RepoStats | null>(null);
|
||||||
const [bw, setBW] = useState<BWStats | null>(null);
|
|
||||||
const [peerCount, setPeerCount] = useState(0);
|
const [peerCount, setPeerCount] = useState(0);
|
||||||
const [pinCount, setPinCount] = useState(0);
|
const [pinCount, setPinCount] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -25,16 +24,14 @@ export default function UsagePage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const [u, r, b, p, pins] = await Promise.all([
|
const [u, r, p, pins] = await Promise.all([
|
||||||
checkUploadAllowed().catch(() => null),
|
checkUploadAllowed().catch(() => null),
|
||||||
getRepoStats().catch(() => null),
|
getRepoStats().catch(() => null),
|
||||||
getBWStats().catch(() => null),
|
|
||||||
getPeers().catch(() => []),
|
getPeers().catch(() => []),
|
||||||
listPins().catch(() => []),
|
listPins().catch(() => []),
|
||||||
]);
|
]);
|
||||||
setUsage(u);
|
setUsage(u);
|
||||||
setRepo(r);
|
setRepo(r);
|
||||||
setBW(b);
|
|
||||||
setPeerCount(p.length);
|
setPeerCount(p.length);
|
||||||
setPinCount(pins.length);
|
setPinCount(pins.length);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -122,7 +119,7 @@ export default function UsagePage() {
|
|||||||
<span className="text-xs text-surface-400">In</span>
|
<span className="text-xs text-surface-400">In</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-semibold text-surface-200">
|
<span className="text-sm font-semibold text-surface-200">
|
||||||
{bw ? (bw.totalIn / 1e6).toFixed(1) : '—'} MB
|
— MB
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between p-3 rounded-lg bg-surface-900/50 border border-surface-800">
|
<div className="flex items-center justify-between p-3 rounded-lg bg-surface-900/50 border border-surface-800">
|
||||||
@@ -131,7 +128,7 @@ export default function UsagePage() {
|
|||||||
<span className="text-xs text-surface-400">Uit</span>
|
<span className="text-xs text-surface-400">Uit</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-semibold text-surface-200">
|
<span className="text-sm font-semibold text-surface-200">
|
||||||
{bw ? (bw.totalOut / 1e6).toFixed(1) : '—'} MB
|
— MB
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -45,11 +45,11 @@ describe('formatBytes', () => {
|
|||||||
describe('gatewayLink', () => {
|
describe('gatewayLink', () => {
|
||||||
it('returns correct gateway URL for valid CID', () => {
|
it('returns correct gateway URL for valid CID', () => {
|
||||||
const cid = 'QmTest123';
|
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 empty string', () => {
|
it('handles empty string', () => {
|
||||||
expect(gatewayLink('')).toBe('https://maos.dedyn.io/ipfs/');
|
expect(gatewayLink('')).toBe('https://ipfs.maos.dedyn.io/');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -85,7 +85,12 @@ export async function uploadFile(file: File): Promise<{ cid: string; size: numbe
|
|||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`Upload failed: ${res.statusText}`);
|
if (!res.ok) throw new Error(`Upload failed: ${res.statusText}`);
|
||||||
return res.json();
|
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 }[]> {
|
export async function listFiles(): Promise<{ name: string; cid: string; size: number; modified: string }[]> {
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ export function formatBytes(bytes: number): string {
|
|||||||
|
|
||||||
/* ── Gateway link for CID ── */
|
/* ── Gateway link for CID ── */
|
||||||
export function gatewayLink(cid: string): string {
|
export function gatewayLink(cid: string): string {
|
||||||
return `https://maos.dedyn.io/ipfs/${cid}`;
|
return `https://ipfs.maos.dedyn.io/${cid}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Truncate CID for display ── */
|
/* ── Truncate CID for display ── */
|
||||||
|
|||||||
+14
-2
@@ -12,6 +12,9 @@ export interface PortalSettings {
|
|||||||
storageMax: number; // MB
|
storageMax: number; // MB
|
||||||
refreshInterval: number; // seconds (dashboard auto-refresh)
|
refreshInterval: number; // seconds (dashboard auto-refresh)
|
||||||
theme: 'dark' | 'light';
|
theme: 'dark' | 'light';
|
||||||
|
allowedFileTypes: string; // comma-separated extensions, '' = all
|
||||||
|
maxFileSize: number; // bytes (0 = unlimited)
|
||||||
|
maxFiles: number; // max files per upload batch
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UploadRecord {
|
export interface UploadRecord {
|
||||||
@@ -29,11 +32,14 @@ const STORAGE_KEY = 'ipfs-portal-settings';
|
|||||||
const HISTORY_KEY = 'ipfs-portal-history';
|
const HISTORY_KEY = 'ipfs-portal-history';
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: PortalSettings = {
|
const DEFAULT_SETTINGS: PortalSettings = {
|
||||||
gatewayUrl: 'https://maos.dedyn.io/ipfs',
|
gatewayUrl: 'https://ipfs.maos.dedyn.io',
|
||||||
apiEndpoint: '',
|
apiEndpoint: '',
|
||||||
storageMax: 30720, // 30 GB
|
storageMax: 30720, // 30 GB
|
||||||
refreshInterval: 15,
|
refreshInterval: 15,
|
||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
|
allowedFileTypes: '', // '' = all types allowed
|
||||||
|
maxFileSize: 100 * 1024 * 1024, // 100 MB
|
||||||
|
maxFiles: 20,
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ════════════════════════════ Settings ════════════════════════════ */
|
/* ════════════════════════════ Settings ════════════════════════════ */
|
||||||
@@ -72,8 +78,14 @@ export function getHistory(): UploadRecord[] {
|
|||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(HISTORY_KEY);
|
const raw = localStorage.getItem(HISTORY_KEY);
|
||||||
if (!raw) return [];
|
if (!raw) return [];
|
||||||
return JSON.parse(raw) as UploadRecord[];
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
localStorage.removeItem(HISTORY_KEY);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return parsed as UploadRecord[];
|
||||||
} catch {
|
} catch {
|
||||||
|
localStorage.removeItem(HISTORY_KEY);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-26
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import useSWRInfinite from 'swr/infinite';
|
import useSWRInfinite from 'swr/infinite';
|
||||||
import type { IPFSPin, RepoStats, BWStats, IPFSNodeInfo, IPFSUser } from './api';
|
import type { IPFSPin, RepoStats, IPFSNodeInfo, IPFSUser } from './api';
|
||||||
|
|
||||||
/* ════════════════════════════ Fetcher ════════════════════════════ */
|
/* ════════════════════════════ Fetcher ════════════════════════════ */
|
||||||
|
|
||||||
@@ -88,38 +88,19 @@ export function useRepoStats(refreshInterval = DEFAULT_REFRESH) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ════════════════════════════ Bandwidth ════════════════════════════ */
|
|
||||||
|
|
||||||
export function useBandwidth(refreshInterval = DEFAULT_REFRESH) {
|
|
||||||
return useSWR<BWStats>('/api/bw/stats', async (path) => {
|
|
||||||
const raw = await fetcher<{
|
|
||||||
TotalIn?: number; TotalOut?: number; RateIn?: number; RateOut?: number;
|
|
||||||
}>(path);
|
|
||||||
return {
|
|
||||||
totalIn: raw.TotalIn ?? 0,
|
|
||||||
totalOut: raw.TotalOut ?? 0,
|
|
||||||
rateIn: raw.RateIn ?? 0,
|
|
||||||
rateOut: raw.RateOut ?? 0,
|
|
||||||
};
|
|
||||||
}, {
|
|
||||||
refreshInterval,
|
|
||||||
revalidateOnFocus: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ════════════════════════════ Dashboard (alles-in-1) ════════════════════════════ */
|
/* ════════════════════════════ Dashboard (alles-in-1) ════════════════════════════ */
|
||||||
|
|
||||||
export interface DashboardData {
|
export interface DashboardData {
|
||||||
peers: Peer[];
|
peers: Peer[];
|
||||||
pins: IPFSPin[];
|
pins: IPFSPin[];
|
||||||
repo: RepoStats | null;
|
repo: RepoStats | null;
|
||||||
bw: BWStats | null;
|
bw: null;
|
||||||
health: { status: string; node: string } | null;
|
health: { status: string; node: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDashboard(refreshInterval = DEFAULT_REFRESH) {
|
export function useDashboard(refreshInterval = DEFAULT_REFRESH) {
|
||||||
return useSWR<DashboardData>('/api/health', async (healthPath) => {
|
return useSWR<DashboardData>('/api/health', async (healthPath) => {
|
||||||
const [health, peers, pins, repo, bw] = await Promise.all([
|
const [health, peers, pins, repo] = await Promise.all([
|
||||||
fetcher<{ status: string; node: string }>(healthPath).catch(() => null as any),
|
fetcher<{ status: string; node: string }>(healthPath).catch(() => null as any),
|
||||||
fetcher<{ Peers?: { Peer: string; Addr: string; Latency: string }[] }>('/api/peers')
|
fetcher<{ Peers?: { Peer: string; Addr: string; Latency: string }[] }>('/api/peers')
|
||||||
.then((raw) => (raw.Peers || []).map((p) => ({ id: p.Peer, addr: p.Addr, latency: p.Latency || '—' })))
|
.then((raw) => (raw.Peers || []).map((p) => ({ id: p.Peer, addr: p.Addr, latency: p.Latency || '—' })))
|
||||||
@@ -130,12 +111,9 @@ export function useDashboard(refreshInterval = DEFAULT_REFRESH) {
|
|||||||
fetcher<{ RepoSize?: number; StorageMax?: number; NumObjects?: number }>('/api/repo/stats')
|
fetcher<{ RepoSize?: number; StorageMax?: number; NumObjects?: number }>('/api/repo/stats')
|
||||||
.then((raw) => ({ repoSize: raw.RepoSize ?? 0, storageMax: raw.StorageMax ?? 0, numObjects: raw.NumObjects ?? 0, repoPath: '', version: '' }))
|
.then((raw) => ({ repoSize: raw.RepoSize ?? 0, storageMax: raw.StorageMax ?? 0, numObjects: raw.NumObjects ?? 0, repoPath: '', version: '' }))
|
||||||
.catch(() => null),
|
.catch(() => null),
|
||||||
fetcher<{ TotalIn?: number; TotalOut?: number }>('/api/bw/stats')
|
|
||||||
.then((raw) => ({ totalIn: raw.TotalIn ?? 0, totalOut: raw.TotalOut ?? 0, rateIn: 0, rateOut: 0 }))
|
|
||||||
.catch(() => null),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { peers, pins, repo, bw, health };
|
return { peers, pins, repo, bw: null, health };
|
||||||
}, {
|
}, {
|
||||||
refreshInterval,
|
refreshInterval,
|
||||||
revalidateOnFocus: true,
|
revalidateOnFocus: true,
|
||||||
|
|||||||
+24
-5
@@ -39,6 +39,10 @@ export interface RealtimeState {
|
|||||||
|
|
||||||
/* ════════════════════════════ Hook ════════════════════════════ */
|
/* ════════════════════════════ Hook ════════════════════════════ */
|
||||||
|
|
||||||
|
const MAX_RECONNECT_ATTEMPTS = 10;
|
||||||
|
const BASE_RECONNECT_DELAY = 2_000; // start 2s
|
||||||
|
const MAX_RECONNECT_DELAY = 30_000; // cap at 30s
|
||||||
|
|
||||||
export function useRealtime(): RealtimeState {
|
export function useRealtime(): RealtimeState {
|
||||||
const [state, setState] = useState<RealtimeState>({
|
const [state, setState] = useState<RealtimeState>({
|
||||||
connected: false,
|
connected: false,
|
||||||
@@ -49,6 +53,7 @@ export function useRealtime(): RealtimeState {
|
|||||||
|
|
||||||
const eventSourceRef = useRef<EventSource | null>(null);
|
const eventSourceRef = useRef<EventSource | null>(null);
|
||||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const attemptRef = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -60,7 +65,10 @@ export function useRealtime(): RealtimeState {
|
|||||||
eventSourceRef.current = es;
|
eventSourceRef.current = es;
|
||||||
|
|
||||||
es.addEventListener('connected', () => {
|
es.addEventListener('connected', () => {
|
||||||
if (!cancelled) setState((prev) => ({ ...prev, connected: true }));
|
if (!cancelled) {
|
||||||
|
attemptRef.current = 0;
|
||||||
|
setState((prev) => ({ ...prev, connected: true }));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
es.addEventListener('bandwidth', (e: MessageEvent) => {
|
es.addEventListener('bandwidth', (e: MessageEvent) => {
|
||||||
@@ -87,11 +95,22 @@ export function useRealtime(): RealtimeState {
|
|||||||
es.onerror = () => {
|
es.onerror = () => {
|
||||||
es.close();
|
es.close();
|
||||||
eventSourceRef.current = null;
|
eventSourceRef.current = null;
|
||||||
if (!cancelled) {
|
if (cancelled) return;
|
||||||
setState((prev) => ({ ...prev, connected: false }));
|
|
||||||
// Auto-reconnect na 3s
|
setState((prev) => ({ ...prev, connected: false }));
|
||||||
reconnectTimeoutRef.current = setTimeout(connect, 3_000);
|
|
||||||
|
attemptRef.current += 1;
|
||||||
|
if (attemptRef.current > MAX_RECONNECT_ATTEMPTS) {
|
||||||
|
// Maximum bereikt — stop met reconnecten
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exponential backoff: base * 2^attempt, capped
|
||||||
|
const delay = Math.min(
|
||||||
|
BASE_RECONNECT_DELAY * Math.pow(2, attemptRef.current - 1),
|
||||||
|
MAX_RECONNECT_DELAY,
|
||||||
|
);
|
||||||
|
reconnectTimeoutRef.current = setTimeout(connect, delay);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -1,4 +1,10 @@
|
|||||||
/* ── Wallet connection (EIP-6963 + MAOS extension) ── */
|
/* ── Wallet connection (EIP-6963 + MAOS extension) ──
|
||||||
|
*
|
||||||
|
* NOTE: MetaMask extension injects `MaxListenersExceededWarning` en
|
||||||
|
* `ObjectMultiplex` errors in de console. Deze komen uit de extension
|
||||||
|
* zelf (background script → content script communicatie) en zijn niet
|
||||||
|
* door onze code te fixen. Ze zijn onschadelijk.
|
||||||
|
*/
|
||||||
|
|
||||||
/* ── Global type augmentation for window properties ── */
|
/* ── Global type augmentation for window properties ── */
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* ── IPFS Portal Middleware ──
|
/* ── IPFS Portal Proxy (Route Guard) ──
|
||||||
*
|
*
|
||||||
* Server-side route guard.
|
* Next.js 16+ proxy — vervangt middleware.ts.
|
||||||
* Beschermt private pages op basis van JWT session cookie (wallet-based auth).
|
* Beschermt private pages op basis van JWT session cookie (wallet-based auth).
|
||||||
* Verifieert JWT lokaal — geen external call naar Python backend.
|
* Verifieert JWT lokaal — geen external call naar Python backend.
|
||||||
*/
|
*/
|
||||||
@@ -39,7 +39,7 @@ const guards: RouteGuard[] = [
|
|||||||
|
|
||||||
/* ════════════════════════════ Handler ════════════════════════════ */
|
/* ════════════════════════════ Handler ════════════════════════════ */
|
||||||
|
|
||||||
export async function middleware(req: NextRequest) {
|
export async function proxy(req: NextRequest) {
|
||||||
const { pathname } = req.nextUrl;
|
const { pathname } = req.nextUrl;
|
||||||
|
|
||||||
// Check if path matches any guard
|
// Check if path matches any guard
|
||||||
Reference in New Issue
Block a user