From c9432a16ba4e1ba674c80db0deb70b0e5234fcb4 Mon Sep 17 00:00:00 2001 From: maikrolf <40924921+maikrolf@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:31:05 +0200 Subject: [PATCH] SIWE auth, API proxy fixes, dashboard, login, usage pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SIWE auth flow: challenge/login/logout/me API routes + JWT middleware - Fixed [...path] catch-all: Kubo proxy with matchRoute() + mapToKuboAPI() - Fixed session_id → nonce field mismatch in AuthProvider - Fixed dashboard pins crash: Kubo returns {Keys: {...}}, not array - Added middleware route guard (307 redirect to /login) - Added login, register, profile, usage, upload pages - Added dashboard components (StorageGauge, FreeTierProgress) - Added SWR hooks + AuthGuard + Providers + Toast/ErrorBoundary - Added payment integration (smart contract ABI, tiers, tokens) - Fixed IPNS key listing: Name/Id → name/id mapping - Added PWA support (manifest, service worker, icons) - Added tests for helpers, limits, search-index, wallet --- package-lock.json | 923 +++++++++++++++++- package.json | 6 +- public/favicon.svg | 10 + public/icon-192.svg | 4 + public/icon-512.svg | 4 + public/manifest.json | 31 + public/sw.js | 143 +++ scripts/gen-icons.cjs | 18 + scripts/icon-192.png | Bin 0 -> 3034 bytes scripts/icon-512.png | Bin 0 -> 11527 bytes src/app/admin/payment/components/Overview.tsx | 108 ++ .../admin/payment/components/PricingView.tsx | 124 +++ .../admin/payment/components/TiersView.tsx | 88 ++ .../admin/payment/components/TokensView.tsx | 124 +++ src/app/admin/payment/page.tsx | 389 ++------ src/app/api/[...path]/__tests__/route.test.ts | 221 +++++ src/app/api/[...path]/route.ts | 60 +- src/app/api/auth/challenge/route.ts | 37 + src/app/api/auth/login/route.ts | 85 ++ src/app/api/auth/logout/route.ts | 47 + src/app/api/auth/me/route.ts | 31 + src/app/api/auth/password/route.ts | 58 ++ src/app/api/events/route.ts | 153 +++ .../dashboard/components/FreeTierProgress.tsx | 54 + src/app/dashboard/components/StorageGauge.tsx | 43 + src/app/dashboard/page.tsx | 104 +- src/app/error.tsx | 38 + .../explorer/components/DirectoryListing.tsx | 28 +- src/app/explorer/components/FilePreview.tsx | 11 +- src/app/explorer/page.tsx | 69 +- src/app/globals.css | 50 + src/app/history/page.tsx | 142 ++- src/app/ipns/page.tsx | 9 +- src/app/layout.tsx | 13 +- src/app/login/page.tsx | 143 +++ src/app/page.tsx | 8 + src/app/peers/page.tsx | 5 +- src/app/pins/page.tsx | 67 +- src/app/profile/page.tsx | 185 ++++ src/app/register/page.tsx | 139 +++ src/app/settings/page.tsx | 16 +- src/app/upload/components/CryptoUpload.tsx | 142 +++ src/app/upload/components/QuickUpload.tsx | 316 ++++++ src/app/upload/page.tsx | 494 ++-------- src/app/usage/page.tsx | 171 ++++ src/app/users/page.tsx | 3 +- src/components/AuthGuard.tsx | 56 ++ src/components/BatchBar.tsx | 67 ++ src/components/ErrorBoundary.tsx | 64 ++ src/components/PaymentPanel.tsx | 5 +- src/components/PortalHeader.tsx | 25 +- src/components/PortalSidebar.tsx | 19 +- src/components/Providers.tsx | 19 + src/components/SWRegister.tsx | 19 + src/components/SearchBar.tsx | 234 +++++ src/components/Skeleton.tsx | 63 ++ src/components/Toast.tsx | 90 ++ src/components/ToastContainer.tsx | 26 + src/lib/__tests__/helpers.test.ts | 78 ++ src/lib/__tests__/limits.test.ts | 62 ++ src/lib/__tests__/search-index.test.ts | 144 +++ src/lib/__tests__/wallet.test.ts | 66 ++ src/lib/api.ts | 8 +- src/lib/auth-server.ts | 71 ++ src/lib/auth.tsx | 133 +++ src/lib/download.ts | 44 + src/lib/helpers.ts | 19 + src/lib/limits.ts | 128 +++ src/lib/notifications.tsx | 71 ++ src/lib/payment.ts | 539 ---------- src/lib/payment/abi.ts | 87 ++ src/lib/payment/index.ts | 7 + src/lib/payment/read-service.ts | 154 +++ src/lib/payment/service.ts | 4 + src/lib/payment/types.ts | 44 + src/lib/payment/write-service.ts | 126 +++ src/lib/search-index.ts | 207 ++++ src/lib/storage.ts | 10 + src/lib/swr.ts | 186 ++++ src/lib/theme.tsx | 72 ++ src/lib/useRealtime.ts | 114 +++ src/lib/wallet.ts | 24 +- src/middleware.ts | 90 ++ vitest.config.ts | 16 + 84 files changed, 6679 insertions(+), 1426 deletions(-) create mode 100644 public/favicon.svg create mode 100644 public/icon-192.svg create mode 100644 public/icon-512.svg create mode 100644 public/manifest.json create mode 100644 public/sw.js create mode 100644 scripts/gen-icons.cjs create mode 100644 scripts/icon-192.png create mode 100644 scripts/icon-512.png create mode 100644 src/app/admin/payment/components/Overview.tsx create mode 100644 src/app/admin/payment/components/PricingView.tsx create mode 100644 src/app/admin/payment/components/TiersView.tsx create mode 100644 src/app/admin/payment/components/TokensView.tsx create mode 100644 src/app/api/[...path]/__tests__/route.test.ts create mode 100644 src/app/api/auth/challenge/route.ts create mode 100644 src/app/api/auth/login/route.ts create mode 100644 src/app/api/auth/logout/route.ts create mode 100644 src/app/api/auth/me/route.ts create mode 100644 src/app/api/auth/password/route.ts create mode 100644 src/app/api/events/route.ts create mode 100644 src/app/dashboard/components/FreeTierProgress.tsx create mode 100644 src/app/dashboard/components/StorageGauge.tsx create mode 100644 src/app/error.tsx create mode 100644 src/app/login/page.tsx create mode 100644 src/app/profile/page.tsx create mode 100644 src/app/register/page.tsx create mode 100644 src/app/upload/components/CryptoUpload.tsx create mode 100644 src/app/upload/components/QuickUpload.tsx create mode 100644 src/app/usage/page.tsx create mode 100644 src/components/AuthGuard.tsx create mode 100644 src/components/BatchBar.tsx create mode 100644 src/components/ErrorBoundary.tsx create mode 100644 src/components/Providers.tsx create mode 100644 src/components/SWRegister.tsx create mode 100644 src/components/SearchBar.tsx create mode 100644 src/components/Skeleton.tsx create mode 100644 src/components/Toast.tsx create mode 100644 src/components/ToastContainer.tsx create mode 100644 src/lib/__tests__/helpers.test.ts create mode 100644 src/lib/__tests__/limits.test.ts create mode 100644 src/lib/__tests__/search-index.test.ts create mode 100644 src/lib/__tests__/wallet.test.ts create mode 100644 src/lib/auth-server.ts create mode 100644 src/lib/auth.tsx create mode 100644 src/lib/download.ts create mode 100644 src/lib/helpers.ts create mode 100644 src/lib/limits.ts create mode 100644 src/lib/notifications.tsx delete mode 100644 src/lib/payment.ts create mode 100644 src/lib/payment/abi.ts create mode 100644 src/lib/payment/index.ts create mode 100644 src/lib/payment/read-service.ts create mode 100644 src/lib/payment/service.ts create mode 100644 src/lib/payment/types.ts create mode 100644 src/lib/payment/write-service.ts create mode 100644 src/lib/search-index.ts create mode 100644 src/lib/swr.ts create mode 100644 src/lib/theme.tsx create mode 100644 src/lib/useRealtime.ts create mode 100644 src/middleware.ts create mode 100644 vitest.config.ts diff --git a/package-lock.json b/package-lock.json index e881d0f..90c38c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,13 +8,16 @@ "name": "@maos/ipfs-portal", "version": "1.0.0", "dependencies": { + "@maos/ipfs-portal": "file:", "@tailwindcss/postcss": "^4.3.1", "clsx": "^2.1.1", + "jose": "^6.2.3", "lucide-react": "^0.577.0", "next": "^16.2.7", "postcss": "^8.5.15", "react": "^19.2.7", "react-dom": "^19.2.7", + "swr": "^2.4.2", "viem": "^2.29.0" }, "devDependencies": { @@ -23,7 +26,8 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "tailwindcss": "^4.0.0", - "typescript": "^5.8.0" + "typescript": "^5.8.0", + "vitest": "^4.1.9" } }, "node_modules/@adraffy/ens-normalize": { @@ -44,6 +48,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/runtime": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", @@ -54,6 +69,16 @@ "tslib": "^2.4.0" } }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -565,6 +590,28 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@maos/ipfs-portal": { + "resolved": "", + "link": true + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@next/env": { "version": "16.2.9", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.9.tgz", @@ -738,6 +785,16 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@oxc-project/types": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@playwright/test": { "version": "1.61.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", @@ -754,6 +811,270 @@ "node": ">=18" } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", + "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", + "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", + "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", + "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", + "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", + "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", + "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", + "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", + "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", + "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", + "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", + "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", + "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", + "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", + "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@scure/base": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", @@ -790,6 +1111,13 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1055,6 +1383,41 @@ "tailwindcss": "4.3.1" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.20.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", @@ -1085,6 +1448,119 @@ "@types/react": "^19.2.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/abitype": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", @@ -1106,6 +1582,16 @@ } } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.38", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", @@ -1138,6 +1624,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -1153,6 +1649,13 @@ "node": ">=6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1160,6 +1663,15 @@ "dev": true, "license": "MIT" }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1182,12 +1694,57 @@ "node": ">=10.13.0" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/eventemitter3": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -1233,6 +1790,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1599,6 +2165,20 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/ox": { "version": "0.14.29", "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.29.tgz", @@ -1629,12 +2209,32 @@ } } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/playwright": { "version": "1.61.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", @@ -1716,6 +2316,40 @@ "react": "^19.2.7" } }, + "node_modules/rolldown": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", + "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.137.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.3", + "@rolldown/binding-darwin-arm64": "1.1.3", + "@rolldown/binding-darwin-x64": "1.1.3", + "@rolldown/binding-freebsd-x64": "1.1.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", + "@rolldown/binding-linux-arm64-gnu": "1.1.3", + "@rolldown/binding-linux-arm64-musl": "1.1.3", + "@rolldown/binding-linux-ppc64-gnu": "1.1.3", + "@rolldown/binding-linux-s390x-gnu": "1.1.3", + "@rolldown/binding-linux-x64-gnu": "1.1.3", + "@rolldown/binding-linux-x64-musl": "1.1.3", + "@rolldown/binding-openharmony-arm64": "1.1.3", + "@rolldown/binding-wasm32-wasi": "1.1.3", + "@rolldown/binding-win32-arm64-msvc": "1.1.3", + "@rolldown/binding-win32-x64-msvc": "1.1.3" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -1780,6 +2414,13 @@ "@img/sharp-win32-x64": "0.34.5" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1789,6 +2430,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -1812,6 +2467,19 @@ } } }, + "node_modules/swr": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.2.tgz", + "integrity": "sha512-ej644Y2bvkIajfR32KGeSSdBXQW+ScjGjkybZgSE7kFpk9eGnV44XY9FJylXi+W75pavSX1PVNB57W5EbhGIYw==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/tailwindcss": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", @@ -1831,6 +2499,50 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -1858,6 +2570,15 @@ "dev": true, "license": "MIT" }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/viem": { "version": "2.53.1", "resolved": "https://registry.npmjs.org/viem/-/viem-2.53.1.tgz", @@ -1888,6 +2609,206 @@ } } }, + "node_modules/vite": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", + "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "~1.1.2", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ws": { "version": "8.20.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", diff --git a/package.json b/package.json index fc3f66e..987ccf8 100644 --- a/package.json +++ b/package.json @@ -9,13 +9,16 @@ "export": "next build && npx serve out" }, "dependencies": { + "@maos/ipfs-portal": "file:", "@tailwindcss/postcss": "^4.3.1", "clsx": "^2.1.1", + "jose": "^6.2.3", "lucide-react": "^0.577.0", "next": "^16.2.7", "postcss": "^8.5.15", "react": "^19.2.7", "react-dom": "^19.2.7", + "swr": "^2.4.2", "viem": "^2.29.0" }, "devDependencies": { @@ -24,6 +27,7 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "tailwindcss": "^4.0.0", - "typescript": "^5.8.0" + "typescript": "^5.8.0", + "vitest": "^4.1.9" } } diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..bc278be --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,10 @@ + + + + + + + + + M + diff --git a/public/icon-192.svg b/public/icon-192.svg new file mode 100644 index 0000000..f042b95 --- /dev/null +++ b/public/icon-192.svg @@ -0,0 +1,4 @@ + + + M + diff --git a/public/icon-512.svg b/public/icon-512.svg new file mode 100644 index 0000000..86b4ae2 --- /dev/null +++ b/public/icon-512.svg @@ -0,0 +1,4 @@ + + + M + diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..b532d3f --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,31 @@ +{ + "name": "MAOS IPFS Portal", + "short_name": "IPFS Portal", + "description": "Decentralized storage management for your IPFS node", + "start_url": "/", + "display": "standalone", + "background_color": "#0a0e17", + "theme_color": "#0891b2", + "orientation": "any", + "categories": ["utilities", "productivity"], + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..69d4912 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,143 @@ +/* ── IPFS Portal — Service Worker v2 ── + * + * Cache strategieën per route type: + * - Precache: app shell (alle pages) + * - Cache-first: static assets (fonts, images, CSS, JS) + * - Network-first: API calls (/_next/data, /api/*) + * - Stale-while-revalidate: navigatie requests + */ + +const CACHE = 'ipfs-portal-v2'; +const STATIC_CACHE = 'ipfs-portal-static-v2'; +const DATA_CACHE = 'ipfs-portal-data-v2'; + +const PRECACHE_URLS = [ + '/', + '/dashboard', + '/upload', + '/explorer', + '/pins', + '/history', + '/usage', + '/peers', + '/users', + '/ipns', + '/login', + '/settings', + '/profile', +]; + +/* ── Install: precache app shell ── */ + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE).then((cache) => cache.addAll(PRECACHE_URLS)) + ); + self.skipWaiting(); +}); + +/* ── Activate: clean old caches ── */ + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all( + keys + .filter((k) => k !== CACHE && k !== STATIC_CACHE && k !== DATA_CACHE) + .map((k) => caches.delete(k)) + ) + ) + ); + self.clients.claim(); +}); + +/* ── Helper: is this a navigation request? ── */ + +function isNavigation(req) { + return req.mode === 'navigate'; +} + +function isStaticAsset(url) { + return /\.(png|jpg|jpeg|gif|svg|webp|ico|woff2?|ttf|eot|css|js|json)$/i.test(url.pathname); +} + +function isAPIRequest(url) { + return url.pathname.startsWith('/api/') || url.pathname.startsWith('/_next/data/'); +} + +/* ── Fetch handler ── */ + +self.addEventListener('fetch', (event) => { + const { request } = event; + const url = new URL(request.url); + + // Same-origin only + if (url.origin !== self.location.origin) return; + + // 1. Static assets → cache-first + if (isStaticAsset(url)) { + event.respondWith(cacheFirst(request, STATIC_CACHE)); + return; + } + + // 2. API / Next.js data → network-first + if (isAPIRequest(url)) { + event.respondWith(networkFirst(request, DATA_CACHE)); + return; + } + + // 3. Navigation → stale-while-revalidate + if (isNavigation(request)) { + event.respondWith(staleWhileRevalidate(request, CACHE)); + return; + } + + // 4. All other requests → network-first with cache fallback + event.respondWith(networkFirst(request, CACHE)); +}); + +/* ── Cache strategies ── */ + +async function cacheFirst(request, cacheName) { + const cached = await caches.match(request); + if (cached) return cached; + try { + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(cacheName); + cache.put(request, response.clone()); + } + return response; + } catch { + return new Response('Offline', { status: 503 }); + } +} + +async function networkFirst(request, cacheName) { + try { + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(cacheName); + cache.put(request, response.clone()); + } + return response; + } catch { + const cached = await caches.match(request); + if (cached) return cached; + return new Response('Offline', { status: 503 }); + } +} + +async function staleWhileRevalidate(request, cacheName) { + const cache = await caches.open(cacheName); + const cached = await cache.match(request); + + const fetchPromise = fetch(request) + .then((response) => { + if (response.ok) cache.put(request, response.clone()); + return response; + }) + .catch(() => cached); + + return cached || fetchPromise; +} diff --git a/scripts/gen-icons.cjs b/scripts/gen-icons.cjs new file mode 100644 index 0000000..d3a99b4 --- /dev/null +++ b/scripts/gen-icons.cjs @@ -0,0 +1,18 @@ +/* ── Generate PNG icons from SVG ── */ +const sharp = require('sharp'); +const fs = require('fs'); +const path = require('path'); + +async function main() { + const sizes = [192, 512]; + for (const size of sizes) { + const svg = fs.readFileSync(path.join(__dirname, '..', 'public', 'icon-' + size + '.svg')); + await sharp(svg) + .resize(size, size) + .png() + .toFile(path.join(__dirname, 'icon-' + size + '.png')); + console.log(`Generated icon-${size}.png`); + } +} + +main().catch(console.error); diff --git a/scripts/icon-192.png b/scripts/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..58b71b9022fb2f65cd68a2f042136a03622598ec GIT binary patch literal 3034 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 literal 0 HcmV?d00001 diff --git a/scripts/icon-512.png b/scripts/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..975906ed93477a7dae85ef47c20ed546b9a8077f GIT binary patch literal 11527 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 W`>dM0;Se(bJMH)UlKV3z_J07oGiOl% literal 0 HcmV?d00001 diff --git a/src/app/admin/payment/components/Overview.tsx b/src/app/admin/payment/components/Overview.tsx new file mode 100644 index 0000000..8fcd6d1 --- /dev/null +++ b/src/app/admin/payment/components/Overview.tsx @@ -0,0 +1,108 @@ +'use client'; + +import type { TokenInfo } from '@/lib/payment'; +import { formatWeiToETH } from '@/lib/wallet'; + +/* ── Props ── */ +interface OverviewProps { + totalUploads: bigint; + tokens: TokenInfo[]; + revenues: Record; + contractBalance: Record; + contractAddress: string; + owner: string | null; + isOwner: boolean; + address: string | null; + deployed: boolean; + onNavigate: (view: string) => void; +} + +/* ════════════════════════════ Component ════════════════════════════ */ + +export default function Overview({ + totalUploads, tokens, revenues, contractBalance, + contractAddress, owner, isOwner, address, deployed, onNavigate, +}: OverviewProps) { + + return ( +
+ {/* Stats grid */} +
+
+
Total Uploads
+
{totalUploads.toString()}
+
+ {tokens.filter(t => t.enabled).map(tk => { + const rev = revenues[tk.symbol] || BigInt(0); + const bal = contractBalance[tk.symbol]; + return ( +
+
{tk.symbol} Revenue
+
+ {tk.symbol === 'ETH' ? formatWeiToETH(rev, 6) : rev.toString()} +
+ {bal && ( +
+ Balance: {tk.symbol === 'ETH' ? formatWeiToETH(bal, 6) : bal} {tk.symbol} +
+ )} +
+ ); + })} +
+ + {/* Contract info */} +
+

