feat(frontend): harden admin API handling and add vitest tests

cognitive committed Mar 3, 2026 at 20:03 UTC 7a181e595ccef9eaf6e97c552ff7c3daf8d37861
12 files changed +2288 -591
cmd/relay-server/frontend/README.md
+83 -154
@@ -1,102 +1,91 @@
1 # Relay Server Frontend
2
3 -ServerShare-style frontend built with React + TypeScript + shadcn/ui, featuring dynamic GeoIP-based server filtering and SSR data integration.
3 +React + TypeScript frontend for relay server discovery and onboarding.
4
5 ## Tech Stack
6
7 -- **React 19** - UI library
8 -- **TypeScript** - Type safety
9 -- **Vite 7** - Build tool
10 -- **Tailwind CSS 4** - CSS-first configuration styling
11 -- **shadcn/ui** - UI components (Radix UI-based)
12 -- **Lucide React** - Icons
7 +- React 19
8 +- TypeScript
9 +- Vite 7
10 +- Tailwind CSS 4
11 +- shadcn/ui (Radix-based)
12 +- Lucide React
13
14 ## Project Structure
15
16 -```
16 +```text
17 frontend/
18 ├── src/
19 │ ├── components/
20 │ │ ├── ui/ # shadcn/ui base components
21 -│ │ │ ├── button.tsx
22 -│ │ │ ├── input.tsx
23 -│ │ │ └── select.tsx
24 -│ │ ├── Header.tsx # Header component
25 -│ │ ├── SearchBar.tsx # Search and filter bar (Country, Status, Sort By)
26 -│ │ ├── ServerCard.tsx # Server card component
27 -│ │ └── Pagination.tsx # Pagination component
21 +│ │ ├── Header.tsx # Header + add-server entry point
22 +│ │ ├── SearchBar.tsx # Search + filters (status, sort, tags)
23 +│ │ ├── ServerCard.tsx # Server list card
24 +│ │ ├── ServerListView.tsx # Shared server/admin list view
25 +│ │ ├── TagCombobox.tsx # Tag filter control
26 +│ │ └── FloatingActionBar.tsx # Admin bulk actions
27 │ ├── hooks/
29 -│ │ └── useSSRData.ts # SSR data hook (reads __SSR_DATA__ from Go backend)
28 +│ │ ├── useSSRData.ts # Reads __SSR_DATA__ injected by Go backend
29 +│ │ ├── useServerList.ts # Converts SSR payload into list models
30 +│ │ ├── useAdmin.ts # Admin API integration and actions
31 +│ │ ├── useList.ts # Shared list filtering/sorting state
32 +│ │ └── useAuth.ts # Admin auth helper hooks
33 │ ├── lib/
31 -│ │ ├── utils.ts # Utility functions
32 -│ │ └── countries.ts # ISO 3166-1 alpha-2 country code mapping (~200 countries)
33 -│ ├── App.tsx # Main app component (filter logic, dynamic country extraction)
34 -│ ├── main.tsx # Entry point
35 -│ └── index.css # Global styles and Tailwind CSS 4 configuration
34 +│ │ ├── apiClient.ts
35 +│ │ ├── apiPaths.ts
36 +│ │ ├── testUtils.ts # Optional test fixtures
37 +│ │ └── utils.ts
38 +│ ├── pages/
39 +│ │ ├── Admin.tsx # Admin area shell
40 +│ │ ├── AdminLogin.tsx # Login flow UI
41 +│ │ └── ServerList.tsx # Listing pages and route assembly
42 +│ ├── App.tsx
43 +│ ├── main.tsx
44 +│ └── index.css
45 ├── index.html
46 ├── package.json
47 ├── tsconfig.json
39 -└── vite.config.ts # @tailwindcss/vite plugin
48 +└── vite.config.ts
49 ```
50
42 -## Key Features
43 -
44 -### 1. Server-Side Rendering (SSR)
45 -- Go backend injects server data into HTML via `<script id="__SSR_DATA__">` tag
46 -- Frontend reads SSR data using `useSSRData()` hook
47 -- Zero initial loading time for server list
48 -
49 -### 2. GeoIP Integration
50 -- Backend uses MaxMind GeoLite2-Country.mmdb database
51 -- Automatically detects server location based on IP address
52 -- Provides ISO 3166-1 alpha-2 country codes (e.g., "US", "KR", "JP")
53 -
54 -### 3. Dynamic Country Filtering
55 -- Extracts unique countries from currently connected servers
56 -- Only displays countries that actually have servers online
57 -- Full country mapping (~200 countries) in `lib/countries.ts`
58 -- Converts country codes to human-readable names
51 +## Core Behavior
52
60 -### 4. Advanced Filter System
61 -Three Select dropdowns:
62 -- **Country**: Filter by GeoIP-detected location (dynamic list)
63 -- **Status**: Filter by online/offline status
64 -- **Sort By**: Sort by Description, Tags, or Owner
53 +### Server-Side Data Bootstrap
54
66 -### 5. Search Functionality
67 -Search across:
68 -- Server names
69 -- Descriptions
70 -- Tags
55 +1. Go backend injects lease data into `portal.html` using `<script id="__SSR_DATA__">`.
56 +2. Frontend reads it with `useSSRData()`.
57 +3. UI renders server list immediately without an initial fetch.
58 +4. Admin pages then call `/admin/*` endpoints through `apiClient` for stateful actions (approve, deny, ban, settings).
59
72 -### Tailwind CSS 4 Migration
60 +### List Filtering and Sort
61
74 -This project uses Tailwind CSS v4:
62 +- List logic is centralized in `useList` and shared across admin and public server views.
63 +- Search fields include server name, description, and tags.
64 +- Filters include status and tag selection.
65 +- Sort options include default, description, tags, owner, and timestamp ordering.
66
76 -- ✅ CSS-first configuration (`@theme` directive in `index.css`)
77 -- ✅ `@tailwindcss/vite` plugin
78 -- ✅ Removed `tailwind.config.js` (config moved to CSS)
79 -- ✅ Removed `postcss.config.js` (auto-handled)
80 -- ✅ `@import "tailwindcss"` syntax
67 +## Tailwind CSS v4 Notes
68
82 -For details, see [Tailwind CSS v4 Official Documentation](https://tailwindcss.com/docs/upgrade-guide).
69 +- Uses CSS-first config (`@theme` in `index.css`)
70 +- Uses `@tailwindcss/vite` plugin
71 +- Uses `@import "tailwindcss"` syntax
72
84 -## Installation and Build
73 +## Install and Build
74
86 -### Install Dependencies
75 +### Install
76
77 ```bash
78 cd cmd/relay-server/frontend
79 npm install
80 ```
81
93 -### Development Server
82 +### Development
83
84 ```bash
85 npm run dev
86 ```
87
99 -Development server runs at `http://localhost:5173`.
88 +Default dev URL: `http://localhost:5173`.
89
90 ### Production Build
91
@@ -104,124 +93,64 @@ Development server runs at `http://localhost:5173`.
93 npm run build
94 ```
95
107 -Build output in `/dist` directory:
108 -- `portal.html` - Main HTML file (served by Go server with SSR data injection)
109 -- `assets/` - JS and CSS bundles
110 -
111 -### Using Makefile
112 -
113 -```bash
114 -# Install dependencies
115 -make install
96 +Build output:
97
117 -# Build
118 -make build
98 +- `dist/portal.html` (entry HTML served by Go server)
99 +- `dist/assets/` (bundled JS/CSS)
100
120 -# Development server
121 -make dev
101 +### NPM Scripts
102
123 -# Clean
124 -make clean
103 +```bash
104 +npm run dev
105 +npm run build
106 +npm run lint
107 +npm run typecheck
108 ```
109
127 -## Go Server Integration
110 +## Relay Server Integration
111 +
112 +Relay server exposes:
113
129 -The relay-server serves the frontend at:
114 +- `/` - React frontend with SSR bootstrap payload
115 +- `/app/` - Static frontend assets
116 +- `/healthz` - Health endpoint
117 +- `/admin/*` - Admin API/control endpoints used by server management UI
118 +- `/sdk/*` - SDK/control endpoints (`/sdk/connect` opens the raw TCP reverse channel used by the relay)
119
131 -- `/` - React frontend (ServerShare UI with SSR data)
132 -- `/app/` - React app static assets
133 -- `/healthz` - Health check endpoint
134 -- `/sdk/*` - SDK control and reverse-connect endpoints (`/sdk/connect` uses raw TCP stream after HTTP handshake)
120 +Admin endpoints use a JSON envelope contract (`{ ok, data, error }`) and reject malformed or non-JSON responses with explicit API client errors.
121
136 -### Running the Server
122 +### Run with Relay Server
123
124 ```bash
125 # Build frontend
126 cd cmd/relay-server/frontend
127 npm run build
128
143 -# Run server
129 +# Run relay server
130 cd ../../..
145 -go run cmd/relay-server/*.go -port 4017
131 +go run cmd/relay-server/*.go -adminport 4017
132 ```
133
148 -Or specify static directory:
134 +Or with explicit static directory:
135
136 ```bash
151 -STATIC_DIR=./dist go run cmd/relay-server/*.go -port 4017
137 +STATIC_DIR=./dist go run cmd/relay-server/*.go -adminport 4017
138 ```
139
154 -### SSR Data Flow
155 -
156 -1. Go backend reads lease entries and GeoIP data
157 -2. Converts to JSON and injects into `portal.html` as `<script id="__SSR_DATA__">`
158 -3. React app reads SSR data via `useSSRData()` hook
159 -4. Displays server list with GeoIP-based filtering
160 -
161 -## Design System
162 -
163 -### Colors
164 -
165 -- **Primary**: `#47cdff` - Primary action buttons
166 -- **Background Light**: `#f5f8f8` - Light mode background
167 -- **Background Dark**: `#0f1e23` - Dark mode background (default)
168 -- **Green Status**: `#50E3C2` - Online status indicator
169 -
170 -### Components
171 -
172 -#### Header
173 -Navigation header with logo and "Add Your Server" button
174 -
175 -#### SearchBar
176 -Search input + three filter dropdowns:
177 -- Country (GeoIP-based, dynamic)
178 -- Status (All/Online/Offline)
179 -- Sort By (Default/Description/Tags/Owner)
180 -
181 -#### ServerCard
182 -Server information card displaying:
183 -- Thumbnail image
184 -- Online/offline status badge
185 -- Server name
186 -- Description
187 -- Tags (including country)
188 -- Owner information
189 -- Connect button
190 -
191 -#### Pagination
192 -Page navigation with previous/next buttons (6 items per page)
193 -
194 -## Implementation Status
195 -
196 -### Completed Features
197 -- ✅ Server-side rendering (SSR) with Go backend
198 -- ✅ GeoIP integration with MaxMind GeoLite2-Country database
199 -- ✅ Dynamic country filtering (shows only countries with connected servers)
200 -- ✅ Search functionality (name, description, tags)
201 -- ✅ Status filtering (online/offline)
202 -- ✅ Sort by Description, Tags, or Owner
203 -- ✅ Pagination (6 servers per page)
204 -- ✅ Dark mode UI (default)
205 -- ✅ Responsive design (mobile/tablet/desktop)
206 -- ✅ ISO 3166-1 alpha-2 country code mapping (~200 countries)
207 -- ✅ Radix UI Select components with proper value handling
208 -
140 ## Technical Notes
141
211 -### Radix UI Select Constraints
212 -- Select values cannot be empty strings (`""`)
213 -- Use meaningful defaults: `"all"`, `"default"` instead of `""`
142 +- Backend transport is raw TCP reverse-connect only; there is no websocket control or data plane in relay transport semantics.
143 +- SNI routing keeps exact `PORTAL_URL` host fallbacks on the admin/API listener to preserve portal dashboard control-plane locality.
144 +
145 +### Radix Select Values
146 +
147 +Radix Select values cannot be empty strings. Use stable values such as `"all"` and `"default"`.
148
215 -### GeoIP Detection
216 -- Detects country based on server IP address
217 -- Localhost (::1) connections show no country
218 -- Requires external IP addresses for proper country detection
149 +### API-Response Edge Cases
150
220 -### Dynamic Country List
221 -- Extracts unique countries from connected servers using `useMemo` and `Set`
222 -- Updates automatically as servers connect/disconnect
223 -- Maps country codes to human-readable names via `COUNTRY_NAMES`
151 +- API responses are validated via `APIClient` envelope decoding; malformed payloads are surfaced as explicit runtime errors.
152 +- Non-admin rendering still works using SSR bootstrap data when admin calls are unavailable.
153
154 ## License
155
227 -This project is part of gosuda/portal.
156 +Part of `gosuda/portal`.
cmd/relay-server/frontend/package-lock.json
+1136 -50
@@ -27,6 +27,7 @@
27 },
28 "devDependencies": {
29 "@tailwindcss/vite": "^4.1.17",
30 + "@testing-library/react": "^16.3.2",
31 "@types/node": "^24.10.1",
32 "@types/react": "^19.2.7",
33 "@types/react-dom": "^19.2.3",
@@ -37,11 +38,78 @@
38 "eslint": "^9.39.1",
39 "eslint-plugin-react-hooks": "^7.0.1",
40 "eslint-plugin-react-refresh": "^0.4.24",
41 + "jsdom": "^28.1.0",
42 "tailwindcss": "^4.1.17",
43 "typescript": "^5.9.3",
42 - "vite": "^7.2.6"
44 + "vite": "^7.2.6",
45 + "vitest": "^4.0.18"
46 }
47 },
48 + "node_modules/@acemir/cssom": {
49 + "version": "0.9.31",
50 + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz",
51 + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==",
52 + "dev": true,
53 + "license": "MIT"
54 + },
55 + "node_modules/@asamuzakjp/css-color": {
56 + "version": "5.0.1",
57 + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz",
58 + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==",
59 + "dev": true,
60 + "license": "MIT",
61 + "dependencies": {
62 + "@csstools/css-calc": "^3.1.1",
63 + "@csstools/css-color-parser": "^4.0.2",
64 + "@csstools/css-parser-algorithms": "^4.0.0",
65 + "@csstools/css-tokenizer": "^4.0.0",
66 + "lru-cache": "^11.2.6"
67 + },
68 + "engines": {
69 + "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
70 + }
71 + },
72 + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
73 + "version": "11.2.6",
74 + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
75 + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
76 + "dev": true,
77 + "license": "BlueOak-1.0.0",
78 + "engines": {
79 + "node": "20 || >=22"
80 + }
81 + },
82 + "node_modules/@asamuzakjp/dom-selector": {
83 + "version": "6.8.1",
84 + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz",
85 + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==",
86 + "dev": true,
87 + "license": "MIT",
88 + "dependencies": {
89 + "@asamuzakjp/nwsapi": "^2.3.9",
90 + "bidi-js": "^1.0.3",
91 + "css-tree": "^3.1.0",
92 + "is-potential-custom-element-name": "^1.0.1",
93 + "lru-cache": "^11.2.6"
94 + }
95 + },
96 + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
97 + "version": "11.2.6",
98 + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
99 + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
100 + "dev": true,
101 + "license": "BlueOak-1.0.0",
102 + "engines": {
103 + "node": "20 || >=22"
104 + }
105 + },
106 + "node_modules/@asamuzakjp/nwsapi": {
107 + "version": "2.3.9",
108 + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
109 + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
110 + "dev": true,
111 + "license": "MIT"
112 + },
113 "node_modules/@babel/code-frame": {
114 "version": "7.29.0",
115 "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -296,6 +364,16 @@
364 "@babel/core": "^7.0.0-0"
365 }
366 },
367 + "node_modules/@babel/runtime": {
368 + "version": "7.28.6",
369 + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
370 + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
371 + "dev": true,
372 + "license": "MIT",
373 + "engines": {
374 + "node": ">=6.9.0"
375 + }
376 + },
377 "node_modules/@babel/template": {
378 "version": "7.28.6",
379 "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -344,6 +422,151 @@
422 "node": ">=6.9.0"
423 }
424 },
425 + "node_modules/@bramus/specificity": {
426 + "version": "2.4.2",
427 + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
428 + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
429 + "dev": true,
430 + "license": "MIT",
431 + "dependencies": {
432 + "css-tree": "^3.0.0"
433 + },
434 + "bin": {
435 + "specificity": "bin/cli.js"
436 + }
437 + },
438 + "node_modules/@csstools/color-helpers": {
439 + "version": "6.0.2",
440 + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
441 + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
442 + "dev": true,
443 + "funding": [
444 + {
445 + "type": "github",
446 + "url": "https://github.com/sponsors/csstools"
447 + },
448 + {
449 + "type": "opencollective",
450 + "url": "https://opencollective.com/csstools"
451 + }
452 + ],
453 + "license": "MIT-0",
454 + "engines": {
455 + "node": ">=20.19.0"
456 + }
457 + },
458 + "node_modules/@csstools/css-calc": {
459 + "version": "3.1.1",
460 + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz",
461 + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==",
462 + "dev": true,
463 + "funding": [
464 + {
465 + "type": "github",
466 + "url": "https://github.com/sponsors/csstools"
467 + },
468 + {
469 + "type": "opencollective",
470 + "url": "https://opencollective.com/csstools"
471 + }
472 + ],
473 + "license": "MIT",
474 + "engines": {
475 + "node": ">=20.19.0"
476 + },
477 + "peerDependencies": {
478 + "@csstools/css-parser-algorithms": "^4.0.0",
479 + "@csstools/css-tokenizer": "^4.0.0"
480 + }
481 + },
482 + "node_modules/@csstools/css-color-parser": {
483 + "version": "4.0.2",
484 + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz",
485 + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==",
486 + "dev": true,
487 + "funding": [
488 + {
489 + "type": "github",
490 + "url": "https://github.com/sponsors/csstools"
491 + },
492 + {
493 + "type": "opencollective",
494 + "url": "https://opencollective.com/csstools"
495 + }
496 + ],
497 + "license": "MIT",
498 + "dependencies": {
499 + "@csstools/color-helpers": "^6.0.2",
500 + "@csstools/css-calc": "^3.1.1"
501 + },
502 + "engines": {
503 + "node": ">=20.19.0"
504 + },
505 + "peerDependencies": {
506 + "@csstools/css-parser-algorithms": "^4.0.0",
507 + "@csstools/css-tokenizer": "^4.0.0"
508 + }
509 + },
510 + "node_modules/@csstools/css-parser-algorithms": {
511 + "version": "4.0.0",
512 + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
513 + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
514 + "dev": true,
515 + "funding": [
516 + {
517 + "type": "github",
518 + "url": "https://github.com/sponsors/csstools"
519 + },
520 + {
521 + "type": "opencollective",
522 + "url": "https://opencollective.com/csstools"
523 + }
524 + ],
525 + "license": "MIT",
526 + "engines": {
527 + "node": ">=20.19.0"
528 + },
529 + "peerDependencies": {
530 + "@csstools/css-tokenizer": "^4.0.0"
531 + }
532 + },
533 + "node_modules/@csstools/css-syntax-patches-for-csstree": {
534 + "version": "1.0.29",
535 + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.29.tgz",
536 + "integrity": "sha512-jx9GjkkP5YHuTmko2eWAvpPnb0mB4mGRr2U7XwVNwevm8nlpobZEVk+GNmiYMk2VuA75v+plfXWyroWKmICZXg==",
537 + "dev": true,
538 + "funding": [
539 + {
540 + "type": "github",
541 + "url": "https://github.com/sponsors/csstools"
542 + },
543 + {
544 + "type": "opencollective",
545 + "url": "https://opencollective.com/csstools"
546 + }
547 + ],
548 + "license": "MIT-0"
549 + },
550 + "node_modules/@csstools/css-tokenizer": {
551 + "version": "4.0.0",
552 + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
553 + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
554 + "dev": true,
555 + "funding": [
556 + {
557 + "type": "github",
558 + "url": "https://github.com/sponsors/csstools"
559 + },
560 + {
561 + "type": "opencollective",
562 + "url": "https://opencollective.com/csstools"
563 + }
564 + ],
565 + "license": "MIT",
566 + "engines": {
567 + "node": ">=20.19.0"
568 + }
569 + },
570 "node_modules/@esbuild/aix-ppc64": {
571 "version": "0.27.3",
572 "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
@@ -849,9 +1072,9 @@
1072 }
1073 },
1074 "node_modules/@eslint/config-array/node_modules/minimatch": {
852 - "version": "3.1.3",
853 - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz",
854 - "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==",
1075 + "version": "3.1.5",
1076 + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
1077 + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
1078 "dev": true,
1079 "license": "ISC",
1080 "dependencies": {
@@ -940,9 +1163,9 @@
1163 }
1164 },
1165 "node_modules/@eslint/eslintrc/node_modules/minimatch": {
943 - "version": "3.1.3",
944 - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz",
945 - "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==",
1166 + "version": "3.1.5",
1167 + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
1168 + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
1169 "dev": true,
1170 "license": "ISC",
1171 "dependencies": {
@@ -989,32 +1212,50 @@
1212 "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
1213 }
1214 },
1215 + "node_modules/@exodus/bytes": {
1216 + "version": "1.14.1",
1217 + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz",
1218 + "integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==",
1219 + "dev": true,
1220 + "license": "MIT",
1221 + "engines": {
1222 + "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
1223 + },
1224 + "peerDependencies": {
1225 + "@noble/hashes": "^1.8.0 || ^2.0.0"
1226 + },
1227 + "peerDependenciesMeta": {
1228 + "@noble/hashes": {
1229 + "optional": true
1230 + }
1231 + }
1232 + },
1233 "node_modules/@floating-ui/core": {
993 - "version": "1.7.4",
994 - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz",
995 - "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==",
1234 + "version": "1.7.5",
1235 + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
1236 + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
1237 "license": "MIT",
1238 "dependencies": {
998 - "@floating-ui/utils": "^0.2.10"
1239 + "@floating-ui/utils": "^0.2.11"
1240 }
1241 },
1242 "node_modules/@floating-ui/dom": {
1002 - "version": "1.7.5",
1003 - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz",
1004 - "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==",
1243 + "version": "1.7.6",
1244 + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
1245 + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
1246 "license": "MIT",
1247 "dependencies": {
1007 - "@floating-ui/core": "^1.7.4",
1008 - "@floating-ui/utils": "^0.2.10"
1248 + "@floating-ui/core": "^1.7.5",
1249 + "@floating-ui/utils": "^0.2.11"
1250 }
1251 },
1252 "node_modules/@floating-ui/react-dom": {
1012 - "version": "2.1.7",
1013 - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz",
1014 - "integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==",
1253 + "version": "2.1.8",
1254 + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
1255 + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==",
1256 "license": "MIT",
1257 "dependencies": {
1017 - "@floating-ui/dom": "^1.7.5"
1258 + "@floating-ui/dom": "^1.7.6"
1259 },
1260 "peerDependencies": {
1261 "react": ">=16.8.0",
@@ -1022,9 +1263,9 @@
1263 }
1264 },
1265 "node_modules/@floating-ui/utils": {
1025 - "version": "0.2.10",
1026 - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
1027 - "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
1266 + "version": "0.2.11",
1267 + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
1268 + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
1269 "license": "MIT"
1270 },
1271 "node_modules/@humanfs/core": {
@@ -2286,6 +2527,13 @@
2527 "react-dom": "^19.0.0"
2528 }
2529 },
2530 + "node_modules/@standard-schema/spec": {
2531 + "version": "1.1.0",
2532 + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
2533 + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
2534 + "dev": true,
2535 + "license": "MIT"
2536 + },
2537 "node_modules/@tailwindcss/node": {
2538 "version": "4.2.1",
2539 "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz",
@@ -2558,6 +2806,63 @@
2806 "vite": "^5.2.0 || ^6 || ^7"
2807 }
2808 },
2809 + "node_modules/@testing-library/dom": {
2810 + "version": "10.4.1",
2811 + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
2812 + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
2813 + "dev": true,
2814 + "license": "MIT",
2815 + "peer": true,
2816 + "dependencies": {
2817 + "@babel/code-frame": "^7.10.4",
2818 + "@babel/runtime": "^7.12.5",
2819 + "@types/aria-query": "^5.0.1",
2820 + "aria-query": "5.3.0",
2821 + "dom-accessibility-api": "^0.5.9",
2822 + "lz-string": "^1.5.0",
2823 + "picocolors": "1.1.1",
2824 + "pretty-format": "^27.0.2"
2825 + },
2826 + "engines": {
2827 + "node": ">=18"
2828 + }
2829 + },
2830 + "node_modules/@testing-library/react": {
2831 + "version": "16.3.2",
2832 + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
2833 + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
2834 + "dev": true,
2835 + "license": "MIT",
2836 + "dependencies": {
2837 + "@babel/runtime": "^7.12.5"
2838 + },
2839 + "engines": {
2840 + "node": ">=18"
2841 + },
2842 + "peerDependencies": {
2843 + "@testing-library/dom": "^10.0.0",
2844 + "@types/react": "^18.0.0 || ^19.0.0",
2845 + "@types/react-dom": "^18.0.0 || ^19.0.0",
2846 + "react": "^18.0.0 || ^19.0.0",
2847 + "react-dom": "^18.0.0 || ^19.0.0"
2848 + },
2849 + "peerDependenciesMeta": {
2850 + "@types/react": {
2851 + "optional": true
2852 + },
2853 + "@types/react-dom": {
2854 + "optional": true
2855 + }
2856 + }
2857 + },
2858 + "node_modules/@types/aria-query": {
2859 + "version": "5.0.4",
2860 + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
2861 + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
2862 + "dev": true,
2863 + "license": "MIT",
2864 + "peer": true
2865 + },
2866 "node_modules/@types/babel__core": {
2867 "version": "7.20.5",
2868 "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -2603,6 +2908,24 @@
2908 "@babel/types": "^7.28.2"
2909 }
2910 },
2911 + "node_modules/@types/chai": {
2912 + "version": "5.2.3",
2913 + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
2914 + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
2915 + "dev": true,
2916 + "license": "MIT",
2917 + "dependencies": {
2918 + "@types/deep-eql": "*",
2919 + "assertion-error": "^2.0.1"
2920 + }
2921 + },
2922 + "node_modules/@types/deep-eql": {
2923 + "version": "4.0.2",
2924 + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
2925 + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
2926 + "dev": true,
2927 + "license": "MIT"
2928 + },
2929 "node_modules/@types/estree": {
2930 "version": "1.0.8",
2931 "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -2618,9 +2941,9 @@
2941 "license": "MIT"
2942 },
2943 "node_modules/@types/node": {
2621 - "version": "24.10.13",
2622 - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz",
2623 - "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==",
2944 + "version": "24.11.0",
2945 + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.0.tgz",
2946 + "integrity": "sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==",
2947 "dev": true,
2948 "license": "MIT",
2949 "dependencies": {
@@ -2901,6 +3224,117 @@
3224 "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
3225 }
3226 },
3227 + "node_modules/@vitest/expect": {
3228 + "version": "4.0.18",
3229 + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
3230 + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
3231 + "dev": true,
3232 + "license": "MIT",
3233 + "dependencies": {
3234 + "@standard-schema/spec": "^1.0.0",
3235 + "@types/chai": "^5.2.2",
3236 + "@vitest/spy": "4.0.18",
3237 + "@vitest/utils": "4.0.18",
3238 + "chai": "^6.2.1",
3239 + "tinyrainbow": "^3.0.3"
3240 + },
3241 + "funding": {
3242 + "url": "https://opencollective.com/vitest"
3243 + }
3244 + },
3245 + "node_modules/@vitest/mocker": {
3246 + "version": "4.0.18",
3247 + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
3248 + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
3249 + "dev": true,
3250 + "license": "MIT",
3251 + "dependencies": {
3252 + "@vitest/spy": "4.0.18",
3253 + "estree-walker": "^3.0.3",
3254 + "magic-string": "^0.30.21"
3255 + },
3256 + "funding": {
3257 + "url": "https://opencollective.com/vitest"
3258 + },
3259 + "peerDependencies": {
3260 + "msw": "^2.4.9",
3261 + "vite": "^6.0.0 || ^7.0.0-0"
3262 + },
3263 + "peerDependenciesMeta": {
3264 + "msw": {
3265 + "optional": true
3266 + },
3267 + "vite": {
3268 + "optional": true
3269 + }
3270 + }
3271 + },
3272 + "node_modules/@vitest/pretty-format": {
3273 + "version": "4.0.18",
3274 + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
3275 + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
3276 + "dev": true,
3277 + "license": "MIT",
3278 + "dependencies": {
3279 + "tinyrainbow": "^3.0.3"
3280 + },
3281 + "funding": {
3282 + "url": "https://opencollective.com/vitest"
3283 + }
3284 + },
3285 + "node_modules/@vitest/runner": {
3286 + "version": "4.0.18",
3287 + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
3288 + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
3289 + "dev": true,
3290 + "license": "MIT",
3291 + "dependencies": {
3292 + "@vitest/utils": "4.0.18",
3293 + "pathe": "^2.0.3"
3294 + },
3295 + "funding": {
3296 + "url": "https://opencollective.com/vitest"
3297 + }
3298 + },
3299 + "node_modules/@vitest/snapshot": {
3300 + "version": "4.0.18",
3301 + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
3302 + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
3303 + "dev": true,
3304 + "license": "MIT",
3305 + "dependencies": {
3306 + "@vitest/pretty-format": "4.0.18",
3307 + "magic-string": "^0.30.21",
3308 + "pathe": "^2.0.3"
3309 + },
3310 + "funding": {
3311 + "url": "https://opencollective.com/vitest"
3312 + }
3313 + },
3314 + "node_modules/@vitest/spy": {
3315 + "version": "4.0.18",
3316 + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
3317 + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
3318 + "dev": true,
3319 + "license": "MIT",
3320 + "funding": {
3321 + "url": "https://opencollective.com/vitest"
3322 + }
3323 + },
3324 + "node_modules/@vitest/utils": {
3325 + "version": "4.0.18",
3326 + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
3327 + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
3328 + "dev": true,
3329 + "license": "MIT",
3330 + "dependencies": {
3331 + "@vitest/pretty-format": "4.0.18",
3332 + "tinyrainbow": "^3.0.3"
3333 + },
3334 + "funding": {
3335 + "url": "https://opencollective.com/vitest"
3336 + }
3337 + },
3338 "node_modules/acorn": {
3339 "version": "8.16.0",
3340 "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -2924,6 +3358,16 @@
3358 "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
3359 }
3360 },
3361 + "node_modules/agent-base": {
3362 + "version": "7.1.4",
3363 + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
3364 + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
3365 + "dev": true,
3366 + "license": "MIT",
3367 + "engines": {
3368 + "node": ">= 14"
3369 + }
3370 + },
3371 "node_modules/ajv": {
3372 "version": "6.14.0",
3373 "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
@@ -2941,6 +3385,17 @@
3385 "url": "https://github.com/sponsors/epoberezkin"
3386 }
3387 },
3388 + "node_modules/ansi-regex": {
3389 + "version": "5.0.1",
3390 + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
3391 + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
3392 + "dev": true,
3393 + "license": "MIT",
3394 + "peer": true,
3395 + "engines": {
3396 + "node": ">=8"
3397 + }
3398 + },
3399 "node_modules/ansi-styles": {
3400 "version": "4.3.0",
3401 "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -2976,6 +3431,27 @@
3431 "node": ">=10"
3432 }
3433 },
3434 + "node_modules/aria-query": {
3435 + "version": "5.3.0",
3436 + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
3437 + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
3438 + "dev": true,
3439 + "license": "Apache-2.0",
3440 + "peer": true,
3441 + "dependencies": {
3442 + "dequal": "^2.0.3"
3443 + }
3444 + },
3445 + "node_modules/assertion-error": {
3446 + "version": "2.0.1",
3447 + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
3448 + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
3449 + "dev": true,
3450 + "license": "MIT",
3451 + "engines": {
3452 + "node": ">=12"
3453 + }
3454 + },
3455 "node_modules/babel-plugin-react-compiler": {
3456 "version": "1.0.0",
3457 "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz",
@@ -3009,10 +3485,20 @@
3485 "node": ">=6.0.0"
3486 }
3487 },
3488 + "node_modules/bidi-js": {
3489 + "version": "1.0.3",
3490 + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
3491 + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
3492 + "dev": true,
3493 + "license": "MIT",
3494 + "dependencies": {
3495 + "require-from-string": "^2.0.2"
3496 + }
3497 + },
3498 "node_modules/brace-expansion": {
3013 - "version": "5.0.3",
3014 - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
3015 - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
3499 + "version": "5.0.4",
3500 + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
3501 + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
3502 "dev": true,
3503 "license": "MIT",
3504 "dependencies": {
@@ -3067,9 +3553,9 @@
3553 }
3554 },
3555 "node_modules/caniuse-lite": {
3070 - "version": "1.0.30001774",
3071 - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz",
3072 - "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==",
3556 + "version": "1.0.30001776",
3557 + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001776.tgz",
3558 + "integrity": "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw==",
3559 "dev": true,
3560 "funding": [
3561 {
@@ -3087,6 +3573,16 @@
3573 ],
3574 "license": "CC-BY-4.0"
3575 },
3576 + "node_modules/chai": {
3577 + "version": "6.2.2",
3578 + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
3579 + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
3580 + "dev": true,
3581 + "license": "MIT",
3582 + "engines": {
3583 + "node": ">=18"
3584 + }
3585 + },
3586 "node_modules/chalk": {
3587 "version": "4.1.2",
3588 "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -3203,6 +3699,46 @@
3699 "node": ">= 8"
3700 }
3701 },
3702 + "node_modules/css-tree": {
3703 + "version": "3.1.0",
3704 + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
3705 + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==",
3706 + "dev": true,
3707 + "license": "MIT",
3708 + "dependencies": {
3709 + "mdn-data": "2.12.2",
3710 + "source-map-js": "^1.0.1"
3711 + },
3712 + "engines": {
3713 + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
3714 + }
3715 + },
3716 + "node_modules/cssstyle": {
3717 + "version": "6.2.0",
3718 + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz",
3719 + "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==",
3720 + "dev": true,
3721 + "license": "MIT",
3722 + "dependencies": {
3723 + "@asamuzakjp/css-color": "^5.0.1",
3724 + "@csstools/css-syntax-patches-for-csstree": "^1.0.28",
3725 + "css-tree": "^3.1.0",
3726 + "lru-cache": "^11.2.6"
3727 + },
3728 + "engines": {
3729 + "node": ">=20"
3730 + }
3731 + },
3732 + "node_modules/cssstyle/node_modules/lru-cache": {
3733 + "version": "11.2.6",
3734 + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
3735 + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
3736 + "dev": true,
3737 + "license": "BlueOak-1.0.0",
3738 + "engines": {
3739 + "node": "20 || >=22"
3740 + }
3741 + },
3742 "node_modules/csstype": {
3743 "version": "3.2.3",
3744 "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -3210,6 +3746,20 @@
3746 "devOptional": true,
3747 "license": "MIT"
3748 },
3749 + "node_modules/data-urls": {
3750 + "version": "7.0.0",
3751 + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
3752 + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
3753 + "dev": true,
3754 + "license": "MIT",
3755 + "dependencies": {
3756 + "whatwg-mimetype": "^5.0.0",
3757 + "whatwg-url": "^16.0.0"
3758 + },
3759 + "engines": {
3760 + "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
3761 + }
3762 + },
3763 "node_modules/debug": {
3764 "version": "4.4.3",
3765 "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -3228,6 +3778,13 @@
3778 }
3779 }
3780 },
3781 + "node_modules/decimal.js": {
3782 + "version": "10.6.0",
3783 + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
3784 + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
3785 + "dev": true,
3786 + "license": "MIT"
3787 + },
3788 "node_modules/deep-is": {
3789 "version": "0.1.4",
3790 "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -3235,6 +3792,17 @@
3792 "dev": true,
3793 "license": "MIT"
3794 },
3795 + "node_modules/dequal": {
3796 + "version": "2.0.3",
3797 + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
3798 + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
3799 + "dev": true,
3800 + "license": "MIT",
3801 + "peer": true,
3802 + "engines": {
3803 + "node": ">=6"
3804 + }
3805 + },
3806 "node_modules/detect-libc": {
3807 "version": "2.1.2",
3808 "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -3251,17 +3819,25 @@
3819 "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
3820 "license": "MIT"
3821 },
3822 + "node_modules/dom-accessibility-api": {
3823 + "version": "0.5.16",
3824 + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
3825 + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
3826 + "dev": true,
3827 + "license": "MIT",
3828 + "peer": true
3829 + },
3830 "node_modules/electron-to-chromium": {
3255 - "version": "1.5.302",
3256 - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
3257 - "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==",
3831 + "version": "1.5.307",
3832 + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz",
3833 + "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==",
3834 "dev": true,
3835 "license": "ISC"
3836 },
3837 "node_modules/enhanced-resolve": {
3262 - "version": "5.19.0",
3263 - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
3264 - "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==",
3838 + "version": "5.20.0",
3839 + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz",
3840 + "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==",
3841 "dev": true,
3842 "license": "MIT",
3843 "dependencies": {
@@ -3272,6 +3848,26 @@
3848 "node": ">=10.13.0"
3849 }
3850 },
3851 + "node_modules/entities": {
3852 + "version": "6.0.1",
3853 + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
3854 + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
3855 + "dev": true,
3856 + "license": "BSD-2-Clause",
3857 + "engines": {
3858 + "node": ">=0.12"
3859 + },
3860 + "funding": {
3861 + "url": "https://github.com/fb55/entities?sponsor=1"
3862 + }
3863 + },
3864 + "node_modules/es-module-lexer": {
3865 + "version": "1.7.0",
3866 + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
3867 + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
3868 + "dev": true,
3869 + "license": "MIT"
3870 + },
3871 "node_modules/esbuild": {
3872 "version": "0.27.3",
3873 "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
@@ -3499,9 +4095,9 @@
4095 }
4096 },
4097 "node_modules/eslint/node_modules/minimatch": {
3502 - "version": "3.1.3",
3503 - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz",
3504 - "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==",
4098 + "version": "3.1.5",
4099 + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
4100 + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
4101 "dev": true,
4102 "license": "ISC",
4103 "dependencies": {
@@ -3578,6 +4174,16 @@
4174 "node": ">=4.0"
4175 }
4176 },
4177 + "node_modules/estree-walker": {
4178 + "version": "3.0.3",
4179 + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
4180 + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
4181 + "dev": true,
4182 + "license": "MIT",
4183 + "dependencies": {
4184 + "@types/estree": "^1.0.0"
4185 + }
4186 + },
4187 "node_modules/esutils": {
4188 "version": "2.0.3",
4189 "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -3588,6 +4194,16 @@
4194 "node": ">=0.10.0"
4195 }
4196 },
4197 + "node_modules/expect-type": {
4198 + "version": "1.3.0",
4199 + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
4200 + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
4201 + "dev": true,
4202 + "license": "Apache-2.0",
4203 + "engines": {
4204 + "node": ">=12.0.0"
4205 + }
4206 + },
4207 "node_modules/fast-deep-equal": {
4208 "version": "3.1.3",
4209 "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -3672,9 +4288,9 @@
4288 }
4289 },
4290 "node_modules/flatted": {
3675 - "version": "3.3.3",
3676 - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
3677 - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
4291 + "version": "3.3.4",
4292 + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz",
4293 + "integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==",
4294 "dev": true,
4295 "license": "ISC"
4296 },
@@ -3772,6 +4388,47 @@
4388 "hermes-estree": "0.25.1"
4389 }
4390 },
4391 + "node_modules/html-encoding-sniffer": {
4392 + "version": "6.0.0",
4393 + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
4394 + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
4395 + "dev": true,
4396 + "license": "MIT",
4397 + "dependencies": {
4398 + "@exodus/bytes": "^1.6.0"
4399 + },
4400 + "engines": {
4401 + "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
4402 + }
4403 + },
4404 + "node_modules/http-proxy-agent": {
4405 + "version": "7.0.2",
4406 + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
4407 + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
4408 + "dev": true,
4409 + "license": "MIT",
4410 + "dependencies": {
4411 + "agent-base": "^7.1.0",
4412 + "debug": "^4.3.4"
4413 + },
4414 + "engines": {
4415 + "node": ">= 14"
4416 + }
4417 + },
4418 + "node_modules/https-proxy-agent": {
4419 + "version": "7.0.6",
4420 + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
4421 + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
4422 + "dev": true,
4423 + "license": "MIT",
4424 + "dependencies": {
4425 + "agent-base": "^7.1.2",
4426 + "debug": "4"
4427 + },
4428 + "engines": {
4429 + "node": ">= 14"
4430 + }
4431 + },
4432 "node_modules/ignore": {
4433 "version": "7.0.5",
4434 "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
@@ -3832,6 +4489,13 @@
4489 "node": ">=0.10.0"
4490 }
4491 },
4492 + "node_modules/is-potential-custom-element-name": {
4493 + "version": "1.0.1",
4494 + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
4495 + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
4496 + "dev": true,
4497 + "license": "MIT"
4498 + },
4499 "node_modules/isexe": {
4500 "version": "2.0.0",
4501 "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -3869,6 +4533,47 @@
4533 "js-yaml": "bin/js-yaml.js"
4534 }
4535 },
4536 + "node_modules/jsdom": {
4537 + "version": "28.1.0",
4538 + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz",
4539 + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
4540 + "dev": true,
4541 + "license": "MIT",
4542 + "dependencies": {
4543 + "@acemir/cssom": "^0.9.31",
4544 + "@asamuzakjp/dom-selector": "^6.8.1",
4545 + "@bramus/specificity": "^2.4.2",
4546 + "@exodus/bytes": "^1.11.0",
4547 + "cssstyle": "^6.0.1",
4548 + "data-urls": "^7.0.0",
4549 + "decimal.js": "^10.6.0",
4550 + "html-encoding-sniffer": "^6.0.0",
4551 + "http-proxy-agent": "^7.0.2",
4552 + "https-proxy-agent": "^7.0.6",
4553 + "is-potential-custom-element-name": "^1.0.1",
4554 + "parse5": "^8.0.0",
4555 + "saxes": "^6.0.0",
4556 + "symbol-tree": "^3.2.4",
4557 + "tough-cookie": "^6.0.0",
4558 + "undici": "^7.21.0",
4559 + "w3c-xmlserializer": "^5.0.0",
4560 + "webidl-conversions": "^8.0.1",
4561 + "whatwg-mimetype": "^5.0.0",
4562 + "whatwg-url": "^16.0.0",
4563 + "xml-name-validator": "^5.0.0"
4564 + },
4565 + "engines": {
4566 + "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
4567 + },
4568 + "peerDependencies": {
4569 + "canvas": "^3.0.0"
4570 + },
4571 + "peerDependenciesMeta": {
4572 + "canvas": {
4573 + "optional": true
4574 + }
4575 + }
4576 + },
4577 "node_modules/jsesc": {
4578 "version": "3.1.0",
4579 "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -4243,6 +4948,17 @@
4948 "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
4949 }
4950 },
4951 + "node_modules/lz-string": {
4952 + "version": "1.5.0",
4953 + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
4954 + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
4955 + "dev": true,
4956 + "license": "MIT",
4957 + "peer": true,
4958 + "bin": {
4959 + "lz-string": "bin/bin.js"
4960 + }
4961 + },
4962 "node_modules/magic-string": {
4963 "version": "0.30.21",
4964 "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -4253,10 +4969,17 @@
4969 "@jridgewell/sourcemap-codec": "^1.5.5"
4970 }
4971 },
4972 + "node_modules/mdn-data": {
4973 + "version": "2.12.2",
4974 + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz",
4975 + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==",
4976 + "dev": true,
4977 + "license": "CC0-1.0"
4978 + },
4979 "node_modules/minimatch": {
4257 - "version": "10.2.2",
4258 - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
4259 - "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
4980 + "version": "10.2.4",
4981 + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
4982 + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
4983 "dev": true,
4984 "license": "BlueOak-1.0.0",
4985 "dependencies": {
@@ -4309,6 +5032,17 @@
5032 "dev": true,
5033 "license": "MIT"
5034 },
5035 + "node_modules/obug": {
5036 + "version": "2.1.1",
5037 + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
5038 + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
5039 + "dev": true,
5040 + "funding": [
5041 + "https://github.com/sponsors/sxzz",
5042 + "https://opencollective.com/debug"
5043 + ],
5044 + "license": "MIT"
5045 + },
5046 "node_modules/optionator": {
5047 "version": "0.9.4",
5048 "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -4372,6 +5106,19 @@
5106 "node": ">=6"
5107 }
5108 },
5109 + "node_modules/parse5": {
5110 + "version": "8.0.0",
5111 + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
5112 + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
5113 + "dev": true,
5114 + "license": "MIT",
5115 + "dependencies": {
5116 + "entities": "^6.0.0"
5117 + },
5118 + "funding": {
5119 + "url": "https://github.com/inikulin/parse5?sponsor=1"
5120 + }
5121 + },
5122 "node_modules/path-exists": {
5123 "version": "4.0.0",
5124 "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -4392,6 +5139,13 @@
5139 "node": ">=8"
5140 }
5141 },
5142 + "node_modules/pathe": {
5143 + "version": "2.0.3",
5144 + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
5145 + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
5146 + "dev": true,
5147 + "license": "MIT"
5148 + },
5149 "node_modules/picocolors": {
5150 "version": "1.1.1",
5151 "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -4413,9 +5167,9 @@
5167 }
5168 },
5169 "node_modules/postcss": {
4416 - "version": "8.5.6",
4417 - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
4418 - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
5170 + "version": "8.5.8",
5171 + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
5172 + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
5173 "dev": true,
5174 "funding": [
5175 {
@@ -4451,6 +5205,36 @@
5205 "node": ">= 0.8.0"
5206 }
5207 },
5208 + "node_modules/pretty-format": {
5209 + "version": "27.5.1",
5210 + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
5211 + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
5212 + "dev": true,
5213 + "license": "MIT",
5214 + "peer": true,
5215 + "dependencies": {
5216 + "ansi-regex": "^5.0.1",
5217 + "ansi-styles": "^5.0.0",
5218 + "react-is": "^17.0.1"
5219 + },
5220 + "engines": {
5221 + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
5222 + }
5223 + },
5224 + "node_modules/pretty-format/node_modules/ansi-styles": {
5225 + "version": "5.2.0",
5226 + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
5227 + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
5228 + "dev": true,
5229 + "license": "MIT",
5230 + "peer": true,
5231 + "engines": {
5232 + "node": ">=10"
5233 + },
5234 + "funding": {
5235 + "url": "https://github.com/chalk/ansi-styles?sponsor=1"
5236 + }
5237 + },
5238 "node_modules/punycode": {
5239 "version": "2.3.1",
5240 "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -4482,6 +5266,14 @@
5266 "react": "^19.2.4"
5267 }
5268 },
5269 + "node_modules/react-is": {
5270 + "version": "17.0.2",
5271 + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
5272 + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
5273 + "dev": true,
5274 + "license": "MIT",
5275 + "peer": true
5276 + },
5277 "node_modules/react-refresh": {
5278 "version": "0.18.0",
5279 "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
@@ -4599,6 +5391,16 @@
5391 }
5392 }
5393 },
5394 + "node_modules/require-from-string": {
5395 + "version": "2.0.2",
5396 + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
5397 + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
5398 + "dev": true,
5399 + "license": "MIT",
5400 + "engines": {
5401 + "node": ">=0.10.0"
5402 + }
5403 + },
5404 "node_modules/resolve-from": {
5405 "version": "4.0.0",
5406 "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -4654,6 +5456,19 @@
5456 "fsevents": "~2.3.2"
5457 }
5458 },
5459 + "node_modules/saxes": {
5460 + "version": "6.0.0",
5461 + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
5462 + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
5463 + "dev": true,
5464 + "license": "ISC",
5465 + "dependencies": {
5466 + "xmlchars": "^2.2.0"
5467 + },
5468 + "engines": {
5469 + "node": ">=v12.22.7"
5470 + }
5471 + },
5472 "node_modules/scheduler": {
5473 "version": "0.27.0",
5474 "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -4702,6 +5517,13 @@
5517 "node": ">=8"
5518 }
5519 },
5520 + "node_modules/siginfo": {
5521 + "version": "2.0.0",
5522 + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
5523 + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
5524 + "dev": true,
5525 + "license": "ISC"
5526 + },
5527 "node_modules/source-map-js": {
5528 "version": "1.2.1",
5529 "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -4712,6 +5534,20 @@
5534 "node": ">=0.10.0"
5535 }
5536 },
5537 + "node_modules/stackback": {
5538 + "version": "0.0.2",
5539 + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
5540 + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
5541 + "dev": true,
5542 + "license": "MIT"
5543 + },
5544 + "node_modules/std-env": {
5545 + "version": "3.10.0",
5546 + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
5547 + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
5548 + "dev": true,
5549 + "license": "MIT"
5550 + },
5551 "node_modules/strip-json-comments": {
5552 "version": "3.1.1",
5553 "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -4738,6 +5574,13 @@
5574 "node": ">=8"
5575 }
5576 },
5577 + "node_modules/symbol-tree": {
5578 + "version": "3.2.4",
5579 + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
5580 + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
5581 + "dev": true,
5582 + "license": "MIT"
5583 + },
5584 "node_modules/tailwind-merge": {
5585 "version": "3.5.0",
5586 "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
@@ -4769,6 +5612,23 @@
5612 "url": "https://opencollective.com/webpack"
5613 }
5614 },
5615 + "node_modules/tinybench": {
5616 + "version": "2.9.0",
5617 + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
5618 + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
5619 + "dev": true,
5620 + "license": "MIT"
5621 + },
5622 + "node_modules/tinyexec": {
5623 + "version": "1.0.2",
5624 + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
5625 + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
5626 + "dev": true,
5627 + "license": "MIT",
5628 + "engines": {
5629 + "node": ">=18"
5630 + }
5631 + },
5632 "node_modules/tinyglobby": {
5633 "version": "0.2.15",
5634 "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -4786,6 +5646,62 @@
5646 "url": "https://github.com/sponsors/SuperchupuDev"
5647 }
5648 },
5649 + "node_modules/tinyrainbow": {
5650 + "version": "3.0.3",
5651 + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
5652 + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
5653 + "dev": true,
5654 + "license": "MIT",
5655 + "engines": {
5656 + "node": ">=14.0.0"
5657 + }
5658 + },
5659 + "node_modules/tldts": {
5660 + "version": "7.0.24",
5661 + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.24.tgz",
5662 + "integrity": "sha512-1r6vQTTt1rUiJkI5vX7KG8PR342Ru/5Oh13kEQP2SMbRSZpOey9SrBe27IDxkoWulx8ShWu4K6C0BkctP8Z1bQ==",
5663 + "dev": true,
5664 + "license": "MIT",
5665 + "dependencies": {
5666 + "tldts-core": "^7.0.24"
5667 + },
5668 + "bin": {
5669 + "tldts": "bin/cli.js"
5670 + }
5671 + },
5672 + "node_modules/tldts-core": {
5673 + "version": "7.0.24",
5674 + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.24.tgz",
5675 + "integrity": "sha512-pj7yygNMoMRqG7ML2SDQ0xNIOfN3IBDUcPVM2Sg6hP96oFNN2nqnzHreT3z9xLq85IWJyNTvD38O002DdOrPMw==",
5676 + "dev": true,
5677 + "license": "MIT"
5678 + },
5679 + "node_modules/tough-cookie": {
5680 + "version": "6.0.0",
5681 + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz",
5682 + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==",
5683 + "dev": true,
5684 + "license": "BSD-3-Clause",
5685 + "dependencies": {
5686 + "tldts": "^7.0.5"
5687 + },
5688 + "engines": {
5689 + "node": ">=16"
5690 + }
5691 + },
5692 + "node_modules/tr46": {
5693 + "version": "6.0.0",
5694 + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
5695 + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
5696 + "dev": true,
5697 + "license": "MIT",
5698 + "dependencies": {
5699 + "punycode": "^2.3.1"
5700 + },
5701 + "engines": {
5702 + "node": ">=20"
5703 + }
5704 + },
5705 "node_modules/ts-api-utils": {
5706 "version": "2.4.0",
5707 "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
@@ -4832,6 +5748,16 @@
5748 "node": ">=14.17"
5749 }
5750 },
5751 + "node_modules/undici": {
5752 + "version": "7.22.0",
5753 + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz",
5754 + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==",
5755 + "dev": true,
5756 + "license": "MIT",
5757 + "engines": {
5758 + "node": ">=20.18.1"
5759 + }
5760 + },
5761 "node_modules/undici-types": {
5762 "version": "7.16.0",
5763 "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
@@ -4998,6 +5924,132 @@
5924 }
5925 }
5926 },
5927 + "node_modules/vitest": {
5928 + "version": "4.0.18",
5929 + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
5930 + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
5931 + "dev": true,
5932 + "license": "MIT",
5933 + "dependencies": {
5934 + "@vitest/expect": "4.0.18",
5935 + "@vitest/mocker": "4.0.18",
5936 + "@vitest/pretty-format": "4.0.18",
5937 + "@vitest/runner": "4.0.18",
5938 + "@vitest/snapshot": "4.0.18",
5939 + "@vitest/spy": "4.0.18",
5940 + "@vitest/utils": "4.0.18",
5941 + "es-module-lexer": "^1.7.0",
5942 + "expect-type": "^1.2.2",
5943 + "magic-string": "^0.30.21",
5944 + "obug": "^2.1.1",
5945 + "pathe": "^2.0.3",
5946 + "picomatch": "^4.0.3",
5947 + "std-env": "^3.10.0",
5948 + "tinybench": "^2.9.0",
5949 + "tinyexec": "^1.0.2",
5950 + "tinyglobby": "^0.2.15",
5951 + "tinyrainbow": "^3.0.3",
5952 + "vite": "^6.0.0 || ^7.0.0",
5953 + "why-is-node-running": "^2.3.0"
5954 + },
5955 + "bin": {
5956 + "vitest": "vitest.mjs"
5957 + },
5958 + "engines": {
5959 + "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
5960 + },
5961 + "funding": {
5962 + "url": "https://opencollective.com/vitest"
5963 + },
5964 + "peerDependencies": {
5965 + "@edge-runtime/vm": "*",
5966 + "@opentelemetry/api": "^1.9.0",
5967 + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
5968 + "@vitest/browser-playwright": "4.0.18",
5969 + "@vitest/browser-preview": "4.0.18",
5970 + "@vitest/browser-webdriverio": "4.0.18",
5971 + "@vitest/ui": "4.0.18",
5972 + "happy-dom": "*",
5973 + "jsdom": "*"
5974 + },
5975 + "peerDependenciesMeta": {
5976 + "@edge-runtime/vm": {
5977 + "optional": true
5978 + },
5979 + "@opentelemetry/api": {
5980 + "optional": true
5981 + },
5982 + "@types/node": {
5983 + "optional": true
5984 + },
5985 + "@vitest/browser-playwright": {
5986 + "optional": true
5987 + },
5988 + "@vitest/browser-preview": {
5989 + "optional": true
5990 + },
5991 + "@vitest/browser-webdriverio": {
5992 + "optional": true
5993 + },
5994 + "@vitest/ui": {
5995 + "optional": true
5996 + },
5997 + "happy-dom": {
5998 + "optional": true
5999 + },
6000 + "jsdom": {
6001 + "optional": true
6002 + }
6003 + }
6004 + },
6005 + "node_modules/w3c-xmlserializer": {
6006 + "version": "5.0.0",
6007 + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
6008 + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
6009 + "dev": true,
6010 + "license": "MIT",
6011 + "dependencies": {
6012 + "xml-name-validator": "^5.0.0"
6013 + },
6014 + "engines": {
6015 + "node": ">=18"
6016 + }
6017 + },
6018 + "node_modules/webidl-conversions": {
6019 + "version": "8.0.1",
6020 + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
6021 + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
6022 + "dev": true,
6023 + "license": "BSD-2-Clause",
6024 + "engines": {
6025 + "node": ">=20"
6026 + }
6027 + },
6028 + "node_modules/whatwg-mimetype": {
6029 + "version": "5.0.0",
6030 + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
6031 + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
6032 + "dev": true,
6033 + "license": "MIT",
6034 + "engines": {
6035 + "node": ">=20"
6036 + }
6037 + },
6038 + "node_modules/whatwg-url": {
6039 + "version": "16.0.1",
6040 + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
6041 + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
6042 + "dev": true,
6043 + "license": "MIT",
6044 + "dependencies": {
6045 + "@exodus/bytes": "^1.11.0",
6046 + "tr46": "^6.0.0",
6047 + "webidl-conversions": "^8.0.1"
6048 + },
6049 + "engines": {
6050 + "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
6051 + }
6052 + },
6053 "node_modules/which": {
6054 "version": "2.0.2",
6055 "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -5014,6 +6066,23 @@
6066 "node": ">= 8"
6067 }
6068 },
6069 + "node_modules/why-is-node-running": {
6070 + "version": "2.3.0",
6071 + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
6072 + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
6073 + "dev": true,
6074 + "license": "MIT",
6075 + "dependencies": {
6076 + "siginfo": "^2.0.0",
6077 + "stackback": "0.0.2"
6078 + },
6079 + "bin": {
6080 + "why-is-node-running": "cli.js"
6081 + },
6082 + "engines": {
6083 + "node": ">=8"
6084 + }
6085 + },
6086 "node_modules/word-wrap": {
6087 "version": "1.2.5",
6088 "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -5024,6 +6093,23 @@
6093 "node": ">=0.10.0"
6094 }
6095 },
6096 + "node_modules/xml-name-validator": {
6097 + "version": "5.0.0",
6098 + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
6099 + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
6100 + "dev": true,
6101 + "license": "Apache-2.0",
6102 + "engines": {
6103 + "node": ">=18"
6104 + }
6105 + },
6106 + "node_modules/xmlchars": {
6107 + "version": "2.2.0",
6108 + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
6109 + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
6110 + "dev": true,
6111 + "license": "MIT"
6112 + },
6113 "node_modules/yallist": {
6114 "version": "3.1.1",
6115 "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
cmd/relay-server/frontend/package.json
+8 -2
@@ -9,9 +9,12 @@
9 "lint": "eslint . --max-warnings 0",
10 "lint:fix": "eslint . --fix",
11 "typecheck": "tsc --noEmit",
12 + "test": "vitest run",
13 + "test:watch": "vitest",
14 + "test:coverage": "vitest run --coverage",
15 "preview": "vite preview",
16 "build:go": "cd ../../.. && CGO_ENABLED=0 go build -o bin/relay-server cmd/relay-server/*.go",
14 - "serve": "npm run build && npm run build:go && STATIC_DIR=../../../dist ../../../bin/relay-server -port 4017"
17 + "serve": "npm run build && npm run build:go && STATIC_DIR=../../../dist ../../../bin/relay-server -adminport 4017"
18 },
19 "dependencies": {
20 "@radix-ui/react-dialog": "^1.1.15",
@@ -33,6 +36,7 @@
36 },
37 "devDependencies": {
38 "@tailwindcss/vite": "^4.1.17",
39 + "@testing-library/react": "^16.3.2",
40 "@types/node": "^24.10.1",
41 "@types/react": "^19.2.7",
42 "@types/react-dom": "^19.2.3",
@@ -43,8 +47,10 @@
47 "eslint": "^9.39.1",
48 "eslint-plugin-react-hooks": "^7.0.1",
49 "eslint-plugin-react-refresh": "^0.4.24",
50 + "jsdom": "^28.1.0",
51 "tailwindcss": "^4.1.17",
52 "typescript": "^5.9.3",
48 - "vite": "^7.2.6"
53 + "vite": "^7.2.6",
54 + "vitest": "^4.0.18"
55 }
56 }
cmd/relay-server/frontend/src/components/ServerListView.tsx
+175 -119
@@ -1,4 +1,4 @@
1 -import { useState } from "react";
1 +import { useCallback, useEffect, useMemo, useState } from "react";
2 import { Header } from "@/components/Header";
3 import { SearchBar } from "@/components/SearchBar";
4 import { ServerCard } from "@/components/ServerCard";
@@ -18,28 +18,23 @@ import {
18 DialogTitle,
19 } from "@/components/ui/dialog";
20
21 -// Admin-specific filter for ban status
21 export type BanFilter = "all" | "banned" | "active";
22 +type ListServer = ClientServer | AdminServer;
23
24 interface ServerListViewProps {
25 - // Header customization
25 title?: string;
27 - // Search & Filter state
26 searchQuery: string;
27 status: StatusFilter;
28 sortBy: SortOption;
29 selectedTags: string[];
30 availableTags: string[];
33 - // Server data
31 filteredServers: ClientServer[] | AdminServer[];
32 favorites: number[];
36 - // Handlers
33 onSearchChange: (value: string) => void;
34 onStatusChange: (value: StatusFilter) => void;
35 onSortByChange: (value: SortOption) => void;
36 onTagToggle: (tag: string) => void;
37 onToggleFavorite: (serverId: number) => void;
42 - // Admin mode (optional)
38 isAdmin?: boolean;
39 banFilter?: BanFilter;
40 approvalMode?: ApprovalMode;
@@ -50,20 +45,20 @@ interface ServerListViewProps {
45 onApproveStatusChange?: (leaseId: string, approve: boolean) => void;
46 onDenyStatusChange?: (leaseId: string, deny: boolean) => void;
47 onIPBanStatusChange?: (ip: string, isBan: boolean) => void;
53 - // Bulk action handlers
48 onBulkApprove?: (leaseIds: string[]) => void;
49 onBulkDeny?: (leaseIds: string[]) => void;
50 onBulkBan?: (leaseIds: string[]) => void;
57 - // Logout handler (admin only)
51 onLogout?: () => void;
52 }
53
61 -function isAdminServer(
62 - server: ClientServer | AdminServer
63 -): server is AdminServer {
54 +function isAdminServer(server: ListServer): server is AdminServer {
55 return "peerId" in server;
56 }
57
58 +function toAdminServer(server: ListServer): AdminServer | undefined {
59 + return isAdminServer(server) ? server : undefined;
60 +}
61 +
62 export function ServerListView({
63 title = "PORTAL",
64 searchQuery,
@@ -78,7 +73,6 @@ export function ServerListView({
73 onSortByChange,
74 onTagToggle,
75 onToggleFavorite,
81 - // Admin props
76 isAdmin = false,
77 banFilter = "all",
78 approvalMode = "auto",
@@ -89,19 +83,18 @@ export function ServerListView({
83 onApproveStatusChange,
84 onDenyStatusChange,
85 onIPBanStatusChange,
92 - // Bulk action handlers
86 onBulkApprove,
87 onBulkDeny,
88 onBulkBan,
96 - // Logout handler
89 onLogout,
90 }: ServerListViewProps) {
91 const [showFilterModal, setShowFilterModal] = useState(false);
92 const [selectedLeaseIds, setSelectedLeaseIds] = useState<Set<string>>(
93 new Set()
94 );
95 + const serverItems = filteredServers as ListServer[];
96 + const favoriteIds = useMemo(() => new Set(favorites), [favorites]);
97
104 - // Toggle selection for a single card
98 const handleToggleSelect = (leaseId: string) => {
99 setSelectedLeaseIds((prev) => {
100 const next = new Set(prev);
@@ -114,22 +107,66 @@ export function ServerListView({
107 });
108 };
109
117 - // Clear all selections
110 const handleClearSelection = () => {
111 setSelectedLeaseIds(new Set());
112 };
113
122 - // Get all selectable lease IDs from filtered servers
123 - const allLeaseIds = (filteredServers as (ClientServer | AdminServer)[])
124 - .filter(isAdminServer)
125 - .map((server) => server.peerId);
114 + const serverRows = useMemo(
115 + () =>
116 + serverItems.map((server) => ({
117 + server,
118 + adminServer: toAdminServer(server),
119 + })),
120 + [serverItems]
121 + );
122 +
123 + const allLeaseIds = useMemo(
124 + () => [
125 + ...new Set(
126 + serverRows
127 + .map(({ adminServer }) => adminServer?.peerId)
128 + .filter(
129 + (leaseId): leaseId is string =>
130 + typeof leaseId === "string" && leaseId.trim().length > 0
131 + )
132 + ),
133 + ],
134 + [serverRows]
135 + );
136 +
137 + useEffect(() => {
138 + const validLeaseIDs = new Set(allLeaseIds);
139 + setSelectedLeaseIds((prev) => {
140 + if (prev.size === 0) {
141 + return prev;
142 + }
143 +
144 + const next = new Set<string>();
145 + prev.forEach((leaseId) => {
146 + if (validLeaseIDs.has(leaseId)) {
147 + next.add(leaseId);
148 + }
149 + });
150 +
151 + if (next.size === prev.size) {
152 + return prev;
153 + }
154 +
155 + return next;
156 + });
157 + }, [allLeaseIds]);
158 +
159 + useEffect(() => {
160 + if (isAdmin) {
161 + return;
162 + }
163 + setSelectedLeaseIds((prev) => (prev.size === 0 ? prev : new Set()));
164 + }, [isAdmin]);
165
127 - // Check if all items are selected
166 const isAllSelected =
167 allLeaseIds.length > 0 &&
168 allLeaseIds.every((id) => selectedLeaseIds.has(id));
169
132 - // Select all / Deselect all
170 const handleSelectAll = () => {
171 if (isAllSelected) {
172 setSelectedLeaseIds(new Set());
@@ -138,32 +175,77 @@ export function ServerListView({
175 }
176 };
177
141 - // Bulk action handlers
142 - const handleBulkApprove = () => {
143 - if (onBulkApprove && selectedLeaseIds.size > 0) {
144 - onBulkApprove(Array.from(selectedLeaseIds));
145 - handleClearSelection();
178 + const triggerBulkAction = async (handler?: (leaseIds: string[]) => void) => {
179 + if (!handler || selectedLeaseIds.size === 0) {
180 + return;
181 }
147 - };
182
149 - const handleBulkDeny = () => {
150 - if (onBulkDeny && selectedLeaseIds.size > 0) {
151 - onBulkDeny(Array.from(selectedLeaseIds));
183 + try {
184 + await Promise.resolve(handler(Array.from(selectedLeaseIds)));
185 handleClearSelection();
186 + } catch (err) {
187 + console.error("Failed bulk admin action", err);
188 }
189 };
190
156 - const handleBulkBan = () => {
157 - if (onBulkBan && selectedLeaseIds.size > 0) {
158 - onBulkBan(Array.from(selectedLeaseIds));
159 - handleClearSelection();
160 - }
161 - };
191 + const handleBulkApprove = () => triggerBulkAction(onBulkApprove);
192 + const handleBulkDeny = () => triggerBulkAction(onBulkDeny);
193 + const handleBulkBan = () => triggerBulkAction(onBulkBan);
194 +
195 + const invokeAsyncHandler = useCallback(
196 + (action: (() => void | Promise<void>) | undefined) => {
197 + if (!action) {
198 + return;
199 + }
200 + void Promise.resolve(action()).catch((error) => {
201 + console.error("Failed admin action", error);
202 + });
203 + },
204 + []
205 + );
206 +
207 + const handleCardBanStatusChange = useCallback(
208 + (leaseId: string, isBan: boolean) =>
209 + invokeAsyncHandler(
210 + onBanStatusChange ? () => onBanStatusChange(leaseId, isBan) : undefined
211 + ),
212 + [invokeAsyncHandler, onBanStatusChange]
213 + );
214 +
215 + const handleCardBPSChange = useCallback(
216 + (leaseId: string, bps: number) =>
217 + invokeAsyncHandler(onBPSChange ? () => onBPSChange(leaseId, bps) : undefined),
218 + [invokeAsyncHandler, onBPSChange]
219 + );
220 +
221 + const handleCardApproveStatusChange = useCallback(
222 + (leaseId: string, approve: boolean) =>
223 + invokeAsyncHandler(
224 + onApproveStatusChange
225 + ? () => onApproveStatusChange(leaseId, approve)
226 + : undefined
227 + ),
228 + [invokeAsyncHandler, onApproveStatusChange]
229 + );
230
163 - // Admin filter content (Ban Status + Approval) - for desktop only
164 - const AdminFilterContent = () => (
231 + const handleCardDenyStatusChange = useCallback(
232 + (leaseId: string, deny: boolean) =>
233 + invokeAsyncHandler(
234 + onDenyStatusChange ? () => onDenyStatusChange(leaseId, deny) : undefined
235 + ),
236 + [invokeAsyncHandler, onDenyStatusChange]
237 + );
238 +
239 + const handleCardIPBanStatusChange = useCallback(
240 + (ip: string, isBan: boolean) =>
241 + invokeAsyncHandler(
242 + onIPBanStatusChange ? () => onIPBanStatusChange(ip, isBan) : undefined
243 + ),
244 + [invokeAsyncHandler, onIPBanStatusChange]
245 + );
246 +
247 + const adminFilterControls = (
248 <>
166 - {/* Ban Status Filter Buttons */}
249 {onBanFilterChange && (
250 <div className="flex items-center gap-3">
251 <span className="text-sm font-medium text-text-muted">
@@ -175,8 +257,6 @@ export function ServerListView({
257 />
258 </div>
259 )}
178 -
179 - {/* Approval Mode Toggle */}
260 {onApprovalModeChange && (
261 <div className="flex items-center gap-3">
262 <span className="text-sm font-medium text-text-muted">Approval</span>
@@ -214,13 +294,11 @@ export function ServerListView({
294 />
295 </div>
296 </div>
217 - {/* Desktop filters - hidden on mobile */}
297 {isAdmin && (
298 <div className="hidden sm:flex flex-wrap items-center gap-6 mt-4 px-4 sm:px-6">
220 - <AdminFilterContent />
299 + {adminFilterControls}
300 </div>
301 )}
223 - {/* Mobile-only Approval filter - always visible outside modal */}
302 {isAdmin && onApprovalModeChange && (
303 <div className="sm:hidden flex items-center gap-3 mt-4 px-4">
304 <span className="text-sm font-medium text-text-muted">
@@ -235,70 +313,59 @@ export function ServerListView({
313 </div>
314 <main className="flex-1 z-0">
315 <div className="grid grid-cols-1 min-[500px]:grid-cols-2 md:grid-cols-3 gap-6 p-4 min-[500px]:p-6">
238 - {filteredServers.length > 0 ? (
239 - filteredServers.map((server) => (
240 - <ServerCard
241 - key={server.id}
242 - serverId={server.id}
243 - name={server.name}
244 - description={server.description}
245 - tags={server.tags}
246 - thumbnail={server.thumbnail}
247 - owner={server.owner}
248 - online={server.online}
249 - dns={server.dns}
250 - serverUrl={server.link}
251 - navigationPath={server.link || "#"}
252 - navigationState={{
253 - id: server.id,
254 - name: server.name,
255 - description: server.description,
256 - tags: server.tags,
257 - thumbnail: server.thumbnail,
258 - owner: server.owner,
259 - online: server.online,
260 - serverUrl: server.link,
261 - }}
262 - firstSeen={server.firstSeen}
263 - isFavorite={favorites.includes(server.id)}
264 - onToggleFavorite={onToggleFavorite}
265 - // Admin controls
266 - showAdminControls={isAdmin && isAdminServer(server)}
267 - leaseId={
268 - isAdminServer(server) ? server.peerId : undefined
269 - }
270 - isBanned={
271 - isAdminServer(server) ? server.isBanned : undefined
272 - }
273 - isApproved={
274 - isAdminServer(server) ? server.isApproved : undefined
275 - }
276 - isDenied={
277 - isAdminServer(server) ? server.isDenied : undefined
278 - }
279 - bps={isAdminServer(server) ? server.bps : undefined}
280 - ip={isAdminServer(server) ? server.ip : undefined}
281 - isIPBanned={
282 - isAdminServer(server) ? server.isIPBanned : undefined
283 - }
284 - onBanStatusChange={onBanStatusChange}
285 - onBPSChange={onBPSChange}
286 - onApproveStatusChange={onApproveStatusChange}
287 - onDenyStatusChange={onDenyStatusChange}
288 - onIPBanStatusChange={onIPBanStatusChange}
289 - // Selection for bulk actions
290 - isSelected={
291 - isAdminServer(server)
292 - ? selectedLeaseIds.has(server.peerId)
293 - : false
294 - }
295 - onToggleSelect={handleToggleSelect}
296 - />
297 - ))
316 + {serverRows.length > 0 ? (
317 + serverRows.map(({ server, adminServer }) => {
318 + const isSelected = adminServer
319 + ? selectedLeaseIds.has(adminServer.peerId)
320 + : false;
321 + return (
322 + <ServerCard
323 + key={server.id}
324 + serverId={server.id}
325 + name={server.name}
326 + description={server.description}
327 + tags={server.tags}
328 + thumbnail={server.thumbnail}
329 + owner={server.owner}
330 + online={server.online}
331 + dns={server.dns}
332 + serverUrl={server.link}
333 + navigationPath={server.link || "#"}
334 + navigationState={{
335 + id: server.id,
336 + name: server.name,
337 + description: server.description,
338 + tags: server.tags,
339 + thumbnail: server.thumbnail,
340 + owner: server.owner,
341 + online: server.online,
342 + serverUrl: server.link,
343 + }}
344 + firstSeen={server.firstSeen}
345 + isFavorite={favoriteIds.has(server.id)}
346 + onToggleFavorite={onToggleFavorite}
347 + showAdminControls={isAdmin && !!adminServer}
348 + leaseId={adminServer?.peerId}
349 + isBanned={adminServer?.isBanned}
350 + isApproved={adminServer?.isApproved}
351 + isDenied={adminServer?.isDenied}
352 + bps={adminServer?.bps}
353 + ip={adminServer?.ip}
354 + isIPBanned={adminServer?.isIPBanned}
355 + onBanStatusChange={handleCardBanStatusChange}
356 + onBPSChange={handleCardBPSChange}
357 + onApproveStatusChange={handleCardApproveStatusChange}
358 + onDenyStatusChange={handleCardDenyStatusChange}
359 + onIPBanStatusChange={handleCardIPBanStatusChange}
360 + isSelected={isSelected}
361 + onToggleSelect={handleToggleSelect}
362 + />
363 + );
364 + })
365 ) : (
366 <div className="col-span-full text-center py-12">
367 <p className="text-text-muted text-lg">
301 - No servers found matching your criteria
368 + No servers match these filters
369 </p>
370 </div>
371 )}
@@ -308,14 +375,12 @@ export function ServerListView({
375 </div>
376 </div>
377
311 - {/* Filter Modal for mobile - contains SearchBar filters (Status, Sort, Tag) */}
378 <Dialog open={showFilterModal} onOpenChange={setShowFilterModal}>
379 <DialogContent className="sm:hidden max-w-sm rounded-sm">
380 <DialogHeader>
381 <DialogTitle>Filters</DialogTitle>
382 </DialogHeader>
383 <div className="flex flex-col gap-4">
318 - {/* Online/Offline Status Filter - Select style */}
384 <div className="flex flex-col gap-2">
385 <span className="text-sm font-medium text-text-muted">
386 Status
@@ -326,8 +391,6 @@ export function ServerListView({
391 className="w-full!"
392 />
393 </div>
329 -
330 - {/* Admin Ban Status Filter - All/Active/Banned (button group style) */}
394 {isAdmin && onBanFilterChange && (
395 <div className="flex flex-col gap-2">
396 <span className="text-sm font-medium text-text-muted">
@@ -340,20 +403,14 @@ export function ServerListView({
403 />
404 </div>
405 )}
343 -
344 - {/* Sort By */}
406 <div className="flex flex-col gap-2">
346 - <span className="text-sm font-medium text-text-muted">
347 - Sort By
348 - </span>
407 + <span className="text-sm font-medium text-text-muted">Sort</span>
408 <SortbySelect
409 className="w-full!"
410 sortBy={sortBy}
411 onSortByChange={onSortByChange}
412 />
413 </div>
355 -
356 - {/* Tag Filter */}
414 <div className="flex flex-col gap-2">
415 <span className="text-sm font-medium text-text-muted">Tags</span>
416 <TagCombobox
@@ -367,7 +424,6 @@ export function ServerListView({
424 </DialogContent>
425 </Dialog>
426
370 - {/* Floating Action Bar - shows when items are selected in admin mode */}
427 {isAdmin && (
428 <FloatingActionBar
429 selectedCount={selectedLeaseIds.size}
cmd/relay-server/frontend/src/hooks/useAdmin.test.ts new
+182
@@ -0,0 +1,182 @@
1 +import { act, renderHook, waitFor } from "@testing-library/react";
2 +import { beforeEach, describe, expect, it, vi } from "vitest";
3 +
4 +import type { ServerData } from "@/hooks/useSSRData";
5 +import { useAdmin } from "@/hooks/useAdmin";
6 +import { API_PATHS, adminLeasePath, encodeLeaseID } from "@/lib/apiPaths";
7 +import { APIClientError, apiClient } from "@/lib/apiClient";
8 +
9 +vi.mock("@/hooks/useList", () => ({
10 + useList: vi.fn(() => ({
11 + searchQuery: "",
12 + status: "all",
13 + sortBy: "default",
14 + selectedTags: [],
15 + favorites: [],
16 + availableTags: [],
17 + filteredServers: [],
18 + handleSearchChange: vi.fn(),
19 + handleStatusChange: vi.fn(),
20 + handleSortByChange: vi.fn(),
21 + handleTagToggle: vi.fn(),
22 + handleToggleFavorite: vi.fn(),
23 + })),
24 +}));
25 +
26 +vi.mock("@/lib/apiClient", async () => {
27 + const actual = await vi.importActual<typeof import("@/lib/apiClient")>(
28 + "@/lib/apiClient",
29 + );
30 +
31 + return {
32 + ...actual,
33 + apiClient: {
34 + get: vi.fn(),
35 + post: vi.fn(),
36 + delete: vi.fn(),
37 + },
38 + };
39 +});
40 +
41 +function buildLease(peer: string): ServerData {
42 + return {
43 + Peer: peer,
44 + Name: "relay-1",
45 + Kind: "relay",
46 + Connected: true,
47 + DNS: "relay.example.com",
48 + LastSeen: "2026-03-03T00:00:00Z",
49 + LastSeenISO: "2026-03-03T00:00:00Z",
50 + FirstSeenISO: "2026-03-02T00:00:00Z",
51 + TTL: "1h",
52 + Link: "https://relay.example.com",
53 + StaleRed: false,
54 + Hide: false,
55 + Metadata: JSON.stringify({
56 + description: "relay",
57 + tags: ["core"],
58 + thumbnail: "",
59 + owner: "ops",
60 + hide: false,
61 + }),
62 + BPS: 1024,
63 + IsApproved: true,
64 + IsDenied: false,
65 + IP: "203.0.113.10",
66 + IsIPBanned: false,
67 + };
68 +}
69 +
70 +async function waitForLoaded(result: { current: { loading: boolean } }) {
71 + await waitFor(() => {
72 + expect(result.current.loading).toBe(false);
73 + });
74 +}
75 +
76 +describe("useAdmin", () => {
77 + const mockGet = vi.mocked(apiClient.get);
78 + const mockPost = vi.mocked(apiClient.post);
79 + const mockDelete = vi.mocked(apiClient.delete);
80 +
81 + beforeEach(() => {
82 + vi.clearAllMocks();
83 +
84 + mockGet.mockImplementation(async (path: string) => {
85 + if (path === API_PATHS.admin.leases) {
86 + return [buildLease(encodeLeaseID("peer-a"))] as never;
87 + }
88 + if (path === API_PATHS.admin.bannedLeases) {
89 + return [
90 + ` ${encodeLeaseID("peer-a")} `,
91 + encodeLeaseID("peer-a"),
92 + encodeLeaseID("peer-b"),
93 + ] as never;
94 + }
95 + if (path === API_PATHS.admin.settings) {
96 + return { approval_mode: "not-a-mode" } as never;
97 + }
98 + if (path === API_PATHS.admin.approvalMode) {
99 + return { approval_mode: "manual" } as never;
100 + }
101 + throw new Error(`Unexpected GET path: ${path}`);
102 + });
103 +
104 + mockPost.mockResolvedValue({} as never);
105 + mockDelete.mockResolvedValue({} as never);
106 + });
107 +
108 + it("normalizes fetchData results on success", async () => {
109 + const { result } = renderHook(() => useAdmin());
110 +
111 + await waitForLoaded(result);
112 +
113 + expect(result.current.error).toBe("");
114 + expect(result.current.approvalMode).toBe("auto");
115 + expect(result.current.bannedLeases).toEqual(["peer-a", "peer-b"]);
116 + expect(result.current.servers[0]?.peerId).toBe("peer-a");
117 + });
118 +
119 + it("surfaces fetchData API errors", async () => {
120 + mockGet.mockImplementation(async (path: string) => {
121 + if (path === API_PATHS.admin.leases) {
122 + throw new APIClientError("failed to load leases", 500, "server_error");
123 + }
124 + if (path === API_PATHS.admin.bannedLeases) {
125 + return [] as never;
126 + }
127 + if (path === API_PATHS.admin.settings) {
128 + return { approval_mode: "manual" } as never;
129 + }
130 + if (path === API_PATHS.admin.approvalMode) {
131 + return { approval_mode: "manual" } as never;
132 + }
133 + throw new Error(`Unexpected GET path: ${path}`);
134 + });
135 +
136 + const { result } = renderHook(() => useAdmin());
137 +
138 + await waitForLoaded(result);
139 +
140 + expect(result.current.error).toBe("failed to load leases");
141 + });
142 +
143 + it("validates missing IP in handleIPBanStatus", async () => {
144 + const { result } = renderHook(() => useAdmin());
145 + await waitForLoaded(result);
146 +
147 + await act(async () => {
148 + await expect(result.current.handleIPBanStatus(" ", true)).rejects.toThrow(
149 + "Missing IP address",
150 + );
151 + });
152 + await waitFor(() => {
153 + expect(result.current.error).toContain("Missing IP address");
154 + });
155 + });
156 +
157 + it("bulk deny posts normalized, deduped lease IDs", async () => {
158 + const { result } = renderHook(() => useAdmin());
159 + await waitForLoaded(result);
160 + const normalizedPeerA = encodeLeaseID("peer-a");
161 + const normalizedPeerB = encodeLeaseID("peer-b");
162 +
163 + await act(async () => {
164 + await result.current.handleBulkDeny([
165 + ` ${encodeLeaseID(normalizedPeerA)} `,
166 + encodeLeaseID(normalizedPeerA),
167 + encodeLeaseID(normalizedPeerB),
168 + ]);
169 + });
170 +
171 + const calledPaths = mockPost.mock.calls.map(([path]) => path as string);
172 + expect(calledPaths).toEqual(
173 + expect.arrayContaining([
174 + adminLeasePath(normalizedPeerA, "deny"),
175 + adminLeasePath(normalizedPeerB, "deny"),
176 + ]),
177 + );
178 +
179 + const denyCalls = calledPaths.filter((path) => path.endsWith("/deny"));
180 + expect(denyCalls).toHaveLength(2);
181 + });
182 +});
cmd/relay-server/frontend/src/hooks/useAdmin.ts
+268 -162
@@ -8,29 +8,77 @@ import {
8 adminLeasePath,
9 encodeLeaseID,
10 } from "@/lib/apiPaths";
11 -import { apiClient } from "@/lib/apiClient";
11 +import { APIClientError, apiClient } from "@/lib/apiClient";
12
13 -// Approval mode type
13 export type ApprovalMode = "auto" | "manual";
14
16 -// Extended BaseServer with admin-specific fields
15 +type LeaseAction = "approve" | "deny" | "ban";
16 +
17 +type SettingsResponse = {
18 + approval_mode?: ApprovalMode;
19 +};
20 +
21 +interface LeaseActionResult {
22 + approval_mode?: ApprovalMode;
23 +}
24 +
25 export interface AdminServer extends BaseServer {
26 peerId: string;
27 isBanned: boolean;
20 - bps: number; // bytes-per-second limit (0 = unlimited)
21 - isApproved: boolean; // whether lease is approved (for manual mode)
22 - isDenied: boolean; // whether lease is denied (for manual mode)
23 - ip: string; // client IP address (for IP-based ban)
24 - isIPBanned: boolean; // whether the IP is banned
28 + bps: number;
29 + isApproved: boolean;
30 + isDenied: boolean;
31 + ip: string;
32 + isIPBanned: boolean;
33 }
34
27 -// Convert ServerData (from API) to AdminServer format
28 -function convertServerDataToAdminServer(
29 - row: ServerData,
30 - index: number,
31 - bannedLeases: string[]
32 -): AdminServer {
33 - let metadata: Metadata = {
35 +function decodeBase64URLSafe(input: string): string {
36 + const normalized = input.trim().replace(/-/g, "+").replace(/_/g, "/");
37 + const padded =
38 + normalized.length % 4 === 0 ? normalized : normalized + "=".repeat(4 - (normalized.length % 4));
39 + return padded;
40 +}
41 +
42 +function decodeLeaseID(raw: string): string {
43 + const value = raw.trim();
44 + if (!value) {
45 + return "";
46 + }
47 +
48 + try {
49 + return atob(decodeBase64URLSafe(value));
50 + } catch {
51 + return value;
52 + }
53 +}
54 +
55 +function normalizeLeaseID(raw: string): string {
56 + const value = raw.trim();
57 + if (!value) {
58 + return "";
59 + }
60 + const decoded = decodeLeaseID(value).trim();
61 + return decoded || value;
62 +}
63 +
64 +function encodeLeaseIDForPath(raw: string): string {
65 + const leaseID = normalizeLeaseID(raw);
66 + if (!leaseID) {
67 + throw new Error("Missing lease ID");
68 + }
69 + return encodeLeaseID(leaseID);
70 +}
71 +
72 +function sanitizeMetadata(row: ServerData): Metadata {
73 + const isRecord = (value: unknown): value is Record<string, unknown> => {
74 + return (
75 + typeof value === "object" &&
76 + value !== null &&
77 + !Array.isArray(value)
78 + );
79 + };
80 +
81 + const fallback: Metadata = {
82 description: "",
83 tags: [],
84 thumbnail: "",
@@ -38,35 +86,59 @@ function convertServerDataToAdminServer(
86 hide: false,
87 };
88
89 + if (!row.Metadata) {
90 + return fallback;
91 + }
92 +
93 try {
42 - if (row.Metadata) {
43 - metadata = JSON.parse(row.Metadata);
94 + const parsed = JSON.parse(row.Metadata);
95 + if (!isRecord(parsed)) {
96 + return fallback;
97 }
45 - } catch (err) {
46 - console.error("[Admin] Failed to parse metadata:", err, row.Metadata);
98 +
99 + const rawTags = parsed.tags;
100 + const tags = Array.isArray(rawTags)
101 + ? rawTags
102 + .map((tag) => (typeof tag === "string" ? tag.trim() : ""))
103 + .filter(Boolean)
104 + : [];
105 +
106 + return {
107 + description:
108 + typeof parsed.description === "string" ? parsed.description : "",
109 + tags,
110 + thumbnail:
111 + typeof parsed.thumbnail === "string" ? parsed.thumbnail : "",
112 + owner: typeof parsed.owner === "string" ? parsed.owner : "",
113 + hide: typeof parsed.hide === "boolean" ? parsed.hide : false,
114 + };
115 + } catch {
116 + return fallback;
117 }
118 +}
119
49 - const normalizedTags = Array.isArray(metadata.tags)
50 - ? metadata.tags
51 - .map((tag) => (typeof tag === "string" ? tag.trim() : ""))
52 - .filter(Boolean)
53 - : [];
120 +function toAdminServer(
121 + row: ServerData,
122 + index: number,
123 + bannedLeases: Set<string>
124 +): AdminServer {
125 + const metadata = sanitizeMetadata(row);
126 + const peerId = normalizeLeaseID(row.Peer);
127
128 return {
129 id: index + 1,
130 name: row.Name || row.DNS || "(unnamed)",
58 - description: metadata.description || "",
59 - tags: normalizedTags,
60 - thumbnail: metadata.thumbnail || "",
61 - owner: metadata.owner || "",
131 + description: metadata.description,
132 + tags: metadata.tags,
133 + thumbnail: metadata.thumbnail,
134 + owner: metadata.owner,
135 online: row.Connected,
136 dns: row.DNS || "",
137 link: row.Link,
138 lastUpdated: row.LastSeenISO || row.LastSeen || undefined,
139 firstSeen: row.FirstSeenISO || undefined,
67 - // Admin-specific fields
68 - peerId: row.Peer,
69 - isBanned: bannedLeases.includes(row.Peer),
140 + peerId,
141 + isBanned: bannedLeases.has(peerId),
142 bps: row.BPS || 0,
143 isApproved: row.IsApproved || false,
144 isDenied: row.IsDenied || false,
@@ -75,12 +147,23 @@ function convertServerDataToAdminServer(
147 };
148 }
149
78 -function decodeLeaseID(value: string): string {
79 - try {
80 - return atob(value);
81 - } catch {
82 - return value;
83 - }
150 +function normalizeApprovalMode(value: string | undefined): ApprovalMode {
151 + return value === "manual" ? "manual" : "auto";
152 +}
153 +
154 +function dedupeStrings(values: string[]): string[] {
155 + const seen = new Set<string>();
156 + const output: string[] = [];
157 +
158 + values.forEach((value) => {
159 + if (seen.has(value)) {
160 + return;
161 + }
162 + seen.add(value);
163 + output.push(value);
164 + });
165 +
166 + return output;
167 }
168
169 export function useAdmin() {
@@ -90,22 +173,43 @@ export function useAdmin() {
173 const [loading, setLoading] = useState(true);
174 const [error, setError] = useState("");
175
93 - // Admin-specific filter state
176 const [banFilter, setBanFilter] = useState<BanFilter>("all");
177
178 const fetchData = useCallback(async () => {
179 + setError("");
180 + setLoading(true);
181 +
182 try {
183 + const settingsRequest = apiClient
184 + .get<SettingsResponse>(API_PATHS.admin.settings)
185 + .catch(async (err) => {
186 + if (err instanceof APIClientError && err.status === 404) {
187 + return apiClient.get<SettingsResponse>(API_PATHS.admin.approvalMode);
188 + }
189 + throw err;
190 + });
191 +
192 const [leasesData, bannedData, settings] = await Promise.all([
193 apiClient.get<ServerData[]>(API_PATHS.admin.leases),
194 apiClient.get<string[]>(API_PATHS.admin.bannedLeases),
101 - apiClient.get<{ approval_mode?: ApprovalMode }>(API_PATHS.admin.settings),
195 + settingsRequest,
196 ]);
197
104 - setServerData(leasesData || []);
105 - setBannedLeases((bannedData || []).map(decodeLeaseID));
106 - setApprovalMode(settings?.approval_mode || "auto");
198 + const normalizedBans = (Array.isArray(bannedData) ? bannedData : [])
199 + .map((leaseID) =>
200 + typeof leaseID === "string" ? normalizeLeaseID(leaseID) : ""
201 + )
202 + .filter(Boolean);
203 +
204 + setServerData(Array.isArray(leasesData) ? leasesData : []);
205 + setBannedLeases(dedupeStrings(normalizedBans));
206 + setApprovalMode(normalizeApprovalMode(settings?.approval_mode));
207 } catch (err: unknown) {
108 - setError(err instanceof Error ? err.message : String(err));
208 + if (err instanceof APIClientError) {
209 + setError(err.message);
210 + } else {
211 + setError(err instanceof Error ? err.message : String(err));
212 + }
213 } finally {
214 setLoading(false);
215 }
@@ -115,14 +219,17 @@ export function useAdmin() {
219 fetchData();
220 }, [fetchData]);
221
118 - // Convert ServerData to AdminServer format
222 + const bannedLeaseSet = useMemo(
223 + () => new Set(bannedLeases.map((leaseID) => normalizeLeaseID(leaseID))),
224 + [bannedLeases]
225 + );
226 +
227 const servers: AdminServer[] = useMemo(() => {
228 return serverData.map((row, index) =>
121 - convertServerDataToAdminServer(row, index, bannedLeases)
229 + toAdminServer(row, index, bannedLeaseSet)
230 );
123 - }, [serverData, bannedLeases]);
231 + }, [serverData, bannedLeaseSet]);
232
125 - // Additional filter for ban status
233 const additionalFilter = useCallback(
234 (server: AdminServer) => {
235 switch (banFilter) {
@@ -137,176 +244,176 @@ export function useAdmin() {
244 [banFilter]
245 );
246
140 - // Use common list logic with additional ban filter
247 const listState = useList({
248 servers,
249 storageKey: "adminFavorites",
250 additionalFilter,
251 });
252
147 - // Admin-specific handlers
148 - const handleBanFilterChange = useCallback((value: BanFilter) => {
149 - setBanFilter(value);
150 - }, []);
151 -
152 - const handleBanStatus = useCallback(
153 - async (peerId: string, isBan: boolean) => {
253 + const runAdminAction = useCallback(
254 + async (action: () => Promise<void>) => {
255 + setError("");
256 try {
155 - const encodedLeaseID = encodeLeaseID(peerId);
156 - if (isBan) {
157 - await apiClient.post<unknown>(adminLeasePath(encodedLeaseID, "ban"));
158 - } else {
159 - await apiClient.delete<unknown>(adminLeasePath(encodedLeaseID, "ban"));
160 - }
257 + await action();
258 await fetchData();
162 - } catch (err) {
259 + } catch (err: unknown) {
260 + const message =
261 + err instanceof APIClientError
262 + ? err.message
263 + : err instanceof Error
264 + ? err.message
265 + : "Action failed";
266 console.error(err);
267 + setError(message);
268 + throw err;
269 }
270 },
271 [fetchData]
272 );
273
169 - const handleBPSChange = useCallback(
170 - async (peerId: string, bps: number) => {
171 - try {
172 - const encodedLeaseID = encodeLeaseID(peerId);
173 - if (bps <= 0) {
174 - await apiClient.delete<unknown>(adminLeasePath(encodedLeaseID, "bps"));
175 - } else {
176 - await apiClient.post<unknown>(adminLeasePath(encodedLeaseID, "bps"), {
177 - bps,
178 - });
179 - }
180 - await fetchData();
181 - } catch (err) {
182 - console.error(err);
183 - }
274 + const updateLeaseAction = useCallback(
275 + async (peerId: string, action: LeaseAction, enabled: boolean) => {
276 + const encodedLeaseID = encodeLeaseIDForPath(peerId);
277 + const method = enabled ? apiClient.post : apiClient.delete;
278 + await method<LeaseActionResult>(adminLeasePath(encodedLeaseID, action));
279 },
185 - [fetchData]
280 + []
281 );
282
188 - const handleApprovalModeChange = useCallback(async (mode: ApprovalMode) => {
189 - try {
190 - await apiClient.post<unknown>(API_PATHS.admin.approvalMode, { mode });
191 - setApprovalMode(mode);
192 - } catch (err) {
193 - console.error(err);
194 - }
283 + const handleBanFilterChange = useCallback((value: BanFilter) => {
284 + setBanFilter(value);
285 }, []);
286
197 - const handleApproveStatus = useCallback(
198 - async (peerId: string, approve: boolean) => {
199 - try {
200 - const encodedLeaseID = encodeLeaseID(peerId);
201 - if (approve) {
202 - await apiClient.post<unknown>(adminLeasePath(encodedLeaseID, "approve"));
203 - } else {
204 - await apiClient.delete<unknown>(adminLeasePath(encodedLeaseID, "approve"));
287 + const handleBanStatus = useCallback(
288 + (peerId: string, isBan: boolean) =>
289 + runAdminAction(() => updateLeaseAction(peerId, "ban", isBan)),
290 + [runAdminAction, updateLeaseAction]
291 + );
292 +
293 + const handleBPSChange = useCallback(
294 + (peerId: string, bps: number) =>
295 + runAdminAction(async () => {
296 + const encodedLeaseID = encodeLeaseIDForPath(peerId);
297 + const normalizedBPS = Math.trunc(bps);
298 + if (!Number.isFinite(normalizedBPS) || normalizedBPS <= 0) {
299 + await apiClient.delete<LeaseActionResult>(
300 + adminLeasePath(encodedLeaseID, "bps")
301 + );
302 + return;
303 }
206 - await fetchData();
207 - } catch (err) {
208 - console.error(err);
209 - }
304 + await apiClient.post<LeaseActionResult>(adminLeasePath(encodedLeaseID, "bps"), {
305 + bps: normalizedBPS,
306 + });
307 + }),
308 + [runAdminAction]
309 + );
310 +
311 + const handleApprovalModeChange = useCallback(
312 + async (mode: ApprovalMode) => {
313 + await runAdminAction(async () => {
314 + const response = await apiClient.post<SettingsResponse>(
315 + API_PATHS.admin.approvalMode,
316 + { mode }
317 + );
318 + const nextMode = normalizeApprovalMode(response?.approval_mode ?? mode);
319 + setApprovalMode(nextMode);
320 + });
321 },
211 - [fetchData]
322 + [runAdminAction]
323 + );
324 +
325 + const handleApproveStatus = useCallback(
326 + (peerId: string, approve: boolean) =>
327 + runAdminAction(() => updateLeaseAction(peerId, "approve", approve)),
328 + [runAdminAction, updateLeaseAction]
329 );
330
331 const handleDenyStatus = useCallback(
215 - async (peerId: string, deny: boolean) => {
216 - try {
217 - const encodedLeaseID = encodeLeaseID(peerId);
218 - if (deny) {
219 - await apiClient.post<unknown>(adminLeasePath(encodedLeaseID, "deny"));
220 - } else {
221 - await apiClient.delete<unknown>(adminLeasePath(encodedLeaseID, "deny"));
222 - }
223 - await fetchData();
224 - } catch (err) {
225 - console.error(err);
226 - }
227 - },
228 - [fetchData]
332 + (peerId: string, deny: boolean) =>
333 + runAdminAction(() => updateLeaseAction(peerId, "deny", deny)),
334 + [runAdminAction, updateLeaseAction]
335 );
336
337 const handleIPBanStatus = useCallback(
232 - async (ip: string, isBan: boolean) => {
233 - try {
338 + (ip: string, isBan: boolean) =>
339 + runAdminAction(async () => {
340 + const normalizedIP = ip.trim();
341 + if (!normalizedIP) {
342 + throw new Error("Missing IP address");
343 + }
344 if (isBan) {
235 - await apiClient.post<unknown>(adminIPBanPath(ip));
236 - } else {
237 - await apiClient.delete<unknown>(adminIPBanPath(ip));
345 + await apiClient.post<LeaseActionResult>(adminIPBanPath(normalizedIP));
346 + return;
347 }
239 - await fetchData();
240 - } catch (err) {
241 - console.error(err);
242 - }
243 - },
244 - [fetchData]
348 + await apiClient.delete<LeaseActionResult>(adminIPBanPath(normalizedIP));
349 + }),
350 + [runAdminAction]
351 );
352
247 - // Bulk action handlers
353 const runBulkLeaseAction = useCallback(
249 - async (peerIds: string[], action: "approve" | "deny" | "ban") => {
250 - await Promise.all(
251 - peerIds.map((peerId) =>
252 - apiClient.post<unknown>(adminLeasePath(encodeLeaseID(peerId), action))
354 + async (peerIds: string[], action: LeaseAction) => {
355 + const normalizedPeerIDs = dedupeStrings(
356 + peerIds
357 + .map((peerId) => normalizeLeaseID(peerId))
358 + .filter(Boolean)
359 + );
360 + if (normalizedPeerIDs.length === 0) {
361 + throw new Error("No valid leases selected");
362 + }
363 +
364 + const results = await Promise.allSettled(
365 + normalizedPeerIDs.map((peerId) =>
366 + apiClient.post<LeaseActionResult>(
367 + adminLeasePath(encodeLeaseIDForPath(peerId), action)
368 + )
369 )
370 );
371 +
372 + const failed = results.find(
373 + (
374 + result
375 + ): result is PromiseRejectedResult =>
376 + result.status === "rejected"
377 + );
378 + if (failed) {
379 + throw failed.reason instanceof Error
380 + ? failed.reason
381 + : new Error(String(failed.reason));
382 + }
383 },
384 []
385 );
386
387 + const handleBulkAction = useCallback(
388 + (peerIds: string[], action: LeaseAction) =>
389 + runAdminAction(() => runBulkLeaseAction(peerIds, action)),
390 + [runAdminAction, runBulkLeaseAction]
391 + );
392 +
393 const handleBulkApprove = useCallback(
260 - async (peerIds: string[]) => {
261 - try {
262 - await runBulkLeaseAction(peerIds, "approve");
263 - await fetchData();
264 - } catch (err) {
265 - console.error(err);
266 - }
267 - },
268 - [fetchData, runBulkLeaseAction]
394 + (peerIds: string[]) => handleBulkAction(peerIds, "approve"),
395 + [handleBulkAction]
396 );
397
398 const handleBulkDeny = useCallback(
272 - async (peerIds: string[]) => {
273 - try {
274 - await runBulkLeaseAction(peerIds, "deny");
275 - await fetchData();
276 - } catch (err) {
277 - console.error(err);
278 - }
279 - },
280 - [fetchData, runBulkLeaseAction]
399 + (peerIds: string[]) => handleBulkAction(peerIds, "deny"),
400 + [handleBulkAction]
401 );
402
403 const handleBulkBan = useCallback(
284 - async (peerIds: string[]) => {
285 - try {
286 - await runBulkLeaseAction(peerIds, "ban");
287 - await fetchData();
288 - } catch (err) {
289 - console.error(err);
290 - }
291 - },
292 - [fetchData, runBulkLeaseAction]
404 + (peerIds: string[]) => handleBulkAction(peerIds, "ban"),
405 + [handleBulkAction]
406 );
407
408 return {
296 - // Raw data
409 serverData,
410 bannedLeases,
299 - // Converted servers (before filtering)
411 servers,
301 - // All list state and handlers from useList
412 ...listState,
303 - // Admin-specific filter state
413 banFilter,
414 approvalMode,
306 - // State
415 loading,
416 error,
309 - // Admin-specific handlers
417 handleBanFilterChange,
418 handleBanStatus,
419 handleBPSChange,
@@ -314,7 +421,6 @@ export function useAdmin() {
421 handleApproveStatus,
422 handleDenyStatus,
423 handleIPBanStatus,
317 - // Bulk action handlers
424 handleBulkApprove,
425 handleBulkDeny,
426 handleBulkBan,
cmd/relay-server/frontend/src/hooks/useList.ts
+167 -78
@@ -1,7 +1,6 @@
1 import { useCallback, useEffect, useMemo, useState } from "react";
2 import type { SortOption, StatusFilter } from "@/types/filters";
3
4 -// Base server interface that both ClientServer and AdminServer extend
4 export interface BaseServer {
5 id: number;
6 name: string;
@@ -19,21 +18,17 @@ export interface BaseServer {
18 export interface UseListOptions<T extends BaseServer> {
19 servers: T[];
20 storageKey: string;
22 - // Optional additional filter function for extended filtering (e.g., ban filter)
21 additionalFilter?: (server: T) => boolean;
22 }
23
24 export interface UseListReturn<T extends BaseServer> {
27 - // Filter states
25 searchQuery: string;
26 status: StatusFilter;
27 sortBy: SortOption;
28 selectedTags: string[];
29 favorites: number[];
33 - // Derived data
30 availableTags: string[];
31 filteredServers: T[];
36 - // Handlers
32 handleSearchChange: (value: string) => void;
33 handleStatusChange: (value: StatusFilter) => void;
34 handleSortByChange: (value: SortOption) => void;
@@ -41,6 +36,66 @@ export interface UseListReturn<T extends BaseServer> {
36 handleToggleFavorite: (serverId: number) => void;
37 }
38
39 +function dedupeNumbers(values: number[]): number[] {
40 + const seen = new Set<number>();
41 + const next: number[] = [];
42 + values.forEach((value) => {
43 + if (seen.has(value)) {
44 + return;
45 + }
46 + seen.add(value);
47 + next.push(value);
48 + });
49 + return next;
50 +}
51 +
52 +function readStoredFavorites(storageKey: string): number[] {
53 + let raw: string | null = null;
54 + try {
55 + raw = localStorage.getItem(storageKey);
56 + } catch {
57 + return [];
58 + }
59 +
60 + if (!raw) {
61 + return [];
62 + }
63 +
64 + try {
65 + const parsed = JSON.parse(raw);
66 + if (!Array.isArray(parsed)) {
67 + return [];
68 + }
69 + return dedupeNumbers(
70 + parsed.filter(
71 + (value): value is number => Number.isInteger(value) && value > 0
72 + )
73 + );
74 + } catch {
75 + return [];
76 + }
77 +}
78 +
79 +function parseTimestamp(value?: string, fallback = 0): number {
80 + if (!value) {
81 + return fallback;
82 + }
83 +
84 + const parsed = Date.parse(value);
85 + return Number.isNaN(parsed) ? fallback : parsed;
86 +}
87 +
88 +function matchesStatus(online: boolean, status: StatusFilter): boolean {
89 + switch (status) {
90 + case "online":
91 + return online;
92 + case "offline":
93 + return !online;
94 + default:
95 + return true;
96 + }
97 +}
98 +
99 export function useList<T extends BaseServer>({
100 servers,
101 storageKey,
@@ -50,102 +105,133 @@ export function useList<T extends BaseServer>({
105 const [status, setStatus] = useState<StatusFilter>("all");
106 const [sortBy, setSortBy] = useState<SortOption>("duration");
107 const [selectedTags, setSelectedTags] = useState<string[]>([]);
53 - const [favorites, setFavorites] = useState<number[]>(() => {
54 - const stored = localStorage.getItem(storageKey);
55 - return stored ? JSON.parse(stored) : [];
56 - });
108 + const [favorites, setFavorites] = useState<number[]>(() =>
109 + readStoredFavorites(storageKey)
110 + );
111 +
112 + useEffect(() => {
113 + setFavorites(readStoredFavorites(storageKey));
114 + }, [storageKey]);
115
58 - // Save favorites to localStorage whenever they change
116 useEffect(() => {
60 - localStorage.setItem(storageKey, JSON.stringify(favorites));
117 + try {
118 + localStorage.setItem(storageKey, JSON.stringify(favorites));
119 + } catch {
120 + // Ignore storage write failures (quota/private browsing).
121 + }
122 }, [favorites, storageKey]);
123
63 - // Extract available tags
124 const availableTags = useMemo(() => {
125 const counts = new Map<string, number>();
126 servers.forEach((server) => {
127 server.tags.forEach((tag) => {
68 - counts.set(tag, (counts.get(tag) || 0) + 1);
128 + const normalizedTag = typeof tag === "string" ? tag.trim().toLowerCase() : "";
129 + if (!normalizedTag) {
130 + return;
131 + }
132 + counts.set(normalizedTag, (counts.get(normalizedTag) || 0) + 1);
133 });
134 });
135 +
136 return Array.from(counts.entries())
137 .sort((a, b) => b[1] - a[1])
138 .map(([tag]) => tag);
139 }, [servers]);
140
76 - // Filter and sort servers
141 + useEffect(() => {
142 + const validIDs = new Set(servers.map((server) => server.id));
143 + setFavorites((prev) => {
144 + const next = dedupeNumbers(prev.filter((id) => validIDs.has(id)));
145 + if (
146 + next.length === prev.length &&
147 + next.every((value, index) => value === prev[index])
148 + ) {
149 + return prev;
150 + }
151 + return next;
152 + });
153 + }, [servers]);
154 +
155 + useEffect(() => {
156 + const availableTagSet = new Set(availableTags);
157 + setSelectedTags((prev) => {
158 + const next = prev.filter((tag) => availableTagSet.has(tag));
159 + if (
160 + next.length === prev.length &&
161 + next.every((value, index) => value === prev[index])
162 + ) {
163 + return prev;
164 + }
165 + return next;
166 + });
167 + }, [availableTags]);
168 +
169 const filteredServers = useMemo(() => {
78 - const query = searchQuery.toLowerCase();
170 + const query = searchQuery.trim().toLowerCase();
171 + const selectedTagSet = new Set(selectedTags);
172 + const favoriteSet = new Set(favorites);
173 + const now = Date.now();
174 +
175 + const matchesTags = (server: T): boolean => {
176 + if (selectedTagSet.size === 0) {
177 + return true;
178 + }
179
80 - const matchesTags = (server: T) => {
81 - if (selectedTags.length === 0) return true;
82 - const tagsLower = server.tags.map((t) => t.toLowerCase());
83 - return selectedTags.some((tag) => tagsLower.includes(tag.toLowerCase()));
180 + return server.tags.some((tag) => selectedTagSet.has(tag.toLowerCase().trim()));
181 };
182
86 - const filtered = servers.filter((server) => {
87 - const matchesSearch =
88 - query === "" ||
183 + const matchesSearch = (server: T): boolean => {
184 + if (query === "") {
185 + return true;
186 + }
187 +
188 + return (
189 server.name.toLowerCase().includes(query) ||
190 server.description.toLowerCase().includes(query) ||
91 - server.tags.some((tag) => tag.toLowerCase().includes(query));
92 -
93 - const matchesStatus =
94 - status === "all" ||
95 - (status === "online" && server.online) ||
96 - (status === "offline" && !server.online);
191 + server.tags.some((tag) => tag.toLowerCase().includes(query))
192 + );
193 + };
194
195 + const filtered = servers.filter((server) => {
196 const matchesAdditional = additionalFilter ? additionalFilter(server) : true;
197
100 - return matchesSearch && matchesStatus && matchesTags(server) && matchesAdditional;
198 + return (
199 + matchesSearch(server) &&
200 + matchesStatus(server.online, status) &&
201 + matchesTags(server) &&
202 + matchesAdditional
203 + );
204 });
205
103 - const sorted = [...filtered];
104 - switch (sortBy) {
105 - case "name-asc":
106 - sorted.sort((a, b) => a.name.localeCompare(b.name));
107 - break;
108 - case "name-desc":
109 - sorted.sort((a, b) => b.name.localeCompare(a.name));
110 - break;
111 - case "updated":
112 - sorted.sort((a, b) => {
113 - const aTime = a.lastUpdated ? Date.parse(a.lastUpdated) : 0;
114 - const bTime = b.lastUpdated ? Date.parse(b.lastUpdated) : 0;
115 - return bTime - aTime;
116 - });
117 - break;
118 - case "duration":
119 - sorted.sort((a, b) => {
120 - // Duration = Now - FirstSeen.
121 - // Longer duration = Older FirstSeen.
122 - // Sort Descending (Longest/Oldest first) -> Ascending FirstSeen timestamp.
123 - const aTime = a.firstSeen ? Date.parse(a.firstSeen) : Date.now();
124 - const bTime = b.firstSeen ? Date.parse(b.firstSeen) : Date.now();
125 - return aTime - bTime;
126 - });
127 - break;
128 - case "description":
129 - sorted.sort((a, b) => a.description.localeCompare(b.description));
130 - break;
131 - case "tags":
132 - sorted.sort((a, b) => {
133 - const aTag = a.tags[0] || "";
134 - const bTag = b.tags[0] || "";
135 - return aTag.localeCompare(bTag);
136 - });
137 - break;
138 - case "owner":
139 - sorted.sort((a, b) => a.owner.localeCompare(b.owner));
140 - break;
141 - default:
142 - break;
143 - }
206 + const sortByField = (sortValue: SortOption) => {
207 + switch (sortValue) {
208 + case "name-asc":
209 + return (a: T, b: T) => a.name.localeCompare(b.name);
210 + case "name-desc":
211 + return (a: T, b: T) => b.name.localeCompare(a.name);
212 + case "updated":
213 + return (a: T, b: T) =>
214 + parseTimestamp(b.lastUpdated, 0) - parseTimestamp(a.lastUpdated, 0);
215 + case "duration":
216 + return (a: T, b: T) =>
217 + parseTimestamp(a.firstSeen, now) - parseTimestamp(b.firstSeen, now);
218 + case "description":
219 + return (a: T, b: T) => a.description.localeCompare(b.description);
220 + case "tags":
221 + return (a: T, b: T) => (a.tags[0] || "").localeCompare(b.tags[0] || "");
222 + case "owner":
223 + return (a: T, b: T) => a.owner.localeCompare(b.owner);
224 + case "default":
225 + return (a: T, b: T) => a.id - b.id;
226 + default:
227 + return (a: T, b: T) => a.id - b.id;
228 + }
229 + };
230
145 - // Sort by favorites first
231 + const sorted = [...filtered].sort(sortByField(sortBy));
232 sorted.sort((a, b) => {
147 - const aIsFav = favorites.includes(a.id);
148 - const bIsFav = favorites.includes(b.id);
233 + const aIsFav = favoriteSet.has(a.id);
234 + const bIsFav = favoriteSet.has(b.id);
235 if (aIsFav && !bIsFav) return -1;
236 if (!aIsFav && bIsFav) return 1;
237 return 0;
@@ -154,7 +240,6 @@ export function useList<T extends BaseServer>({
240 return sorted;
241 }, [servers, searchQuery, status, sortBy, selectedTags, favorites, additionalFilter]);
242
157 - // Handlers
243 const handleSearchChange = useCallback((value: string) => {
244 setSearchQuery(value);
245 }, []);
@@ -168,8 +253,15 @@ export function useList<T extends BaseServer>({
253 }, []);
254
255 const handleTagToggle = useCallback((tag: string) => {
256 + const normalizedTag = tag.trim().toLowerCase();
257 + if (!normalizedTag) {
258 + return;
259 + }
260 +
261 setSelectedTags((prev) =>
172 - prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
262 + prev.includes(normalizedTag)
263 + ? prev.filter((candidate) => candidate !== normalizedTag)
264 + : [...prev, normalizedTag]
265 );
266 }, []);
267
@@ -182,16 +274,13 @@ export function useList<T extends BaseServer>({
274 }, []);
275
276 return {
185 - // Filter states
277 searchQuery,
278 status,
279 sortBy,
280 selectedTags,
281 favorites,
191 - // Derived data
282 availableTags,
283 filteredServers,
194 - // Handlers
284 handleSearchChange,
285 handleStatusChange,
286 handleSortByChange,
cmd/relay-server/frontend/src/lib/apiClient.test.ts new
+128
@@ -0,0 +1,128 @@
1 +import { APIClientError, apiClient } from "@/lib/apiClient";
2 +import { beforeEach, describe, expect, it, vi } from "vitest";
3 +
4 +function jsonResponse(payload: unknown, init?: ResponseInit): Response {
5 + return new Response(JSON.stringify(payload), {
6 + status: 200,
7 + headers: { "Content-Type": "application/json" },
8 + ...init,
9 + });
10 +}
11 +
12 +describe("apiClient", () => {
13 + const fetchMock = vi.fn();
14 +
15 + beforeEach(() => {
16 + fetchMock.mockReset();
17 + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
18 + });
19 +
20 + it("returns data when API envelope is ok", async () => {
21 + fetchMock.mockResolvedValueOnce(
22 + jsonResponse({ ok: true, data: { value: 42 } }),
23 + );
24 +
25 + const data = await apiClient.get<{ value: number }>("/api/test");
26 +
27 + expect(data).toEqual({ value: 42 });
28 + const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
29 + expect(init.method).toBe("GET");
30 + expect(init.credentials).toBe("same-origin");
31 + expect(init.headers).toEqual({ Accept: "application/json" });
32 + });
33 +
34 + it("accepts successful non-envelope JSON payloads", async () => {
35 + fetchMock.mockResolvedValueOnce(jsonResponse({ direct: true }));
36 +
37 + const data = await apiClient.get<{ direct: boolean }>("/api/test");
38 +
39 + expect(data).toEqual({ direct: true });
40 + });
41 +
42 + it("throws APIClientError for server-side envelope failures", async () => {
43 + fetchMock.mockResolvedValueOnce(
44 + jsonResponse(
45 + { ok: false, error: { code: "forbidden", message: "Denied" } },
46 + { status: 403, statusText: "Forbidden" },
47 + ),
48 + );
49 +
50 + await expect(apiClient.get("/api/test")).rejects.toMatchObject({
51 + name: "APIClientError",
52 + status: 403,
53 + code: "forbidden",
54 + message: "Denied",
55 + } satisfies Partial<APIClientError>);
56 + });
57 +
58 + it("throws invalid_envelope when a failed response has no envelope", async () => {
59 + fetchMock.mockResolvedValueOnce(
60 + jsonResponse({ message: "not wrapped" }, { status: 400 }),
61 + );
62 +
63 + await expect(apiClient.get("/api/test")).rejects.toMatchObject({
64 + name: "APIClientError",
65 + status: 400,
66 + code: "invalid_envelope",
67 + } satisfies Partial<APIClientError>);
68 + });
69 +
70 + it("throws invalid_json when response body is not parseable JSON", async () => {
71 + fetchMock.mockResolvedValueOnce(
72 + new Response("not-json", {
73 + status: 200,
74 + headers: { "Content-Type": "application/json" },
75 + }),
76 + );
77 +
78 + await expect(apiClient.get("/api/test")).rejects.toMatchObject({
79 + name: "APIClientError",
80 + status: 200,
81 + code: "invalid_json",
82 + } satisfies Partial<APIClientError>);
83 + });
84 +
85 + it("maps fetch failures to network_error", async () => {
86 + fetchMock.mockRejectedValueOnce(new Error("network down"));
87 +
88 + await expect(apiClient.get("/api/test")).rejects.toMatchObject({
89 + name: "APIClientError",
90 + status: 0,
91 + code: "network_error",
92 + message: "Network request failed",
93 + } satisfies Partial<APIClientError>);
94 + });
95 +
96 + it("maps AbortError failures to aborted", async () => {
97 + fetchMock.mockRejectedValueOnce(new DOMException("Aborted", "AbortError"));
98 +
99 + await expect(apiClient.get("/api/test")).rejects.toMatchObject({
100 + name: "APIClientError",
101 + status: 0,
102 + code: "aborted",
103 + message: "Request was aborted",
104 + } satisfies Partial<APIClientError>);
105 + });
106 +
107 + it("sends JSON bodies for post and omits content-type for delete without body", async () => {
108 + fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true, data: {} }));
109 + await apiClient.post("/api/post", { id: 1 });
110 +
111 + const postInit = fetchMock.mock.calls[0]?.[1] as RequestInit;
112 + expect(postInit.method).toBe("POST");
113 + expect(postInit.body).toBe(JSON.stringify({ id: 1 }));
114 + expect(postInit.headers).toEqual({
115 + Accept: "application/json",
116 + "Content-Type": "application/json",
117 + });
118 +
119 + fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true, data: {} }));
120 + await apiClient.delete("/api/post");
121 +
122 + const deleteInit = fetchMock.mock.calls[1]?.[1] as RequestInit;
123 + expect(deleteInit.method).toBe("DELETE");
124 + expect(deleteInit.headers).toEqual({
125 + Accept: "application/json",
126 + });
127 + });
128 +});
cmd/relay-server/frontend/src/lib/apiClient.ts
+115 -23
@@ -1,10 +1,10 @@
1 -type APIErrorPayload = {
1 +export type APIErrorPayload = {
2 code?: string;
3 message?: string;
4 };
5
6 -type APIEnvelope<T> = {
7 - ok: boolean;
6 +export type APIEnvelope<T> = {
7 + ok?: boolean;
8 data?: T;
9 error?: APIErrorPayload;
10 };
@@ -23,10 +23,79 @@ export class APIClientError extends Error {
23 }
24 }
25
26 -async function decodeEnvelope<T>(response: Response): Promise<APIEnvelope<T>> {
26 +function isRecord(value: unknown): value is Record<string, unknown> {
27 + return typeof value === "object" && value !== null && !Array.isArray(value);
28 +}
29 +
30 +function headersToObject(headers?: HeadersInit): Record<string, string> {
31 + if (!headers) {
32 + return {};
33 + }
34 + if (headers instanceof Headers) {
35 + return Object.fromEntries(headers.entries());
36 + }
37 + if (Array.isArray(headers)) {
38 + return Object.fromEntries(headers);
39 + }
40 + return { ...headers };
41 +}
42 +
43 +function ensureJsonEnvelope<T>(raw: unknown, path: string): APIEnvelope<T> {
44 + if (!isRecord(raw)) {
45 + throw new APIClientError(
46 + `Unexpected API response for ${path}: envelope is not an object`,
47 + 0,
48 + "invalid_envelope",
49 + raw
50 + );
51 + }
52 +
53 + const okValue = raw.ok;
54 + if (typeof okValue !== "boolean") {
55 + throw new APIClientError(
56 + `Unexpected API response for ${path}: missing ok flag`,
57 + 0,
58 + "invalid_envelope",
59 + raw
60 + );
61 + }
62 +
63 + const errorValue = raw.error;
64 + if (errorValue !== undefined && !isRecord(errorValue)) {
65 + throw new APIClientError(
66 + `Unexpected API response for ${path}: invalid error payload`,
67 + 0,
68 + "invalid_envelope",
69 + errorValue
70 + );
71 + }
72 +
73 + return {
74 + ok: okValue,
75 + data: (raw as { data?: T }).data,
76 + error: errorValue
77 + ? {
78 + code: typeof errorValue.code === "string" ? errorValue.code : "request_failed",
79 + message:
80 + typeof errorValue.message === "string"
81 + ? errorValue.message
82 + : "Request failed",
83 + }
84 + : undefined,
85 + };
86 +}
87 +
88 +async function decodeEnvelope<T>(path: string, response: Response): Promise<APIEnvelope<T>> {
89 const text = await response.text();
90 if (!text) {
29 - throw new APIClientError("Empty API response", response.status, "empty_response");
91 + if (response.ok) {
92 + return { ok: true };
93 + }
94 + throw new APIClientError(
95 + `Empty API response from ${path}`,
96 + response.status,
97 + "empty_response"
98 + );
99 }
100
101 let payload: unknown;
@@ -34,46 +103,69 @@ async function decodeEnvelope<T>(response: Response): Promise<APIEnvelope<T>> {
103 payload = JSON.parse(text);
104 } catch {
105 throw new APIClientError(
37 - "API returned non-JSON payload",
106 + `API response from ${path} was not valid JSON`,
107 response.status,
108 "invalid_json",
109 text
110 );
111 }
112
44 - if (
45 - typeof payload !== "object" ||
46 - payload === null ||
47 - !("ok" in payload) ||
48 - typeof (payload as { ok?: unknown }).ok !== "boolean"
49 - ) {
50 - throw new APIClientError(
51 - "API response did not match envelope format",
52 - response.status,
53 - "invalid_envelope",
54 - payload
55 - );
113 + if (isRecord(payload) && typeof payload.ok === "boolean") {
114 + return ensureJsonEnvelope<T>(payload, path);
115 + }
116 +
117 + if (response.ok) {
118 + return {
119 + ok: true,
120 + data: payload as T,
121 + };
122 }
123
58 - return payload as APIEnvelope<T>;
124 + throw new APIClientError(
125 + `Unexpected API response for ${path}: missing ok envelope`,
126 + response.status,
127 + "invalid_envelope",
128 + payload
129 + );
130 }
131
132 async function request<T>(path: string, init: RequestInit): Promise<T> {
62 - const response = await fetch(path, init);
63 - const envelope = await decodeEnvelope<T>(response);
133 + let response: Response;
134 + try {
135 + const requestHeaders = headersToObject(init.headers);
136 + response = await fetch(path, {
137 + credentials: "same-origin",
138 + ...init,
139 + headers: {
140 + Accept: "application/json",
141 + ...requestHeaders,
142 + },
143 + });
144 + } catch (error) {
145 + const isAbortError =
146 + error instanceof DOMException && error.name === "AbortError";
147 + throw new APIClientError(
148 + isAbortError ? "Request was aborted" : "Network request failed",
149 + 0,
150 + isAbortError ? "aborted" : "network_error",
151 + error
152 + );
153 + }
154
155 + const envelope = await decodeEnvelope<T>(path, response);
156 if (envelope.ok) {
157 return envelope.data as T;
158 }
159
69 - const message = envelope.error?.message?.trim() || "Request failed";
160 + const message =
161 + envelope.error?.message?.trim() || response.statusText || "Request failed";
162 const code = envelope.error?.code?.trim() || "request_failed";
163 throw new APIClientError(message, response.status, code, envelope.data);
164 }
165
166 function jsonRequestInit(method: "POST" | "DELETE", body?: unknown): RequestInit {
167 if (body === undefined) {
76 - return { method };
168 + return { method, headers: {} };
169 }
170
171 return {
cmd/relay-server/frontend/src/lib/apiPaths.ts
+2 -2
@@ -38,9 +38,9 @@ export function adminLeasePath(
38 encodedLeaseID: string,
39 action: "ban" | "bps" | "approve" | "deny"
40 ): string {
41 - return `${API_PATHS.admin.leases}/${encodedLeaseID}/${action}`;
41 + return `${API_PATHS.admin.leases}/${encodeURIComponent(encodedLeaseID.trim())}/${action}`;
42 }
43
44 export function adminIPBanPath(ip: string): string {
45 - return `${API_PATHS.admin.prefix}/ips/${ip}/ban`;
45 + return `${API_PATHS.admin.prefix}/ips/${encodeURIComponent(ip.trim())}/ban`;
46 }
cmd/relay-server/frontend/src/test/setup.ts new
+8
@@ -0,0 +1,8 @@
1 +import { cleanup } from "@testing-library/react";
2 +import { afterEach, vi } from "vitest";
3 +
4 +afterEach(() => {
5 + cleanup();
6 + vi.restoreAllMocks();
7 + vi.unstubAllGlobals();
8 +});
cmd/relay-server/frontend/vite.config.ts
+16 -1
@@ -2,7 +2,7 @@ import { defineConfig } from "vite";
2 import react from "@vitejs/plugin-react";
3 import tailwindcss from "@tailwindcss/vite";
4 import { resolve } from "path";
5 -import { renameSync } from "fs";
5 +import { existsSync, renameSync } from "fs";
6
7 // https://vitejs.dev/config/
8 export default defineConfig({
@@ -18,9 +18,18 @@ export default defineConfig({
18 {
19 name: "rename-index",
20 closeBundle() {
21 + if (process.env.VITEST) {
22 + return;
23 + }
24 +
25 const appDir = resolve(process.cwd(), "../dist/app");
26 const indexPath = resolve(appDir, "index.html");
27 const portalPath = resolve(appDir, "portal.html");
28 +
29 + if (!existsSync(indexPath)) {
30 + return;
31 + }
32 +
33 try {
34 renameSync(indexPath, portalPath);
35 console.log("✓ Renamed index.html to portal.html");
@@ -44,4 +53,10 @@ export default defineConfig({
53 },
54 },
55 },
56 + test: {
57 + globals: true,
58 + environment: "jsdom",
59 + setupFiles: "./src/test/setup.ts",
60 + include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
61 + },
62 });