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
});