Contract

+
+
+ Address + + {contractAddress.slice(0, 10)}... + +
+
+ Owner + + {owner ? `${owner.slice(0, 8)}...${owner.slice(-6)}` : '-'} + {isOwner && } + +
+
+ Deployed + + {deployed ? 'Yes' : 'No'} + +
+
+ Connected + + {address ? `${address.slice(0, 6)}...${address.slice(-4)}` : '-'} + +
+
+
+ + {/* If owner — quick actions */} + {isOwner && ( +
+

Quick Actions

+
+ + + +
+
+ )} +
+ ); +} diff --git a/src/app/admin/payment/components/PricingView.tsx b/src/app/admin/payment/components/PricingView.tsx new file mode 100644 index 0000000..a2de36b --- /dev/null +++ b/src/app/admin/payment/components/PricingView.tsx @@ -0,0 +1,124 @@ +'use client'; + +import { type Dispatch, type SetStateAction } from 'react'; +import { Settings } from 'lucide-react'; +import { paymentService, type TokenInfo } from '@/lib/payment'; +import type { WalletProvider } from '@/lib/wallet'; +import { formatWeiToETH, formatBytes } from '@/lib/wallet'; + +/* ── Props ── */ +interface PricingViewProps { + basePrice: bigint; + freeTier: bigint; + isOwner: boolean; + tokens: TokenInfo[]; + contractBalance: Record; + priceInput: string; + setPriceInput: Dispatch>; + freeInput: string; + setFreeInput: Dispatch>; + doTx: (label: string, fn: () => Promise) => void; + provider: WalletProvider | null; + address: string | null; +} + +/* ════════════════════════════ Component ════════════════════════════ */ + +export default function PricingView({ + basePrice, freeTier, isOwner, tokens, contractBalance, + priceInput, setPriceInput, freeInput, setFreeInput, + doTx, provider, address, +}: PricingViewProps) { + + return ( +
+
+

+ Pricing Config +

+ + {/* Current values */} +
+
+
Base Price / MB
+
{formatWeiToETH(basePrice, 8)} ETH
+
+
+
Free Tier
+
{formatBytes(freeTier)}
+
+
+ + {isOwner && ( + <> + {/* Set price */} +
+ +
+ setPriceInput(e.target.value)} + placeholder={basePrice.toString()} + className="flex-1 px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 text-xs font-mono focus:outline-none focus:border-brand-500" + /> + +
+
+ Current: {formatWeiToETH(basePrice, 8)} ETH — e.g. 100000000000000 = 0.0001 ETH +
+
+ + {/* Set free tier */} +
+ +
+ setFreeInput(e.target.value)} + placeholder={freeTier.toString()} + className="flex-1 px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 text-xs font-mono focus:outline-none focus:border-brand-500" + /> + +
+
+ Current: {formatBytes(freeTier)} — 10485760 = 10 MB +
+
+ + {/* Withdraw */} +
+ +
+ {tokens.filter(t => t.enabled).map(tk => { + const bal = contractBalance[tk.symbol]; + if (!bal || bal === '0') return null; + return ( + + ); + })} +
+
+ + )} +
+
+ ); +} diff --git a/src/app/admin/payment/components/TiersView.tsx b/src/app/admin/payment/components/TiersView.tsx new file mode 100644 index 0000000..ce23732 --- /dev/null +++ b/src/app/admin/payment/components/TiersView.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { type Dispatch, type SetStateAction } from 'react'; +import { Tags, Plus, Trash2 } from 'lucide-react'; +import { paymentService, type MAOSDiscountTier } from '@/lib/payment'; +import type { WalletProvider } from '@/lib/wallet'; +import { formatWeiToETH } from '@/lib/wallet'; + +/* ── Props ── */ +interface TiersViewProps { + tiers: MAOSDiscountTier[]; + isOwner: boolean; + tierBalance: string; + setTierBalance: Dispatch>; + tierBps: string; + setTierBps: Dispatch>; + doTx: (label: string, fn: () => Promise) => void; + provider: WalletProvider | null; +} + +/* ════════════════════════════ Component ════════════════════════════ */ + +export default function TiersView({ + tiers, isOwner, + tierBalance, setTierBalance, tierBps, setTierBps, + doTx, provider, +}: TiersViewProps) { + + return ( +
+
+

+ MAOS Discount Tiers +

+ + {/* Tier list */} +
+ {tiers.map((tier, i) => ( +
+
+ {formatWeiToETH(tier.minBalance, 0)} MAOS + {tier.discountBps / 100}% off +
+ {isOwner && ( + + )} +
+ ))} +
+
+ + {/* Admin: add tier */} + {isOwner && ( +
+

+ Add / Update Tier +

+
+
+ + setTierBalance(e.target.value)} + placeholder="e.g. 5000" className="w-full px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 text-xs font-mono focus:outline-none focus:border-brand-500" + /> +
+
+ + setTierBps(e.target.value)} + placeholder="e.g. 50" className="w-full px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 text-xs font-mono focus:outline-none focus:border-brand-500" + /> +
+ +
+
+ )} +
+ ); +} diff --git a/src/app/admin/payment/components/TokensView.tsx b/src/app/admin/payment/components/TokensView.tsx new file mode 100644 index 0000000..6e9d5b8 --- /dev/null +++ b/src/app/admin/payment/components/TokensView.tsx @@ -0,0 +1,124 @@ +'use client'; + +import { type Dispatch, type SetStateAction } from 'react'; +import { Coins, PiggyBank } from 'lucide-react'; +import { paymentService, type TokenInfo } from '@/lib/payment'; +import type { WalletProvider } from '@/lib/wallet'; +import { formatWeiToETH } from '@/lib/wallet'; + +/* ── Props ── */ +interface TokensViewProps { + tokens: TokenInfo[]; + isOwner: boolean; + contractBalance: Record; + tokenSymbol: string; + setTokenSymbol: Dispatch>; + tokenAddr: string; + setTokenAddr: Dispatch>; + tokenDec: string; + setTokenDec: Dispatch>; + tokenWei: string; + setTokenWei: Dispatch>; + tokenEnabled: boolean; + setTokenEnabled: Dispatch>; + doTx: (label: string, fn: () => Promise) => void; + provider: WalletProvider | null; + address: string | null; +} + +/* ════════════════════════════ Component ════════════════════════════ */ + +export default function TokensView({ + tokens, isOwner, contractBalance, + tokenSymbol, setTokenSymbol, tokenAddr, setTokenAddr, + tokenDec, setTokenDec, tokenWei, setTokenWei, + tokenEnabled, setTokenEnabled, doTx, provider, address, +}: TokensViewProps) { + + return ( +
+
+

+ Supported Tokens +

+ + {/* Token list */} +
+ {tokens.map(tk => ( +
+
+ + {tk.symbol} + {tk.address.slice(0, 10)}... + {tk.decimals} decimals +
+ {formatWeiToETH(tk.weiPerToken, 4)} wei/token +
+ ))} +
+
+ + {/* Admin: configure token */} + {isOwner && ( +
+

Configure Token

+
+ setTokenSymbol(e.target.value.toUpperCase())} + placeholder="Symbol (e.g. USDC)" className="px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 font-mono focus:outline-none focus:border-brand-500" + /> + setTokenAddr(e.target.value)} + placeholder="Token address (0x...)" className="px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 font-mono focus:outline-none focus:border-brand-500" + /> + setTokenDec(e.target.value)} + placeholder="Decimals (e.g. 6)" className="px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 font-mono focus:outline-none focus:border-brand-500" + /> + setTokenWei(e.target.value)} + placeholder="weiPerToken (e.g. 500000000000000)" className="px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 font-mono focus:outline-none focus:border-brand-500" + /> + + +
+
+ )} + + {/* Withdraw section */} + {isOwner && Object.keys(contractBalance).length > 0 && ( +
+

+ Withdraw +

+
+ {Object.entries(contractBalance).filter(([_, bal]) => bal !== '0').map(([sym, bal]) => ( + + ))} + {Object.values(contractBalance).every(b => b === '0') && ( + No balance to withdraw + )} +
+
+ )} +
+ ); +} diff --git a/src/app/admin/payment/page.tsx b/src/app/admin/payment/page.tsx index b26d0eb..6d12b72 100644 --- a/src/app/admin/payment/page.tsx +++ b/src/app/admin/payment/page.tsx @@ -2,16 +2,20 @@ import { useState, useEffect, useCallback } from 'react'; import PortalLayout from '@/app/layout-portal'; +import AuthGuard from '@/components/AuthGuard'; import { paymentService, type TokenInfo, type MAOSDiscountTier } from '@/lib/payment'; import { connectWallet, switchToChain270, getInjectedProvider, formatWeiToETH, formatBytes, type WalletProvider, } from '@/lib/wallet'; import { - Wallet, Coins, Loader2, CheckCircle, XCircle, - Settings, PiggyBank, Tags, RefreshCw, ExternalLink, - Plus, Trash2, Copy, AlertTriangle, + Wallet, Loader2, CheckCircle, XCircle, + RefreshCw, AlertTriangle, } from 'lucide-react'; +import Overview from './components/Overview'; +import PricingView from './components/PricingView'; +import TokensView from './components/TokensView'; +import TiersView from './components/TiersView'; type ViewMode = 'overview' | 'pricing' | 'tokens' | 'tiers'; type TxStatus = { ok: boolean; msg: string } | null; @@ -37,14 +41,14 @@ export default function AdminPaymentPage() { const [loading, setLoading] = useState(true); const [contractBalance, setContractBalance] = useState>({}); - const isOwner = address && owner && address.toLowerCase() === owner.toLowerCase(); + const isOwner = !!(address && owner && address.toLowerCase() === owner.toLowerCase()); // ── Load all contract state ── const refresh = useCallback(async () => { setLoading(true); setTxStatus(null); try { - paymentService.isDeployed().then(setDeployed); + paymentService.isDeployed().then(setDeployed).catch(() => setDeployed(false)); const [own, price, free, t, tiersData, uploads] = await Promise.all([ paymentService.getOwner(), paymentService.getBasePricePerMB(), @@ -58,8 +62,7 @@ export default function AdminPaymentPage() { setFreeTier(free); setTiers(tiersData); - // Revenue per token - // Deduplicate by symbol (contract has a bug where USDC appears twice) + // Deduplicate by symbol const seen = new Set(); const uniqueTokens = t.filter(tk => { if (seen.has(tk.symbol)) return false; @@ -122,11 +125,8 @@ export default function AdminPaymentPage() { const switched = await switchToChain270(prov || undefined); if (switched) setWrongChain(false); } - // @ts-expect-error if (window.maosv6) setWalletType('MAOS Wallet'); - // @ts-expect-error else if (window.ethereum?.isRabby) setWalletType('Rabby'); - // @ts-expect-error else if (window.ethereum?.isMetaMask) setWalletType('MetaMask'); else setWalletType('Wallet'); } catch (e: any) { @@ -162,6 +162,7 @@ export default function AdminPaymentPage() { const [tokenEnabled, setTokenEnabled] = useState(true); return ( + {/* ── Header ── */}
@@ -224,329 +225,70 @@ export default function AdminPaymentPage() {
) : ( <> - {/* ── Overview ── */} {view === 'overview' && ( -
- {/* Stats grid */} -
-
-
Total Uploads
-
{totalUploads.toString()}
-
- {tokens.filter(t => t.enabled).map(tk => { - const rev = revenues[tk.symbol] || BigInt(0); - const bal = contractBalance[tk.symbol]; - return ( -
-
{tk.symbol} Revenue
-
- {tk.symbol === 'ETH' ? formatWeiToETH(rev, 6) : rev.toString()} -
- {bal && ( -
- Balance: {tk.symbol === 'ETH' ? formatWeiToETH(bal, 6) : bal} {tk.symbol} -
- )} -
- ); - })} -
- - {/* Contract info */} -
-

Contract

-
-
- Address - - {paymentService['contractAddress'].slice(0, 10)}... - -
-
- Owner - - {owner ? `${owner.slice(0, 8)}...${owner.slice(-6)}` : '-'} - {isOwner && } - -
-
- Deployed - - {deployed ? 'Yes' : 'No'} - -
-
- Connected - - {address.slice(0, 6)}...{address.slice(-4)} - -
-
-
- - {/* If owner — quick actions */} - {isOwner && ( -
-

Quick Actions

-
- - - -
-
- )} -
+ setView(v as ViewMode)} + /> )} - {/* ── Pricing ── */} {view === 'pricing' && ( -
-
-

- Pricing Config -

- - {/* Current values */} -
-
-
Base Price / MB
-
{formatWeiToETH(basePrice, 8)} ETH
-
-
-
Free Tier
-
{formatBytes(freeTier)}
-
-
- - {isOwner && ( - <> - {/* Set price */} -
- -
- setPriceInput(e.target.value)} - placeholder={basePrice.toString()} - className="flex-1 px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 text-xs font-mono focus:outline-none focus:border-brand-500" - /> - -
-
- Current: {formatWeiToETH(basePrice, 8)} ETH — e.g. 100000000000000 = 0.0001 ETH -
-
- - {/* Set free tier */} -
- -
- setFreeInput(e.target.value)} - placeholder={freeTier.toString()} - className="flex-1 px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 text-xs font-mono focus:outline-none focus:border-brand-500" - /> - -
-
- Current: {formatBytes(freeTier)} — 10485760 = 10 MB -
-
- - {/* Withdraw */} -
- -
- {tokens.filter(t => t.enabled).map(tk => { - const bal = contractBalance[tk.symbol]; - if (!bal || bal === '0') return null; - return ( - - ); - })} -
-
- - )} -
-
+ )} - {/* ── Tokens ── */} {view === 'tokens' && ( -
-
-

- Supported Tokens -

- - {/* Token list */} -
- {tokens.map(tk => ( -
-
- - {tk.symbol} - {tk.address.slice(0, 10)}... - {tk.decimals} decimals -
- {formatWeiToETH(tk.weiPerToken, 4)} wei/token -
- ))} -
-
- - {/* Admin: configure token */} - {isOwner && ( -
-

Configure Token

-
- setTokenSymbol(e.target.value.toUpperCase())} - placeholder="Symbol (e.g. USDC)" className="px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 font-mono focus:outline-none focus:border-brand-500" - /> - setTokenAddr(e.target.value)} - placeholder="Token address (0x...)" className="px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 font-mono focus:outline-none focus:border-brand-500" - /> - setTokenDec(e.target.value)} - placeholder="Decimals (e.g. 6)" className="px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 font-mono focus:outline-none focus:border-brand-500" - /> - setTokenWei(e.target.value)} - placeholder="weiPerToken (e.g. 500000000000000)" className="px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 font-mono focus:outline-none focus:border-brand-500" - /> - - -
-
- )} - - {/* Withdraw section */} - {isOwner && Object.keys(contractBalance).length > 0 && ( -
-

- Withdraw -

-
- {Object.entries(contractBalance).filter(([_, bal]) => bal !== '0').map(([sym, bal]) => ( - - ))} - {Object.values(contractBalance).every(b => b === '0') && ( - No balance to withdraw - )} -
-
- )} -
+ )} - {/* ── Discount Tiers ── */} {view === 'tiers' && ( -
-
-

- MAOS Discount Tiers -

- - {/* Tier list */} -
- {tiers.map((tier, i) => ( -
-
- {formatWeiToETH(tier.minBalance, 0)} MAOS - {tier.discountBps / 100}% off -
- {isOwner && ( - - )} -
- ))} -
-
- - {/* Admin: add tier */} - {isOwner && ( -
-

- Add / Update Tier -

-
-
- - setTierBalance(e.target.value)} - placeholder="e.g. 5000" className="w-full px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 text-xs font-mono focus:outline-none focus:border-brand-500" - /> -
-
- - setTierBps(e.target.value)} - placeholder="e.g. 50" className="w-full px-3 py-1.5 rounded-lg bg-surface-800 border border-surface-700 text-surface-200 text-xs font-mono focus:outline-none focus:border-brand-500" - /> -
- -
-
- )} -
+ )} )} @@ -565,5 +307,6 @@ export default function AdminPaymentPage() { )}
+
); } diff --git a/src/app/api/[...path]/__tests__/route.test.ts b/src/app/api/[...path]/__tests__/route.test.ts new file mode 100644 index 0000000..2d1a69d --- /dev/null +++ b/src/app/api/[...path]/__tests__/route.test.ts @@ -0,0 +1,221 @@ +import { describe, it, expect } from 'vitest'; + +/* ── Route matching & Kubo mapping tests ── + * + * These test the pure functions from route.ts in isolation. + * We import the source directly since these functions have no + * external dependencies (no fetch, no cookies, no process.env). + */ + +// Inline import — vitest resolves @/ alias via vitest.config.ts +// We need to import the actual functions. Since they're not exported +// (they're module-private), we duplicate the logic here for testing. +// In a real project, consider exporting them for testability. + +function matchRoute(path: string[], method: string): { target: string; path: string; method: string } | null { + if (!path || path.length === 0) return null; + const [segment, ...rest] = path; + + switch (segment) { + case 'health': + return { target: 'health', path: '/health', method }; + case 'auth': + return { target: 'auth', path: '/' + (rest.length > 0 ? rest.join('/') : ''), method }; + case 'users': + return { target: 'users', path: '/users' + (rest.length > 0 ? '/' + rest.join('/') : ''), method }; + case 'explorer': + case 'ipns': + return { target: 'ipfs', path: '/' + path.join('/'), method }; + default: + return { target: 'ipfs', path: '/' + path.join('/'), method }; + } +} + +function mapToKuboAPI(path: string, method: string): string { + const p = path.replace(/^\/+/, '').replace(/\/+$/, ''); + switch (p) { + case 'node/info': + return '/api/v0/id'; + case 'peers': + return '/api/v0/swarm/peers'; + case 'pins': + return method === 'GET' ? '/api/v0/pin/ls' : '/api/v0/pin/add'; + default: + if (p.startsWith('pins/')) { + const cid = p.slice(5); + return `/api/v0/pin/rm?arg=${encodeURIComponent(cid)}`; + } + if (p === 'files/upload') { + return '/api/v0/add?pin=true'; + } + if (p.startsWith('files/')) { + return '/api/v0/ls'; + } + if (p.startsWith('explorer/ls/')) { + const cid = p.slice('explorer/ls/'.length); + return `/api/v0/ls?arg=${encodeURIComponent(cid)}`; + } + if (p.startsWith('explorer/cat/')) { + const cid = p.slice('explorer/cat/'.length); + return `/api/v0/cat?arg=${encodeURIComponent(cid)}`; + } + if (p.startsWith('explorer/stat/')) { + const cid = p.slice('explorer/stat/'.length); + return `/api/v0/object/stat?arg=${encodeURIComponent(cid)}`; + } + if (p.startsWith('explorer/resolve/')) { + const name = p.slice('explorer/resolve/'.length); + return `/api/v0/resolve?arg=${encodeURIComponent(name)}`; + } + if (p === 'ipns/publish') return '/api/v0/name/publish'; + if (p === 'ipns/keys') return '/api/v0/key/list'; + if (p.startsWith('ipns/resolve/')) { + const name = p.slice('ipns/resolve/'.length); + return `/api/v0/name/resolve?arg=${encodeURIComponent(name)}`; + } + if (p.startsWith('ipns/keys/gen/')) { + const name = p.slice('ipns/keys/gen/'.length); + return `/api/v0/key/gen?arg=${encodeURIComponent(name)}`; + } + if (p === 'repo/stats') return '/api/v0/repo/stat'; + if (p === 'bw/stats') return '/api/v0/bw/stats'; + return '/api/v0/' + p; + } +} + +/* ════════════════════════ matchRoute ════════════════════════ */ + +describe('matchRoute', () => { + it('returns null for empty path', () => { + expect(matchRoute([], 'GET')).toBeNull(); + }); + + it('routes health', () => { + const r = matchRoute(['health'], 'GET'); + expect(r?.target).toBe('health'); + expect(r?.path).toBe('/health'); + }); + + it('routes auth', () => { + const r = matchRoute(['auth'], 'GET'); + expect(r?.target).toBe('auth'); + expect(r?.path).toBe('/'); + }); + + it('routes auth/me', () => { + const r = matchRoute(['auth', 'me'], 'GET'); + expect(r?.target).toBe('auth'); + expect(r?.path).toBe('/me'); + }); + + it('routes auth/login with POST', () => { + const r = matchRoute(['auth', 'login'], 'POST'); + expect(r?.target).toBe('auth'); + expect(r?.path).toBe('/login'); + expect(r?.method).toBe('POST'); + }); + + it('routes users', () => { + const r = matchRoute(['users'], 'GET'); + expect(r?.target).toBe('users'); + expect(r?.path).toBe('/users'); + }); + + it('routes users with subpath', () => { + const r = matchRoute(['users', 'create'], 'POST'); + expect(r?.target).toBe('users'); + expect(r?.path).toBe('/users/create'); + }); + + it('routes explorer to ipfs', () => { + const r = matchRoute(['explorer', 'ls', 'QmTest'], 'GET'); + expect(r?.target).toBe('ipfs'); + }); + + it('routes ipns to ipfs', () => { + const r = matchRoute(['ipns', 'publish'], 'POST'); + expect(r?.target).toBe('ipfs'); + }); + + it('routes unknown segments to ipfs (catch-all)', () => { + const r = matchRoute(['pins'], 'GET'); + expect(r?.target).toBe('ipfs'); + expect(r?.path).toBe('/pins'); + }); +}); + +/* ════════════════════════ mapToKuboAPI ════════════════════════ */ + +describe('mapToKuboAPI', () => { + it('maps node/info to /api/v0/id', () => { + expect(mapToKuboAPI('node/info', 'GET')).toBe('/api/v0/id'); + }); + + it('maps peers to /api/v0/swarm/peers', () => { + expect(mapToKuboAPI('peers', 'GET')).toBe('/api/v0/swarm/peers'); + }); + + it('maps pins GET to /api/v0/pin/ls', () => { + expect(mapToKuboAPI('pins', 'GET')).toBe('/api/v0/pin/ls'); + }); + + it('maps pins POST to /api/v0/pin/add', () => { + expect(mapToKuboAPI('pins', 'POST')).toBe('/api/v0/pin/add'); + }); + + it('maps pins/rm to rm with CID', () => { + expect(mapToKuboAPI('pins/QmTest123', 'DELETE')).toBe('/api/v0/pin/rm?arg=QmTest123'); + }); + + it('maps files/upload to add with pin', () => { + expect(mapToKuboAPI('files/upload', 'POST')).toBe('/api/v0/add?pin=true'); + }); + + it('maps files/list to ls', () => { + expect(mapToKuboAPI('files/list', 'GET')).toBe('/api/v0/ls'); + }); + + it('maps explorer/ls/', () => { + expect(mapToKuboAPI('explorer/ls/QmTest', 'GET')).toBe('/api/v0/ls?arg=QmTest'); + }); + + it('maps explorer/cat/', () => { + expect(mapToKuboAPI('explorer/cat/QmTest', 'GET')).toBe('/api/v0/cat?arg=QmTest'); + }); + + it('maps explorer/stat/', () => { + expect(mapToKuboAPI('explorer/stat/QmTest', 'GET')).toBe('/api/v0/object/stat?arg=QmTest'); + }); + + it('maps explorer/resolve/', () => { + expect(mapToKuboAPI('explorer/resolve/example', 'GET')).toBe('/api/v0/resolve?arg=example'); + }); + + it('maps ipns/publish', () => { + expect(mapToKuboAPI('ipns/publish', 'POST')).toBe('/api/v0/name/publish'); + }); + + it('maps ipns/keys', () => { + expect(mapToKuboAPI('ipns/keys', 'GET')).toBe('/api/v0/key/list'); + }); + + it('maps ipns/resolve/', () => { + expect(mapToKuboAPI('ipns/resolve/k51...', 'GET')).toBe('/api/v0/name/resolve?arg=k51...'); + }); + + it('maps ipns/keys/gen/', () => { + expect(mapToKuboAPI('ipns/keys/gen/mykey', 'GET')).toBe('/api/v0/key/gen?arg=mykey'); + }); + + it('maps repo/stats', () => { + expect(mapToKuboAPI('repo/stats', 'GET')).toBe('/api/v0/repo/stat'); + }); + + it('maps bw/stats', () => { + expect(mapToKuboAPI('bw/stats', 'GET')).toBe('/api/v0/bw/stats'); + }); + + it('falls through for unknown paths', () => { + expect(mapToKuboAPI('version', 'GET')).toBe('/api/v0/version'); + }); +}); diff --git a/src/app/api/[...path]/route.ts b/src/app/api/[...path]/route.ts index cf89ba9..3746acd 100644 --- a/src/app/api/[...path]/route.ts +++ b/src/app/api/[...path]/route.ts @@ -1,19 +1,17 @@ /* ── IPFS Portal API Proxy ── * - * Catch-all /api/* handler. + * Catch-all /api/* handler (exclusief /api/auth/* — die hebben eigen route files). * Routes incoming calls to the appropriate backend: - * /api/users* → Python User Management API (port 8444) + * /api/node/* → Python User Management API (port 8444) + * /api/portal/* → Python User Management API * /api/health → inline health check - * /api/pins* → IPFS Kubo API (via nginx proxy in production) - * /api/node/* → IPFS Kubo API - * /api/peers → IPFS Kubo API - * /api/files/* → IPFS Kubo API + * /api/* → IPFS Kubo API * - * In dev (localhost), this file proxies to the remote server. - * In production, nginx handles the backend routing directly. + * Leest X-Session-Token uit JWT cookie voor authenticated requests. */ import { NextRequest, NextResponse } from 'next/server'; +import { verifySessionJWT, SESSION_COOKIE } from '@/lib/auth-server'; export const dynamic = 'force-dynamic'; @@ -25,7 +23,7 @@ const ADMIN_KEY = process.env.USER_API_ADMIN_KEY || 'maos-admin-2024'; /* ── Route matching ── */ interface RouteMatch { - target: 'users' | 'ipfs' | 'health'; + target: 'user' | 'ipfs' | 'health'; path: string; // path relative to the backend method: string; } @@ -39,15 +37,20 @@ function matchRoute(path: string[], method: string): RouteMatch | null { case 'health': return { target: 'health', path: '/health', method }; + case 'node': + // /api/node/info → Python backend /node/info + return { target: 'user', path: '/' + path.join('/'), method }; + + case 'portal': + // /api/portal/users → Python backend /users, /api/portal/pins → /pins, etc. + return { target: 'user', path: '/' + rest.join('/'), method }; + case 'users': - return { target: 'users', path: '/users' + (rest.length > 0 ? '/' + rest.join('/') : ''), method }; + // Legacy /api/users* → Python backend /users* + return { target: 'user', path: '/users' + (rest.length > 0 ? '/' + rest.join('/') : ''), method }; case 'explorer': - // /explorer/ls/ → IPFS Kubo with full path for mapToKuboAPI - return { target: 'ipfs', path: '/' + path.join('/'), method }; - case 'ipns': - // /ipns/publish, /ipns/keys, etc. return { target: 'ipfs', path: '/' + path.join('/'), method }; default: @@ -55,9 +58,18 @@ function matchRoute(path: string[], method: string): RouteMatch | null { } } +/* ── Extract session token from JWT cookie ── */ + +async function getSessionToken(req: NextRequest): Promise { + const token = req.cookies.get(SESSION_COOKIE)?.value; + if (!token) return null; + const session = await verifySessionJWT(token); + return session?.sessionToken ?? null; +} + /* ── User API proxy ── */ -async function proxyUserAPI(path: string, method: string, body: ReadableStream | null): Promise { +async function proxyUserAPI(path: string, method: string, body: ReadableStream | null, req: NextRequest): Promise { const url = `${USER_API_BASE}${path}`; const headers: Record = { 'X-Admin-Key': ADMIN_KEY, @@ -67,6 +79,12 @@ async function proxyUserAPI(path: string, method: string, body: ReadableStream ({})); + return NextResponse.json( + { error: data.error || 'Password change failed' }, + { status: res.status }, + ); + } + + return NextResponse.json({ success: true }); + } catch (e: any) { + return NextResponse.json({ error: e.message || 'Password change failed' }, { status: 500 }); + } +} diff --git a/src/app/api/events/route.ts b/src/app/api/events/route.ts new file mode 100644 index 0000000..99cb5f8 --- /dev/null +++ b/src/app/api/events/route.ts @@ -0,0 +1,153 @@ +/* ── SSE (Server-Sent Events) endpoint ── + * + * Pusht live updates naar de dashboard/client: + * - Bandwidth stats (totalIn, totalOut, rateIn, rateOut) + * - Peer count + * - Repo size (periodiek) + * + * Next.js route handlers ondersteunen ReadableStream voor SSE. + * Alternatief voor WebSocket (werkt door proxy heen). + */ + +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +const KUBO_API = process.env.KUBO_API_URL || process.env.NEXT_PUBLIC_KUBO_API_URL; +const KUBO_AUTH = process.env.KUBO_BASIC_AUTH; +const POLL_INTERVAL = 3_000; // 3 seconden + +/* ── Helpers ── */ + +function encodeSSE(event: string, data: unknown): string { + return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; +} + +async function kuboFetch(path: string): Promise { + if (!KUBO_API) return null; + try { + const url = new URL(KUBO_API); + url.pathname = path; + const headers: Record = {}; + if (KUBO_AUTH) { + headers['Authorization'] = 'Basic ' + Buffer.from(KUBO_AUTH).toString('base64'); + } + const res = await fetch(url.toString(), { + method: 'POST', + headers, + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) return null; + return res.json(); + } catch { + return null; + } +} + +async function fetchBWStats() { + const raw = await kuboFetch('/api/v0/bw/stats'); + if (!raw) return null; + return { + totalIn: raw.TotalIn ?? 0, + totalOut: raw.TotalOut ?? 0, + rateIn: raw.RateIn ?? 0, + rateOut: raw.RateOut ?? 0, + }; +} + +async function fetchPeerCount() { + const raw = await kuboFetch('/api/v0/swarm/peers'); + if (!raw || !raw.Peers) return 0; + return raw.Peers.length; +} + +async function fetchRepoStats() { + const raw = await kuboFetch('/api/v0/repo/stat'); + if (!raw) return null; + return { + repoSize: raw.RepoSize ?? 0, + storageMax: raw.StorageMax ?? 0, + numObjects: raw.NumObjects ?? 0, + }; +} + +/* ── GET handler — SSE stream ── */ + +export async function GET(req: Request): Promise { + const abortController = new AbortController(); + const signal = abortController.signal; + + const stream = new ReadableStream({ + async start(controller) { + // Send initial connection event + controller.enqueue(new TextEncoder().encode(encodeSSE('connected', { ts: Date.now() }))); + + let bwPrev = { totalIn: 0, totalOut: 0 }; + let lastRepoPoll = 0; + + const interval = setInterval(async () => { + if (signal.aborted) { + clearInterval(interval); + return; + } + + try { + // Bandwidth (elke poll) + const bw = await fetchBWStats(); + if (bw) { + const deltaIn = bw.totalIn - bwPrev.totalIn; + const deltaOut = bw.totalOut - bwPrev.totalOut; + bwPrev = { totalIn: bw.totalIn, totalOut: bw.totalOut }; + + controller.enqueue( + new TextEncoder().encode(encodeSSE('bandwidth', { + totalIn: bw.totalIn, + totalOut: bw.totalOut, + rateIn: bw.rateIn, + rateOut: bw.rateOut, + deltaIn: Math.max(0, deltaIn), + deltaOut: Math.max(0, deltaOut), + })) + ); + } + + // Peer count (elke poll) + const peerCount = await fetchPeerCount(); + controller.enqueue( + new TextEncoder().encode(encodeSSE('peers', { count: peerCount })) + ); + + // Repo stats (elke 15s) + const now = Date.now(); + if (now - lastRepoPoll >= 15_000) { + lastRepoPoll = now; + const repo = await fetchRepoStats(); + if (repo) { + controller.enqueue( + new TextEncoder().encode(encodeSSE('repo', repo)) + ); + } + } + } catch { + // Silently continue — connection blijft open + } + }, POLL_INTERVAL); + + // Cleanup on abort + signal.addEventListener('abort', () => { + clearInterval(interval); + }); + }, + cancel() { + abortController.abort(); + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + }); +} diff --git a/src/app/dashboard/components/FreeTierProgress.tsx b/src/app/dashboard/components/FreeTierProgress.tsx new file mode 100644 index 0000000..9a3b2b9 --- /dev/null +++ b/src/app/dashboard/components/FreeTierProgress.tsx @@ -0,0 +1,54 @@ +'use client'; + +/* ════════════════════════════ FreeTierProgress ════════════════════════════ */ + +interface FreeTierProgressProps { + used: number; + max: number; + label?: string; +} + +export default function FreeTierProgress({ + used, + max, + label = 'Free uploads vandaag', +}: FreeTierProgressProps) { + const pct = max > 0 ? Math.min((used / max) * 100, 100) : 0; + const remaining = Math.max(0, max - used); + const color = + remaining === 0 + ? 'bg-accent-rose' + : remaining < 5 + ? 'bg-accent-amber' + : 'bg-accent-green'; + + return ( +
+
+ {label} + + {remaining} / {max} + +
+
+
+
+
+ + {used} gebruikt vandaag + + {remaining <= 3 && remaining > 0 && ( + Bijna op + )} + {remaining === 0 && ( + Limiet bereikt + )} +
+
+ ); +} diff --git a/src/app/dashboard/components/StorageGauge.tsx b/src/app/dashboard/components/StorageGauge.tsx new file mode 100644 index 0000000..422ebf6 --- /dev/null +++ b/src/app/dashboard/components/StorageGauge.tsx @@ -0,0 +1,43 @@ +'use client'; + +/* ════════════════════════════ StorageGauge ════════════════════════════ */ + +interface StorageGaugeProps { + usedGB: number; + maxGB: number; + label?: string; +} + +export default function StorageGauge({ usedGB, maxGB, label = 'Storage' }: StorageGaugeProps) { + const pct = maxGB > 0 ? Math.min((usedGB / maxGB) * 100, 100) : 0; + const color = + pct > 90 ? 'bg-accent-rose' : pct > 70 ? 'bg-accent-amber' : 'bg-accent-cyan'; + + const textColor = + pct > 90 ? 'text-accent-rose' : pct > 70 ? 'text-accent-amber' : 'text-accent-cyan'; + + return ( +
+
+ {label} + + {usedGB.toFixed(1)} / {maxGB.toFixed(1)} GB + +
+
+
+
+
+ {pct.toFixed(0)}% gebruikt + {pct > 80 && ( + + ⚠️ Bijna vol + + )} +
+
+ ); +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 44f0826..fd3a5e5 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,13 +1,12 @@ 'use client'; -import { useEffect, useState } from 'react'; -import { getPeers, listPins, checkHealth, getRepoStats, getBWStats } from '@/lib/api'; -import type { RepoStats, BWStats } from '@/lib/api'; import PortalLayout from '@/app/layout-portal'; import Link from 'next/link'; +import { SkeletonCard, SkeletonRow } from '@/components/Skeleton'; +import { useDashboard } from '@/lib/swr'; +import { useRealtime } from '@/lib/useRealtime'; import { HardDrive, - Globe, Wifi, Database, Server, @@ -17,49 +16,42 @@ import { Fingerprint, } from 'lucide-react'; -interface PeerData { id: string; addr: string; latency: string } -interface PinData { cid: string; name: string; size: number } - export default function DashboardPage() { - const [peers, setPeers] = useState([]); - const [pins, setPins] = useState([]); - const [health, setHealth] = useState<{ status: string; node: string } | null>(null); - const [repo, setRepo] = useState(null); - const [bw, setBW] = useState(null); - const [loading, setLoading] = useState(true); + const { data, isValidating } = useDashboard(); + const realtime = useRealtime(); - useEffect(() => { - async function load() { - try { - const [h, p, pinList, r, b] = await Promise.all([ - checkHealth().catch(() => null), - getPeers().catch(() => [] as PeerData[]), - listPins().catch(() => [] as PinData[]), - getRepoStats().catch(() => null), - getBWStats().catch(() => null), - ]); - setHealth(h); - setPeers(p); - setPins(pinList); - setRepo(r); - setBW(b); - } finally { - setLoading(false); + // Use SWR data, fall back to SSE live data where available + const peers = data?.peers ?? []; + const pins = data?.pins ?? []; + const health = data?.health; + const repo = data?.repo; + + // Bandwidth: prefer SSE live data, fallback to SWR + const bwLive = realtime.bandwidth; + const bw = bwLive + ? { + totalIn: bwLive.totalIn, + totalOut: bwLive.totalOut, + rateIn: bwLive.rateIn, + rateOut: bwLive.rateOut, } - } - load(); - const iv = setInterval(load, 15000); - return () => clearInterval(iv); - }, []); + : data?.bw; + + const loading = isValidating && !data; const repoSizeGb = repo ? (repo.repoSize / 1e9).toFixed(2) : '—'; const bwIn = bw ? (bw.totalIn / 1e6).toFixed(1) : '—'; const bwOut = bw ? (bw.totalOut / 1e6).toFixed(1) : '—'; + // Peer count from SSE (live) or SWR + const peerCount = realtime.peers?.count ?? peers.length; + // Pin count from SWR + const pinCount = pins.length; + const stats = [ - { label: 'Peers', value: peers.length, icon: Wifi, color: 'text-accent-cyan', bg: 'bg-accent-cyan/10' }, - { label: 'Pins', value: pins.length, icon: HardDrive, color: 'text-accent-purple', bg: 'bg-accent-purple/10' }, - { label: 'Node', value: health?.status ?? '—', icon: Server, color: 'text-accent-green', bg: 'bg-accent-green/10' }, + { label: 'Peers', value: peerCount, icon: Wifi, color: 'text-accent-cyan', bg: 'bg-accent-cyan/10' }, + { label: 'Pins', value: pinCount, icon: HardDrive, color: 'text-accent-purple', bg: 'bg-accent-purple/10' }, + { label: 'Node', value: health?.status ?? (realtime.connected ? 'connected' : '—'), icon: Server, color: 'text-accent-green', bg: 'bg-accent-green/10' }, { label: 'Repo', value: `${repoSizeGb} GB`, icon: Database, color: 'text-accent-amber', bg: 'bg-accent-amber/10' }, ]; @@ -71,17 +63,33 @@ export default function DashboardPage() {

Dashboard

IPFS node overview, storage, and bandwidth

- {loading && ( -
-
- refreshing… -
- )} +
+ {realtime.connected && ( + + + Live + + )} + {!realtime.connected && ( + + + Polling + + )} + {loading && ( +
+
+ loading… +
+ )} +
{/* Stat cards */}
- {stats.map((s) => ( + {loading + ? Array.from({ length: 4 }).map((_, i) => ) + : stats.map((s) => (
@@ -101,9 +109,11 @@ export default function DashboardPage() {

Connected Peers - {peers.length} peer{peers.length !== 1 ? 's' : ''} + {peerCount} peer{peerCount !== 1 ? 's' : ''}

- {peers.length === 0 ? ( + {loading + ? Array.from({ length: 5 }).map((_, i) => ) + : peers.length === 0 ? (

No peers connected

) : (
diff --git a/src/app/error.tsx b/src/app/error.tsx new file mode 100644 index 0000000..c7e9aaf --- /dev/null +++ b/src/app/error.tsx @@ -0,0 +1,38 @@ +'use client'; + +/* ════════════════════════════ Error (Next.js root) ════════════════════════════ + * Next.js error boundary voor root layout crashes. + */ + +import { AlertCircle, RefreshCw } from 'lucide-react'; + +interface Props { + error: Error & { digest?: string }; + reset: () => void; +} + +export default function RootError({ error, reset }: Props) { + return ( +
+
+ +

Er ging iets mis

+

+ De applicatie kon niet laden. Probeer het opnieuw. +

+ {error.digest && ( +

+ Error ID: {error.digest} +

+ )} + +
+
+ ); +} diff --git a/src/app/explorer/components/DirectoryListing.tsx b/src/app/explorer/components/DirectoryListing.tsx index 5e210f3..1fb3aa5 100644 --- a/src/app/explorer/components/DirectoryListing.tsx +++ b/src/app/explorer/components/DirectoryListing.tsx @@ -2,7 +2,9 @@ import type { IPFSEntry } from '@/lib/api'; import FileIcon from './FileIcon'; -import { CheckCircle } from 'lucide-react'; +import { CheckCircle, Download } from 'lucide-react'; +import { truncateCid } from '@/lib/helpers'; +import { downloadFile } from '@/lib/download'; interface DirectoryListingProps { entries: IPFSEntry[]; @@ -20,11 +22,6 @@ function formatSize(bytes: number): string { return `${s} ${units[i] ?? ''}`; } -function truncateHash(hash: string): string { - if (hash.length <= 12) return hash; - return `${hash.slice(0, 6)}…${hash.slice(-4)}`; -} - function SkeletonRow() { return (
@@ -82,7 +79,7 @@ export default function DirectoryListing({
Type
Size
Hash
-
+
@@ -113,10 +110,23 @@ export default function DirectoryListing({ - {truncateHash(entry.hash)} + {truncateCid(entry.hash, 6)} -
+
+ {entry.type === 'file' && ( + + )} {isPinned && ( )} diff --git a/src/app/explorer/components/FilePreview.tsx b/src/app/explorer/components/FilePreview.tsx index 6204ab7..368b613 100644 --- a/src/app/explorer/components/FilePreview.tsx +++ b/src/app/explorer/components/FilePreview.tsx @@ -19,8 +19,17 @@ function detectFileType(filename: string | undefined): 'image' | 'markdown' | 'j return 'other'; } -function renderMarkdown(text: string): string { +function escapeHtml(text: string): string { return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function renderMarkdown(text: string): string { + return escapeHtml(text) .replace(/^### (.+)$/gm, '

$1

') .replace(/^## (.+)$/gm, '

$1

') .replace(/^# (.+)$/gm, '

$1

') diff --git a/src/app/explorer/page.tsx b/src/app/explorer/page.tsx index e0a8da9..84c0ac8 100644 --- a/src/app/explorer/page.tsx +++ b/src/app/explorer/page.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'; import PortalLayout from '@/app/layout-portal'; -import { explorerLs, explorerCat } from '@/lib/api'; +import { explorerLs, explorerCat, listPins } from '@/lib/api'; import type { IPFSEntry } from '@/lib/api'; import CIDInput from './components/CIDInput'; import DirectoryListing from './components/DirectoryListing'; @@ -10,7 +10,10 @@ import FilePreview from './components/FilePreview'; import Breadcrumbs from './components/Breadcrumbs'; import PinBadge from './components/PinBadge'; import GatewayLink from './components/GatewayLink'; -import { Search, AlertCircle, RefreshCw } from 'lucide-react'; +import SearchBar from '@/components/SearchBar'; +import type { SearchResult } from '@/lib/search-index'; +import { isTextFile, extractText, addToIndex, clearIndex, getIndexStats } from '@/lib/search-index'; +import { Search, AlertCircle, RefreshCw, Database, Loader2 } from 'lucide-react'; const RECENT_STORAGE_KEY = 'ipfs-explorer-recent-cids'; @@ -52,6 +55,8 @@ export default function ExplorerPage() { const [error, setError] = useState(''); const [pins, setPins] = useState([]); const [recentCids] = useState(loadRecentCids); + const [indexStats, setIndexStats] = useState(getIndexStats); + const [rebuilding, setRebuilding] = useState(false); // Load pins from the pins page (shared localStorage or just track in-memory) function handlePinToggle(cid: string) { @@ -122,6 +127,46 @@ export default function ExplorerPage() { if (cid) navigateTo(cid); } + /* ── Search ── */ + + function handleSearchResult(result: SearchResult) { + navigateTo(result.entry.cid); + } + + async function rebuildSearchIndex() { + setRebuilding(true); + try { + const pinsList = await listPins(); + clearIndex(); + let indexed = 0; + let failed = 0; + for (const pin of pinsList) { + if (isTextFile(pin.name) || !pin.name) { + try { + const text = await explorerCat(pin.cid); + const extracted = extractText(text); + if (extracted.length > 0) { + addToIndex({ + cid: pin.cid, + name: pin.name || pin.cid.slice(0, 20), + type: 'file', + text: extracted, + size: text.length, + indexedAt: Date.now(), + }); + indexed++; + } + } catch { + failed++; + } + } + } + setIndexStats({ entries: indexed, totalChars: 0, lastIndexed: Date.now() }); + } finally { + setRebuilding(false); + } + } + const isPinned = previewCid ? pins.includes(previewCid) : false; return ( @@ -134,6 +179,26 @@ export default function ExplorerPage() {

+ {/* Search + Index rebuild */} +
+
+ +
+ +
+ {/* CID input */}
diff --git a/src/app/globals.css b/src/app/globals.css index 14b5556..2c2ea27 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -47,6 +47,50 @@ body { font-feature-settings: "cv02", "cv03", "cv04", "cv11"; } +/* ── Light mode overrides ── */ +.light body, +.light { + --color-surface-950: #f8fafc; + --color-surface-900: #f1f5f9; + --color-surface-850: #e2e8f0; + --color-surface-800: #cbd5e1; + --color-surface-700: #94a3b8; + --color-surface-600: #64748b; + --color-surface-500: #475569; + --color-surface-400: #334155; + --color-surface-300: #1e293b; + --color-surface-200: #0f172a; + --color-surface-50: #020617; +} +.light body { + background-color: #f8fafc; + color: #1e293b; +} +.light .glass { + background: rgba(255, 255, 255, 0.8); + border-color: rgba(0, 0, 0, 0.08); +} +.light .glass-hover:hover { + background: rgba(255, 255, 255, 0.95); + border-color: rgba(6, 182, 212, 0.25); +} + +/* ── Transition for theme switch ── + * Applied via .theme-transitioning on during toggle only. + */ +html.theme-transitioning, +html.theme-transitioning *, +html.theme-transitioning *::before, +html.theme-transitioning *::after { + transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease; +} + +/* ── Focus-visible: toon focus ring alleen bij keyboard nav ── */ +*:focus-visible { + outline: 2px solid var(--color-brand-500); + outline-offset: 2px; +} + /* ── Scrollbar ── */ ::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar-track { background: transparent; } @@ -86,5 +130,11 @@ body { 50% { box-shadow: 0 0 12px rgba(34, 211, 238, 0.6); } } +@keyframes slide-up { + from { opacity: 0; transform: translateY(16px) translateX(-50%); } + to { opacity: 1; transform: translateY(0) translateX(-50%); } +} + .animate-fade-in { animation: fade-in 0.4s ease-out both; } .animate-pulse-glow { animation: pulse-glow 2s ease-in-out infinite; } +.animate-slide-up { animation: slide-up 0.3s ease-out both; } diff --git a/src/app/history/page.tsx b/src/app/history/page.tsx index aa7f93a..a834c26 100644 --- a/src/app/history/page.tsx +++ b/src/app/history/page.tsx @@ -1,29 +1,20 @@ 'use client'; import { useEffect, useState, useMemo } from 'react'; -import { getHistory, clearHistory, getSettings, type UploadRecord } from '@/lib/storage'; +import { getHistory, clearHistory, removeMultipleFromHistory, type UploadRecord } from '@/lib/storage'; +import { formatBytes, gatewayLink, truncateCid } from '@/lib/helpers'; +import { downloadFile } from '@/lib/download'; +import { useNotify } from '@/lib/notifications'; +import { SkeletonTable } from '@/components/Skeleton'; +import BatchBar from '@/components/BatchBar'; import PortalLayout from '@/app/layout-portal'; import Link from 'next/link'; import { Clock, HardDrive, Copy, ExternalLink, Trash2, Search, Upload, - Database, Calendar, Filter, X, CheckCircle, Link as LinkIcon, + Database, Calendar, Filter, X, CheckCircle, Download, + Link as LinkIcon, } from 'lucide-react'; -/* ── Helpers ── */ - -function formatBytes(bytes: number): string { - if (bytes === 0) return '0 B'; - const units = ['B', 'KB', 'MB', 'GB', 'TB']; - const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); - const val = bytes / Math.pow(1024, i); - return val < 10 ? val.toFixed(1) + ' ' + units[i] : Math.round(val) + ' ' + units[i]; -} - -function truncateCid(cid: string): string { - if (cid.length <= 18) return cid; - return cid.slice(0, 12) + '…' + cid.slice(-4); -} - function getMethodBadge(method: UploadRecord['method']): { bg: string; text: string; label: string } { switch (method) { case 'free': return { bg: 'bg-accent-green/10', text: 'text-accent-green', label: 'free' }; @@ -39,9 +30,14 @@ export default function HistoryPage() { const [history, setHistory] = useState([]); const [search, setSearch] = useState(''); const [copied, setCopied] = useState(null); + const [loading, setLoading] = useState(true); + const [selected, setSelected] = useState>(new Set()); + + const recordKey = (r: UploadRecord) => `${r.cid}||${r.date}`; function load() { setHistory(getHistory()); + setLoading(false); } useEffect(() => { load(); }, []); @@ -79,13 +75,41 @@ export default function HistoryPage() { } catch { /* ignore */ } } - /* ── Gateway URL ── */ - const gatewayUrl = useMemo(() => { - try { return getSettings().gatewayUrl; } - catch { return 'https://maos.dedyn.io/ipfs'; } - }, []); + /* ── Selection ── */ + function toggleSelection(key: string) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + } - const gatewayLink = (cid: string) => `${gatewayUrl}/${cid}`; + function toggleAll() { + if (selected.size === filtered.length) { + setSelected(new Set()); + } else { + setSelected(new Set(filtered.map(recordKey))); + } + } + + /* ── Batch delete ── */ + const { notify } = useNotify(); + + function handleBatchDelete() { + const count = selected.size; + if (!confirm(`Delete ${count} upload${count !== 1 ? 's' : ''} from history?`)) return; + + const keys = Array.from(selected).map((key) => { + const idx = key.lastIndexOf('||'); + return { cid: key.slice(0, idx), date: key.slice(idx + 2) }; + }); + + removeMultipleFromHistory(keys); + setHistory((prev) => prev.filter((r) => !selected.has(recordKey(r)))); + setSelected(new Set()); + notify({ type: 'success', title: `Deleted ${count} upload${count !== 1 ? 's' : ''}` }); + } /* ── Render ── */ return ( @@ -133,8 +157,17 @@ export default function HistoryPage() {
)} + {/* ── Loading skeleton ── */} + {loading && ( +
+
+ +
+
+ )} + {/* ── Empty state ── */} - {history.length === 0 && ( + {!loading && history.length === 0 && (
@@ -163,6 +196,14 @@ export default function HistoryPage() { + @@ -177,6 +218,15 @@ export default function HistoryPage() { const gwLink = gatewayLink(rec.cid); return ( + {/* Checkbox */} + {/* File name */}
+ 0} + onChange={toggleAll} + className="rounded border-surface-600 bg-surface-800 text-brand-500 focus:ring-brand-500" + /> + File Name CID Size
+ toggleSelection(recordKey(rec))} + className="rounded border-surface-600 bg-surface-800 text-brand-500 focus:ring-brand-500" + /> +
@@ -246,6 +296,15 @@ export default function HistoryPage() { )} + {/* Download */} + + {/* Open in gateway */} - {/* Name + method badge */} + {/* Checkbox + Name + method badge */}
+ toggleSelection(recordKey(rec))} + className="rounded border-surface-600 bg-surface-800 text-brand-500 focus:ring-brand-500 shrink-0 mt-0.5" + /> {rec.name} @@ -338,7 +403,16 @@ export default function HistoryPage() { Copy Link - {/* Download / Open */} + {/* Download */} + + + {/* Open */} 0 && filtered.length === 0 && ( + {!loading && history.length > 0 && filtered.length === 0 && (
@@ -387,6 +461,20 @@ export default function HistoryPage() {
)} + {/* ── Batch bar ── */} + setSelected(new Set())} + actions={[ + { + key: 'delete', + label: 'Delete selected', + icon: , + variant: 'danger', + onClick: handleBatchDelete, + }, + ]} + />
); diff --git a/src/app/ipns/page.tsx b/src/app/ipns/page.tsx index fca72ff..2841d0d 100644 --- a/src/app/ipns/page.tsx +++ b/src/app/ipns/page.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'; import { listIPNSKeys, ipnsPublish, ipnsResolve, ipnsGenKey, type IPNSKey } from '@/lib/api'; +import PortalLayout from '@/app/layout-portal'; import { RefreshCw, ExternalLink, Key, FileText, Plus } from 'lucide-react'; type Status = 'idle' | 'loading' | 'success' | 'error'; @@ -93,11 +94,11 @@ export default function IPNSPage() { }; return ( -
+ {/* Header */} -
+
-

IPNS

+

IPNS

InterPlanetary Name System — human-readable names for IPFS content

@@ -280,6 +281,6 @@ export default function IPNSPage() { )}
-
+ ); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 90e1828..ec6d859 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,21 +1,28 @@ import type { Metadata, Viewport } from 'next'; import './globals.css'; +import Providers from '@/components/Providers'; +import SWRegister from '@/components/SWRegister'; export const metadata: Metadata = { title: 'MAOS IPFS Portal', description: 'Decentralized storage management for your IPFS node', + manifest: '/manifest.json', + icons: { icon: '/favicon.svg' }, }; export const viewport: Viewport = { width: 'device-width', initialScale: 1, - themeColor: '#020617', + themeColor: '#0891b2', }; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - - {children} + + + + {children} + ); } diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..eacb9f2 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,143 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useAuth } from '@/lib/auth'; +import { useNotify } from '@/lib/notifications'; +import { useRouter } from 'next/navigation'; +import { Wallet, Loader2, Shield, ExternalLink } from 'lucide-react'; +import { discoverWallets, connectWallet, type WalletProvider, type DetectedWallet } from '@/lib/wallet'; + +export default function LoginPage() { + const { loginWithWallet } = useAuth(); + const { notify } = useNotify(); + const router = useRouter(); + + const [wallets, setWallets] = useState([]); + const [busy, setBusy] = useState(false); + const [busyWallet, setBusyWallet] = useState(null); + + /* Discover available wallets on mount */ + useEffect(() => { + discoverWallets().then(setWallets); + }, []); + + async function handleConnect(dw: DetectedWallet) { + setBusy(true); + setBusyWallet(dw.name); + try { + const { address } = await connectWallet(dw.provider); + await loginWithWallet(address, dw.provider); + notify({ type: 'success', title: 'Ingelogd', message: `Wallet ${address.slice(0, 6)}…${address.slice(-4)}` }); + router.push('/dashboard'); + } catch (err: any) { + notify({ type: 'error', title: 'Login mislukt', message: err.message || 'Onbekende fout' }); + } finally { + setBusy(false); + setBusyWallet(null); + } + } + + /* Wallet icon — use EIP-6963 provider icon or fallback */ + function walletIcon(dw: DetectedWallet): string | null { + return dw.icon ?? null; + } + + return ( +
+
+ {/* Logo */} +
+
+ M +
+

IPFS Portal

+

Verbind je wallet om in te loggen

+
+ + {/* Wallet connect card */} +
+ {wallets.length === 0 && !busy && ( + + )} + + {wallets.length > 0 && ( +
+ {wallets.map((dw, i) => { + const isLoading = busy && busyWallet === dw.name; + const icon = walletIcon(dw); + return ( + + ); + })} +
+ )} + + {/* Refresh button */} + +
+ + {/* Footer */} +

+ + Verbonden met MAOS IPFS node +

+
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 8a7fc37..066c0e9 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -91,6 +91,14 @@ export default function LandingPage() { )} + {/* Login link */} +

+ Of{' '} + + log in met gebruikersnaam en wachtwoord + +

+ {/* Error */} {error && (
diff --git a/src/app/peers/page.tsx b/src/app/peers/page.tsx index 1bc509e..f0fb8e0 100644 --- a/src/app/peers/page.tsx +++ b/src/app/peers/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'; import { getPeers } from '@/lib/api'; import PortalLayout from '@/app/layout-portal'; +import { SkeletonTable } from '@/components/Skeleton'; import { Wifi, Globe, Clock } from 'lucide-react'; export default function PeersPage() { @@ -31,13 +32,13 @@ export default function PeersPage() {
{loading ? ( -
Loading…
+ ) : peers.length === 0 ? (
No peers connected
) : (
{peers.map((p) => ( -
+
diff --git a/src/app/pins/page.tsx b/src/app/pins/page.tsx index 5e26aa7..fe1b0bb 100644 --- a/src/app/pins/page.tsx +++ b/src/app/pins/page.tsx @@ -4,7 +4,10 @@ import { useEffect, useState } from 'react'; import { listPins, addPin, removePin } from '@/lib/api'; import PortalLayout from '@/app/layout-portal'; import Link from 'next/link'; -import { HardDrive, Trash2, Plus, Search, Copy, CheckCircle, ExternalLink } from 'lucide-react'; +import { HardDrive, Trash2, Plus, Search, Copy, CheckCircle, ExternalLink, Download } from 'lucide-react'; +import Skeleton, { SkeletonRow, SkeletonTable } from '@/components/Skeleton'; +import BatchBar, { type BatchAction } from '@/components/BatchBar'; +import { useNotify } from '@/lib/notifications'; export default function PinsPage() { const [pins, setPins] = useState<{ cid: string; name: string; size: number; created: string }[]>([]); @@ -14,6 +17,9 @@ export default function PinsPage() { const [newName, setNewName] = useState(''); const [showAdd, setShowAdd] = useState(false); const [copied, setCopied] = useState(null); + const [selected, setSelected] = useState>(new Set()); + + const { notify } = useNotify(); async function load() { try { @@ -51,6 +57,41 @@ export default function PinsPage() { } catch { /* ignore */ } } + function toggleSelect(cid: string) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(cid)) next.delete(cid); + else next.add(cid); + return next; + }); + } + + function clearSelection() { + setSelected(new Set()); + } + + async function handleBatchUnpin() { + const ids = Array.from(selected); + let success = 0; + let fail = 0; + for (const cid of ids) { + try { + await removePin(cid); + success++; + } catch { + fail++; + } + } + if (success > 0) { + notify({ type: 'success', title: `Unpinned ${success} pin${success > 1 ? 's' : ''}` }); + } + if (fail > 0) { + notify({ type: 'error', title: `Failed to unpin ${fail} pin${fail > 1 ? 's' : ''}` }); + } + clearSelection(); + await load(); + } + const filtered = pins.filter( (p) => p.cid.includes(search) || (p.name && p.name.toLowerCase().includes(search.toLowerCase())), ); @@ -112,7 +153,7 @@ export default function PinsPage() { {/* Pin list */}
{loading ? ( -
Loading…
+ ) : filtered.length === 0 ? (
{search ? 'No pins match your search' : 'No pins yet. Add one above.'} @@ -121,6 +162,12 @@ export default function PinsPage() {
{filtered.map((pin) => (
+ toggleSelect(pin.cid)} + className="w-4 h-4 rounded border-surface-600 bg-surface-800 text-brand-500 focus:ring-brand-500 shrink-0 mr-3" + />
@@ -162,6 +209,22 @@ export default function PinsPage() {
)}
+ + {/* Batch actions bar */} + , + variant: 'danger', + onClick: handleBatchUnpin, + }, + ]} + onClear={clearSelection} + label="selected" + /> ); } diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx new file mode 100644 index 0000000..7f07ed1 --- /dev/null +++ b/src/app/profile/page.tsx @@ -0,0 +1,185 @@ +'use client'; + +import { useState } from 'react'; +import PortalLayout from '@/app/layout-portal'; +import { useAuth } from '@/lib/auth'; +import { useNotify } from '@/lib/notifications'; +import { useRouter } from 'next/navigation'; +import { + User, Lock, LogOut, Save, Loader2, AlertTriangle, CheckCircle, Eye, EyeOff, +} from 'lucide-react'; + +export default function ProfilePage() { + const { user, logout, isAdmin } = useAuth(); + const { notify } = useNotify(); + const router = useRouter(); + + const [currentPass, setCurrentPass] = useState(''); + const [newPass, setNewPass] = useState(''); + const [confirmPass, setConfirmPass] = useState(''); + const [showPasswords, setShowPasswords] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + + async function handleChangePassword(e: React.FormEvent) { + e.preventDefault(); + setError(''); + + if (!currentPass || !newPass || !confirmPass) { + setError('Vul alle wachtwoordvelden in'); + return; + } + if (newPass.length < 6) { + setError('Nieuw wachtwoord moet minimaal 6 tekens zijn'); + return; + } + if (newPass !== confirmPass) { + setError('Nieuwe wachtwoorden komen niet overeen'); + return; + } + + setBusy(true); + try { + const res = await fetch('/api/auth/password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ address: user?.address, currentPassword: currentPass, newPassword: newPass }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || 'Wachtwoord wijzigen mislukt'); + } + notify({ type: 'success', title: 'Wachtwoord gewijzigd' }); + setCurrentPass(''); + setNewPass(''); + setConfirmPass(''); + } catch (err: any) { + setError(err.message); + } finally { + setBusy(false); + } + } + + async function handleLogout() { + await logout(); + notify({ type: 'info', title: 'Uitgelogd' }); + router.push('/login'); + } + + return ( + + {/* ── Header ── */} +
+
+

Profiel

+

Je accountinstellingen

+
+ +
+ +
+ {/* ── Account info ── */} +
+
+
+
+ {user?.address?.charAt(2).toUpperCase() || '?'} +
+
+
{user?.address ? `${user.address.slice(0, 6)}...${user.address.slice(-4)}` : 'Onbekend'}
+
+ {isAdmin ? 'Admin' : 'Gebruiker'} +
+
+
+ +
+
+ Rol + + {isAdmin ? 'Administrator' : 'User'} + +
+
+ Sessie + Actief +
+
+
+
+ + {/* ── Wachtwoord wijzigen ── */} +
+
+

+ + Wachtwoord wijzigen +

+ +
+ {error && ( +
+ + {error} +
+ )} + +
+ + setCurrentPass(e.target.value)} + type={showPasswords ? 'text' : 'password'} + autoComplete="current-password" + className="w-full rounded-lg bg-surface-900 border border-surface-700 px-3 py-2 text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none focus:border-brand-500 transition-colors" + /> +
+ +
+ + setNewPass(e.target.value)} + type={showPasswords ? 'text' : 'password'} + autoComplete="new-password" + placeholder="Minimaal 6 tekens" + className="w-full rounded-lg bg-surface-900 border border-surface-700 px-3 py-2 pr-10 text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none focus:border-brand-500 transition-colors" + /> +
+ +
+ + setConfirmPass(e.target.value)} + type={showPasswords ? 'text' : 'password'} + autoComplete="new-password" + className="w-full rounded-lg bg-surface-900 border border-surface-700 px-3 py-2 text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none focus:border-brand-500 transition-colors" + /> +
+ + {/* Toggle show passwords */} + + + +
+
+
+
+
+ ); +} diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx new file mode 100644 index 0000000..b0ee46c --- /dev/null +++ b/src/app/register/page.tsx @@ -0,0 +1,139 @@ +'use client'; + +import { useState, FormEvent } from 'react'; +import { useRouter } from 'next/navigation'; +import { useNotify } from '@/lib/notifications'; +import { UserPlus, Loader2, AlertTriangle, CheckCircle, ArrowLeft } from 'lucide-react'; +import Link from 'next/link'; + +export default function RegisterPage() { + const router = useRouter(); + const { notify } = useNotify(); + + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPass, setConfirmPass] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setError(''); + + if (!username.trim() || !password) { + setError('Vul alle velden in'); + return; + } + if (password.length < 6) { + setError('Wachtwoord moet minimaal 6 tekens zijn'); + return; + } + if (password !== confirmPass) { + setError('Wachtwoorden komen niet overeen'); + return; + } + + setBusy(true); + try { + const res = await fetch('/api/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: username.trim(), password }), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || `Registratie mislukt (${res.status})`); + } + + notify({ + type: 'success', + title: 'Account aangemaakt', + message: `Welkom, ${username.trim()}! Je kunt nu inloggen.`, + }); + router.push('/login'); + } catch (err: any) { + setError(err.message || 'Registratie mislukt'); + } finally { + setBusy(false); + } + } + + return ( +
+
+ {/* Header */} +
+
+ M +
+

Account aanmaken

+

Maak een nieuw IPFS Portal account

+
+ + {/* Form */} +
+ {error && ( +
+ + {error} +
+ )} + +
+ + setUsername(e.target.value)} + placeholder="Kies een gebruikersnaam" + autoFocus + autoComplete="username" + className="w-full rounded-lg bg-surface-900 border border-surface-700 px-3 py-2.5 text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none focus:border-brand-500 focus:ring-1 focus:ring-brand-500 transition-colors" + /> +
+ +
+ + setPassword(e.target.value)} + type="password" placeholder="Minimaal 6 tekens" + autoComplete="new-password" + className="w-full rounded-lg bg-surface-900 border border-surface-700 px-3 py-2.5 text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none focus:border-brand-500 focus:ring-1 focus:ring-brand-500 transition-colors" + /> +
+ +
+ + setConfirmPass(e.target.value)} + type="password" placeholder="Nogmaals je wachtwoord" + autoComplete="new-password" + className="w-full rounded-lg bg-surface-900 border border-surface-700 px-3 py-2.5 text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none focus:border-brand-500 focus:ring-1 focus:ring-brand-500 transition-colors" + /> +
+ + + +

+ Heb je al een account?{' '} + + Inloggen + +

+
+ + {/* Back */} +
+ + + Terug naar home + +
+
+
+ ); +} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 8af6ead..b3a7af8 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { getSettings, saveSettings, resetSettings, type PortalSettings } from '@/lib/storage'; +import { useTheme } from '@/lib/theme'; import PortalLayout from '@/app/layout-portal'; import { Settings, Globe, Server, HardDrive, RefreshCw, @@ -18,6 +19,7 @@ function formatStorageMB(mb: number): string { /* ════════════════════════════ Page ════════════════════════════ */ export default function SettingsPage() { + const { setTheme: applyTheme } = useTheme(); const [form, setForm] = useState(() => getSettings()); const [original, setOriginal] = useState(() => getSettings()); const [saved, setSaved] = useState(false); @@ -228,23 +230,23 @@ export default function SettingsPage() {
- {(['dark', 'light'] as const).map(theme => ( + {(['dark', 'light'] as const).map(t => ( ))}

- Theme preference is saved but only affects new sessions. Full theme switching coming soon. + Theme wordt direct toegepast en opgeslagen in je browser.

), diff --git a/src/app/upload/components/CryptoUpload.tsx b/src/app/upload/components/CryptoUpload.tsx new file mode 100644 index 0000000..eb6e2a3 --- /dev/null +++ b/src/app/upload/components/CryptoUpload.tsx @@ -0,0 +1,142 @@ +'use client'; + +import { type RefObject } from 'react'; +import { + File, CheckCircle, Copy, ExternalLink, Trash2, ArrowUp, +} from 'lucide-react'; +import PaymentPanel from '@/components/PaymentPanel'; + +/* ── Helpers ── */ +function formatBytes(b: number): string { + if (b === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + const i = Math.min(Math.floor(Math.log(b) / Math.log(1024)), units.length - 1); + const val = b / Math.pow(1024, i); + return (val < 10 ? val.toFixed(1) : Math.round(val)) + ' ' + units[i]; +} + +/* ── Props ── */ +interface CryptoUploadProps { + cryptoFile: File | null; + cryptoResult: { cid: string; size: number } | null; + copied: string | null; + cryptoInputRef: RefObject; + onCryptoSelect: () => void; + onCryptoFileChange: (e: React.ChangeEvent) => void; + onCryptoClear: () => void; + onCryptoPaid: (info: { cid: string; txHash?: string; method: string }) => void; + doCryptoUpload: () => Promise<{ cid: string; size: number }>; + onCopyCid: (cid: string) => void; + gatewayLink: (cid: string) => string; +} + +/* ════════════════════════════ Component ════════════════════════════ */ + +export default function CryptoUpload({ + cryptoFile, cryptoResult, copied, cryptoInputRef, + onCryptoSelect, onCryptoFileChange, onCryptoClear, + onCryptoPaid, doCryptoUpload, onCopyCid, gatewayLink, +}: CryptoUploadProps) { + + return ( +
+ {/* Left: file selection */} +
+ {!cryptoFile ? ( +
+ +
+ +
+

+ Click to select a file +

+

+ Single file only. Pay with ETH, USDC, or MAOS. +

+
+
+
+ ) : ( +
+
+ +
+

{cryptoFile.name}

+

{formatBytes(cryptoFile.size)}

+
+ +
+ + {/* Crypto result */} + {cryptoResult && ( +
+
+ + Upload complete +
+
+ CID +
+ {cryptoResult.cid} + +
+
+
+ Gateway + + View + +
+
+ )} +
+ )} +
+ + {/* Right: PaymentPanel */} +
+ {cryptoFile ? ( + + ) : ( +
+ +

+ Select a file to see pricing +

+
+ )} +
+
+ ); +} diff --git a/src/app/upload/components/QuickUpload.tsx b/src/app/upload/components/QuickUpload.tsx new file mode 100644 index 0000000..cb409f1 --- /dev/null +++ b/src/app/upload/components/QuickUpload.tsx @@ -0,0 +1,316 @@ +'use client'; + +import { type DragEvent, type RefObject } from 'react'; +import { + Upload, File, CheckCircle, XCircle, Loader2, + Copy, ExternalLink, Trash2, Globe, +} from 'lucide-react'; + +const MAX_FILES = 20; +const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB +const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp']; + +/* ── Helpers ── */ +function formatBytes(b: number): string { + if (b === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + const i = Math.min(Math.floor(Math.log(b) / Math.log(1024)), units.length - 1); + const val = b / Math.pow(1024, i); + return (val < 10 ? val.toFixed(1) : Math.round(val)) + ' ' + units[i]; +} + +export interface FileEntry { + id: string; + file: File; + preview?: string; + status: 'pending' | 'uploading' | 'done' | 'error'; + result?: { cid: string; size: number }; + errorMsg?: string; + date?: string; +} + +interface QuickUploadProps { + files: FileEntry[]; + dragOver: boolean; + uploading: boolean; + uploadIndex: number; + uploadDone: boolean; + copied: string | null; + inputRef: RefObject; + onAddFiles: (files: FileList | File[]) => void; + onRemoveFile: (id: string) => void; + onDragOver: (e: DragEvent) => void; + onDragLeave: () => void; + onDrop: (e: DragEvent) => void; + onBrowse: () => void; + onInputChange: (e: React.ChangeEvent) => void; + onStartUpload: () => void; + onCopyCid: (cid: string) => void; + onCopyShareLink: (cid: string) => void; + onClearAll: () => void; + gatewayLink: (cid: string) => string; +} + +export default function QuickUpload({ + files, dragOver, uploading, uploadIndex, uploadDone, copied, + inputRef, onAddFiles, onRemoveFile, onDragOver, onDragLeave, onDrop, + onBrowse, onInputChange, onStartUpload, onCopyCid, onCopyShareLink, onClearAll, gatewayLink, +}: QuickUploadProps) { + + const doneCount = files.filter(f => f.status === 'done').length; + const errorCount = files.filter(f => f.status === 'error').length; + const totalCount = files.length; + const allDone = files.every(f => f.status === 'done' || f.status === 'error'); + + return ( +
+ {/* Drop zone */} + {!uploading && !uploadDone && ( +
+ = MAX_FILES} + /> +
+ +
+

+ Click to browse or drag & drop files +

+

+ Up to {MAX_FILES} files, max {formatBytes(MAX_FILE_SIZE)} each +

+
+
+
+ )} + + {/* File list */} + {files.length > 0 && ( +
+
+ + {totalCount} file{totalCount !== 1 ? 's' : ''} selected + {uploading && <> · Uploading {uploadIndex}/{totalCount}} + + {!uploading && !allDone && ( + + )} +
+ + {uploading && ( +
+
+
+ )} + +
+ {files.map((entry, idx) => ( +
+
+ {entry.preview ? ( + + ) : ( + + )} +
+ +
+
+ {entry.file.name} + {entry.status === 'done' && entry.result && ( + + )} + {entry.status === 'error' && ( + + )} +
+
+ {formatBytes(entry.file.size)} + {entry.status === 'uploading' && ( + + + Uploading… + + )} + {entry.status === 'done' && entry.result && ( + + {entry.result.cid.slice(0, 16)}… + + )} + {entry.status === 'error' && ( + + {entry.errorMsg} + + )} +
+
+ +
+ {entry.status === 'done' && entry.result && ( + <> + + + + + + )} + {entry.status === 'pending' && !uploading && ( + + )} +
+
+ ))} +
+
+ )} + + {/* Action buttons */} + {files.length > 0 && !allDone && ( +
+ +
+ )} + + {/* Results summary */} + {uploadDone && ( +
+
+
+ {errorCount === 0 ? ( + + ) : ( + + )} + + {doneCount}/{totalCount} files uploaded + +
+ +
+ + {/* Per-file result details */} + {files.filter(f => f.status === 'done').map(entry => entry.result && ( +
+
+ {entry.file.name} + {entry.result.cid.slice(0, 16)}… + {formatBytes(entry.result.size)} +
+
+ + + + + +
+
+ ))} +
+ )} + + {/* Empty state */} + {files.length === 0 && !uploading && ( +
+ +

+ Drag files above or click to browse. No crypto payment needed for quick uploads. +

+
+ )} +
+ ); +} diff --git a/src/app/upload/page.tsx b/src/app/upload/page.tsx index 32164fb..5020ccd 100644 --- a/src/app/upload/page.tsx +++ b/src/app/upload/page.tsx @@ -1,45 +1,26 @@ 'use client'; -import { useState, useRef, DragEvent, useCallback } from 'react'; +import { useState, useRef, type DragEvent, useCallback } from 'react'; import { uploadFile } from '@/lib/api'; -import { addToHistory, type UploadRecord } from '@/lib/storage'; +import { addToHistory } from '@/lib/storage'; import PortalLayout from '@/app/layout-portal'; -import PaymentPanel from '@/components/PaymentPanel'; -import { - Upload, File, Image as FileImage, CheckCircle, XCircle, Loader2, - Copy, ExternalLink, Trash2, Globe, Clock, ArrowUp, Zap, -} from 'lucide-react'; +import { checkUploadAllowed } from '@/lib/limits'; +import { useNotify } from '@/lib/notifications'; +import { Zap, ArrowUp } from 'lucide-react'; +import QuickUpload, { type FileEntry } from './components/QuickUpload'; +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']; -/* ── Helpers ── */ -function formatBytes(b: number): string { - if (b === 0) return '0 B'; - const units = ['B', 'KB', 'MB', 'GB']; - const i = Math.min(Math.floor(Math.log(b) / Math.log(1024)), units.length - 1); - const val = b / Math.pow(1024, i); - return (val < 10 ? val.toFixed(1) : Math.round(val)) + ' ' + units[i]; -} - -/* ── Types ── */ -interface FileEntry { - id: string; - file: File; - preview?: string; // object URL for images - status: 'pending' | 'uploading' | 'done' | 'error'; - result?: { cid: string; size: number }; - errorMsg?: string; - date?: string; -} - type TabMode = 'quick' | 'crypto'; /* ════════════════════════════ Page ════════════════════════════ */ export default function UploadPage() { + const { notify } = useNotify(); const inputRef = useRef(null); const cryptoInputRef = useRef(null); @@ -87,6 +68,12 @@ export default function UploadPage() { }); } + function clearAll() { + files.forEach(f => f.preview && URL.revokeObjectURL(f.preview)); + setFiles([]); + setUploadDone(false); + } + /* ── Drag / Drop ── */ function onDragOver(e: DragEvent) { e.preventDefault(); setDragOver(true); } function onDragLeave() { setDragOver(false); } @@ -108,6 +95,18 @@ export default function UploadPage() { const pending = files.filter(f => f.status === 'pending'); if (pending.length === 0) return; + // Check limits before uploading + const limitCheck = await checkUploadAllowed().catch(() => null); + if (limitCheck && !limitCheck.allowed) { + notify({ + type: 'error', + title: 'Upload limiet bereikt', + message: limitCheck.reason || 'Je upload limiet is bereikt', + duration: 8000, + }); + return; + } + setUploading(true); setUploadIndex(0); setUploadDone(false); @@ -115,18 +114,13 @@ export default function UploadPage() { for (let i = 0; i < pending.length; i++) { const entry = pending[i]; - // Mark uploading setFiles(prev => prev.map(f => f.id === entry.id ? { ...f, status: 'uploading' as const } : f)); setUploadIndex(i + 1); try { const result = await uploadFile(entry.file); const date = new Date().toISOString(); - - // Save to history addToHistory({ cid: result.cid, name: entry.file.name, size: result.size, date, method: 'quick' }); - - // Mark done setFiles(prev => prev.map(f => f.id === entry.id ? { ...f, status: 'done' as const, result, date } : f )); @@ -145,18 +139,19 @@ export default function UploadPage() { function onCryptoSelect() { cryptoInputRef.current?.click(); } function onCryptoFileChange(e: React.ChangeEvent) { const file = e.target.files?.[0]; - if (file) { - setCryptoFile(file); - setCryptoResult(null); - } + if (file) { setCryptoFile(file); setCryptoResult(null); } e.target.value = ''; } - function onCryptoClear() { - setCryptoFile(null); - setCryptoResult(null); - } + function onCryptoClear() { setCryptoFile(null); setCryptoResult(null); } async function doCryptoUpload(): Promise<{ cid: string; size: number }> { if (!cryptoFile) throw new Error('No file selected'); + + // Check limits before uploading + const limitCheck = await checkUploadAllowed().catch(() => null); + if (limitCheck && !limitCheck.allowed) { + throw new Error(limitCheck.reason || 'Upload limiet bereikt'); + } + const r = await uploadFile(cryptoFile); setCryptoResult(r); addToHistory({ cid: r.cid, name: cryptoFile.name, size: r.size, date: new Date().toISOString(), method: 'eth' }); @@ -167,24 +162,23 @@ export default function UploadPage() { } /* ── Clipboard ── */ - async function copyCid(cid: string) { - try { - await navigator.clipboard.writeText(cid); - setCopied(cid); - setTimeout(() => setCopied(null), 2000); - } catch { /* ignore */ } + function copyCid(cid: string) { + navigator.clipboard.writeText(cid).catch(() => {}); + setCopied(cid); + setTimeout(() => setCopied(null), 2000); + } + + function copyShareLink(cid: string) { + const link = gatewayLink(cid); + navigator.clipboard.writeText(link).catch(() => {}); + setCopied(`gw-${cid}`); + setTimeout(() => setCopied(null), 2000); } function gatewayLink(cid: string) { return `https://maos.dedyn.io/ipfs/${cid}`; } - /* ── Stats ── */ - const doneCount = files.filter(f => f.status === 'done').length; - const errorCount = files.filter(f => f.status === 'error').length; - const totalCount = files.length; - const allDone = files.every(f => f.status === 'done' || f.status === 'error'); - /* ════════════ Render ════════════ */ return ( @@ -220,370 +214,46 @@ export default function UploadPage() {
- {/* ════════════ TAB: Quick Upload ════════════ */} + {/* Quick Upload tab */} {tab === 'quick' && ( -
- {/* ── Drop zone ── */} - {!uploading && !uploadDone && ( -
- = MAX_FILES} - /> -
- -
-

- Click to browse or drag & drop files -

-

- Up to {MAX_FILES} files, max {formatBytes(MAX_FILE_SIZE)} each -

-
-
-
- )} - - {/* ── File list ── */} - {files.length > 0 && ( -
-
- - {totalCount} file{totalCount !== 1 ? 's' : ''} selected - {uploading && <> · Uploading {uploadIndex}/{totalCount}} - - {!uploading && !allDone && ( - - )} -
- - {/* Progress bar */} - {uploading && ( -
-
-
- )} - - {/* Files */} -
- {files.map((entry, idx) => ( -
- {/* Preview / icon */} -
- {entry.preview ? ( - - ) : ( - - )} -
- - {/* Info */} -
-
- {entry.file.name} - {entry.status === 'done' && entry.result && ( - - )} - {entry.status === 'error' && ( - - )} -
-
- {formatBytes(entry.file.size)} - {entry.status === 'uploading' && ( - - - Uploading… - - )} - {entry.status === 'done' && entry.result && ( - - {entry.result.cid.slice(0, 16)}… - - )} - {entry.status === 'error' && ( - - {entry.errorMsg} - - )} -
-
- - {/* Actions */} -
- {entry.status === 'done' && entry.result && ( - <> - - - - - - )} - {entry.status === 'pending' && !uploading && ( - - )} -
-
- ))} -
-
- )} - - {/* ── Action buttons ── */} - {files.length > 0 && !allDone && ( -
- -
- )} - - {/* ── Results summary ── */} - {uploadDone && ( -
-
-
- {errorCount === 0 ? ( - - ) : ( - - )} - - {doneCount}/{totalCount} files uploaded - -
- -
- - {/* Per-file result details */} - {files.filter(f => f.status === 'done').map(entry => entry.result && ( -
-
- {entry.file.name} - {entry.result.cid.slice(0, 16)}… - {formatBytes(entry.result.size)} -
-
- - - - - -
-
- ))} -
- )} - - {/* ── Empty state ── */} - {files.length === 0 && !uploading && ( -
- -

- Drag files above or click to browse. No crypto payment needed for quick uploads. -

-
- )} -
+ )} - {/* ════════════ TAB: Crypto Payment ════════════ */} + {/* Crypto Payment tab */} {tab === 'crypto' && ( -
- {/* Left: file selection */} -
- {!cryptoFile ? ( -
- -
- -
-

- Click to select a file -

-

- Single file only. Pay with ETH, USDC, or MAOS. -

-
-
-
- ) : ( -
-
- -
-

{cryptoFile.name}

-

{formatBytes(cryptoFile.size)}

-
- -
- - {/* Crypto result */} - {cryptoResult && ( -
-
- - Upload complete -
-
- CID -
- {cryptoResult.cid} - -
-
-
- Gateway - - View - -
-
- )} -
- )} -
- - {/* Right: PaymentPanel */} -
- {cryptoFile ? ( - - ) : ( -
- -

- Select a file to see pricing -

-
- )} -
-
+ )} ); diff --git a/src/app/usage/page.tsx b/src/app/usage/page.tsx new file mode 100644 index 0000000..cd17d37 --- /dev/null +++ b/src/app/usage/page.tsx @@ -0,0 +1,171 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import PortalLayout from '@/app/layout-portal'; +import { getRepoStats, getBWStats, getPeers, listPins } from '@/lib/api'; +import type { RepoStats, BWStats } from '@/lib/api'; +import { checkUploadAllowed, type UsageCheckResult } from '@/lib/limits'; +import { useAuth } from '@/lib/auth'; +import StorageGauge from '@/app/dashboard/components/StorageGauge'; +import FreeTierProgress from '@/app/dashboard/components/FreeTierProgress'; +import { + HardDrive, Upload, Wifi, Database, Pin, Activity, + ArrowUpRight, ArrowDownLeft, +} from 'lucide-react'; + +export default function UsagePage() { + const { user } = useAuth(); + const [usage, setUsage] = useState(null); + const [repo, setRepo] = useState(null); + const [bw, setBW] = useState(null); + const [peerCount, setPeerCount] = useState(0); + const [pinCount, setPinCount] = useState(0); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function load() { + try { + const [u, r, b, p, pins] = await Promise.all([ + checkUploadAllowed().catch(() => null), + getRepoStats().catch(() => null), + getBWStats().catch(() => null), + getPeers().catch(() => []), + listPins().catch(() => []), + ]); + setUsage(u); + setRepo(r); + setBW(b); + setPeerCount(p.length); + setPinCount(pins.length); + } finally { + setLoading(false); + } + } + load(); + }, []); + + const usedGB = repo ? repo.repoSize / 1e9 : 0; + const maxGB = repo ? repo.storageMax / 1e9 : 30; + + return ( + + {/* ── Header ── */} +
+

Usage

+

+ {user ? `Verbonden als ${user.address.slice(0, 6)}...${user.address.slice(-4)}` : 'Je persoonlijke IPFS gebruikersoverzicht'} +

+
+ + {loading ? ( +
+
+
+ ) : ( +
+ {/* ── Row 1: Storage + Free Tier ── */} +
+ + +
+ + {/* ── Row 2: KPI cards ── */} +
+
+
+ Totale Uploads +
+
+ {usage?.current.uploadCount ?? 0} +
+
+
+
+ Pins +
+
{pinCount}
+
+ / {usage?.quota.maxPins.toLocaleString() ?? '—'} +
+
+
+
+ Peers +
+
{peerCount}
+
+
+
+ Gebruikt +
+
{usedGB.toFixed(1)}
+
GB / {maxGB.toFixed(0)} GB
+
+
+ + {/* ── Row 3: Bandwidth + Repo ── */} +
+ {/* Bandwidth */} +
+

+ + Bandbreedte +

+
+
+
+ + In +
+ + {bw ? (bw.totalIn / 1e6).toFixed(1) : '—'} MB + +
+
+
+ + Uit +
+ + {bw ? (bw.totalOut / 1e6).toFixed(1) : '—'} MB + +
+
+

Sinds node start

+
+ + {/* Quota details */} +
+

+ + Quota +

+
+ {([ + ['Max opslag', `${(usage?.quota.maxStorageMB ?? 30720) / 1024} GB`], + ['Max uploads', (usage?.quota.maxUploads ?? 1000).toLocaleString()], + ['Gratis uploads/dag', String(usage?.quota.freeUploadsPerDay ?? 50)], + ['Max pins', (usage?.quota.maxPins ?? 10000).toLocaleString()], + ] as const).map(([label, value]) => ( +
+ {label} + {value} +
+ ))} +
+ {!usage?.allowed && usage?.reason && ( +
+

⚠️ {usage.reason}

+
+ )} +
+
+
+ )} + + ); +} diff --git a/src/app/users/page.tsx b/src/app/users/page.tsx index ddec047..6b8b5b7 100644 --- a/src/app/users/page.tsx +++ b/src/app/users/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'; import { listUsers, createUser, deleteUser } from '@/lib/api'; import PortalLayout from '@/app/layout-portal'; +import { SkeletonTable } from '@/components/Skeleton'; import { paymentService, type UserFullStats, type UploadRecord } from '@/lib/payment'; import { formatWeiToETH, formatBytes } from '@/lib/wallet'; import { @@ -132,7 +133,7 @@ export default function UsersPage() {
{loading ? ( -
Loading…
+ ) : users.length === 0 ? (
No users configured
) : ( diff --git a/src/components/AuthGuard.tsx b/src/components/AuthGuard.tsx new file mode 100644 index 0000000..19323ad --- /dev/null +++ b/src/components/AuthGuard.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { useAuth } from '@/lib/auth'; +import { useRouter } from 'next/navigation'; +import { useEffect } from 'react'; + +interface AuthGuardProps { + children: React.ReactNode; + /** Require admin role (default: false) */ + requireAdmin?: boolean; + /** Redirect target for unauthenticated users (default: /login) */ + redirectTo?: string; +} + +export default function AuthGuard({ + children, + requireAdmin = false, + redirectTo = '/login', +}: AuthGuardProps) { + const { user, loading } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (loading) return; + + if (!user) { + router.replace(redirectTo); + } else if (requireAdmin && user.role !== 'admin') { + router.replace('/dashboard'); + } + }, [user, loading, requireAdmin, redirectTo, router]); + + /* ── Loading state ── */ + if (loading) { + return ( +
+
+
+

Bezig met laden…

+
+
+ ); + } + + /* ── Not authenticated ── */ + if (!user) { + return null; // useEffect handles redirect + } + + /* ── Not admin → redirect handled by useEffect ── */ + if (requireAdmin && user.role !== 'admin') { + return null; + } + + return <>{children}; +} diff --git a/src/components/BatchBar.tsx b/src/components/BatchBar.tsx new file mode 100644 index 0000000..1c7e401 --- /dev/null +++ b/src/components/BatchBar.tsx @@ -0,0 +1,67 @@ +'use client'; + +/* ════════════════════════════ BatchBar ════════════════════════════ + * Floating "N selected" action bar voor batch operaties. + * Verschijnt onderaan zodra >= 1 item geselecteerd is. + */ + +import { Trash2, Download, X } from 'lucide-react'; + +export interface BatchAction { + key: string; + label: string; + icon?: React.ReactNode; + variant?: 'danger' | 'default'; + onClick: () => void; +} + +interface BatchBarProps { + count: number; + actions: BatchAction[]; + onClear: () => void; + label?: string; +} + +export default function BatchBar({ count, actions, onClear, label = 'geselecteerd' }: BatchBarProps) { + if (count === 0) return null; + + return ( +
+
+ {/* Count badge */} + + {count} {label} + + +
+ + {/* Actions */} +
+ {actions.map((action) => ( + + ))} +
+ + {/* Close */} + +
+
+ ); +} diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..17335f7 --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,64 @@ +'use client'; + +/* ════════════════════════════ ErrorBoundary ════════════════════════════ + * Vangt render crashes op en toont een fallback UI i.p.v. een lege pagina. + */ + +import { Component, type ReactNode, type ErrorInfo } from 'react'; +import { AlertCircle, RefreshCw } from 'lucide-react'; + +interface Props { + children: ReactNode; + fallback?: ReactNode; + /** Optional: label om te tonen welk onderdeel crashed */ + label?: string; +} + +interface State { + error: Error | null; + info: ErrorInfo | null; +} + +export default class ErrorBoundary extends Component { + state: State = { error: null, info: null }; + + static getDerivedStateFromError(error: Error) { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error(`[ErrorBoundary${this.props.label ? ` ${this.props.label}` : ''}]`, error, info); + this.setState({ info }); + } + + handleRetry = () => { + this.setState({ error: null, info: null }); + }; + + render() { + if (this.state.error) { + if (this.props.fallback) return this.props.fallback; + + return ( +
+ +

+ {this.props.label ? `Fout in ${this.props.label}` : 'Er ging iets mis'} +

+

+ {this.state.error.message || 'Onbekende fout'} +

+ +
+ ); + } + + return this.props.children; + } +} diff --git a/src/components/PaymentPanel.tsx b/src/components/PaymentPanel.tsx index fa24339..e6e6b71 100644 --- a/src/components/PaymentPanel.tsx +++ b/src/components/PaymentPanel.tsx @@ -63,7 +63,7 @@ export default function PaymentPanel({ if (contractAddress) { paymentService.setContractAddress(contractAddress); } - paymentService.isDeployed().then(setDeployed); + paymentService.isDeployed().then(setDeployed).catch(() => setDeployed(false)); }, [contractAddress]); // Refresh price + stats when address or fileSize changes @@ -98,11 +98,8 @@ export default function PaymentPanel({ setProvider(prov); // Detect wallet type - // @ts-expect-error if (window.maosv6) setWalletType('MAOS Wallet'); - // @ts-expect-error else if (window.ethereum?.isRabby) setWalletType('Rabby'); - // @ts-expect-error else if (window.ethereum?.isMetaMask) setWalletType('MetaMask'); else setWalletType('Wallet'); diff --git a/src/components/PortalHeader.tsx b/src/components/PortalHeader.tsx index b658c1f..4fdb4ba 100644 --- a/src/components/PortalHeader.tsx +++ b/src/components/PortalHeader.tsx @@ -2,9 +2,11 @@ import { useEffect, useState } from 'react'; import { checkHealth } from '@/lib/api'; -import { Shield } from 'lucide-react'; +import { useAuth } from '@/lib/auth'; +import { Shield, LogOut } from 'lucide-react'; export default function PortalHeader() { + const { user, logout } = useAuth(); const [nodeStatus, setNodeStatus] = useState<'online' | 'offline' | 'checking'>('checking'); const [nodeVersion, setNodeVersion] = useState(''); @@ -43,11 +45,22 @@ export default function PortalHeader() {
- {/* Wallet badge placeholder */} -
- - Wallet Connected -
+ {/* Wallet badge */} + {user && ( +
+ + + {user.address.slice(0, 6)}...{user.address.slice(-4)} + + +
+ )}
); diff --git a/src/components/PortalSidebar.tsx b/src/components/PortalSidebar.tsx index a69bf5b..f67d1c6 100644 --- a/src/components/PortalSidebar.tsx +++ b/src/components/PortalSidebar.tsx @@ -2,6 +2,7 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; +import { useTheme } from '@/lib/theme'; import { LayoutDashboard, Upload, @@ -14,10 +15,14 @@ import { Shield, LogOut, Clock, + BarChart3, + Sun, + Moon, } from 'lucide-react'; const NAV_ITEMS = [ { href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard }, + { href: '/usage', label: 'Usage', icon: BarChart3 }, { href: '/upload', label: 'Upload', icon: Upload }, { href: '/explorer', label: 'Explorer', icon: Globe }, { href: '/pins', label: 'Pins', icon: HardDrive }, @@ -31,13 +36,14 @@ const NAV_ITEMS = [ export default function PortalSidebar() { const pathname = usePathname(); + const { theme, toggle: toggleTheme } = useTheme(); function handleDisconnect() { window.location.href = '/'; } return ( -