diff --git a/docs/docs.go b/docs/docs.go
index 9c4cde39..ceb1a18e 100644
--- a/docs/docs.go
+++ b/docs/docs.go
@@ -10849,7 +10849,7 @@ const docTemplate = `{
"data": {
"type": "array",
"items": {
- "$ref": "#/definitions/model.Zone"
+ "$ref": "#/definitions/zone.ListItem"
}
}
}
@@ -16857,36 +16857,12 @@ const docTemplate = `{
"type": "string"
}
},
- "cert_id": {
- "type": "integer"
- },
- "cert_ids": {
- "type": "array",
- "items": {
- "type": "integer"
- }
- },
"custom_headers": {
"type": "array",
"items": {
"$ref": "#/definitions/proxy_route.CustomHeaderInput"
}
},
- "domain": {
- "type": "string"
- },
- "domain_cert_ids": {
- "type": "array",
- "items": {
- "type": "integer"
- }
- },
- "domains": {
- "type": "array",
- "items": {
- "type": "string"
- }
- },
"enable_https": {
"type": "boolean"
},
@@ -16955,6 +16931,12 @@ const docTemplate = `{
"items": {
"type": "string"
}
+ },
+ "zone_domain_ids": {
+ "type": "array",
+ "items": {
+ "type": "integer"
+ }
}
}
},
@@ -16985,15 +16967,6 @@ const docTemplate = `{
"cache_rules": {
"type": "string"
},
- "cert_id": {
- "type": "integer"
- },
- "cert_ids": {
- "type": "array",
- "items": {
- "type": "integer"
- }
- },
"created_at": {
"type": "string"
},
@@ -17006,24 +16979,6 @@ const docTemplate = `{
"custom_headers": {
"type": "string"
},
- "domain": {
- "type": "string"
- },
- "domain_cert_ids": {
- "type": "array",
- "items": {
- "type": "integer"
- }
- },
- "domain_count": {
- "type": "integer"
- },
- "domains": {
- "type": "array",
- "items": {
- "type": "string"
- }
- },
"enable_https": {
"type": "boolean"
},
@@ -17054,9 +17009,6 @@ const docTemplate = `{
"pages_project_id": {
"type": "integer"
},
- "primary_domain": {
- "type": "string"
- },
"redirect_http": {
"type": "boolean"
},
@@ -17092,6 +17044,35 @@ const docTemplate = `{
},
"upstreams": {
"type": "string"
+ },
+ "zone_domain_ids": {
+ "type": "array",
+ "items": {
+ "type": "integer"
+ }
+ },
+ "zone_domains": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/proxy_route.ZoneDomainView"
+ }
+ }
+ }
+ },
+ "proxy_route.ZoneDomainView": {
+ "type": "object",
+ "properties": {
+ "cert_id": {
+ "type": "integer"
+ },
+ "domain": {
+ "type": "string"
+ },
+ "id": {
+ "type": "integer"
+ },
+ "zone_id": {
+ "type": "integer"
}
}
},
@@ -18799,6 +18780,29 @@ const docTemplate = `{
}
}
},
+ "zone.ListItem": {
+ "type": "object",
+ "properties": {
+ "created_at": {
+ "type": "string"
+ },
+ "domain": {
+ "type": "string"
+ },
+ "domain_count": {
+ "type": "integer"
+ },
+ "id": {
+ "type": "integer"
+ },
+ "remark": {
+ "type": "string"
+ },
+ "updated_at": {
+ "type": "string"
+ }
+ }
+ },
"zone.Overview": {
"type": "object",
"properties": {
diff --git a/docs/plan/20260712-zone-domain-refactor.md b/docs/plan/20260712-zone-domain-refactor.md
index 4ed96af4..0912d32a 100644
--- a/docs/plan/20260712-zone-domain-refactor.md
+++ b/docs/plan/20260712-zone-domain-refactor.md
@@ -52,7 +52,7 @@
- Produces: `model.Zone`, `model.ZoneDomain`, `ListZoneDomainsByRouteID(ctx, routeID)`, `ReplaceZoneDomainRouteBindings(ctx, routeID, domainIDs)`.
- Consumes: existing `model.ProxyRoute` and `model.TLSCertificate` IDs; no physical FK.
-- [ ] **Step 1: 写失败的模型与迁移测试**
+- [x] **Step 1: 写失败的模型与迁移测试**
```go
func TestReplaceZoneDomainRouteBindingsRejectsForeignDomain(t *testing.T) {
@@ -64,7 +64,7 @@ Run: `go test ./internal/model ./internal/db/migrator -run 'Zone|Migrat' -count=
Expected: FAIL,因为 Zone 模型和 goose 文件尚不存在。
-- [ ] **Step 2: 新建双方言 DDL**
+- [x] **Step 2: 新建双方言 DDL**
```sql
CREATE TABLE IF NOT EXISTS of_zones (
@@ -94,7 +94,7 @@ CREATE INDEX IF NOT EXISTS idx_of_zone_domains_cert_id ON of_zone_domains (cert_
SQLite 使用 `INTEGER PRIMARY KEY AUTOINCREMENT`、`DATETIME`,字段/索引语义完全对齐。此任务不得删除旧列或旧表。
-- [ ] **Step 3: 实现模型和受事务保护的绑定替换**
+- [x] **Step 3: 实现模型和受事务保护的绑定替换**
```go
type Zone struct { ID uint; Domain string; Remark string; CreatedAt time.Time; UpdatedAt time.Time }
@@ -105,13 +105,13 @@ func ReplaceZoneDomainRouteBindings(ctx context.Context, routeID uint, domainIDs
实现先锁定/读取请求域名,拒绝已绑定到其他路由的记录,再把当前路由已绑定但不在 `domainIDs` 的记录置空,最后将请求记录写为 `routeID`;所有动作放在同一 `db.DB(ctx).Transaction` 内。
-- [ ] **Step 4: 运行模型与迁移测试**
+- [x] **Step 4: 运行模型与迁移测试**
Run: `go test ./internal/model ./internal/db/migrator -run 'Zone|Migrat' -count=1`
Expected: PASS,空 SQLite 库可应用迁移,唯一域名和绑定排他性受保护。
-- [ ] **Step 5: Commit**
+- [x] **Step 5: Commit**
```bash
git add internal/db/migrator/goose internal/model
@@ -132,7 +132,7 @@ git commit -m "feat(zone): add normalized zone domain schema"
- Produces: `zone.Create`, `zone.Update`, `zone.GetOverview`, `zone.ImportLegacy(ctx) (ImportReport, error)` and Zone REST handlers.
- Consumes: Task 1 models; legacy `managed_domains` and proxy-route columns only inside `ImportLegacy`.
-- [ ] **Step 1: 写失败的逻辑与 API 测试**
+- [x] **Step 1: 写失败的逻辑与 API 测试**
```go
func TestCreateZoneDomainRejectsWildcard(t *testing.T) { _, err := CreateDomain(ctx, zoneID, DomainInput{Domain: "*.example.com"}); require.EqualError(t, err, errDomainWildcardUnsupported) }
@@ -145,7 +145,7 @@ func TestLegacyImportUsesEffectiveTLDPlusOne(t *testing.T) {
集成测试请求 `POST /api/v1/d/zones/`、`POST /api/v1/d/zones/:id/domains`,并断言错误响应使用 400 信封。
-- [ ] **Step 2: 实现精确域名和 Zone 归属验证**
+- [x] **Step 2: 实现精确域名和 Zone 归属验证**
```go
func zoneRoot(domain string) (string, error) { return publicsuffix.EffectiveTLDPlusOne(strings.ToLower(strings.TrimSpace(domain))) }
@@ -154,7 +154,7 @@ func CreateDomain(ctx context.Context, zoneID uint, input DomainInput) (*model.Z
拒绝空值、协议、路径和 `*`;要求 `zoneRoot(input.Domain) == zone.Domain`;若 `cert_id` 非空,验证 TLS 证书存在。Zone 根域创建也必须经 `EffectiveTLDPlusOne` 验证且输入等于结果。
-- [ ] **Step 3: 实现显式导入命令**
+- [x] **Step 3: 实现显式导入命令**
```go
var migrateZonesCmd = &cobra.Command{Use: "migrate-zones", RunE: func(_ *cobra.Command, _ []string) error {
@@ -165,7 +165,7 @@ var migrateZonesCmd = &cobra.Command{Use: "migrate-zones", RunE: func(_ *cobra.C
导入以事务执行:用 `routeidentity.DecodeDomains(route.Domains, route.Domain)` 读取旧路由;按 `domain_cert_ids` 的同一索引写入 `zone_domains.cert_id`;只在无路由域名时导入旧 `managed_domains`。发现无效根域、通配符记录或全局域名冲突时回滚并输出全部冲突项。重复执行不得生成重复 Zone/ZoneDomain。
-- [ ] **Step 4: 注册 API 并删除旧 managed-domain 路由**
+- [x] **Step 4: 注册 API 并删除旧 managed-domain 路由**
```go
zoneGroup := apiGroup.Group("/zones")
@@ -177,7 +177,7 @@ zoneGroup.GET("/:id/overview", zone.GetOverviewHandler)
把 `managed-domains` 路由块从 `register_tls.go` 移除;每个 Handler 使用 `apiutil.BindJSON` 和 `response.AbortBadRequest/AbortNotFound/AbortConflict`。
-- [ ] **Step 5: 验证并 Commit**
+- [x] **Step 5: 验证并 Commit**
Run: `go test ./internal/apps/openflare/zone ./internal/apps/openflare/integration -count=1 && make swagger`
@@ -200,7 +200,7 @@ git commit -m "feat(zone): add zone management api and legacy importer"
- Consumes: `zone_domain_ids []uint` and Task 1 binding API.
- Produces: `proxy_route.Input{ZoneDomainIDs []uint}`, `proxy_route.View{ZoneDomains []ZoneDomainView}`.
-- [ ] **Step 1: 写失败的路由逻辑测试**
+- [x] **Step 1: 写失败的路由逻辑测试**
```go
input := Input{SiteName: "api", ZoneDomainIDs: []uint{domainA.ID, domainB.ID}, EnableHTTPS: true}
@@ -211,7 +211,7 @@ require.Equal(t, []uint{domainA.ID, domainB.ID}, view.ZoneDomainIDs)
同时覆盖:空 `zone_domain_ids`、重复 ID、其他路由已占用域名、HTTPS 域名无证书、证书 SAN 不覆盖。
-- [ ] **Step 2: 删除路由输入/视图中的旧域名与证书字段**
+- [x] **Step 2: 删除路由输入/视图中的旧域名与证书字段**
```go
type ZoneDomainBindingInput struct {
@@ -222,17 +222,17 @@ type ZoneDomainView struct { ID uint `json:"id"`; ZoneID uint `json:"zone_id"`;
移除 `Input.Domain`、`Input.Domains`、`Input.CertID`、`Input.CertIDs`、`Input.DomainCertIDs` 及对应 View 字段;删除旧证书派生辅助函数与 `WebsiteService.match` 所需后端逻辑。
-- [ ] **Step 3: 用关联记录验证并构建路由**
+- [x] **Step 3: 用关联记录验证并构建路由**
在 `buildProxyRoute` 中读取所有 `ZoneDomainIDs`,对每个 HTTPS 域名调用现有 `validateCertificateCoverage`,再调用 `ReplaceZoneDomainRouteBindings`。更新/删除路由也必须在事务内同步解除关联。来源、证书删除检查和 Origin 路由摘要改从 `zone_domains` 查询域名/证书。
-- [ ] **Step 4: 运行路由和 TLS 回归测试**
+- [x] **Step 4: 运行路由和 TLS 回归测试**
Run: `go test ./internal/apps/openflare/proxy_route ./internal/apps/openflare/tls ./internal/apps/openflare/origin -count=1`
Expected: PASS;任一证书已被 Zone 域名引用时,删除证书被拒绝。
-- [ ] **Step 5: Commit**
+- [x] **Step 5: Commit**
```bash
git add internal/apps/openflare/proxy_route internal/apps/openflare/tls internal/apps/openflare/origin internal/model
@@ -250,7 +250,7 @@ git commit -m "refactor(proxy): bind routes through zone domains"
**Interfaces:**
- Produces: snapshot/render `Route{SiteName, Domains, DomainCertIDs}` built transiently from ZoneDomain rows; neither DB model nor API stores those fields.
-- [ ] **Step 1: 写快照等价性失败测试**
+- [x] **Step 1: 写快照等价性失败测试**
```go
func TestBuildSnapshotReadsZoneDomainCertificates(t *testing.T) {
@@ -260,7 +260,7 @@ func TestBuildSnapshotReadsZoneDomainCertificates(t *testing.T) {
加入 Pages、Tunnel、WAF 绑定测试,断言 Route ID 与 `site_name` 未改变。
-- [ ] **Step 2: 在快照边界联查并生成临时渲染字段**
+- [x] **Step 2: 在快照边界联查并生成临时渲染字段**
```go
domains, err := model.ListZoneDomainsByRouteID(ctx, route.ID)
@@ -270,17 +270,17 @@ snapshotRoute.DomainCertIDs = certIDsInDomainOrder(domains)
`pkg/render/openresty.Route` 可继续保留 `Domains` 与 `DomainCertIDs`,因为它是不可变配置快照的渲染输入;移除其中持久化主域/证书回退逻辑,所有错误消息改用 `SiteName`。
-- [ ] **Step 3: 移除旧字段回退路径**
+- [x] **Step 3: 移除旧字段回退路径**
删除 `routeidentity.DecodeDomains` 对持久化 `route.Domain` 的依赖;Flared、Uptime Kuma、配置 diff、WAF 文档和 Pages 错误信息都从 snapshot/ZoneDomain 查询的明确域名获取显示文本。
-- [ ] **Step 4: 运行数据面测试**
+- [x] **Step 4: 运行数据面测试**
Run: `go test ./internal/apps/openflare/config_version ./pkg/render/openresty ./internal/apps/openflare/flared ./internal/apps/openflare/uptimekuma -count=1`
Expected: PASS;迁移后的路由产生的 `server_name`、证书支持文件和 WAF RouteID 绑定与迁移前一致。
-- [ ] **Step 5: Commit**
+- [x] **Step 5: Commit**
```bash
git add internal/apps/openflare/config_version internal/apps/openflare/flared internal/apps/openflare/uptimekuma internal/apps/openflare/routeidentity pkg/render/openresty
@@ -305,7 +305,7 @@ git commit -m "refactor(config): render routes from zone domains"
**Interfaces:**
- Produces: `ZoneService.list/getOverview/create/update/delete`, `ZoneDomainService.create/update/delete` and `ZoneOverview` TypeScript types.
-- [ ] **Step 1: 写服务与页面行为测试**
+- [x] **Step 1: 写服务与页面行为测试**
先安装仅用于本次页面测试的开发依赖:
@@ -320,7 +320,7 @@ expect(screen.getByRole('heading', {name: 'arctel.de'})).toBeVisible()
覆盖 `/websites/42` 的加载、404、空域名、搜索列表和从列表点击 ID 链接。
-- [ ] **Step 2: 实现类型化服务与查询键**
+- [x] **Step 2: 实现类型化服务与查询键**
```ts
export interface ZoneDomainItem { id: number; zone_id: number; proxy_route_id: number | null; domain: string; cert_id: number | null; remark: string }
@@ -330,7 +330,7 @@ export const zoneQueryKey = ['openflare', 'zones'] as const
所有 React Query 回调使用箭头函数,避免静态 service `this` 丢失。
-- [ ] **Step 3: 用 Next 动态段实现 Zone 详情**
+- [x] **Step 3: 用 Next 动态段实现 Zone 详情**
```tsx
export default async function ZonePage({params}: PageProps<'/websites/[zoneId]'>) {
@@ -341,11 +341,11 @@ export default async function ZonePage({params}: PageProps<'/websites/[zoneId]'>
遵循本地 Next 文档:动态 `params` 是 Promise;无效或非正整数 ID 显示既有 `EmptyStateWithBorder`,不把域名写入 URL。主页面只维护页面骨架和 Tabs,具体 Tab 放入同目录组件。
-- [ ] **Step 4: 实现列表和详情交互**
+- [x] **Step 4: 实现列表和详情交互**
列表仅渲染 Zone 根域及计数;详情使用概览、域名、路由、证书、设置 Tabs。域名弹窗拒绝 `*.`,但证书选择器不限制其 SAN。删除 Zone/域名使用确认对话框和服务端错误文案。
-- [ ] **Step 5: 验证并 Commit**
+- [x] **Step 5: 验证并 Commit**
Run: `cd frontend && pnpm exec vitest run && pnpm lint`
@@ -370,7 +370,7 @@ git commit -m "feat(web): add zone-based website management"
- Consumes: `ZoneDomainItem[]` and route `zone_domain_ids: number[]`.
- Produces: selector values with explicit domain/Zone/证书信息;不发送任何旧域名或证书字段。
-- [ ] **Step 1: 写失败的选择器测试**
+- [x] **Step 1: 写失败的选择器测试**
```tsx
render()
@@ -380,11 +380,11 @@ expect(onChange).toHaveBeenCalledWith([7])
覆盖搜索、跨 Zone 多选、已被其他路由占用的禁用项和 HTTPS 缺少证书的表单错误。
-- [ ] **Step 2: 移除旧前端负载与自动匹配**
+- [x] **Step 2: 移除旧前端负载与自动匹配**
从 `ProxyRouteItem`/`ProxyRouteMutationPayload` 删除 `domain`、`domains`、`primary_domain`、`cert_id`、`cert_ids`、`domain_cert_ids`;删除 `WebsiteService.match` 及 `DomainListInput` 自动填证书交互。
-- [ ] **Step 3: 实现 Zone 域名选择和保存负载**
+- [x] **Step 3: 实现 Zone 域名选择和保存负载**
```ts
mutationFn: (payload) => ProxyRouteService.update(route.id, {
@@ -395,13 +395,13 @@ mutationFn: (payload) => ProxyRouteService.update(route.id, {
展示每个选择项的 FQDN、所属 Zone 与证书;路由详情的“域名”区只编辑关联关系,证书链接跳转 Zone 详情而非路由内编辑。
-- [ ] **Step 4: 运行前端类型和交互测试**
+- [x] **Step 4: 运行前端类型和交互测试**
Run: `cd frontend && pnpm exec tsc --noEmit`
Expected: PASS;不存在旧持久化域名/证书字段的 TypeScript 引用。
-- [ ] **Step 5: Commit**
+- [x] **Step 5: Commit**
```bash
git add frontend/app/'(main)'/proxy-routes frontend/lib/services/openflare/types.ts
diff --git a/docs/swagger.json b/docs/swagger.json
index 2f43e071..a98e7191 100644
--- a/docs/swagger.json
+++ b/docs/swagger.json
@@ -10842,7 +10842,7 @@
"data": {
"type": "array",
"items": {
- "$ref": "#/definitions/model.Zone"
+ "$ref": "#/definitions/zone.ListItem"
}
}
}
@@ -16850,36 +16850,12 @@
"type": "string"
}
},
- "cert_id": {
- "type": "integer"
- },
- "cert_ids": {
- "type": "array",
- "items": {
- "type": "integer"
- }
- },
"custom_headers": {
"type": "array",
"items": {
"$ref": "#/definitions/proxy_route.CustomHeaderInput"
}
},
- "domain": {
- "type": "string"
- },
- "domain_cert_ids": {
- "type": "array",
- "items": {
- "type": "integer"
- }
- },
- "domains": {
- "type": "array",
- "items": {
- "type": "string"
- }
- },
"enable_https": {
"type": "boolean"
},
@@ -16948,6 +16924,12 @@
"items": {
"type": "string"
}
+ },
+ "zone_domain_ids": {
+ "type": "array",
+ "items": {
+ "type": "integer"
+ }
}
}
},
@@ -16978,15 +16960,6 @@
"cache_rules": {
"type": "string"
},
- "cert_id": {
- "type": "integer"
- },
- "cert_ids": {
- "type": "array",
- "items": {
- "type": "integer"
- }
- },
"created_at": {
"type": "string"
},
@@ -16999,24 +16972,6 @@
"custom_headers": {
"type": "string"
},
- "domain": {
- "type": "string"
- },
- "domain_cert_ids": {
- "type": "array",
- "items": {
- "type": "integer"
- }
- },
- "domain_count": {
- "type": "integer"
- },
- "domains": {
- "type": "array",
- "items": {
- "type": "string"
- }
- },
"enable_https": {
"type": "boolean"
},
@@ -17047,9 +17002,6 @@
"pages_project_id": {
"type": "integer"
},
- "primary_domain": {
- "type": "string"
- },
"redirect_http": {
"type": "boolean"
},
@@ -17085,6 +17037,35 @@
},
"upstreams": {
"type": "string"
+ },
+ "zone_domain_ids": {
+ "type": "array",
+ "items": {
+ "type": "integer"
+ }
+ },
+ "zone_domains": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/proxy_route.ZoneDomainView"
+ }
+ }
+ }
+ },
+ "proxy_route.ZoneDomainView": {
+ "type": "object",
+ "properties": {
+ "cert_id": {
+ "type": "integer"
+ },
+ "domain": {
+ "type": "string"
+ },
+ "id": {
+ "type": "integer"
+ },
+ "zone_id": {
+ "type": "integer"
}
}
},
@@ -18792,6 +18773,29 @@
}
}
},
+ "zone.ListItem": {
+ "type": "object",
+ "properties": {
+ "created_at": {
+ "type": "string"
+ },
+ "domain": {
+ "type": "string"
+ },
+ "domain_count": {
+ "type": "integer"
+ },
+ "id": {
+ "type": "integer"
+ },
+ "remark": {
+ "type": "string"
+ },
+ "updated_at": {
+ "type": "string"
+ }
+ }
+ },
"zone.Overview": {
"type": "object",
"properties": {
diff --git a/docs/swagger.yaml b/docs/swagger.yaml
index e2eed193..f2795d8d 100644
--- a/docs/swagger.yaml
+++ b/docs/swagger.yaml
@@ -2727,26 +2727,10 @@ definitions:
items:
type: string
type: array
- cert_id:
- type: integer
- cert_ids:
- items:
- type: integer
- type: array
custom_headers:
items:
$ref: '#/definitions/proxy_route.CustomHeaderInput'
type: array
- domain:
- type: string
- domain_cert_ids:
- items:
- type: integer
- type: array
- domains:
- items:
- type: string
- type: array
enable_https:
type: boolean
enabled:
@@ -2793,6 +2777,10 @@ definitions:
items:
type: string
type: array
+ zone_domain_ids:
+ items:
+ type: integer
+ type: array
type: object
proxy_route.View:
properties:
@@ -2812,12 +2800,6 @@ definitions:
type: array
cache_rules:
type: string
- cert_id:
- type: integer
- cert_ids:
- items:
- type: integer
- type: array
created_at:
type: string
custom_header_list:
@@ -2826,18 +2808,6 @@ definitions:
type: array
custom_headers:
type: string
- domain:
- type: string
- domain_cert_ids:
- items:
- type: integer
- type: array
- domain_count:
- type: integer
- domains:
- items:
- type: string
- type: array
enable_https:
type: boolean
enabled:
@@ -2858,8 +2828,6 @@ definitions:
type: string
pages_project_id:
type: integer
- primary_domain:
- type: string
redirect_http:
type: boolean
remark:
@@ -2884,6 +2852,25 @@ definitions:
type: string
upstreams:
type: string
+ zone_domain_ids:
+ items:
+ type: integer
+ type: array
+ zone_domains:
+ items:
+ $ref: '#/definitions/proxy_route.ZoneDomainView'
+ type: array
+ type: object
+ proxy_route.ZoneDomainView:
+ properties:
+ cert_id:
+ type: integer
+ domain:
+ type: string
+ id:
+ type: integer
+ zone_id:
+ type: integer
type: object
push.Config:
properties:
@@ -4026,6 +4013,21 @@ definitions:
remark:
type: string
type: object
+ zone.ListItem:
+ properties:
+ created_at:
+ type: string
+ domain:
+ type: string
+ domain_count:
+ type: integer
+ id:
+ type: integer
+ remark:
+ type: string
+ updated_at:
+ type: string
+ type: object
zone.Overview:
properties:
domains:
@@ -10555,7 +10557,7 @@ paths:
- properties:
data:
items:
- $ref: '#/definitions/model.Zone'
+ $ref: '#/definitions/zone.ListItem'
type: array
type: object
security:
diff --git a/frontend/app/(main)/websites/[zoneId]/components/zone-certificates.tsx b/frontend/app/(main)/websites/[zoneId]/components/zone-certificates.tsx
new file mode 100644
index 00000000..3c8fee49
--- /dev/null
+++ b/frontend/app/(main)/websites/[zoneId]/components/zone-certificates.tsx
@@ -0,0 +1,131 @@
+'use client';
+
+import Link from 'next/link';
+import {useMemo} from 'react';
+import {ExternalLink, FileKey} from 'lucide-react';
+
+import {EmptyStateWithBorder} from '@/components/layout/empty';
+import {Badge} from '@/components/ui/badge';
+import {Button} from '@/components/ui/button';
+import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
+import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@/components/ui/table';
+import type {TlsCertificateItem, ZoneDomainItem} from '@/lib/services/openflare';
+import {formatDateTime} from '@/lib/utils';
+
+import {getCertificateStatus} from '../../components/website-utils';
+
+export function ZoneCertificatesPanel({
+ domains,
+ certificates,
+}: {
+ domains: ZoneDomainItem[];
+ certificates: TlsCertificateItem[];
+}) {
+ const certificateMap = useMemo(
+ () => new Map(certificates.map((certificate) => [certificate.id, certificate])),
+ [certificates],
+ );
+
+ const boundCertificates = useMemo(() => {
+ const rows = new Map<
+ number,
+ {certificate: TlsCertificateItem; domains: ZoneDomainItem[]}
+ >();
+
+ for (const domain of domains) {
+ if (domain.cert_id == null) {
+ continue;
+ }
+ const certificate = certificateMap.get(domain.cert_id);
+ if (!certificate) {
+ continue;
+ }
+ const existing = rows.get(certificate.id);
+ if (existing) {
+ existing.domains.push(domain);
+ } else {
+ rows.set(certificate.id, {certificate, domains: [domain]});
+ }
+ }
+
+ return Array.from(rows.values()).sort((left, right) =>
+ left.certificate.name.localeCompare(right.certificate.name),
+ );
+ }, [certificateMap, domains]);
+
+ const unboundCount = domains.filter((domain) => domain.cert_id == null).length;
+
+ return (
+
+
+
+
+ 本 Zone 使用的证书
+
+ 证书在全局证书库管理;此处仅展示已绑定到本 Zone 域名的证书。
+
+
+
+
+
+ {unboundCount > 0 ? (
+
+ 还有 {unboundCount} 个域名未绑定证书,可在「域名」Tab 中选择证书。
+
+ ) : null}
+
+ {boundCertificates.length === 0 ? (
+
+ ) : (
+
+
+
+
+ 证书
+ 状态
+ 覆盖域名
+ 到期时间
+
+
+
+ {boundCertificates.map(({certificate, domains: boundDomains}) => {
+ const status = getCertificateStatus(certificate);
+ return (
+
+
+ {certificate.name}
+
+ {certificate.primary_domain || `证书 #${certificate.id}`}
+
+
+
+
+ {status.label}
+
+
+
+ {boundDomains.map((domain) => domain.domain).join(' · ')}
+
+
+ {formatDateTime(certificate.not_after)}
+
+
+ );
+ })}
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/app/(main)/websites/[zoneId]/components/zone-domain-dialog.tsx b/frontend/app/(main)/websites/[zoneId]/components/zone-domain-dialog.tsx
new file mode 100644
index 00000000..b9ceda97
--- /dev/null
+++ b/frontend/app/(main)/websites/[zoneId]/components/zone-domain-dialog.tsx
@@ -0,0 +1,26 @@
+'use client'
+
+import {useEffect} from 'react'
+import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
+import {zodResolver} from '@hookform/resolvers/zod'
+import {useForm} from 'react-hook-form'
+import {Loader2} from 'lucide-react'
+import {toast} from 'sonner'
+import {z} from 'zod'
+import {Button} from '@/components/ui/button'
+import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog'
+import {Input} from '@/components/ui/input'
+import {Label} from '@/components/ui/label'
+import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'
+import {Textarea} from '@/components/ui/textarea'
+import {TlsCertificateService, ZoneDomainService, type ZoneDomainItem} from '@/lib/services/openflare'
+
+const schema = z.object({domain: z.string().trim().min(1, '请输入完整域名').refine((value) => !value.includes('*.'), 'Zone 域名不支持通配符'), cert_id: z.string(), remark: z.string().max(255)})
+type Values = z.infer
+export function ZoneDomainDialog({open, onOpenChange, zoneId, domain, onSaved}: {open: boolean; onOpenChange(open: boolean): void; zoneId: number; domain?: ZoneDomainItem | null; onSaved(): Promise | void}) {
+ const form = useForm({resolver: zodResolver(schema), defaultValues: {domain: '', cert_id: '', remark: ''}}); const queryClient = useQueryClient()
+ const certificatesQuery = useQuery({queryKey: ['openflare', 'tls-certificates'], queryFn: () => TlsCertificateService.list(), enabled: open})
+ useEffect(() => { if (open) form.reset({domain: domain?.domain ?? '', cert_id: domain?.cert_id?.toString() ?? '', remark: domain?.remark ?? ''}) }, [domain, form, open])
+ const mutation = useMutation({mutationFn: (values: Values) => {const payload = {domain: values.domain.toLowerCase(), cert_id: values.cert_id ? Number(values.cert_id) : null, remark: values.remark.trim()}; return domain ? ZoneDomainService.update(zoneId, domain.id, payload) : ZoneDomainService.create(zoneId, payload)}, onSuccess: async () => {toast.success(domain ? '域名已更新' : '域名已添加'); await Promise.all([onSaved(), queryClient.invalidateQueries({queryKey: ['openflare', 'zones']})]); onOpenChange(false)}, onError: (error) => toast.error(error instanceof Error ? error.message : '保存失败')})
+ return
+}
diff --git a/frontend/app/(main)/websites/[zoneId]/components/zone-domains-table.tsx b/frontend/app/(main)/websites/[zoneId]/components/zone-domains-table.tsx
new file mode 100644
index 00000000..de8d468a
--- /dev/null
+++ b/frontend/app/(main)/websites/[zoneId]/components/zone-domains-table.tsx
@@ -0,0 +1,174 @@
+'use client';
+
+import {useState} from 'react';
+import {useMutation} from '@tanstack/react-query';
+import {MoreHorizontal, Pencil, Plus, Trash2} from 'lucide-react';
+import {toast} from 'sonner';
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog';
+import {Button} from '@/components/ui/button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import {EmptyStateWithBorder} from '@/components/layout/empty';
+import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@/components/ui/table';
+import {ZoneDomainService, type TlsCertificateItem, type ZoneDomainItem} from '@/lib/services/openflare';
+
+import {ZoneDomainDialog} from './zone-domain-dialog';
+
+export function ZoneDomainsTable({
+ zoneId,
+ domains,
+ certificates,
+ onChanged,
+}: {
+ zoneId: number;
+ domains: ZoneDomainItem[];
+ certificates: TlsCertificateItem[];
+ onChanged(): Promise | void;
+}) {
+ const [editing, setEditing] = useState(undefined);
+ const [deleting, setDeleting] = useState(null);
+
+ const remove = useMutation({
+ mutationFn: (id: number) => ZoneDomainService.deleteById(zoneId, id),
+ onSuccess: async () => {
+ toast.success('域名已删除');
+ setDeleting(null);
+ await onChanged();
+ },
+ onError: (error) => toast.error(error instanceof Error ? error.message : '删除失败'),
+ });
+
+ const certificateMap = new Map(certificates.map((certificate) => [certificate.id, certificate]));
+
+ return (
+ <>
+
+
+
+
+ {domains.length === 0 ? (
+
+ ) : (
+
+
+
+
+ FQDN
+ 证书
+ 关联路由
+ 备注
+
+
+
+
+ {domains.map((domain) => (
+
+ {domain.domain}
+
+ {domain.cert_id
+ ? (certificateMap.get(domain.cert_id)?.name ?? `证书 #${domain.cert_id}`)
+ : '未绑定'}
+
+
+ {domain.proxy_route_id ? `#${domain.proxy_route_id}` : '未关联'}
+
+
+ {domain.remark || '—'}
+
+
+
+
+
+
+
+ setEditing(domain)}>
+
+ 编辑
+
+ setDeleting(domain)}
+ >
+
+ 删除
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+ {
+ if (!open) {
+ setEditing(undefined);
+ }
+ }}
+ zoneId={zoneId}
+ domain={editing ?? null}
+ onSaved={onChanged}
+ />
+
+ {
+ if (!open) {
+ setDeleting(null);
+ }
+ }}
+ >
+
+
+ 确认删除域名
+
+ 确认删除 {deleting?.domain} 吗?此操作不可撤销。
+
+
+
+ 取消
+ {
+ event.preventDefault();
+ if (deleting) {
+ remove.mutate(deleting.id);
+ }
+ }}
+ >
+ {remove.isPending ? '删除中…' : '确认删除'}
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/app/(main)/websites/[zoneId]/components/zone-editor-dialog.tsx b/frontend/app/(main)/websites/[zoneId]/components/zone-editor-dialog.tsx
new file mode 100644
index 00000000..6d40ead9
--- /dev/null
+++ b/frontend/app/(main)/websites/[zoneId]/components/zone-editor-dialog.tsx
@@ -0,0 +1,25 @@
+'use client'
+
+import {useEffect} from 'react'
+import {useMutation, useQueryClient} from '@tanstack/react-query'
+import {zodResolver} from '@hookform/resolvers/zod'
+import {useForm} from 'react-hook-form'
+import {Loader2} from 'lucide-react'
+import {toast} from 'sonner'
+import {z} from 'zod'
+import {Button} from '@/components/ui/button'
+import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog'
+import {Input} from '@/components/ui/input'
+import {Label} from '@/components/ui/label'
+import {Textarea} from '@/components/ui/textarea'
+import {ZoneService, zoneQueryKey, type ZoneItem} from '@/lib/services/openflare'
+
+const schema = z.object({domain: z.string().trim().min(1, '请输入 Zone 根域').refine((value) => !/[*/?#@]|:\/\//.test(value), '请输入不含协议或通配符的根域'), remark: z.string().max(255)})
+type Values = z.infer
+
+export function ZoneEditorDialog({open, onOpenChange, zone}: {open: boolean; onOpenChange(open: boolean): void; zone?: ZoneItem | null}) {
+ const queryClient = useQueryClient(); const form = useForm({resolver: zodResolver(schema), defaultValues: {domain: '', remark: ''}})
+ useEffect(() => { if (open) form.reset({domain: zone?.domain ?? '', remark: zone?.remark ?? ''}) }, [form, open, zone])
+ const mutation = useMutation({mutationFn: (values: Values) => zone ? ZoneService.update(zone.id, values) : ZoneService.create(values), onSuccess: async () => { toast.success(zone ? 'Zone 已更新' : 'Zone 已创建'); await queryClient.invalidateQueries({queryKey: zoneQueryKey}); onOpenChange(false) }, onError: (error) => toast.error(error instanceof Error ? error.message : '保存失败')})
+ return
+}
diff --git a/frontend/app/(main)/websites/[zoneId]/components/zone-overview.tsx b/frontend/app/(main)/websites/[zoneId]/components/zone-overview.tsx
new file mode 100644
index 00000000..8892a1a5
--- /dev/null
+++ b/frontend/app/(main)/websites/[zoneId]/components/zone-overview.tsx
@@ -0,0 +1,14 @@
+import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card'
+import type {ZoneOverview} from '@/lib/services/openflare'
+import {formatDateTime} from '@/lib/utils'
+
+export function ZoneOverviewPanel({overview}: {overview: ZoneOverview}) {
+ const boundRoutes = new Set(overview.domains.flatMap((domain) => domain.proxy_route_id ? [domain.proxy_route_id] : [])).size
+ const certificates = overview.domains.filter((domain) => domain.cert_id !== null).length
+ return
+
域名显式 FQDN{overview.domains.length}
+
关联路由已绑定的不同规则{boundRoutes}
+
已配证书域名级 TLS 绑定{certificates}
+
Zone 信息创建时间
{formatDateTime(overview.zone.created_at)}
备注
{overview.zone.remark || '暂无备注'}
+
+}
diff --git a/frontend/app/(main)/websites/[zoneId]/components/zone-route-summary.tsx b/frontend/app/(main)/websites/[zoneId]/components/zone-route-summary.tsx
new file mode 100644
index 00000000..44a5e444
--- /dev/null
+++ b/frontend/app/(main)/websites/[zoneId]/components/zone-route-summary.tsx
@@ -0,0 +1,9 @@
+import {EmptyStateWithBorder} from '@/components/layout/empty'
+import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card'
+import type {ZoneDomainItem} from '@/lib/services/openflare'
+import {Route} from 'lucide-react'
+
+export function ZoneRouteSummary({domains}: {domains: ZoneDomainItem[]}) {
+ const groups = Map.groupBy(domains.filter((domain) => domain.proxy_route_id !== null), (domain) => domain.proxy_route_id!)
+ return 路由关联域名只能关联一个反代路由;路由可关联多个 Zone 域名。{groups.size === 0 ? : {[...groups].map(([routeID, items]) =>
路由 #{routeID}
{items.map((item) => item.domain).join(' · ')}
)}
}
+}
diff --git a/frontend/app/(main)/websites/[zoneId]/page-client.tsx b/frontend/app/(main)/websites/[zoneId]/page-client.tsx
new file mode 100644
index 00000000..92dd676a
--- /dev/null
+++ b/frontend/app/(main)/websites/[zoneId]/page-client.tsx
@@ -0,0 +1,226 @@
+'use client';
+
+import Link from 'next/link';
+import {useState} from 'react';
+import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
+import {ArrowLeft, Globe, Pencil, Trash2} from 'lucide-react';
+import {toast} from 'sonner';
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog';
+import {Button} from '@/components/ui/button';
+import {EmptyStateWithBorder} from '@/components/layout/empty';
+import {LoadingStateWithBorder} from '@/components/layout/loading';
+import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs';
+import {TlsCertificateService, ZoneService, zoneQueryKey} from '@/lib/services/openflare';
+
+import {getErrorMessage} from '../components/website-utils';
+import {ZoneCertificatesPanel} from './components/zone-certificates';
+import {ZoneDomainsTable} from './components/zone-domains-table';
+import {ZoneEditorDialog} from './components/zone-editor-dialog';
+import {ZoneOverviewPanel} from './components/zone-overview';
+import {ZoneRouteSummary} from './components/zone-route-summary';
+
+export function ZonePageClient({zoneId}: {zoneId: number}) {
+ const queryClient = useQueryClient();
+ const [activeTab, setActiveTab] = useState('overview');
+ const [editZone, setEditZone] = useState(false);
+ const [confirmDelete, setConfirmDelete] = useState(false);
+
+ const overviewQuery = useQuery({
+ queryKey: [...zoneQueryKey, zoneId],
+ queryFn: () => ZoneService.getOverview(zoneId),
+ enabled: Number.isInteger(zoneId) && zoneId > 0,
+ });
+
+ const certificatesQuery = useQuery({
+ queryKey: ['openflare', 'tls-certificates'],
+ queryFn: () => TlsCertificateService.list(),
+ enabled: overviewQuery.isSuccess,
+ });
+
+ const deleteZone = useMutation({
+ mutationFn: () => ZoneService.deleteById(zoneId),
+ onSuccess: async () => {
+ toast.success('Zone 已删除');
+ await queryClient.invalidateQueries({queryKey: zoneQueryKey});
+ window.location.assign('/websites');
+ },
+ onError: (error) => toast.error(getErrorMessage(error)),
+ });
+
+ if (!Number.isInteger(zoneId) || zoneId <= 0) {
+ return (
+
+
+
+ );
+ }
+
+ if (overviewQuery.isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (overviewQuery.isError || !overviewQuery.data) {
+ return (
+
+ );
+ }
+
+ const overview = overviewQuery.data;
+ const certificates = certificatesQuery.data ?? [];
+ const boundCertCount = new Set(
+ overview.domains.map((domain) => domain.cert_id).filter((id): id is number => id != null),
+ ).size;
+
+ return (
+
+
+
+
+
+
{overview.zone.domain}
+
Zone 网站管理
+
+
+
+
+
+
+
+
+
+
+
+
+ 概览
+
+
+ 域名 ({overview.domains.length})
+
+
+ 路由
+
+
+ 证书 ({boundCertCount})
+
+
+ 设置
+
+
+
+
+
+
+
+ overviewQuery.refetch()}
+ />
+
+
+
+
+
+
+
+
+
+
Zone 设置
+
+ 根域和备注可在此 Zone 的编辑操作中维护。
+
+
+
+
+
+
+
+
+
+
+
+ 确认删除 Zone
+
+ 确认删除 {overview.zone.domain} 吗?其下域名须先解绑路由后才能删除,此操作不可撤销。
+
+
+
+ 取消
+ {
+ event.preventDefault();
+ deleteZone.mutate();
+ }}
+ >
+ {deleteZone.isPending ? '删除中…' : '确认删除'}
+
+
+
+
+
+ );
+}
diff --git a/frontend/app/(main)/websites/[zoneId]/page.tsx b/frontend/app/(main)/websites/[zoneId]/page.tsx
new file mode 100644
index 00000000..df6646f0
--- /dev/null
+++ b/frontend/app/(main)/websites/[zoneId]/page.tsx
@@ -0,0 +1,6 @@
+import {ZonePageClient} from './page-client'
+
+export default async function ZonePage({params}: PageProps<'/websites/[zoneId]'>) {
+ const {zoneId} = await params
+ return
+}
diff --git a/frontend/app/(main)/websites/components/schemas.ts b/frontend/app/(main)/websites/components/schemas.ts
index 0a505a9b..ca3cc311 100644
--- a/frontend/app/(main)/websites/components/schemas.ts
+++ b/frontend/app/(main)/websites/components/schemas.ts
@@ -1,33 +1,5 @@
import {z} from 'zod';
-export const managedDomainSchema = z.object({
- domain: z
- .string()
- .trim()
- .min(1, '请输入域名')
- .max(255, '域名不能超过 255 个字符')
- .refine(
- (value) => !value.includes('://') && !value.includes('/'),
- '域名格式不合法',
- )
- .refine(
- (value) =>
- /^(?:\*\.)?(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/.test(
- value,
- ),
- '域名格式不合法',
- )
- .refine(
- (value) =>
- !value.includes('*') ||
- (value.startsWith('*.') && value.indexOf('*', 1) === -1),
- '通配符域名仅支持 *.example.com 格式',
- ),
- cert_id: z.string(),
- enabled: z.boolean(),
- remark: z.string().max(255, '备注不能超过 255 个字符'),
-});
-
export const manualImportSchema = z.object({
name: z
.string()
@@ -39,7 +11,6 @@ export const manualImportSchema = z.object({
remark: z.string().max(255, '备注不能超过 255 个字符'),
});
-export type ManagedDomainFormValues = z.infer;
export type ManualImportFormValues = z.infer;
export type FileImportFormValues = {
@@ -47,13 +18,6 @@ export type FileImportFormValues = {
remark: string;
};
-export const defaultManagedDomainValues: ManagedDomainFormValues = {
- domain: '',
- cert_id: '',
- enabled: true,
- remark: '',
-};
-
export const defaultManualImportValues: ManualImportFormValues = {
name: '',
cert_pem: '',
diff --git a/frontend/app/(main)/websites/components/website-editor-dialog.tsx b/frontend/app/(main)/websites/components/website-editor-dialog.tsx
deleted file mode 100644
index 565f0adc..00000000
--- a/frontend/app/(main)/websites/components/website-editor-dialog.tsx
+++ /dev/null
@@ -1,250 +0,0 @@
-'use client';
-
-import {zodResolver} from '@hookform/resolvers/zod';
-import {useMutation, useQueryClient} from '@tanstack/react-query';
-import {useEffect} from 'react';
-import {useForm, useWatch} from 'react-hook-form';
-import {Loader2} from 'lucide-react';
-
-import {Button} from '@/components/ui/button';
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog';
-import {Input} from '@/components/ui/input';
-import {Label} from '@/components/ui/label';
-import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/components/ui/select';
-import {Switch} from '@/components/ui/switch';
-import type {ManagedDomainItem, TlsCertificateItem} from '@/lib/services/openflare';
-import {WebsiteService} from '@/lib/services/openflare';
-
-import {defaultManagedDomainValues, type ManagedDomainFormValues, managedDomainSchema,} from './schemas';
-import {WebsiteStatusBadge} from './status-badge';
-import {
- buildCertificateLabel,
- getErrorMessage,
- getMatchTypeMeta,
- toManagedDomainFormValues,
- toManagedDomainPayload,
-} from './website-utils';
-
-const domainsQueryKey = ['openflare', 'managed-domains'];
-
-interface WebsiteEditorDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- onSaved?: (domain: ManagedDomainItem, mode: 'create' | 'update') => void;
- onRequestImportCertificate: () => void;
- certificates: TlsCertificateItem[];
- certificatesLoading?: boolean;
- initialDomain?: ManagedDomainItem | null;
- preferredCertificateId?: number | null;
-}
-
-export function WebsiteEditorDialog({
- open,
- onOpenChange,
- onSaved,
- onRequestImportCertificate,
- certificates,
- certificatesLoading = false,
- initialDomain = null,
- preferredCertificateId = null,
-}: WebsiteEditorDialogProps) {
- const queryClient = useQueryClient();
- const form = useForm({
- resolver: zodResolver(managedDomainSchema),
- defaultValues: defaultManagedDomainValues,
- });
-
- const watchedDomain = useWatch({control: form.control, name: 'domain'});
- const watchedCertId = useWatch({control: form.control, name: 'cert_id'});
- const watchedEnabled = useWatch({control: form.control, name: 'enabled'});
-
- const saveMutation = useMutation({
- mutationFn: async (values: ManagedDomainFormValues) => {
- const payload = toManagedDomainPayload(values);
- return initialDomain
- ? WebsiteService.update(initialDomain.id, payload)
- : WebsiteService.create(payload);
- },
- onSuccess: async (domain) => {
- await queryClient.invalidateQueries({queryKey: domainsQueryKey});
- onSaved?.(domain, initialDomain ? 'update' : 'create');
- handleClose();
- },
- });
-
- const currentCertificate = watchedCertId
- ? (certificates.find((item) => item.id === Number(watchedCertId)) ?? null)
- : null;
-
- const handleSubmit = form.handleSubmit((values) => {
- saveMutation.mutate(values);
- });
-
- const handleClose = () => {
- saveMutation.reset();
- form.reset(defaultManagedDomainValues);
- onOpenChange(false);
- };
-
- useEffect(() => {
- if (!open) return;
- form.reset(
- initialDomain
- ? toManagedDomainFormValues(initialDomain)
- : defaultManagedDomainValues,
- );
- }, [form, initialDomain, open]);
-
- useEffect(() => {
- if (!open || !preferredCertificateId) return;
- form.setValue('cert_id', String(preferredCertificateId), {
- shouldDirty: true,
- shouldValidate: true,
- });
- }, [form, open, preferredCertificateId]);
-
- return (
-
- );
-}
diff --git a/frontend/app/(main)/websites/components/website-utils.ts b/frontend/app/(main)/websites/components/website-utils.ts
index fdf23540..592eedf3 100644
--- a/frontend/app/(main)/websites/components/website-utils.ts
+++ b/frontend/app/(main)/websites/components/website-utils.ts
@@ -1,14 +1,11 @@
import type {
- ManagedDomainItem,
- ManagedDomainMutationPayload,
- ProxyRouteItem,
TlsCertificateFileImportPayload,
TlsCertificateItem,
TlsCertificateMutationPayload,
} from '@/lib/services/openflare';
import {formatDateTime} from '@/lib/utils';
-import type {FileImportFormValues, ManagedDomainFormValues, ManualImportFormValues,} from './schemas';
+import type {FileImportFormValues, ManualImportFormValues} from './schemas';
export type StatusTone = 'success' | 'warning' | 'danger' | 'info';
@@ -51,28 +48,6 @@ export function buildCertificateLabel(certificate: TlsCertificateItem) {
: certificate.name;
}
-export function toManagedDomainPayload(
- values: ManagedDomainFormValues,
-): ManagedDomainMutationPayload {
- return {
- domain: values.domain.trim().toLowerCase(),
- cert_id: values.cert_id ? Number(values.cert_id) : null,
- enabled: values.enabled,
- remark: values.remark.trim(),
- };
-}
-
-export function toManagedDomainFormValues(
- domain: ManagedDomainItem,
-): ManagedDomainFormValues {
- return {
- domain: domain.domain,
- cert_id: domain.cert_id ? String(domain.cert_id) : '',
- enabled: domain.enabled,
- remark: domain.remark || '',
- };
-}
-
export function toManualPayload(
values: ManualImportFormValues,
): TlsCertificateMutationPayload {
@@ -100,20 +75,3 @@ export function toFilePayload(
keyFile,
};
}
-
-export function isRouteRelatedToManagedDomain(
- managedDomain: string,
- route: ProxyRouteItem,
-) {
- const domains = route.domains.length > 0 ? route.domains : [route.primary_domain];
- return domains.some((routeDomain) => {
- if (managedDomain === routeDomain) {
- return true;
- }
- if (!managedDomain.startsWith('*.')) {
- return false;
- }
- const suffix = managedDomain.slice(2);
- return routeDomain.endsWith(`.${suffix}`);
- });
-}
diff --git a/frontend/app/(main)/websites/detail/page-client.tsx b/frontend/app/(main)/websites/detail/page-client.tsx
deleted file mode 100644
index 7d0e8a47..00000000
--- a/frontend/app/(main)/websites/detail/page-client.tsx
+++ /dev/null
@@ -1,466 +0,0 @@
-'use client';
-
-import Link from 'next/link';
-import {useRouter, useSearchParams} from 'next/navigation';
-import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
-import {useMemo, useState} from 'react';
-import {ArrowLeft, Globe, Plus, Trash2} from 'lucide-react';
-import {toast} from 'sonner';
-
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from '@/components/ui/alert-dialog';
-import {Button} from '@/components/ui/button';
-import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
-import {EmptyStateWithBorder} from '@/components/layout/empty';
-import {ErrorInline} from '@/components/layout/error';
-import {LoadingStateWithBorder} from '@/components/layout/loading';
-import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/components/ui/table';
-import type {TlsCertificateItem} from '@/lib/services/openflare';
-import {ProxyRouteService, TlsCertificateService, WebsiteService,} from '@/lib/services/openflare';
-import {formatDateTime} from '@/lib/utils';
-import {getUpstreamSummary} from '@/app/(main)/proxy-routes/components/helpers';
-
-import {CertificateApplyDialog} from '../components/certificate-apply-dialog';
-import {CertificateDetailDialog} from '../components/certificate-detail-dialog';
-import {CertificateEditorDialog} from '../components/certificate-editor-dialog';
-import {CertificateImportDialog} from '../components/certificate-import-dialog';
-import {WebsiteStatusBadge} from '../components/status-badge';
-import {WebsiteEditorDialog} from '../components/website-editor-dialog';
-import {
- buildCertificateLabel,
- getCertificateStatus,
- getErrorMessage,
- isRouteRelatedToManagedDomain,
-} from '../components/website-utils';
-
-const domainsQueryKey = ['openflare', 'managed-domains'];
-const certificatesQueryKey = ['openflare', 'tls-certificates'];
-
-export function WebsiteDetailPageClient() {
- const router = useRouter();
- const searchParams = useSearchParams();
- const queryClient = useQueryClient();
- const websiteId = searchParams.get('id')?.trim() ?? '';
-
- const [editorOpen, setEditorOpen] = useState(false);
- const [importOpen, setImportOpen] = useState(false);
- const [detailOpen, setDetailOpen] = useState(false);
- const [certEditorOpen, setCertEditorOpen] = useState(false);
- const [deleteWebsiteOpen, setDeleteWebsiteOpen] = useState(false);
- const [deleteCertOpen, setDeleteCertOpen] = useState(false);
- const [preferredCertificateId, setPreferredCertificateId] = useState(null);
- const [convertCertificate, setConvertCertificate] = useState(null);
-
- const domainsQuery = useQuery({
- queryKey: domainsQueryKey,
- queryFn: () => WebsiteService.list(),
- });
-
- const certificatesQuery = useQuery({
- queryKey: certificatesQueryKey,
- queryFn: () => TlsCertificateService.list(),
- });
-
- const routesQuery = useQuery({
- queryKey: ['openflare', 'proxy-routes'],
- queryFn: () => ProxyRouteService.list(),
- });
-
- const website = useMemo(
- () =>
- (domainsQuery.data ?? []).find((item) => String(item.id) === websiteId) ?? null,
- [domainsQuery.data, websiteId],
- );
-
- const certificates = useMemo(
- () => certificatesQuery.data ?? [],
- [certificatesQuery.data],
- );
- const certificateMap = useMemo(
- () => new Map(certificates.map((item) => [item.id, item])),
- [certificates],
- );
-
- const certificate = website?.cert_id
- ? (certificateMap.get(website.cert_id) ?? null)
- : null;
-
- const relatedRoutes = useMemo(() => {
- if (!website) return [];
- return (routesQuery.data ?? []).filter((route) =>
- isRouteRelatedToManagedDomain(website.domain, route),
- );
- }, [routesQuery.data, website]);
-
- const deleteDomainMutation = useMutation({
- mutationFn: (id: number) => WebsiteService.deleteById(id),
- onSuccess: async () => {
- await queryClient.invalidateQueries({queryKey: domainsQueryKey});
- router.push('/websites');
- },
- onError: (error) => toast.error(getErrorMessage(error)),
- });
-
- const deleteCertificateMutation = useMutation({
- mutationFn: (id: number) => TlsCertificateService.deleteById(id),
- onSuccess: async () => {
- toast.success('证书已删除');
- setDeleteCertOpen(false);
- await Promise.all([
- queryClient.invalidateQueries({queryKey: certificatesQueryKey}),
- queryClient.invalidateQueries({queryKey: domainsQueryKey}),
- ]);
- },
- onError: (error) => toast.error(getErrorMessage(error)),
- });
-
- if (!websiteId) {
- return (
-
-
-
- );
- }
-
- if (
- domainsQuery.isLoading ||
- certificatesQuery.isLoading ||
- routesQuery.isLoading
- ) {
- return (
-
-
-
- );
- }
-
- if (domainsQuery.isError) {
- return (
-
- void domainsQuery.refetch()}
- className="justify-center"
- />
-
- );
- }
-
- if (!website) {
- return (
-
- );
- }
-
- const certificateStatus = certificate ? getCertificateStatus(certificate) : null;
-
- return (
-
-
-
-
-
-
{website.domain}
-
网站详情
-
-
-
-
-
-
-
-
-
-
-
-
-
- 网站信息
- 当前网站的基础配置与托管信息。
-
-
-
-
域名
-
{website.domain}
-
-
-
- 创建时间
-
-
{formatDateTime(website.created_at)}
-
-
-
备注
-
{website.remark || '暂无备注'}
-
-
-
-
-
-
- 证书信息
-
- 当前网站绑定的默认证书信息。若还没有证书,可直接添加后回填。
-
-
-
- {certificate ? (
-
- {certificateStatus ? (
-
- ) : null}
-
-
证书名称:{buildCertificateLabel(certificate)}
-
生效时间:{formatDateTime(certificate.not_before)}
-
到期时间:{formatDateTime(certificate.not_after)}
-
备注:{certificate.remark || '暂无备注'}
-
-
-
-
-
-
- ) : (
-
- )}
-
-
-
-
-
-
- 关联规则
- 命中当前网站域名的代理规则。
-
-
- {relatedRoutes.length === 0 ? (
-
- ) : (
-
-
-
- 规则域名
- 源站
- HTTPS
- 证书
- 状态
- 备注
-
-
-
- {relatedRoutes.map((route) => {
- const routeCertificate = route.cert_id
- ? (certificateMap.get(route.cert_id) ?? null)
- : null;
- const matchedDomain =
- route.domains.find((d) => d === website.domain) ?? route.primary_domain;
-
- return (
-
-
-
-
-
- {getUpstreamSummary(route)}
-
-
-
-
-
- {routeCertificate ? routeCertificate.name : '未绑定证书'}
-
-
-
-
-
- {route.remark || '暂无备注'}
-
-
- );
- })}
-
-
- )}
-
-
-
-
setImportOpen(true)}
- onSaved={() => toast.success('网站已更新')}
- />
-
- {
- setPreferredCertificateId(imported.id);
- toast.success(`证书 ${imported.name} 已导入,可在编辑网站时直接应用`);
- setEditorOpen(true);
- }}
- />
-
- {
- setDetailOpen(false);
- setCertEditorOpen(true);
- }}
- onDelete={() => {
- setDetailOpen(false);
- setDeleteCertOpen(true);
- }}
- deleting={deleteCertificateMutation.isPending}
- />
-
- toast.success(`证书 ${updated.name} 已更新`)}
- onConvert={(manualCertificate) => {
- setCertEditorOpen(false);
- setConvertCertificate(manualCertificate);
- }}
- />
-
- {convertCertificate ? (
- !open && setConvertCertificate(null)}
- mode="convert-upload"
- certificate={convertCertificate}
- onApplied={(converted) => {
- setConvertCertificate(null);
- toast.success(`证书 ${converted.name} 转换申请已提交`);
- }}
- />
- ) : null}
-
-
-
-
- 确认删除网站
-
- 确认删除网站 {website.domain} 吗?
-
-
-
- 取消
- deleteDomainMutation.mutate(website.id)}
- >
- 删除
-
-
-
-
-
-
-
-
- 确认删除证书
-
- 确认删除证书 {certificate?.name} 吗?
-
-
-
- 取消
- certificate && deleteCertificateMutation.mutate(certificate.id)}
- >
- 删除
-
-
-
-
-
- );
-}
diff --git a/frontend/app/(main)/websites/detail/page.tsx b/frontend/app/(main)/websites/detail/page.tsx
deleted file mode 100644
index 4c7b0679..00000000
--- a/frontend/app/(main)/websites/detail/page.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import {Suspense} from 'react';
-
-import {Skeleton} from '@/components/ui/skeleton';
-
-import {WebsiteDetailPageClient} from './page-client';
-
-function WebsiteDetailPageFallback() {
- return (
-
-
-
-
-
- );
-}
-
-export default function WebsiteDetailPage() {
- return (
- }>
-
-
- );
-}
\ No newline at end of file
diff --git a/frontend/app/(main)/websites/page.tsx b/frontend/app/(main)/websites/page.tsx
index d6d6de78..d0e753e2 100644
--- a/frontend/app/(main)/websites/page.tsx
+++ b/frontend/app/(main)/websites/page.tsx
@@ -1,211 +1,119 @@
'use client';
import Link from 'next/link';
-import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {useMemo, useState} from 'react';
-import {Globe, Plus, Trash2} from 'lucide-react';
-import {toast} from 'sonner';
+import {useQuery} from '@tanstack/react-query';
+import {Globe, Plus, Search} from 'lucide-react';
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from '@/components/ui/alert-dialog';
import {Button} from '@/components/ui/button';
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import {EmptyStateWithBorder} from '@/components/layout/empty';
import {ErrorInline} from '@/components/layout/error';
+import {Input} from '@/components/ui/input';
import {LoadingStateWithBorder} from '@/components/layout/loading';
-import type {ManagedDomainItem} from '@/lib/services/openflare';
-import {TlsCertificateService, WebsiteService} from '@/lib/services/openflare';
+import {ZoneService, zoneQueryKey} from '@/lib/services/openflare';
-import {CertificateImportDialog} from './components/certificate-import-dialog';
-import {WebsiteStatusBadge} from './components/status-badge';
-import {WebsiteEditorDialog} from './components/website-editor-dialog';
-import {buildCertificateLabel, getErrorMessage, getMatchTypeMeta,} from './components/website-utils';
-
-const domainsQueryKey = ['openflare', 'managed-domains'];
-const certificatesQueryKey = ['openflare', 'tls-certificates'];
+import {ZoneEditorDialog} from './[zoneId]/components/zone-editor-dialog';
+import {getErrorMessage} from './components/website-utils';
export default function WebsitesPage() {
- const queryClient = useQueryClient();
+ const [search, setSearch] = useState('');
const [editorOpen, setEditorOpen] = useState(false);
- const [importOpen, setImportOpen] = useState(false);
- const [preferredCertificateId, setPreferredCertificateId] = useState(null);
- const [deleteTarget, setDeleteTarget] = useState(null);
-
- const domainsQuery = useQuery({
- queryKey: domainsQueryKey,
- queryFn: () => WebsiteService.list(),
+ const zonesQuery = useQuery({
+ queryKey: zoneQueryKey,
+ queryFn: () => ZoneService.list(),
});
- const certificatesQuery = useQuery({
- queryKey: certificatesQueryKey,
- queryFn: () => TlsCertificateService.list(),
- });
-
- const deleteMutation = useMutation({
- mutationFn: (id: number) => WebsiteService.deleteById(id),
- onSuccess: async () => {
- toast.success('网站已删除');
- setDeleteTarget(null);
- await queryClient.invalidateQueries({queryKey: domainsQueryKey});
- },
- onError: (error) => toast.error(getErrorMessage(error)),
- });
-
- const domains = useMemo(() => domainsQuery.data ?? [], [domainsQuery.data]);
- const certificates = useMemo(
- () => certificatesQuery.data ?? [],
- [certificatesQuery.data],
- );
- const certificateMap = useMemo(
- () => new Map(certificates.map((item) => [item.id, item])),
- [certificates],
- );
+ const zones = useMemo(() => {
+ const keyword = search.trim().toLowerCase();
+ const list = zonesQuery.data ?? [];
+ if (!keyword) {
+ return list;
+ }
+ return list.filter((zone) => zone.domain.toLowerCase().includes(keyword));
+ }, [search, zonesQuery.data]);
return (
-
+
网站
-
-
-
+
-
- 网站列表
- 查看网站绑定的证书、启用状态和更新时间。
+
+
+ Zone 列表
+
+ 以可注册根域组织网站,并在详情中维护明确 FQDN、证书与路由关联。
+
+
+
+
+ setSearch(event.target.value)}
+ className="h-8 pl-8 text-xs"
+ />
+
- {domainsQuery.isLoading ? (
-
- ) : domainsQuery.isError ? (
-
+ {zonesQuery.isLoading ? (
+
+ ) : zonesQuery.isError ? (
+
void domainsQuery.refetch()}
className="justify-center"
+ message={getErrorMessage(zonesQuery.error)}
+ onRetry={() => void zonesQuery.refetch()}
/>
- ) : domains.length === 0 ? (
+ ) : zones.length === 0 ? (
) : (
- {domains.map((domain) => {
- const certificate = domain.cert_id
- ? (certificateMap.get(domain.cert_id) ?? null)
- : null;
- const matchType = getMatchTypeMeta(domain.domain);
-
- return (
-
-
-
-
-
{domain.domain}
-
-
-
-
- {domain.remark || '暂无备注'}
-
-
- 绑定证书:
- {certificate
- ? buildCertificateLabel(certificate)
- : '未绑定证书'}
-
-
-
-
-
-
+ {zones.map((zone) => (
+
+
+
+
{zone.domain}
+
+ {zone.domain_count ?? 0} 个域名
+ {zone.remark ? ` · ${zone.remark}` : ''}
+
+
- );
- })}
+
+ ))}
)}
-
setImportOpen(true)}
- onSaved={(_, mode) => {
- setPreferredCertificateId(null);
- toast.success(mode === 'create' ? '网站已创建' : '网站已更新');
- }}
- />
-
- {
- setPreferredCertificateId(certificate.id);
- toast.success(`证书 ${certificate.name} 已导入,可直接用于当前网站`);
- }}
- />
-
- !open && setDeleteTarget(null)}
- >
-
-
- 确认删除网站
-
- 确认删除网站 {deleteTarget?.domain} 吗?此操作不可撤销。
-
-
-
- 取消
- deleteTarget && deleteMutation.mutate(deleteTarget.id)}
- >
- 删除
-
-
-
-
+
);
}
diff --git a/frontend/lib/services/index.ts b/frontend/lib/services/index.ts
index 67453914..52553230 100644
--- a/frontend/lib/services/index.ts
+++ b/frontend/lib/services/index.ts
@@ -42,6 +42,8 @@ import {
UptimeKumaService,
WafService,
WebsiteService,
+ ZoneDomainService,
+ ZoneService,
} from './openflare';
const services = {
@@ -67,6 +69,8 @@ const services = {
openflareDashboard: DashboardService,
openflareWaf: WafService,
openflareWebsite: WebsiteService,
+ openflareZone: ZoneService,
+ openflareZoneDomain: ZoneDomainService,
openflareTls: TlsCertificateService,
openflareDns: DnsAccountService,
openflarePages: PagesService,
@@ -201,6 +205,7 @@ export {
export type {
NodeItem,
ProxyRouteItem,
+ ProxyRouteZoneDomain,
ProxyRouteConfigSection,
ConfigVersionSummary,
ConfigVersionDetail,
diff --git a/frontend/lib/services/openflare/index.ts b/frontend/lib/services/openflare/index.ts
index f1e29962..76a88d73 100644
--- a/frontend/lib/services/openflare/index.ts
+++ b/frontend/lib/services/openflare/index.ts
@@ -7,6 +7,7 @@ export { ApplyLogService } from './apply-log.service';
export { DashboardService } from './dashboard.service';
export { WafService } from './waf.service';
export { WebsiteService } from './website.service';
+export { ZoneDomainService, ZoneService, zoneQueryKey } from './zone.service';
export { TlsCertificateService } from './tls-certificate.service';
export { DnsAccountService } from './dns-account.service';
export { PagesService } from './pages.service';
@@ -49,6 +50,7 @@ export type {
ProxyRouteItem,
ProxyRouteMutationPayload,
ProxyRoutePoWConfig,
+ ProxyRouteZoneDomain,
ReleaseChannel,
SupportFile,
DashboardOverview,
@@ -105,6 +107,11 @@ export type {
ManagedDomainItem,
ManagedDomainMatchResult,
ManagedDomainMutationPayload,
+ ZoneDomainItem,
+ ZoneDomainMutationPayload,
+ ZoneItem,
+ ZoneMutationPayload,
+ ZoneOverview,
TlsCertificateApplyPayload,
TlsCertificateContentItem,
TlsCertificateDetailItem,
diff --git a/frontend/lib/services/openflare/types.ts b/frontend/lib/services/openflare/types.ts
index a7c3ed27..7a0f56f7 100644
--- a/frontend/lib/services/openflare/types.ts
+++ b/frontend/lib/services/openflare/types.ts
@@ -228,13 +228,19 @@ export interface ProxyRoutePoWConfig {
blacklist: ProxyRoutePoWListConfig;
}
+/** Route-bound Zone domain as returned by proxy-route APIs. */
+export interface ProxyRouteZoneDomain {
+ id: number;
+ zone_id: number;
+ domain: string;
+ cert_id: number | null;
+}
+
export interface ProxyRouteItem {
id: number;
site_name: string;
- domain: string;
- domains: string[];
- primary_domain: string;
- domain_count: number;
+ zone_domain_ids: number[];
+ zone_domains: ProxyRouteZoneDomain[];
origin_id: number | null;
origin_url: string;
origin_host: string;
@@ -242,9 +248,6 @@ export interface ProxyRouteItem {
upstream_list: string[];
enabled: boolean;
enable_https: boolean;
- cert_id: number | null;
- cert_ids: number[];
- domain_cert_ids: number[];
redirect_http: boolean;
limit_conn_per_server: number;
limit_conn_per_ip: number;
@@ -271,8 +274,7 @@ export interface ProxyRouteItem {
export interface ProxyRouteMutationPayload {
site_name?: string;
- domain: string;
- domains?: string[];
+ zone_domain_ids: number[];
origin_id: number | null;
origin_url: string;
origin_scheme: 'http' | 'https';
@@ -283,9 +285,6 @@ export interface ProxyRouteMutationPayload {
upstreams: string[];
enabled: boolean;
enable_https: boolean;
- cert_id: number | null;
- cert_ids?: number[];
- domain_cert_ids?: number[];
redirect_http: boolean;
limit_conn_per_server?: number;
limit_conn_per_ip?: number;
@@ -962,6 +961,43 @@ export interface DashboardOverviewCompact {
// ==================== Websites / TLS / DNS ====================
+export interface ZoneItem {
+ id: number;
+ domain: string;
+ remark: string;
+ /** Present on list API; may be omitted on nested zone objects. */
+ domain_count?: number;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface ZoneMutationPayload {
+ domain: string;
+ remark: string;
+}
+
+export interface ZoneDomainItem {
+ id: number;
+ zone_id: number;
+ proxy_route_id: number | null;
+ domain: string;
+ cert_id: number | null;
+ remark: string;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface ZoneDomainMutationPayload {
+ domain: string;
+ cert_id: number | null;
+ remark: string;
+}
+
+export interface ZoneOverview {
+ zone: ZoneItem;
+ domains: ZoneDomainItem[];
+}
+
export interface ManagedDomainItem {
id: number;
domain: string;
@@ -1078,4 +1114,4 @@ export interface DnsAccountMutationPayload {
name: string;
type: string;
authorization: string;
-}
\ No newline at end of file
+}
diff --git a/frontend/lib/services/openflare/zone.service.ts b/frontend/lib/services/openflare/zone.service.ts
new file mode 100644
index 00000000..a7c7b890
--- /dev/null
+++ b/frontend/lib/services/openflare/zone.service.ts
@@ -0,0 +1,22 @@
+import {OpenFlareBaseService} from './base.service'
+import type {ZoneDomainItem, ZoneDomainMutationPayload, ZoneItem, ZoneMutationPayload, ZoneOverview} from './types'
+
+export const zoneQueryKey = ['openflare', 'zones'] as const
+
+export class ZoneService extends OpenFlareBaseService {
+ protected static override readonly basePath = '/api/v1/d/zones'
+
+ static list(): Promise
{ return this.get('/') }
+ static getOverview(id: number): Promise { return this.get(`/${id}/overview`) }
+ static create(payload: ZoneMutationPayload): Promise { return this.post('/', payload) }
+ static update(id: number, payload: ZoneMutationPayload): Promise { return this.post(`/${id}/update`, payload) }
+ static deleteById(id: number): Promise { return this.post(`/${id}/delete`) }
+}
+
+export class ZoneDomainService extends OpenFlareBaseService {
+ protected static override readonly basePath = '/api/v1/d/zones'
+
+ static create(zoneId: number, payload: ZoneDomainMutationPayload): Promise { return this.post(`/${zoneId}/domains`, payload) }
+ static update(zoneId: number, id: number, payload: ZoneDomainMutationPayload): Promise { return this.post(`/${zoneId}/domains/${id}/update`, payload) }
+ static deleteById(zoneId: number, id: number): Promise { return this.post(`/${zoneId}/domains/${id}/delete`) }
+}
diff --git a/frontend/package.json b/frontend/package.json
index 7fa88df2..07a0ac6d 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -82,13 +82,18 @@
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.5.6",
+ "jsdom": "^29.1.1",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
- "typescript": "^5.9.3"
+ "typescript": "^5.9.3",
+ "vitest": "^4.1.10"
}
}
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 1b5ca5df..61a52497 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -213,6 +213,15 @@ importers:
'@tailwindcss/typography':
specifier: ^0.5.19
version: 0.5.19(tailwindcss@4.1.16)
+ '@testing-library/jest-dom':
+ specifier: ^6.9.1
+ version: 6.9.1
+ '@testing-library/react':
+ specifier: ^16.3.2
+ version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@testing-library/user-event':
+ specifier: ^14.6.1
+ version: 14.6.1(@testing-library/dom@10.4.1)
'@types/node':
specifier: ^20
version: 20.19.24
@@ -228,6 +237,9 @@ importers:
eslint-config-next:
specifier: 15.5.6
version: 15.5.6(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
+ jsdom:
+ specifier: ^29.1.1
+ version: 29.1.1
tailwindcss:
specifier: ^4
version: 4.1.16
@@ -237,13 +249,38 @@ importers:
typescript:
specifier: ^5.9.3
version: 5.9.3
+ vitest:
+ specifier: ^4.1.10
+ version: 4.1.10(@types/node@20.19.24)(jsdom@29.1.1)(vite@8.1.4(@types/node@20.19.24)(jiti@2.6.1))
packages:
+ '@adobe/css-tools@4.5.0':
+ resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==}
+
'@alloc/quick-lru@5.2.0':
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
+ '@asamuzakjp/css-color@5.1.11':
+ resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ '@asamuzakjp/dom-selector@7.1.1':
+ resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ '@asamuzakjp/generational-cache@1.0.1':
+ resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ '@asamuzakjp/nwsapi@2.3.9':
+ resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
+
+ '@babel/code-frame@7.29.7':
+ resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
+ engines: {node: '>=6.9.0'}
+
'@babel/helper-string-parser@7.27.1':
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'}
@@ -252,6 +289,10 @@ packages:
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
engines: {node: '>=6.9.0'}
+ '@babel/helper-validator-identifier@7.29.7':
+ resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
+ engines: {node: '>=6.9.0'}
+
'@babel/runtime@7.28.4':
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
engines: {node: '>=6.9.0'}
@@ -260,6 +301,10 @@ packages:
resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
engines: {node: '>=6.9.0'}
+ '@bramus/specificity@2.4.2':
+ resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
+ hasBin: true
+
'@codemirror/autocomplete@6.20.3':
resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==}
@@ -287,6 +332,42 @@ packages:
'@codemirror/view@6.43.0':
resolution: {integrity: sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==}
+ '@csstools/color-helpers@6.1.0':
+ resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==}
+ engines: {node: '>=20.19.0'}
+
+ '@csstools/css-calc@3.2.1':
+ resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-parser-algorithms': ^4.0.0
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-color-parser@4.1.9':
+ resolution: {integrity: sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-parser-algorithms': ^4.0.0
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-parser-algorithms@4.0.0':
+ resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-syntax-patches-for-csstree@1.1.6':
+ resolution: {integrity: sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==}
+ peerDependencies:
+ css-tree: ^3.2.1
+ peerDependenciesMeta:
+ css-tree:
+ optional: true
+
+ '@csstools/css-tokenizer@4.0.0':
+ resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
+ engines: {node: '>=20.19.0'}
+
'@date-fns/tz@1.4.1':
resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
@@ -318,15 +399,24 @@ packages:
peerDependencies:
react: '>=16.8.0'
+ '@emnapi/core@1.11.1':
+ resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==}
+
'@emnapi/core@1.7.0':
resolution: {integrity: sha512-pJdKGq/1iquWYtv1RRSljZklxHCOCAJFJrImO5ZLKPJVJlVUcs8yFwNQlqS0Lo8xT1VAXXTCZocF9n26FWEKsw==}
+ '@emnapi/runtime@1.11.1':
+ resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==}
+
'@emnapi/runtime@1.7.0':
resolution: {integrity: sha512-oAYoQnCYaQZKVS53Fq23ceWMRxq5EhQsE0x0RdQ55jT7wagMu5k+fS39v1fiSLrtrLQlXwVINenqhLMtTrV/1Q==}
'@emnapi/wasi-threads@1.1.0':
resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
+ '@emnapi/wasi-threads@1.2.2':
+ resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==}
+
'@eslint-community/eslint-utils@4.9.0':
resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -365,6 +455,15 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@exodus/bytes@1.15.1':
+ resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+ peerDependencies:
+ '@noble/hashes': ^1.8.0 || ^2.0.0
+ peerDependenciesMeta:
+ '@noble/hashes':
+ optional: true
+
'@floating-ui/core@1.7.3':
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
@@ -588,6 +687,12 @@ packages:
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
+ '@napi-rs/wasm-runtime@1.1.6':
+ resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
+ peerDependencies:
+ '@emnapi/core': ^1.7.1
+ '@emnapi/runtime': ^1.7.1
+
'@next/env@16.2.7':
resolution: {integrity: sha512-tMJizPlj6ZYpBMMdK8S0LJufrP4QTdR6pcv9KQ/bVETPAmg0j1mlHE9G2c38UyGHxoBapgwuj7XjbGJ2RcDFOg==}
@@ -662,6 +767,9 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
+ '@oxc-project/types@0.139.0':
+ resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==}
+
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
@@ -1448,12 +1556,113 @@ packages:
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
+ '@rolldown/binding-android-arm64@1.1.5':
+ resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [android]
+
+ '@rolldown/binding-darwin-arm64@1.1.5':
+ resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rolldown/binding-darwin-x64@1.1.5':
+ resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rolldown/binding-freebsd-x64@1.1.5':
+ resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rolldown/binding-linux-arm-gnueabihf@1.1.5':
+ resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [linux]
+
+ '@rolldown/binding-linux-arm64-gnu@1.1.5':
+ resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/binding-linux-arm64-musl@1.1.5':
+ resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@rolldown/binding-linux-ppc64-gnu@1.1.5':
+ resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/binding-linux-s390x-gnu@1.1.5':
+ resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/binding-linux-x64-gnu@1.1.5':
+ resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/binding-linux-x64-musl@1.1.5':
+ resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@rolldown/binding-openharmony-arm64@1.1.5':
+ resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rolldown/binding-wasm32-wasi@1.1.5':
+ resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [wasm32]
+
+ '@rolldown/binding-win32-arm64-msvc@1.1.5':
+ resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rolldown/binding-win32-x64-msvc@1.1.5':
+ resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [win32]
+
+ '@rolldown/pluginutils@1.0.1':
+ resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
+
'@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
'@rushstack/eslint-patch@1.14.1':
resolution: {integrity: sha512-jGTk8UD/RdjsNZW8qq10r0RBvxL8OWtoT+kImlzPDFilmozzM+9QmIJsmze9UiSBrFU45ZxhTYBypn9q9z/VfQ==}
+ '@standard-schema/spec@1.1.0':
+ resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
+
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
@@ -1593,9 +1802,47 @@ packages:
'@tanstack/virtual-core@3.13.16':
resolution: {integrity: sha512-njazUC8mDkrxWmyZmn/3eXrDcP8Msb3chSr4q6a65RmwdSbMlMCdnOphv6/8mLO7O3Fuza5s4M4DclmvAO5w0w==}
+ '@testing-library/dom@10.4.1':
+ resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
+ engines: {node: '>=18'}
+
+ '@testing-library/jest-dom@6.9.1':
+ resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==}
+ engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
+
+ '@testing-library/react@16.3.2':
+ resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@testing-library/dom': ^10.0.0
+ '@types/react': ^18.0.0 || ^19.0.0
+ '@types/react-dom': ^18.0.0 || ^19.0.0
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@testing-library/user-event@14.6.1':
+ resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==}
+ engines: {node: '>=12', npm: '>=6'}
+ peerDependencies:
+ '@testing-library/dom': '>=7.21.4'
+
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
+ '@tybys/wasm-util@0.10.3':
+ resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
+
+ '@types/aria-query@5.0.4':
+ resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
+
+ '@types/chai@5.2.3':
+ resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
+
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
@@ -1626,6 +1873,9 @@ packages:
'@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
+ '@types/deep-eql@4.0.2':
+ resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
+
'@types/estree-jsx@1.0.5':
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
@@ -1864,6 +2114,35 @@ packages:
cpu: [x64]
os: [win32]
+ '@vitest/expect@4.1.10':
+ resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==}
+
+ '@vitest/mocker@4.1.10':
+ resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==}
+ peerDependencies:
+ msw: ^2.4.9
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ msw:
+ optional: true
+ vite:
+ optional: true
+
+ '@vitest/pretty-format@4.1.10':
+ resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==}
+
+ '@vitest/runner@4.1.10':
+ resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==}
+
+ '@vitest/snapshot@4.1.10':
+ resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==}
+
+ '@vitest/spy@4.1.10':
+ resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==}
+
+ '@vitest/utils@4.1.10':
+ resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==}
+
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -1877,10 +2156,18 @@ packages:
ajv@6.12.6:
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
+ ansi-regex@5.0.1:
+ resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
+ engines: {node: '>=8'}
+
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
+ ansi-styles@5.2.0:
+ resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
+ engines: {node: '>=10'}
+
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@@ -1888,6 +2175,9 @@ packages:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
+ aria-query@5.3.0:
+ resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
+
aria-query@5.3.2:
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
engines: {node: '>= 0.4'}
@@ -1924,6 +2214,10 @@ packages:
resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
engines: {node: '>= 0.4'}
+ assertion-error@2.0.1:
+ resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
+ engines: {node: '>=12'}
+
ast-types-flow@0.0.8:
resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
@@ -1967,6 +2261,9 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
+ bidi-js@1.0.3:
+ resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
+
brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
@@ -2003,6 +2300,10 @@ packages:
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
+ chai@6.2.2:
+ resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
+ engines: {node: '>=18'}
+
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@@ -2055,6 +2356,9 @@ packages:
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
+ convert-source-map@2.0.0:
+ resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+
core-js@3.47.0:
resolution: {integrity: sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==}
@@ -2068,6 +2372,13 @@ packages:
css-line-break@2.1.0:
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
+ css-tree@3.2.1:
+ resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
+ engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
+
+ css.escape@1.5.1:
+ resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
+
cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
@@ -2123,6 +2434,10 @@ packages:
damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
+ data-urls@7.0.0:
+ resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
data-view-buffer@1.0.2:
resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
engines: {node: '>= 0.4'}
@@ -2161,6 +2476,9 @@ packages:
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
+ decimal.js@10.6.0:
+ resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
+
decode-named-character-reference@1.2.0:
resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==}
@@ -2197,6 +2515,12 @@ packages:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
+ dom-accessibility-api@0.5.16:
+ resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
+
+ dom-accessibility-api@0.6.3:
+ resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
+
dom-helpers@5.2.1:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
@@ -2236,6 +2560,10 @@ packages:
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
engines: {node: '>=10.13.0'}
+ entities@8.0.0:
+ resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
+ engines: {node: '>=20.19.0'}
+
es-abstract@1.24.0:
resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==}
engines: {node: '>= 0.4'}
@@ -2252,6 +2580,9 @@ packages:
resolution: {integrity: sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==}
engines: {node: '>= 0.4'}
+ es-module-lexer@2.3.0:
+ resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==}
+
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
@@ -2391,6 +2722,9 @@ packages:
estree-util-is-identifier-name@3.0.0:
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
+ estree-walker@3.0.3:
+ resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
+
esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
@@ -2398,6 +2732,10 @@ packages:
eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
+ expect-type@1.4.0:
+ resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
+ engines: {node: '>=12.0.0'}
+
extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
@@ -2497,6 +2835,11 @@ packages:
react-dom:
optional: true
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
@@ -2620,6 +2963,10 @@ packages:
highlightjs-vue@1.0.0:
resolution: {integrity: sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==}
+ html-encoding-sniffer@6.0.0:
+ resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
@@ -2643,6 +2990,10 @@ packages:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'}
+ indent-string@4.0.0:
+ resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
+ engines: {node: '>=8'}
+
inline-style-parser@0.2.7:
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
@@ -2746,6 +3097,9 @@ packages:
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
engines: {node: '>=12'}
+ is-potential-custom-element-name@1.0.1:
+ resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
+
is-regex@1.2.1:
resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
engines: {node: '>= 0.4'}
@@ -2803,6 +3157,15 @@ packages:
resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
hasBin: true
+ jsdom@29.1.1:
+ resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
+ peerDependencies:
+ canvas: ^3.0.0
+ peerDependenciesMeta:
+ canvas:
+ optional: true
+
json-buffer@3.0.1:
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
@@ -2843,30 +3206,60 @@ packages:
cpu: [arm64]
os: [android]
+ lightningcss-android-arm64@1.32.0:
+ resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [android]
+
lightningcss-darwin-arm64@1.30.2:
resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
+ lightningcss-darwin-arm64@1.32.0:
+ resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [darwin]
+
lightningcss-darwin-x64@1.30.2:
resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
+ lightningcss-darwin-x64@1.32.0:
+ resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [darwin]
+
lightningcss-freebsd-x64@1.30.2:
resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
+ lightningcss-freebsd-x64@1.32.0:
+ resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+
lightningcss-linux-arm-gnueabihf@1.30.2:
resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
+ lightningcss-linux-arm-gnueabihf@1.32.0:
+ resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm]
+ os: [linux]
+
lightningcss-linux-arm64-gnu@1.30.2:
resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==}
engines: {node: '>= 12.0.0'}
@@ -2874,6 +3267,13 @@ packages:
os: [linux]
libc: [glibc]
+ lightningcss-linux-arm64-gnu@1.32.0:
+ resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
lightningcss-linux-arm64-musl@1.30.2:
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
engines: {node: '>= 12.0.0'}
@@ -2881,6 +3281,13 @@ packages:
os: [linux]
libc: [musl]
+ lightningcss-linux-arm64-musl@1.32.0:
+ resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
lightningcss-linux-x64-gnu@1.30.2:
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
engines: {node: '>= 12.0.0'}
@@ -2888,6 +3295,13 @@ packages:
os: [linux]
libc: [glibc]
+ lightningcss-linux-x64-gnu@1.32.0:
+ resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
lightningcss-linux-x64-musl@1.30.2:
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
engines: {node: '>= 12.0.0'}
@@ -2895,22 +3309,45 @@ packages:
os: [linux]
libc: [musl]
+ lightningcss-linux-x64-musl@1.32.0:
+ resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
lightningcss-win32-arm64-msvc@1.30.2:
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
+ lightningcss-win32-arm64-msvc@1.32.0:
+ resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [win32]
+
lightningcss-win32-x64-msvc@1.30.2:
resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
+ lightningcss-win32-x64-msvc@1.32.0:
+ resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [win32]
+
lightningcss@1.30.2:
resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==}
engines: {node: '>= 12.0.0'}
+ lightningcss@1.32.0:
+ resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
+ engines: {node: '>= 12.0.0'}
+
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@@ -2934,11 +3371,19 @@ packages:
lowlight@3.3.0:
resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==}
+ lru-cache@11.5.2:
+ resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==}
+ engines: {node: 20 || >=22}
+
lucide-react@0.552.0:
resolution: {integrity: sha512-g9WCjmfwqbexSnZE+2cl21PCfXOcqnGeWeMTNAOGEfpPbm/ZF4YIq77Z8qWrxbu660EKuLB4nSLggoKnCb+isw==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ lz-string@1.5.0:
+ resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
+ hasBin: true
+
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -2994,6 +3439,9 @@ packages:
mdast-util-to-string@4.0.0:
resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
+ mdn-data@2.27.1:
+ resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
+
merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
@@ -3094,6 +3542,10 @@ packages:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
+ min-indent@1.0.1:
+ resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
+ engines: {node: '>=4'}
+
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
@@ -3132,6 +3584,11 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
+ nanoid@3.3.15:
+ resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
napi-postinstall@0.3.4:
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
@@ -3202,6 +3659,10 @@ packages:
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
engines: {node: '>= 0.4'}
+ obug@2.1.3:
+ resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==}
+ engines: {node: '>=12.20.0'}
+
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -3228,6 +3689,9 @@ packages:
parse-entities@4.0.2:
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
+ parse5@8.0.1:
+ resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
+
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
@@ -3239,6 +3703,9 @@ packages:
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
+ pathe@2.0.3:
+ resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
+
performance-now@2.1.0:
resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
@@ -3253,6 +3720,10 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
+ picomatch@4.0.5:
+ resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
+ engines: {node: '>=12'}
+
pinyin-match@1.2.10:
resolution: {integrity: sha512-Xo1uMd9n56/xP2EAn7yfApnTmAGCLGqxFIm1fuOnSMty0xhqRF3+ZwVjp2yWFD/alv60RUWa/cZoQSeTSp1HxA==}
@@ -3268,6 +3739,10 @@ packages:
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
engines: {node: ^10 || ^12 || >=14}
+ postcss@8.5.16:
+ resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==}
+ engines: {node: ^10 || ^12 || >=14}
+
postcss@8.5.6:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
@@ -3276,6 +3751,10 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
+ pretty-format@27.5.1:
+ resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
+ engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
+
prismjs@1.30.0:
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
engines: {node: '>=6'}
@@ -3344,6 +3823,9 @@ packages:
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
+ react-is@17.0.2:
+ resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
+
react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
@@ -3416,6 +3898,10 @@ packages:
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ redent@3.0.0:
+ resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
+ engines: {node: '>=8'}
+
reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@@ -3448,6 +3934,10 @@ packages:
remark-stringify@11.0.0:
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
+ require-from-string@2.0.2:
+ resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
+ engines: {node: '>=0.10.0'}
+
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -3472,6 +3962,11 @@ packages:
resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==}
engines: {node: '>= 0.8.15'}
+ rolldown@1.1.5:
+ resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -3487,6 +3982,10 @@ packages:
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
engines: {node: '>= 0.4'}
+ saxes@6.0.0:
+ resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
+ engines: {node: '>=v12.22.7'}
+
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -3539,6 +4038,9 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
+ siginfo@2.0.0:
+ resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
+
size-sensor@1.0.3:
resolution: {integrity: sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==}
@@ -3558,10 +4060,16 @@ packages:
stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
+ stackback@0.0.2:
+ resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
+
stackblur-canvas@2.7.0:
resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==}
engines: {node: '>=0.1.14'}
+ std-env@4.2.0:
+ resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
+
stop-iteration-iterator@1.1.0:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
@@ -3596,6 +4104,10 @@ packages:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'}
+ strip-indent@3.0.0:
+ resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
+ engines: {node: '>=8'}
+
strip-json-comments@3.1.1:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
@@ -3634,6 +4146,9 @@ packages:
resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==}
engines: {node: '>=12.0.0'}
+ symbol-tree@3.2.4:
+ resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
+
tailwind-merge@3.3.1:
resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
@@ -3650,14 +4165,44 @@ packages:
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
+ tinybench@2.9.0:
+ resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
+
+ tinyexec@1.2.4:
+ resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==}
+ engines: {node: '>=18'}
+
tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
+ tinyglobby@0.2.17:
+ resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
+ engines: {node: '>=12.0.0'}
+
+ tinyrainbow@3.1.0:
+ resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
+ engines: {node: '>=14.0.0'}
+
+ tldts-core@7.4.8:
+ resolution: {integrity: sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw==}
+
+ tldts@7.4.8:
+ resolution: {integrity: sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A==}
+ hasBin: true
+
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
+ tough-cookie@6.0.2:
+ resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==}
+ engines: {node: '>=16'}
+
+ tr46@6.0.0:
+ resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
+ engines: {node: '>=20'}
+
trim-lines@3.0.1:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
@@ -3714,6 +4259,10 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+ undici@7.28.0:
+ resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
+ engines: {node: '>=20.18.1'}
+
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
@@ -3787,9 +4336,109 @@ packages:
victory-vendor@36.9.2:
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
+ vite@8.1.4:
+ resolution: {integrity: sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^20.19.0 || >=22.12.0
+ '@vitejs/devtools': ^0.3.0
+ esbuild: ^0.27.0 || ^0.28.0
+ jiti: '>=1.21.0'
+ less: ^4.0.0
+ sass: ^1.70.0
+ sass-embedded: ^1.70.0
+ stylus: '>=0.54.8'
+ sugarss: ^5.0.0
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ '@vitejs/devtools':
+ optional: true
+ esbuild:
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ vitest@4.1.10:
+ resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==}
+ engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
+ hasBin: true
+ peerDependencies:
+ '@edge-runtime/vm': '*'
+ '@opentelemetry/api': ^1.9.0
+ '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
+ '@vitest/browser-playwright': 4.1.10
+ '@vitest/browser-preview': 4.1.10
+ '@vitest/browser-webdriverio': 4.1.10
+ '@vitest/coverage-istanbul': 4.1.10
+ '@vitest/coverage-v8': 4.1.10
+ '@vitest/ui': 4.1.10
+ happy-dom: '*'
+ jsdom: '*'
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ '@edge-runtime/vm':
+ optional: true
+ '@opentelemetry/api':
+ optional: true
+ '@types/node':
+ optional: true
+ '@vitest/browser-playwright':
+ optional: true
+ '@vitest/browser-preview':
+ optional: true
+ '@vitest/browser-webdriverio':
+ optional: true
+ '@vitest/coverage-istanbul':
+ optional: true
+ '@vitest/coverage-v8':
+ optional: true
+ '@vitest/ui':
+ optional: true
+ happy-dom:
+ optional: true
+ jsdom:
+ optional: true
+
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
+ w3c-xmlserializer@5.0.0:
+ resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
+ engines: {node: '>=18'}
+
+ webidl-conversions@8.0.1:
+ resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
+ engines: {node: '>=20'}
+
+ whatwg-mimetype@5.0.0:
+ resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
+ engines: {node: '>=20'}
+
+ whatwg-url@16.0.1:
+ resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@@ -3811,10 +4460,22 @@ packages:
engines: {node: '>= 8'}
hasBin: true
+ why-is-node-running@2.3.0:
+ resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
+ engines: {node: '>=8'}
+ hasBin: true
+
word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
+ xml-name-validator@5.0.0:
+ resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
+ engines: {node: '>=18'}
+
+ xmlchars@2.2.0:
+ resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
+
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -3830,12 +4491,42 @@ packages:
snapshots:
+ '@adobe/css-tools@4.5.0': {}
+
'@alloc/quick-lru@5.2.0': {}
+ '@asamuzakjp/css-color@5.1.11':
+ dependencies:
+ '@asamuzakjp/generational-cache': 1.0.1
+ '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-color-parser': 4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@asamuzakjp/dom-selector@7.1.1':
+ dependencies:
+ '@asamuzakjp/generational-cache': 1.0.1
+ '@asamuzakjp/nwsapi': 2.3.9
+ bidi-js: 1.0.3
+ css-tree: 3.2.1
+ is-potential-custom-element-name: 1.0.1
+
+ '@asamuzakjp/generational-cache@1.0.1': {}
+
+ '@asamuzakjp/nwsapi@2.3.9': {}
+
+ '@babel/code-frame@7.29.7':
+ dependencies:
+ '@babel/helper-validator-identifier': 7.29.7
+ js-tokens: 4.0.0
+ picocolors: 1.1.1
+
'@babel/helper-string-parser@7.27.1': {}
'@babel/helper-validator-identifier@7.28.5': {}
+ '@babel/helper-validator-identifier@7.29.7': {}
+
'@babel/runtime@7.28.4': {}
'@babel/types@7.28.5':
@@ -3843,6 +4534,10 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
+ '@bramus/specificity@2.4.2':
+ dependencies:
+ css-tree: 3.2.1
+
'@codemirror/autocomplete@6.20.3':
dependencies:
'@codemirror/language': 6.12.3
@@ -3905,6 +4600,30 @@ snapshots:
style-mod: 4.1.3
w3c-keyname: 2.2.8
+ '@csstools/color-helpers@6.1.0': {}
+
+ '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-color-parser@4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/color-helpers': 6.1.0
+ '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-syntax-patches-for-csstree@1.1.6(css-tree@3.2.1)':
+ optionalDependencies:
+ css-tree: 3.2.1
+
+ '@csstools/css-tokenizer@4.0.0': {}
+
'@date-fns/tz@1.4.1': {}
'@dnd-kit/accessibility@3.1.1(react@19.2.3)':
@@ -3939,12 +4658,23 @@ snapshots:
react: 19.2.3
tslib: 2.8.1
+ '@emnapi/core@1.11.1':
+ dependencies:
+ '@emnapi/wasi-threads': 1.2.2
+ tslib: 2.8.1
+ optional: true
+
'@emnapi/core@1.7.0':
dependencies:
'@emnapi/wasi-threads': 1.1.0
tslib: 2.8.1
optional: true
+ '@emnapi/runtime@1.11.1':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
'@emnapi/runtime@1.7.0':
dependencies:
tslib: 2.8.1
@@ -3955,6 +4685,11 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@emnapi/wasi-threads@1.2.2':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
'@eslint-community/eslint-utils@4.9.0(eslint@9.39.1(jiti@2.6.1))':
dependencies:
eslint: 9.39.1(jiti@2.6.1)
@@ -4001,6 +4736,8 @@ snapshots:
'@eslint/core': 0.17.0
levn: 0.4.1
+ '@exodus/bytes@1.15.1': {}
+
'@floating-ui/core@1.7.3':
dependencies:
'@floating-ui/utils': 0.2.10
@@ -4171,6 +4908,13 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
+ '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)':
+ dependencies:
+ '@emnapi/core': 1.11.1
+ '@emnapi/runtime': 1.11.1
+ '@tybys/wasm-util': 0.10.3
+ optional: true
+
'@next/env@16.2.7': {}
'@next/eslint-plugin-next@15.5.6':
@@ -4215,6 +4959,8 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
+ '@oxc-project/types@0.139.0': {}
+
'@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.3': {}
@@ -5034,10 +5780,63 @@ snapshots:
'@radix-ui/rect@1.1.1': {}
+ '@rolldown/binding-android-arm64@1.1.5':
+ optional: true
+
+ '@rolldown/binding-darwin-arm64@1.1.5':
+ optional: true
+
+ '@rolldown/binding-darwin-x64@1.1.5':
+ optional: true
+
+ '@rolldown/binding-freebsd-x64@1.1.5':
+ optional: true
+
+ '@rolldown/binding-linux-arm-gnueabihf@1.1.5':
+ optional: true
+
+ '@rolldown/binding-linux-arm64-gnu@1.1.5':
+ optional: true
+
+ '@rolldown/binding-linux-arm64-musl@1.1.5':
+ optional: true
+
+ '@rolldown/binding-linux-ppc64-gnu@1.1.5':
+ optional: true
+
+ '@rolldown/binding-linux-s390x-gnu@1.1.5':
+ optional: true
+
+ '@rolldown/binding-linux-x64-gnu@1.1.5':
+ optional: true
+
+ '@rolldown/binding-linux-x64-musl@1.1.5':
+ optional: true
+
+ '@rolldown/binding-openharmony-arm64@1.1.5':
+ optional: true
+
+ '@rolldown/binding-wasm32-wasi@1.1.5':
+ dependencies:
+ '@emnapi/core': 1.11.1
+ '@emnapi/runtime': 1.11.1
+ '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)
+ optional: true
+
+ '@rolldown/binding-win32-arm64-msvc@1.1.5':
+ optional: true
+
+ '@rolldown/binding-win32-x64-msvc@1.1.5':
+ optional: true
+
+ '@rolldown/pluginutils@1.0.1': {}
+
'@rtsao/scc@1.1.0': {}
'@rushstack/eslint-patch@1.14.1': {}
+ '@standard-schema/spec@1.1.0': {}
+
'@standard-schema/utils@0.3.0': {}
'@swc/helpers@0.5.15':
@@ -5148,11 +5947,57 @@ snapshots:
'@tanstack/virtual-core@3.13.16': {}
+ '@testing-library/dom@10.4.1':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/runtime': 7.28.4
+ '@types/aria-query': 5.0.4
+ aria-query: 5.3.0
+ dom-accessibility-api: 0.5.16
+ lz-string: 1.5.0
+ picocolors: 1.1.1
+ pretty-format: 27.5.1
+
+ '@testing-library/jest-dom@6.9.1':
+ dependencies:
+ '@adobe/css-tools': 4.5.0
+ aria-query: 5.3.2
+ css.escape: 1.5.1
+ dom-accessibility-api: 0.6.3
+ picocolors: 1.1.1
+ redent: 3.0.0
+
+ '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@babel/runtime': 7.28.4
+ '@testing-library/dom': 10.4.1
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.2
+ '@types/react-dom': 19.2.2(@types/react@19.2.2)
+
+ '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
+ dependencies:
+ '@testing-library/dom': 10.4.1
+
'@tybys/wasm-util@0.10.1':
dependencies:
tslib: 2.8.1
optional: true
+ '@tybys/wasm-util@0.10.3':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@types/aria-query@5.0.4': {}
+
+ '@types/chai@5.2.3':
+ dependencies:
+ '@types/deep-eql': 4.0.2
+ assertion-error: 2.0.1
+
'@types/d3-array@3.2.2': {}
'@types/d3-color@3.1.3': {}
@@ -5181,6 +6026,8 @@ snapshots:
dependencies:
'@types/ms': 2.1.0
+ '@types/deep-eql@4.0.2': {}
+
'@types/estree-jsx@1.0.5':
dependencies:
'@types/estree': 1.0.8
@@ -5408,6 +6255,47 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
optional: true
+ '@vitest/expect@4.1.10':
+ dependencies:
+ '@standard-schema/spec': 1.1.0
+ '@types/chai': 5.2.3
+ '@vitest/spy': 4.1.10
+ '@vitest/utils': 4.1.10
+ chai: 6.2.2
+ tinyrainbow: 3.1.0
+
+ '@vitest/mocker@4.1.10(vite@8.1.4(@types/node@20.19.24)(jiti@2.6.1))':
+ dependencies:
+ '@vitest/spy': 4.1.10
+ estree-walker: 3.0.3
+ magic-string: 0.30.21
+ optionalDependencies:
+ vite: 8.1.4(@types/node@20.19.24)(jiti@2.6.1)
+
+ '@vitest/pretty-format@4.1.10':
+ dependencies:
+ tinyrainbow: 3.1.0
+
+ '@vitest/runner@4.1.10':
+ dependencies:
+ '@vitest/utils': 4.1.10
+ pathe: 2.0.3
+
+ '@vitest/snapshot@4.1.10':
+ dependencies:
+ '@vitest/pretty-format': 4.1.10
+ '@vitest/utils': 4.1.10
+ magic-string: 0.30.21
+ pathe: 2.0.3
+
+ '@vitest/spy@4.1.10': {}
+
+ '@vitest/utils@4.1.10':
+ dependencies:
+ '@vitest/pretty-format': 4.1.10
+ convert-source-map: 2.0.0
+ tinyrainbow: 3.1.0
+
acorn-jsx@5.3.2(acorn@8.15.0):
dependencies:
acorn: 8.15.0
@@ -5421,16 +6309,24 @@ snapshots:
json-schema-traverse: 0.4.1
uri-js: 4.4.1
+ ansi-regex@5.0.1: {}
+
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
+ ansi-styles@5.2.0: {}
+
argparse@2.0.1: {}
aria-hidden@1.2.6:
dependencies:
tslib: 2.8.1
+ aria-query@5.3.0:
+ dependencies:
+ dequal: 2.0.3
+
aria-query@5.3.2: {}
array-buffer-byte-length@1.0.2:
@@ -5500,6 +6396,8 @@ snapshots:
get-intrinsic: 1.3.0
is-array-buffer: 3.0.5
+ assertion-error@2.0.1: {}
+
ast-types-flow@0.0.8: {}
async-function@1.0.0: {}
@@ -5535,6 +6433,10 @@ snapshots:
baseline-browser-mapping@2.10.34: {}
+ bidi-js@1.0.3:
+ dependencies:
+ require-from-string: 2.0.2
+
brace-expansion@1.1.12:
dependencies:
balanced-match: 1.0.2
@@ -5583,6 +6485,8 @@ snapshots:
ccount@2.0.1: {}
+ chai@6.2.2: {}
+
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@@ -5640,6 +6544,8 @@ snapshots:
concat-map@0.0.1: {}
+ convert-source-map@2.0.0: {}
+
core-js@3.47.0:
optional: true
@@ -5656,6 +6562,13 @@ snapshots:
utrie: 1.0.2
optional: true
+ css-tree@3.2.1:
+ dependencies:
+ mdn-data: 2.27.1
+ source-map-js: 1.2.1
+
+ css.escape@1.5.1: {}
+
cssesc@3.0.0: {}
csstype@3.1.3: {}
@@ -5700,6 +6613,13 @@ snapshots:
damerau-levenshtein@1.0.8: {}
+ data-urls@7.0.0:
+ dependencies:
+ whatwg-mimetype: 5.0.0
+ whatwg-url: 16.0.1
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
data-view-buffer@1.0.2:
dependencies:
call-bound: 1.0.4
@@ -5732,6 +6652,8 @@ snapshots:
decimal.js-light@2.5.1: {}
+ decimal.js@10.6.0: {}
+
decode-named-character-reference@1.2.0:
dependencies:
character-entities: 2.0.2
@@ -5766,6 +6688,10 @@ snapshots:
dependencies:
esutils: 2.0.3
+ dom-accessibility-api@0.5.16: {}
+
+ dom-accessibility-api@0.6.3: {}
+
dom-helpers@5.2.1:
dependencies:
'@babel/runtime': 7.28.4
@@ -5813,6 +6739,8 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.3.0
+ entities@8.0.0: {}
+
es-abstract@1.24.0:
dependencies:
array-buffer-byte-length: 1.0.2
@@ -5893,6 +6821,8 @@ snapshots:
iterator.prototype: 1.1.5
safe-array-concat: 1.1.3
+ es-module-lexer@2.3.0: {}
+
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
@@ -6114,10 +7044,16 @@ snapshots:
estree-util-is-identifier-name@3.0.0: {}
+ estree-walker@3.0.3:
+ dependencies:
+ '@types/estree': 1.0.8
+
esutils@2.0.3: {}
eventemitter3@4.0.7: {}
+ expect-type@1.4.0: {}
+
extend@3.0.2: {}
fast-deep-equal@3.1.3: {}
@@ -6162,6 +7098,10 @@ snapshots:
optionalDependencies:
picomatch: 4.0.3
+ fdir@6.5.0(picomatch@4.0.5):
+ optionalDependencies:
+ picomatch: 4.0.5
+
fflate@0.8.2: {}
file-entry-cache@8.0.0:
@@ -6209,6 +7149,9 @@ snapshots:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
+ fsevents@2.3.3:
+ optional: true
+
function-bind@1.1.2: {}
function.prototype.name@1.1.8:
@@ -6360,6 +7303,12 @@ snapshots:
highlightjs-vue@1.0.0: {}
+ html-encoding-sniffer@6.0.0:
+ dependencies:
+ '@exodus/bytes': 1.15.1
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
html-url-attributes@3.0.1: {}
html2canvas@1.4.1:
@@ -6379,6 +7328,8 @@ snapshots:
imurmurhash@0.1.4: {}
+ indent-string@4.0.0: {}
+
inline-style-parser@0.2.7: {}
input-otp@1.4.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
@@ -6482,6 +7433,8 @@ snapshots:
is-plain-obj@4.1.0: {}
+ is-potential-custom-element-name@1.0.1: {}
+
is-regex@1.2.1:
dependencies:
call-bound: 1.0.4
@@ -6542,6 +7495,32 @@ snapshots:
dependencies:
argparse: 2.0.1
+ jsdom@29.1.1:
+ dependencies:
+ '@asamuzakjp/css-color': 5.1.11
+ '@asamuzakjp/dom-selector': 7.1.1
+ '@bramus/specificity': 2.4.2
+ '@csstools/css-syntax-patches-for-csstree': 1.1.6(css-tree@3.2.1)
+ '@exodus/bytes': 1.15.1
+ css-tree: 3.2.1
+ data-urls: 7.0.0
+ decimal.js: 10.6.0
+ html-encoding-sniffer: 6.0.0
+ is-potential-custom-element-name: 1.0.1
+ lru-cache: 11.5.2
+ parse5: 8.0.1
+ saxes: 6.0.0
+ symbol-tree: 3.2.4
+ tough-cookie: 6.0.2
+ undici: 7.28.0
+ w3c-xmlserializer: 5.0.0
+ webidl-conversions: 8.0.1
+ whatwg-mimetype: 5.0.0
+ whatwg-url: 16.0.1
+ xml-name-validator: 5.0.0
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
json-buffer@3.0.1: {}
json-schema-traverse@0.4.1: {}
@@ -6588,36 +7567,69 @@ snapshots:
lightningcss-android-arm64@1.30.2:
optional: true
+ lightningcss-android-arm64@1.32.0:
+ optional: true
+
lightningcss-darwin-arm64@1.30.2:
optional: true
+ lightningcss-darwin-arm64@1.32.0:
+ optional: true
+
lightningcss-darwin-x64@1.30.2:
optional: true
+ lightningcss-darwin-x64@1.32.0:
+ optional: true
+
lightningcss-freebsd-x64@1.30.2:
optional: true
+ lightningcss-freebsd-x64@1.32.0:
+ optional: true
+
lightningcss-linux-arm-gnueabihf@1.30.2:
optional: true
+ lightningcss-linux-arm-gnueabihf@1.32.0:
+ optional: true
+
lightningcss-linux-arm64-gnu@1.30.2:
optional: true
+ lightningcss-linux-arm64-gnu@1.32.0:
+ optional: true
+
lightningcss-linux-arm64-musl@1.30.2:
optional: true
+ lightningcss-linux-arm64-musl@1.32.0:
+ optional: true
+
lightningcss-linux-x64-gnu@1.30.2:
optional: true
+ lightningcss-linux-x64-gnu@1.32.0:
+ optional: true
+
lightningcss-linux-x64-musl@1.30.2:
optional: true
+ lightningcss-linux-x64-musl@1.32.0:
+ optional: true
+
lightningcss-win32-arm64-msvc@1.30.2:
optional: true
+ lightningcss-win32-arm64-msvc@1.32.0:
+ optional: true
+
lightningcss-win32-x64-msvc@1.30.2:
optional: true
+ lightningcss-win32-x64-msvc@1.32.0:
+ optional: true
+
lightningcss@1.30.2:
dependencies:
detect-libc: 2.1.2
@@ -6634,6 +7646,22 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.30.2
lightningcss-win32-x64-msvc: 1.30.2
+ lightningcss@1.32.0:
+ dependencies:
+ detect-libc: 2.1.2
+ optionalDependencies:
+ lightningcss-android-arm64: 1.32.0
+ lightningcss-darwin-arm64: 1.32.0
+ lightningcss-darwin-x64: 1.32.0
+ lightningcss-freebsd-x64: 1.32.0
+ lightningcss-linux-arm-gnueabihf: 1.32.0
+ lightningcss-linux-arm64-gnu: 1.32.0
+ lightningcss-linux-arm64-musl: 1.32.0
+ lightningcss-linux-x64-gnu: 1.32.0
+ lightningcss-linux-x64-musl: 1.32.0
+ lightningcss-win32-arm64-msvc: 1.32.0
+ lightningcss-win32-x64-msvc: 1.32.0
+
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@@ -6659,10 +7687,14 @@ snapshots:
devlop: 1.1.0
highlight.js: 11.11.1
+ lru-cache@11.5.2: {}
+
lucide-react@0.552.0(react@19.2.3):
dependencies:
react: 19.2.3
+ lz-string@1.5.0: {}
+
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -6824,6 +7856,8 @@ snapshots:
dependencies:
'@types/mdast': 4.0.4
+ mdn-data@2.27.1: {}
+
merge2@1.4.1: {}
micromark-core-commonmark@2.0.3:
@@ -7028,6 +8062,8 @@ snapshots:
dependencies:
mime-db: 1.52.0
+ min-indent@1.0.1: {}
+
minimatch@3.1.2:
dependencies:
brace-expansion: 1.1.12
@@ -7056,6 +8092,8 @@ snapshots:
nanoid@3.3.11: {}
+ nanoid@3.3.15: {}
+
napi-postinstall@0.3.4: {}
natural-compare@1.4.0: {}
@@ -7134,6 +8172,8 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.1
+ obug@2.1.3: {}
+
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -7173,12 +8213,18 @@ snapshots:
is-decimal: 2.0.1
is-hexadecimal: 2.0.1
+ parse5@8.0.1:
+ dependencies:
+ entities: 8.0.0
+
path-exists@4.0.0: {}
path-key@3.1.1: {}
path-parse@1.0.7: {}
+ pathe@2.0.3: {}
+
performance-now@2.1.0:
optional: true
@@ -7188,6 +8234,8 @@ snapshots:
picomatch@4.0.3: {}
+ picomatch@4.0.5: {}
+
pinyin-match@1.2.10: {}
possible-typed-array-names@1.1.0: {}
@@ -7203,6 +8251,12 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
+ postcss@8.5.16:
+ dependencies:
+ nanoid: 3.3.15
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
postcss@8.5.6:
dependencies:
nanoid: 3.3.11
@@ -7211,6 +8265,12 @@ snapshots:
prelude-ls@1.2.1: {}
+ pretty-format@27.5.1:
+ dependencies:
+ ansi-regex: 5.0.1
+ ansi-styles: 5.2.0
+ react-is: 17.0.2
+
prismjs@1.30.0: {}
prop-types@15.8.1:
@@ -7324,6 +8384,8 @@ snapshots:
react-is@16.13.1: {}
+ react-is@17.0.2: {}
+
react-is@18.3.1: {}
react-markdown@10.1.0(@types/react@19.2.2)(react@19.2.3):
@@ -7417,6 +8479,11 @@ snapshots:
tiny-invariant: 1.3.3
victory-vendor: 36.9.2
+ redent@3.0.0:
+ dependencies:
+ indent-string: 4.0.0
+ strip-indent: 3.0.0
+
reflect.getprototypeof@1.0.10:
dependencies:
call-bind: 1.0.8
@@ -7497,6 +8564,8 @@ snapshots:
mdast-util-to-markdown: 2.1.2
unified: 11.0.5
+ require-from-string@2.0.2: {}
+
resolve-from@4.0.0: {}
resolve-pkg-maps@1.0.0: {}
@@ -7518,6 +8587,27 @@ snapshots:
rgbcolor@1.0.1:
optional: true
+ rolldown@1.1.5:
+ dependencies:
+ '@oxc-project/types': 0.139.0
+ '@rolldown/pluginutils': 1.0.1
+ optionalDependencies:
+ '@rolldown/binding-android-arm64': 1.1.5
+ '@rolldown/binding-darwin-arm64': 1.1.5
+ '@rolldown/binding-darwin-x64': 1.1.5
+ '@rolldown/binding-freebsd-x64': 1.1.5
+ '@rolldown/binding-linux-arm-gnueabihf': 1.1.5
+ '@rolldown/binding-linux-arm64-gnu': 1.1.5
+ '@rolldown/binding-linux-arm64-musl': 1.1.5
+ '@rolldown/binding-linux-ppc64-gnu': 1.1.5
+ '@rolldown/binding-linux-s390x-gnu': 1.1.5
+ '@rolldown/binding-linux-x64-gnu': 1.1.5
+ '@rolldown/binding-linux-x64-musl': 1.1.5
+ '@rolldown/binding-openharmony-arm64': 1.1.5
+ '@rolldown/binding-wasm32-wasi': 1.1.5
+ '@rolldown/binding-win32-arm64-msvc': 1.1.5
+ '@rolldown/binding-win32-x64-msvc': 1.1.5
+
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
@@ -7541,6 +8631,10 @@ snapshots:
es-errors: 1.3.0
is-regex: 1.2.1
+ saxes@6.0.0:
+ dependencies:
+ xmlchars: 2.2.0
+
scheduler@0.27.0: {}
semver@6.3.1: {}
@@ -7635,6 +8729,8 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
+ siginfo@2.0.0: {}
+
size-sensor@1.0.3: {}
sonner@2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
@@ -7648,9 +8744,13 @@ snapshots:
stable-hash@0.0.5: {}
+ stackback@0.0.2: {}
+
stackblur-canvas@2.7.0:
optional: true
+ std-env@4.2.0: {}
+
stop-iteration-iterator@1.1.0:
dependencies:
es-errors: 1.3.0
@@ -7713,6 +8813,10 @@ snapshots:
strip-bom@3.0.0: {}
+ strip-indent@3.0.0:
+ dependencies:
+ min-indent: 1.0.1
+
strip-json-comments@3.1.1: {}
style-mod@4.1.3: {}
@@ -7739,6 +8843,8 @@ snapshots:
svg-pathdata@6.0.3:
optional: true
+ symbol-tree@3.2.4: {}
+
tailwind-merge@3.3.1: {}
tailwindcss@4.1.16: {}
@@ -7752,15 +8858,40 @@ snapshots:
tiny-invariant@1.3.3: {}
+ tinybench@2.9.0: {}
+
+ tinyexec@1.2.4: {}
+
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
+ tinyglobby@0.2.17:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.5)
+ picomatch: 4.0.5
+
+ tinyrainbow@3.1.0: {}
+
+ tldts-core@7.4.8: {}
+
+ tldts@7.4.8:
+ dependencies:
+ tldts-core: 7.4.8
+
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
+ tough-cookie@6.0.2:
+ dependencies:
+ tldts: 7.4.8
+
+ tr46@6.0.0:
+ dependencies:
+ punycode: 2.3.1
+
trim-lines@3.0.1: {}
trough@2.2.0: {}
@@ -7830,6 +8961,8 @@ snapshots:
undici-types@6.21.0: {}
+ undici@7.28.0: {}
+
unified@11.0.5:
dependencies:
'@types/unist': 3.0.3
@@ -7958,8 +9091,64 @@ snapshots:
d3-time: 3.1.0
d3-timer: 3.0.1
+ vite@8.1.4(@types/node@20.19.24)(jiti@2.6.1):
+ dependencies:
+ lightningcss: 1.32.0
+ picomatch: 4.0.5
+ postcss: 8.5.16
+ rolldown: 1.1.5
+ tinyglobby: 0.2.17
+ optionalDependencies:
+ '@types/node': 20.19.24
+ fsevents: 2.3.3
+ jiti: 2.6.1
+
+ vitest@4.1.10(@types/node@20.19.24)(jsdom@29.1.1)(vite@8.1.4(@types/node@20.19.24)(jiti@2.6.1)):
+ dependencies:
+ '@vitest/expect': 4.1.10
+ '@vitest/mocker': 4.1.10(vite@8.1.4(@types/node@20.19.24)(jiti@2.6.1))
+ '@vitest/pretty-format': 4.1.10
+ '@vitest/runner': 4.1.10
+ '@vitest/snapshot': 4.1.10
+ '@vitest/spy': 4.1.10
+ '@vitest/utils': 4.1.10
+ es-module-lexer: 2.3.0
+ expect-type: 1.4.0
+ magic-string: 0.30.21
+ obug: 2.1.3
+ pathe: 2.0.3
+ picomatch: 4.0.3
+ std-env: 4.2.0
+ tinybench: 2.9.0
+ tinyexec: 1.2.4
+ tinyglobby: 0.2.15
+ tinyrainbow: 3.1.0
+ vite: 8.1.4(@types/node@20.19.24)(jiti@2.6.1)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ '@types/node': 20.19.24
+ jsdom: 29.1.1
+ transitivePeerDependencies:
+ - msw
+
w3c-keyname@2.2.8: {}
+ w3c-xmlserializer@5.0.0:
+ dependencies:
+ xml-name-validator: 5.0.0
+
+ webidl-conversions@8.0.1: {}
+
+ whatwg-mimetype@5.0.0: {}
+
+ whatwg-url@16.0.1:
+ dependencies:
+ '@exodus/bytes': 1.15.1
+ tr46: 6.0.0
+ webidl-conversions: 8.0.1
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
which-boxed-primitive@1.1.1:
dependencies:
is-bigint: 1.1.0
@@ -8005,8 +9194,17 @@ snapshots:
dependencies:
isexe: 2.0.0
+ why-is-node-running@2.3.0:
+ dependencies:
+ siginfo: 2.0.0
+ stackback: 0.0.2
+
word-wrap@1.2.5: {}
+ xml-name-validator@5.0.0: {}
+
+ xmlchars@2.2.0: {}
+
yocto-queue@0.1.0: {}
zod@4.1.12: {}
diff --git a/frontend/tests/setup.ts b/frontend/tests/setup.ts
new file mode 100644
index 00000000..a9d0dd31
--- /dev/null
+++ b/frontend/tests/setup.ts
@@ -0,0 +1 @@
+import '@testing-library/jest-dom/vitest'
diff --git a/frontend/tests/zone/websites-page.test.tsx b/frontend/tests/zone/websites-page.test.tsx
new file mode 100644
index 00000000..7e0e2255
--- /dev/null
+++ b/frontend/tests/zone/websites-page.test.tsx
@@ -0,0 +1,64 @@
+import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
+import {fireEvent, render, screen} from '@testing-library/react';
+import {describe, expect, it, vi} from 'vitest';
+
+import WebsitesPage from '@/app/(main)/websites/page';
+import {ZoneService} from '@/lib/services/openflare';
+
+vi.mock('next/link', () => ({
+ default: ({children, href}: {children: React.ReactNode; href: string}) => (
+ {children}
+ ),
+}));
+
+vi.mock('@/lib/services/openflare', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {...actual, ZoneService: {list: vi.fn()}};
+});
+
+function renderPage() {
+ const client = new QueryClient({defaultOptions: {queries: {retry: false}}});
+ return render(
+
+
+ ,
+ );
+}
+
+describe('WebsitesPage', () => {
+ it('filters zones, shows domain counts, and links to stable ID routes', async () => {
+ vi.mocked(ZoneService.list).mockResolvedValue([
+ {
+ id: 42,
+ domain: 'arctel.de',
+ remark: '生产站点',
+ domain_count: 3,
+ created_at: '',
+ updated_at: '',
+ },
+ {
+ id: 43,
+ domain: 'example.com',
+ remark: '',
+ domain_count: 0,
+ created_at: '',
+ updated_at: '',
+ },
+ ]);
+
+ renderPage();
+
+ expect(await screen.findByRole('heading', {name: 'arctel.de'})).toBeVisible();
+ expect(screen.getByText(/3 个域名/)).toBeVisible();
+ expect(screen.getByText(/0 个域名/)).toBeVisible();
+
+ fireEvent.change(screen.getByPlaceholderText('搜索 Zone 根域'), {
+ target: {value: 'arctel'},
+ });
+ expect(screen.getByRole('link', {name: '管理'})).toHaveAttribute(
+ 'href',
+ '/websites/42',
+ );
+ expect(screen.queryByText('example.com')).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/tests/zone/zone-page.test.tsx b/frontend/tests/zone/zone-page.test.tsx
new file mode 100644
index 00000000..8b3e51ab
--- /dev/null
+++ b/frontend/tests/zone/zone-page.test.tsx
@@ -0,0 +1,85 @@
+import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
+import {render, screen, waitFor} from '@testing-library/react';
+import {beforeEach, describe, expect, it, vi} from 'vitest';
+
+import {ZonePageClient} from '@/app/(main)/websites/[zoneId]/page-client';
+import {TlsCertificateService, ZoneService} from '@/lib/services/openflare';
+
+vi.mock('next/link', () => ({
+ default: ({children, href}: {children: React.ReactNode; href: string}) => (
+ {children}
+ ),
+}));
+
+vi.mock('@/lib/services/openflare', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ ZoneService: {
+ getOverview: vi.fn(),
+ deleteById: vi.fn(),
+ list: vi.fn(),
+ },
+ TlsCertificateService: {
+ list: vi.fn(),
+ },
+ };
+});
+
+function renderPage(zoneId: number) {
+ const client = new QueryClient({
+ defaultOptions: {
+ queries: {retry: false, gcTime: 0},
+ },
+ });
+ return render(
+
+
+ ,
+ );
+}
+
+describe('ZonePageClient', () => {
+ beforeEach(() => {
+ vi.mocked(ZoneService.getOverview).mockReset();
+ vi.mocked(ZoneService.deleteById).mockReset();
+ vi.mocked(TlsCertificateService.list).mockReset();
+ vi.mocked(TlsCertificateService.list).mockResolvedValue([]);
+ });
+
+ it('loads the overview by stable ID and exposes all tabs including empty domains', async () => {
+ vi.mocked(ZoneService.getOverview).mockImplementation(async () => ({
+ zone: {id: 42, domain: 'arctel.de', remark: '', created_at: '', updated_at: ''},
+ domains: [],
+ }));
+
+ renderPage(42);
+
+ await waitFor(() => {
+ expect(ZoneService.getOverview).toHaveBeenCalledWith(42);
+ });
+ expect(await screen.findByRole('heading', {name: 'arctel.de'})).toBeVisible();
+ expect(screen.getByRole('tab', {name: '域名 (0)'})).toBeVisible();
+ expect(screen.getByRole('tab', {name: '证书 (0)'})).toBeVisible();
+ expect(screen.getByRole('tab', {name: '路由'})).toBeVisible();
+ expect(screen.getByRole('tab', {name: '设置'})).toBeVisible();
+ });
+
+ it('renders a not-found state for a missing Zone', async () => {
+ vi.mocked(ZoneService.getOverview).mockRejectedValue(new Error('Zone 不存在'));
+
+ renderPage(42);
+
+ expect(
+ await screen.findByText('网站不存在,可能已被删除或 ID 无效。'),
+ ).toBeVisible();
+ });
+
+ it('renders invalid ID empty state without calling the API', async () => {
+ renderPage(0);
+ expect(
+ await screen.findByText('无效的网站 ID,请从网站列表进入详情页。'),
+ ).toBeVisible();
+ expect(ZoneService.getOverview).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts
new file mode 100644
index 00000000..ec2c9767
--- /dev/null
+++ b/frontend/vitest.config.ts
@@ -0,0 +1,15 @@
+import path from 'node:path'
+import {defineConfig} from 'vitest/config'
+
+export default defineConfig({
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname),
+ },
+ },
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ setupFiles: ['./tests/setup.ts'],
+ },
+})
diff --git a/internal/apps/openflare/zone/logics.go b/internal/apps/openflare/zone/logics.go
index e3aa31a7..66d66bb0 100644
--- a/internal/apps/openflare/zone/logics.go
+++ b/internal/apps/openflare/zone/logics.go
@@ -8,6 +8,7 @@ import (
"context"
"errors"
"strings"
+ "time"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
@@ -34,6 +35,16 @@ type Overview struct {
Domains []model.ZoneDomain `json:"domains"`
}
+// ListItem is a Zone list row with denormalized domain count for the UI.
+type ListItem struct {
+ ID uint `json:"id"`
+ Domain string `json:"domain"`
+ Remark string `json:"remark"`
+ DomainCount int64 `json:"domain_count"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
func zoneRoot(domain string) (string, error) {
return publicsuffix.EffectiveTLDPlusOne(strings.ToLower(strings.TrimSpace(domain)))
}
@@ -99,11 +110,41 @@ func Update(ctx context.Context, id uint, input Input) (*model.Zone, error) {
return &zone, nil
}
-// List returns all Zones in stable domain order.
-func List(ctx context.Context) ([]model.Zone, error) {
+// List returns all Zones in stable domain order, with domain counts for list cards.
+func List(ctx context.Context) ([]ListItem, error) {
var zones []model.Zone
- err := db.DB(ctx).Order("domain asc").Find(&zones).Error
- return zones, err
+ if err := db.DB(ctx).Order("domain asc").Find(&zones).Error; err != nil {
+ return nil, err
+ }
+
+ type countRow struct {
+ ZoneID uint `gorm:"column:zone_id"`
+ Count int64 `gorm:"column:count"`
+ }
+ var rows []countRow
+ if err := db.DB(ctx).Model(&model.ZoneDomain{}).
+ Select("zone_id, count(*) as count").
+ Group("zone_id").
+ Scan(&rows).Error; err != nil {
+ return nil, err
+ }
+ counts := make(map[uint]int64, len(rows))
+ for _, row := range rows {
+ counts[row.ZoneID] = row.Count
+ }
+
+ items := make([]ListItem, 0, len(zones))
+ for _, zone := range zones {
+ items = append(items, ListItem{
+ ID: zone.ID,
+ Domain: zone.Domain,
+ Remark: zone.Remark,
+ DomainCount: counts[zone.ID],
+ CreatedAt: zone.CreatedAt,
+ UpdatedAt: zone.UpdatedAt,
+ })
+ }
+ return items, nil
}
// GetOverview returns a Zone and its domains.
diff --git a/internal/apps/openflare/zone/routers.go b/internal/apps/openflare/zone/routers.go
index 455c7361..4c8658fe 100644
--- a/internal/apps/openflare/zone/routers.go
+++ b/internal/apps/openflare/zone/routers.go
@@ -33,7 +33,7 @@ func abort(c *gin.Context, err error, missing string) bool {
// @Tags openflare-zone
// @Produce json
// @Security SessionCookie
-// @Success 200 {object} response.Any{data=[]model.Zone}
+// @Success 200 {object} response.Any{data=[]zone.ListItem}
// @Router /api/v1/d/zones [get]
func ListHandler(c *gin.Context) {
items, err := List(c.Request.Context())