merge: Фаза 1C — React SPA
- web/: Vite+React+TS+TanStack Query+shadcn/Tailwind, тёмная technical console - API-клиент+типы+хуки (зеркало Go DTO), DEFAULT_PROJECT_ID - DiffView+DomainDiffPage (prune-guard по умолчанию выключен) - DomainsPage (импорт зон, привязка шаблона), AccountsPage (secret+Selectel), TemplatesPage+RecordEditor - internal/web: embed SPA + SPA-fallback, cmd/server монтирование (API приоритетно) Финальный ревью: READY TO MERGE. web 32 теста, Go 74/14 пакетов. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+12
@@ -3,3 +3,15 @@
|
||||
/bin/
|
||||
*.env
|
||||
.env
|
||||
|
||||
# web (Vite frontend)
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
|
||||
# internal/web/dist: real build output is generated by `make web` and
|
||||
# gitignored, EXCEPT index.html — a minimal placeholder is committed so
|
||||
# `go build ./...` (which //go:embed all:dist needs) works on a clean
|
||||
# clone without npm/CI web-build step. `make web` overwrites the
|
||||
# placeholder with the real built index.html.
|
||||
internal/web/dist/*
|
||||
!internal/web/dist/index.html
|
||||
|
||||
@@ -5,3 +5,12 @@ test:
|
||||
.PHONY: build
|
||||
build:
|
||||
go build ./...
|
||||
|
||||
.PHONY: web
|
||||
web:
|
||||
cd web && npm ci && npm run build
|
||||
rm -rf internal/web/dist
|
||||
cp -r web/dist internal/web/dist
|
||||
|
||||
.PHONY: build-all
|
||||
build-all: web build
|
||||
|
||||
+29
-1
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
@@ -14,8 +15,17 @@ import (
|
||||
"github.com/vasyakrg/dns-autoresolver/internal/provider/selectel"
|
||||
"github.com/vasyakrg/dns-autoresolver/internal/service"
|
||||
"github.com/vasyakrg/dns-autoresolver/internal/store"
|
||||
"github.com/vasyakrg/dns-autoresolver/internal/web"
|
||||
)
|
||||
|
||||
// isAPIPath reports whether path must be routed to the API router rather
|
||||
// than the SPA. "/api" (no trailing slash) counts as an API path too —
|
||||
// only strings.HasPrefix(path, "/api/") would otherwise miss it and fall
|
||||
// through to the SPA fallback.
|
||||
func isAPIPath(path string) bool {
|
||||
return path == "/api" || strings.HasPrefix(path, "/api/")
|
||||
}
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
cfg, err := config.Load()
|
||||
@@ -42,9 +52,27 @@ func main() {
|
||||
|
||||
svc := service.New(st, st, reg, cipher)
|
||||
a := &api.API{Svc: svc, Store: st, Cipher: cipher, Reg: reg}
|
||||
apiRouter := api.NewRouter(a)
|
||||
|
||||
webHandler, err := web.Handler()
|
||||
if err != nil {
|
||||
log.Printf("web: static UI unavailable: %v", err)
|
||||
}
|
||||
|
||||
mux := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if isAPIPath(r.URL.Path) {
|
||||
apiRouter.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if webHandler != nil {
|
||||
webHandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
|
||||
log.Printf("listening on %s", cfg.ListenAddr)
|
||||
if err := http.ListenAndServe(cfg.ListenAddr, api.NewRouter(a)); err != nil {
|
||||
if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsAPIPath(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{"/api", true},
|
||||
{"/api/", true},
|
||||
{"/api/domains", true},
|
||||
{"/", false},
|
||||
{"/domains/xyz", false},
|
||||
{"/apix", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := isAPIPath(c.path); got != c.want {
|
||||
t.Errorf("isAPIPath(%q) = %v, want %v", c.path, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
<!doctype html><title>DNS Autoresolver</title><body>UI not built. Run: make web</body>
|
||||
@@ -0,0 +1,43 @@
|
||||
// Package web embeds the built React SPA (web/dist, copied to
|
||||
// internal/web/dist by `make web` before go build/test) and serves it
|
||||
// with SPA-fallback: any non-file path resolves to index.html so
|
||||
// client-side routing works.
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var distFS embed.FS
|
||||
|
||||
// Handler serves the embedded SPA with fallback to index.html for
|
||||
// client-side routes (any non-file path that isn't an API route).
|
||||
func Handler() (http.Handler, error) {
|
||||
sub, err := fs.Sub(distFS, "dist")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fileServer := http.FileServer(http.FS(sub))
|
||||
index, err := fs.ReadFile(sub, "index.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// существующий файл (ассет) — отдать как есть
|
||||
p := strings.TrimPrefix(r.URL.Path, "/")
|
||||
if p != "" {
|
||||
if f, err := sub.Open(p); err == nil {
|
||||
_ = f.Close()
|
||||
fileServer.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
// иначе — SPA index
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(index)
|
||||
}), nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHandlerServesIndexAndSPAFallback(t *testing.T) {
|
||||
h, err := Handler()
|
||||
if err != nil {
|
||||
t.Fatalf("handler: %v", err)
|
||||
}
|
||||
// корень → 200 и HTML
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("root status %d", rec.Code)
|
||||
}
|
||||
// неизвестный клиентский путь → SPA fallback (index.html), не 404
|
||||
rec2 := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec2, httptest.NewRequest(http.MethodGet, "/domains/xyz", nil))
|
||||
if rec2.Code != http.StatusOK {
|
||||
t.Fatalf("SPA fallback status %d", rec2.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the Oxlint configuration
|
||||
|
||||
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"options": {
|
||||
"typeAware": true
|
||||
},
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>DNS Autoresolver</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+7026
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@fontsource-variable/hanken-grotesk": "^5.2.8",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.23.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-hook-form": "^7.80.0",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"shadcn": "^4.12.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"jsdom": "^29.1.1",
|
||||
"oxlint": "^1.71.0",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.1",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,19 @@
|
||||
import { render, screen, within } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { App } from "./App"
|
||||
|
||||
test("renders navigation and redirects to domains", () => {
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
// Sidebar nav also renders a "Domains" link label, so scope the assertion
|
||||
// to the routed page content to unambiguously confirm the redirect + page.
|
||||
const main = screen.getByRole("main")
|
||||
expect(within(main).getByText("Domains")).toBeInTheDocument()
|
||||
expect(screen.getByRole("link", { name: /domains/i })).toBeInTheDocument()
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Routes, Route, Navigate } from "react-router-dom"
|
||||
import { Layout } from "@/components/Layout"
|
||||
import { AccountsPage } from "@/pages/AccountsPage"
|
||||
import { DomainDiffPage } from "@/pages/DomainDiffPage"
|
||||
import { DomainsPage } from "@/pages/DomainsPage"
|
||||
import { TemplatesPage } from "@/pages/TemplatesPage"
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/domains" replace />} />
|
||||
<Route path="/domains" element={<DomainsPage />} />
|
||||
<Route path="/domains/:id" element={<DomainDiffPage />} />
|
||||
<Route path="/accounts" element={<AccountsPage />} />
|
||||
<Route path="/templates" element={<TemplatesPage />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { api } from "./client"
|
||||
import { DEFAULT_PROJECT_ID } from "@/lib/config"
|
||||
|
||||
beforeEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
function mockFetch(body: unknown, ok = true, status = 200) {
|
||||
return vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok, status,
|
||||
json: async () => body,
|
||||
text: async () => JSON.stringify(body),
|
||||
} as Response)
|
||||
}
|
||||
|
||||
describe("api client", () => {
|
||||
it("lists accounts at project-scoped path", async () => {
|
||||
const spy = mockFetch([{ id: "a1", provider: "selectel", comment: "" }])
|
||||
const accounts = await api.listAccounts()
|
||||
expect(accounts).toHaveLength(1)
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
`/api/v1/projects/${DEFAULT_PROJECT_ID}/accounts`,
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
)
|
||||
})
|
||||
|
||||
it("sends secret on account creation but path has no secret leakage in response typing", async () => {
|
||||
const spy = mockFetch({ id: "a2", provider: "selectel", comment: "prod" })
|
||||
await api.createAccount({ provider: "selectel", secret: "TOKEN", comment: "prod" })
|
||||
const [, opts] = spy.mock.calls[0]
|
||||
expect((opts as RequestInit).method).toBe("POST")
|
||||
expect(String((opts as RequestInit).body)).toContain("TOKEN")
|
||||
})
|
||||
|
||||
it("throws on non-ok response", async () => {
|
||||
mockFetch({ error: "boom" }, false, 500)
|
||||
await expect(api.listDomains()).rejects.toThrow()
|
||||
})
|
||||
|
||||
it("applies with prune flag", async () => {
|
||||
const spy = mockFetch({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 })
|
||||
await api.applyDomain("d1", { applyUpdates: true, applyPrunes: true })
|
||||
const [url, opts] = spy.mock.calls[0]
|
||||
expect(url).toContain("/domains/d1/apply")
|
||||
expect(String((opts as RequestInit).body)).toContain("applyPrunes")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { API_BASE } from "@/lib/config"
|
||||
import type {
|
||||
Account, CreateAccountInput, Template, CreateTemplateInput,
|
||||
Domain, CreateDomainInput, ChangesetResponse, ApplyRequest,
|
||||
} from "./types"
|
||||
|
||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "GET",
|
||||
...init,
|
||||
})
|
||||
if (!res.ok) {
|
||||
let msg = `HTTP ${res.status}`
|
||||
try { const b = await res.json(); if (b?.error) msg = String(b.error) } catch { /* ignore */ }
|
||||
throw new Error(msg)
|
||||
}
|
||||
if (res.status === 204) return undefined as T
|
||||
return (await res.json()) as T
|
||||
}
|
||||
|
||||
export const api = {
|
||||
listAccounts: () => req<Account[]>("/accounts"),
|
||||
createAccount: (input: CreateAccountInput) =>
|
||||
req<Account>("/accounts", { method: "POST", body: JSON.stringify(input) }),
|
||||
deleteAccount: (id: string) => req<void>(`/accounts/${id}`, { method: "DELETE" }),
|
||||
|
||||
listTemplates: () => req<Template[]>("/templates"),
|
||||
createTemplate: (input: CreateTemplateInput) =>
|
||||
req<Template>("/templates", { method: "POST", body: JSON.stringify(input) }),
|
||||
updateTemplate: (id: string, input: CreateTemplateInput) =>
|
||||
req<Template>(`/templates/${id}`, { method: "PUT", body: JSON.stringify(input) }),
|
||||
deleteTemplate: (id: string) => req<void>(`/templates/${id}`, { method: "DELETE" }),
|
||||
|
||||
listDomains: () => req<Domain[]>("/domains"),
|
||||
createDomain: (input: CreateDomainInput) =>
|
||||
req<Domain>("/domains", { method: "POST", body: JSON.stringify(input) }),
|
||||
deleteDomain: (id: string) => req<void>(`/domains/${id}`, { method: "DELETE" }),
|
||||
importZones: (accountId: string) =>
|
||||
req<Domain[]>(`/accounts/${accountId}/import`, { method: "POST" }),
|
||||
setDomainTemplate: (id: string, templateId: string | null) =>
|
||||
req<Domain>(`/domains/${id}`, { method: "PATCH", body: JSON.stringify({ templateId }) }),
|
||||
|
||||
checkDomain: (id: string) => req<ChangesetResponse>(`/domains/${id}/check`),
|
||||
applyDomain: (id: string, body: ApplyRequest) =>
|
||||
req<ChangesetResponse>(`/domains/${id}/apply`, { method: "POST", body: JSON.stringify(body) }),
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export interface Account { id: string; provider: string; comment: string }
|
||||
export interface CreateAccountInput { provider: string; secret: string; comment: string }
|
||||
|
||||
export interface RecordDTO { type: string; name: string; ttl: number; values: string[] }
|
||||
export interface Template { id: string; name: string; records: RecordDTO[]; version: number }
|
||||
export interface CreateTemplateInput { name: string; records: RecordDTO[] }
|
||||
|
||||
export interface Domain {
|
||||
id: string
|
||||
providerAccountId: string
|
||||
zoneName: string
|
||||
zoneId: string
|
||||
templateId?: string | null // Go omitempty: поле может отсутствовать (undefined), а не null
|
||||
}
|
||||
export interface CreateDomainInput {
|
||||
providerAccountId: string
|
||||
zoneName: string
|
||||
zoneId: string
|
||||
templateId?: string | null
|
||||
}
|
||||
|
||||
export interface RecordView {
|
||||
kind: string // add | update | delete | in_sync
|
||||
type: string
|
||||
name: string
|
||||
desired?: string[]
|
||||
actual?: string[]
|
||||
readOnly: boolean
|
||||
}
|
||||
export interface ChangesetResponse {
|
||||
updates: RecordView[]
|
||||
prunes: RecordView[]
|
||||
readOnly: RecordView[]
|
||||
inSyncCount: number
|
||||
}
|
||||
export interface ApplyRequest { applyUpdates: boolean; applyPrunes: boolean }
|
||||
@@ -0,0 +1,28 @@
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { DiffView } from "./DiffView"
|
||||
import type { ChangesetResponse } from "@/api/types"
|
||||
|
||||
const cs: ChangesetResponse = {
|
||||
updates: [{ kind: "update", type: "A", name: "www.example.com.", desired: ["1.1.1.1"], actual: ["9.9.9.9"], readOnly: false }],
|
||||
prunes: [{ kind: "delete", type: "A", name: "old.example.com.", actual: ["2.2.2.2"], readOnly: false }],
|
||||
readOnly: [{ kind: "update", type: "NS", name: "example.com.", desired: ["ns1."], actual: ["ns2."], readOnly: true }],
|
||||
inSyncCount: 3,
|
||||
}
|
||||
|
||||
test("renders all sections with counts", () => {
|
||||
render(<DiffView changeset={cs} />)
|
||||
expect(screen.getByText(/www\.example\.com\./)).toBeInTheDocument()
|
||||
expect(screen.getByText(/old\.example\.com\./)).toBeInTheDocument()
|
||||
// Anchored (vs. the brief's bare /example\.com\./) — "www.example.com." and
|
||||
// "old.example.com." both contain "example.com." as a trailing substring,
|
||||
// so the unanchored pattern matches all three rows and getByText throws
|
||||
// "multiple elements found". Anchoring targets the read-only apex record
|
||||
// specifically, which is what this assertion is actually verifying.
|
||||
expect(screen.getByText(/^example\.com\.$/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/3/)).toBeInTheDocument() // in-sync count
|
||||
})
|
||||
|
||||
test("marks read-only records", () => {
|
||||
render(<DiffView changeset={cs} />)
|
||||
expect(screen.getByText(/NS/)).toBeInTheDocument()
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
import type { ReactNode } from "react"
|
||||
import { ArrowRight, CircleCheck, Lock, Pencil, Trash2 } from "lucide-react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { ChangesetResponse, RecordView } from "@/api/types"
|
||||
|
||||
type Tone = "update" | "delete" | "readonly"
|
||||
|
||||
const TONE_META: Record<
|
||||
Tone,
|
||||
{ label: string; empty: string; icon: typeof Pencil; dot: string; ring: string }
|
||||
> = {
|
||||
update: {
|
||||
label: "Updates",
|
||||
empty: "Нет изменений — все значения совпадают.",
|
||||
icon: Pencil,
|
||||
dot: "var(--diff-update)",
|
||||
ring: "ring-[color-mix(in_oklch,var(--diff-update),transparent_78%)]",
|
||||
},
|
||||
delete: {
|
||||
label: "Prunes",
|
||||
empty: "Нечего удалять.",
|
||||
icon: Trash2,
|
||||
dot: "var(--diff-delete)",
|
||||
ring: "ring-[color-mix(in_oklch,var(--diff-delete),transparent_78%)]",
|
||||
},
|
||||
readonly: {
|
||||
label: "Read-only",
|
||||
empty: "Нет read-only записей.",
|
||||
icon: Lock,
|
||||
dot: "var(--diff-readonly)",
|
||||
ring: "ring-[color-mix(in_oklch,var(--diff-readonly),transparent_82%)]",
|
||||
},
|
||||
}
|
||||
|
||||
function Values({ values }: { values?: string[] }) {
|
||||
if (!values || values.length === 0) {
|
||||
return <span className="text-muted-foreground/50">—</span>
|
||||
}
|
||||
return <>{values.join(", ")}</>
|
||||
}
|
||||
|
||||
function RecordRow({ record, tone }: { record: RecordView; tone: Tone }) {
|
||||
const meta = TONE_META[tone]
|
||||
const showArrow = tone !== "delete"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group/row flex items-center gap-3 border-l-2 px-3 py-2.5 transition-colors",
|
||||
"hover:bg-foreground/[0.025]",
|
||||
)}
|
||||
style={{ borderLeftColor: meta.dot }}
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="font-dns w-11 shrink-0 justify-center border-border text-[10px] tracking-wide text-muted-foreground"
|
||||
>
|
||||
{record.type}
|
||||
</Badge>
|
||||
|
||||
<span className="font-dns min-w-0 flex-1 truncate text-sm text-foreground">
|
||||
{record.name}
|
||||
</span>
|
||||
|
||||
<span className="font-dns hidden shrink-0 items-center gap-1.5 text-xs text-muted-foreground sm:flex">
|
||||
<Values values={record.actual} />
|
||||
{showArrow && (
|
||||
<>
|
||||
<ArrowRight className="size-3 text-muted-foreground/50" strokeWidth={1.75} />
|
||||
<span style={{ color: meta.dot }}>
|
||||
<Values values={record.desired} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{record.readOnly && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-1 shrink-0 gap-1 bg-muted text-[10px] text-muted-foreground"
|
||||
>
|
||||
<Lock className="size-2.5" strokeWidth={2} />
|
||||
read-only
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({
|
||||
tone,
|
||||
records,
|
||||
}: {
|
||||
tone: Tone
|
||||
records: RecordView[]
|
||||
}) {
|
||||
const meta = TONE_META[tone]
|
||||
const Icon = meta.icon
|
||||
|
||||
return (
|
||||
<section aria-label={meta.label} className="flex flex-col gap-2">
|
||||
<header className="flex items-center gap-2 px-0.5">
|
||||
<Icon className="size-3.5" strokeWidth={1.75} style={{ color: meta.dot }} />
|
||||
<h2 className="text-xs font-semibold tracking-wide text-foreground uppercase">
|
||||
{meta.label}
|
||||
</h2>
|
||||
<Badge variant="outline" className="font-dns h-4.5 px-1.5 text-[10px] text-muted-foreground">
|
||||
{records.length}
|
||||
</Badge>
|
||||
</header>
|
||||
|
||||
{records.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed border-border px-3 py-2.5 text-sm text-muted-foreground/70">
|
||||
{meta.empty}
|
||||
</p>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col divide-y divide-border rounded-lg bg-card ring-1",
|
||||
meta.ring,
|
||||
)}
|
||||
>
|
||||
{records.map((record, i) => (
|
||||
<RecordRow key={`${record.type}-${record.name}-${i}`} record={record} tone={tone} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function DiffView({
|
||||
changeset,
|
||||
footerExtra,
|
||||
}: {
|
||||
changeset: ChangesetResponse
|
||||
footerExtra?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Section tone="update" records={changeset.updates} />
|
||||
<Section tone="delete" records={changeset.prunes} />
|
||||
<Section tone="readonly" records={changeset.readOnly} />
|
||||
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border pt-4">
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<CircleCheck
|
||||
className="size-3.5"
|
||||
strokeWidth={1.75}
|
||||
style={{ color: "var(--diff-insync)" }}
|
||||
/>
|
||||
<span className="font-dns">{changeset.inSyncCount}</span>
|
||||
<span>record{changeset.inSyncCount === 1 ? "" : "s"} in sync</span>
|
||||
</div>
|
||||
{footerExtra}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { ReactNode } from "react"
|
||||
import { NavLink, useLocation } from "react-router-dom"
|
||||
import { Globe, Users, LayoutTemplate, SquareTerminal } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const NAV = [
|
||||
{ to: "/domains", label: "Domains", icon: Globe },
|
||||
{ to: "/accounts", label: "Accounts", icon: Users },
|
||||
{ to: "/templates", label: "Templates", icon: LayoutTemplate },
|
||||
] as const
|
||||
|
||||
export function Layout({ children }: { children: ReactNode }) {
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
|
||||
<aside className="flex w-60 shrink-0 flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground">
|
||||
<div className="flex items-center gap-2 border-b border-sidebar-border px-4 py-4">
|
||||
<SquareTerminal className="size-4 text-primary" strokeWidth={1.75} />
|
||||
<div className="flex flex-col leading-none">
|
||||
<span className="text-sm font-semibold tracking-tight">
|
||||
DNS Autoresolver
|
||||
</span>
|
||||
<span className="font-dns text-[10px] tracking-wider text-muted-foreground uppercase">
|
||||
console
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2 py-3">
|
||||
{NAV.map(({ to, label, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"group flex items-center gap-2.5 rounded-md border-l-2 border-transparent px-2.5 py-2 text-sm font-medium text-muted-foreground transition-colors",
|
||||
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
isActive &&
|
||||
"border-primary bg-sidebar-accent text-sidebar-accent-foreground",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" strokeWidth={1.75} />
|
||||
<span className="flex-1">{label}</span>
|
||||
<span className="font-dns text-[10px] text-muted-foreground/60 group-hover:text-muted-foreground">
|
||||
{to}
|
||||
</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-sidebar-border px-4 py-3 font-dns text-[11px] text-muted-foreground">
|
||||
<span>v0.1.0-dev</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="size-1.5 rounded-full"
|
||||
style={{ background: "var(--diff-insync)" }}
|
||||
aria-hidden
|
||||
/>
|
||||
in sync
|
||||
</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<header className="flex h-11 shrink-0 items-center border-b border-border px-6">
|
||||
<span className="font-dns text-xs text-muted-foreground">
|
||||
{location.pathname}
|
||||
</span>
|
||||
</header>
|
||||
<main className="flex-1 overflow-auto">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
|
||||
import userEvent from "@testing-library/user-event"
|
||||
import { useState } from "react"
|
||||
import { test, expect, vi } from "vitest"
|
||||
import { RecordEditor } from "./RecordEditor"
|
||||
import type { RecordDTO } from "@/api/types"
|
||||
|
||||
function Harness({
|
||||
initial,
|
||||
onChange,
|
||||
}: {
|
||||
initial: RecordDTO[]
|
||||
onChange: (records: RecordDTO[]) => void
|
||||
}) {
|
||||
const [value, setValue] = useState<RecordDTO[]>(initial)
|
||||
return (
|
||||
<RecordEditor
|
||||
value={value}
|
||||
onChange={(next) => {
|
||||
setValue(next)
|
||||
onChange(next)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
test("добавление записи вызывает onChange с новой пустой записью типа A", async () => {
|
||||
const onChange = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<Harness initial={[]} onChange={onChange} />)
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /добавить запись/i }))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([{ type: "A", name: "", ttl: 3600, values: [""] }])
|
||||
})
|
||||
|
||||
test("изменение полей записи вызывает onChange с корректным массивом", async () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<Harness
|
||||
initial={[{ type: "A", name: "", ttl: 3600, values: [""] }]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/имя записи 1/i), { target: { value: "www" } })
|
||||
fireEvent.change(screen.getByLabelText(/ttl записи 1/i), { target: { value: "300" } })
|
||||
fireEvent.change(screen.getByLabelText(/значения записи 1/i), {
|
||||
target: { value: "192.0.2.1" },
|
||||
})
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onChange).toHaveBeenLastCalledWith([
|
||||
{ type: "A", name: "www", ttl: 300, values: ["192.0.2.1"] },
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
test("несколько значений в textarea дают несколько элементов values", async () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<Harness
|
||||
initial={[{ type: "NS", name: "@", ttl: 3600, values: [""] }]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/значения записи 1/i), {
|
||||
target: { value: "ns1.example.com.\nns2.example.com." },
|
||||
})
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onChange).toHaveBeenLastCalledWith([
|
||||
{
|
||||
type: "NS",
|
||||
name: "@",
|
||||
ttl: 3600,
|
||||
values: ["ns1.example.com.", "ns2.example.com."],
|
||||
},
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
test("выбор типа SRV и составное значение prio weight port target", async () => {
|
||||
const onChange = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Harness
|
||||
initial={[{ type: "A", name: "_sip._tcp", ttl: 3600, values: [""] }]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole("combobox", { name: /тип записи 1/i }))
|
||||
await user.click(await screen.findByRole("option", { name: /^srv$/i }))
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/значения записи 1/i), {
|
||||
target: { value: "10 5 5060 sipserver.example.com." },
|
||||
})
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onChange).toHaveBeenLastCalledWith([
|
||||
{
|
||||
type: "SRV",
|
||||
name: "_sip._tcp",
|
||||
ttl: 3600,
|
||||
values: ["10 5 5060 sipserver.example.com."],
|
||||
},
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
test("удаление записи вызывает onChange без удалённой записи", async () => {
|
||||
const onChange = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
const initial: RecordDTO[] = [
|
||||
{ type: "A", name: "a", ttl: 3600, values: ["1.1.1.1"] },
|
||||
{ type: "A", name: "b", ttl: 3600, values: ["2.2.2.2"] },
|
||||
]
|
||||
render(<Harness initial={initial} onChange={onChange} />)
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /удалить запись 1/i }))
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith([
|
||||
{ type: "A", name: "b", ttl: 3600, values: ["2.2.2.2"] },
|
||||
])
|
||||
})
|
||||
|
||||
test("mono-шрифт применён к полям name и values", () => {
|
||||
render(
|
||||
<Harness
|
||||
initial={[{ type: "A", name: "www", ttl: 3600, values: ["1.2.3.4"] }]}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByLabelText(/имя записи 1/i)).toHaveClass("font-dns")
|
||||
expect(screen.getByLabelText(/значения записи 1/i)).toHaveClass("font-dns")
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Plus, Trash2 } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import type { RecordDTO } from "@/api/types"
|
||||
|
||||
const RECORD_TYPES = ["A", "AAAA", "CNAME", "MX", "TXT", "SRV", "NS", "SOA"] as const
|
||||
|
||||
const typeItems = RECORD_TYPES.map((t) => ({ value: t, label: t }))
|
||||
|
||||
const DEFAULT_TTL = 3600
|
||||
|
||||
function emptyRecord(): RecordDTO {
|
||||
return { type: "A", name: "", ttl: DEFAULT_TTL, values: [""] }
|
||||
}
|
||||
|
||||
export function RecordEditor({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: RecordDTO[]
|
||||
onChange: (records: RecordDTO[]) => void
|
||||
}) {
|
||||
function updateRecord(index: number, patch: Partial<RecordDTO>) {
|
||||
onChange(value.map((r, i) => (i === index ? { ...r, ...patch } : r)))
|
||||
}
|
||||
|
||||
function addRecord() {
|
||||
onChange([...value, emptyRecord()])
|
||||
}
|
||||
|
||||
function removeRecord(index: number) {
|
||||
onChange(value.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{value.map((record, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col gap-2 rounded-lg border border-border bg-background/40 p-2.5 sm:flex-row sm:items-start"
|
||||
>
|
||||
<Select
|
||||
items={typeItems}
|
||||
value={record.type}
|
||||
onValueChange={(v) => updateRecord(index, { type: v as string })}
|
||||
>
|
||||
<SelectTrigger aria-label={`Тип записи ${index + 1}`} size="sm" className="font-dns sm:w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{typeItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value} className="font-dns">
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
aria-label={`Имя записи ${index + 1}`}
|
||||
className="font-dns sm:flex-1"
|
||||
placeholder="www"
|
||||
value={record.name}
|
||||
onChange={(e) => updateRecord(index, { name: e.target.value })}
|
||||
/>
|
||||
|
||||
<Input
|
||||
aria-label={`TTL записи ${index + 1}`}
|
||||
type="number"
|
||||
min={0}
|
||||
className="font-dns sm:w-24"
|
||||
value={record.ttl}
|
||||
onChange={(e) => updateRecord(index, { ttl: Number(e.target.value) })}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
aria-label={`Значения записи ${index + 1}`}
|
||||
className="font-dns sm:flex-1"
|
||||
placeholder="192.0.2.1"
|
||||
rows={1}
|
||||
value={record.values.join("\n")}
|
||||
onChange={(e) => updateRecord(index, { values: e.target.value.split("\n") })}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="icon-sm"
|
||||
aria-label={`Удалить запись ${index + 1}`}
|
||||
onClick={() => removeRecord(index)}
|
||||
>
|
||||
<Trash2 className="size-3.5" strokeWidth={1.75} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button type="button" variant="outline" size="sm" onClick={addRecord} className="self-start">
|
||||
<Plus className="size-3.5" strokeWidth={1.75} />
|
||||
Добавить запись
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props: mergeProps<"span">(
|
||||
{
|
||||
className: cn(badgeVariants({ variant }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "badge",
|
||||
variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,103 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-(--card-spacing)", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
|
||||
>
|
||||
<CheckIcon
|
||||
/>
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
@@ -0,0 +1,158 @@
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Backdrop
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: DialogPrimitive.Popup.Props & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||
Close
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Description.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useMemo } from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = "legend",
|
||||
...props
|
||||
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const fieldVariants = cva(
|
||||
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
|
||||
horizontal:
|
||||
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
responsive:
|
||||
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn(
|
||||
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Label>) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
|
||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
|
||||
"last:mt-0 nth-last-2:-mt-1",
|
||||
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Separator className="absolute inset-0 top-1/2" />
|
||||
{children && (
|
||||
<span
|
||||
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
errors?: Array<{ message?: string } | undefined>
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!errors?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const uniqueErrors = [
|
||||
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||
]
|
||||
|
||||
if (uniqueErrors?.length == 1) {
|
||||
return uniqueErrors[0]?.message
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{uniqueErrors.map(
|
||||
(error, index) =>
|
||||
error?.message && <li key={index}>{error.message}</li>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}, [children, errors])
|
||||
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn("text-sm font-normal text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldContent,
|
||||
FieldTitle,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Value
|
||||
data-slot="select-value"
|
||||
className={cn("flex flex-1 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Trigger.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon
|
||||
render={
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
}
|
||||
/>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
alignItemWithTrigger = true,
|
||||
...props
|
||||
}: SelectPrimitive.Popup.Props &
|
||||
Pick<
|
||||
SelectPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||
>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
alignItemWithTrigger={alignItemWithTrigger}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<SelectPrimitive.Popup
|
||||
data-slot="select-content"
|
||||
data-align-trigger={alignItemWithTrigger}
|
||||
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<SelectPrimitive.GroupLabel
|
||||
data-slot="select-label"
|
||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Item.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.Separator.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpArrow
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollUpArrow>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownArrow
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollDownArrow>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorPrimitive.Props) {
|
||||
return (
|
||||
<SeparatorPrimitive
|
||||
data-slot="separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client"
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: (
|
||||
<CircleCheckIcon className="size-4" />
|
||||
),
|
||||
info: (
|
||||
<InfoIcon className="size-4" />
|
||||
),
|
||||
warning: (
|
||||
<TriangleAlertIcon className="size-4" />
|
||||
),
|
||||
error: (
|
||||
<OctagonXIcon className="size-4" />
|
||||
),
|
||||
loading: (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
),
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: "cn-toast",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
@@ -0,0 +1,114 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { api } from "@/api/client"
|
||||
import type { CreateAccountInput, CreateTemplateInput, ApplyRequest } from "@/api/types"
|
||||
|
||||
export function useAccounts() {
|
||||
return useQuery({ queryKey: ["accounts"], queryFn: api.listAccounts })
|
||||
}
|
||||
export function useCreateAccount() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (input: CreateAccountInput) => api.createAccount(input),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["accounts"] }),
|
||||
})
|
||||
}
|
||||
export function useDeleteAccount() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.deleteAccount(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["accounts"] }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useTemplates() {
|
||||
return useQuery({ queryKey: ["templates"], queryFn: api.listTemplates })
|
||||
}
|
||||
export function useCreateTemplate() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (input: CreateTemplateInput) => api.createTemplate(input),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["templates"] }),
|
||||
})
|
||||
}
|
||||
export function useUpdateTemplate() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }: { id: string; input: CreateTemplateInput }) => api.updateTemplate(id, input),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["templates"] }),
|
||||
})
|
||||
}
|
||||
export function useDeleteTemplate() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.deleteTemplate(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["templates"] }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useDomains() {
|
||||
return useQuery({ queryKey: ["domains"], queryFn: api.listDomains })
|
||||
}
|
||||
export function useImportZones() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (accountId: string) => api.importZones(accountId),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["domains"] }),
|
||||
})
|
||||
}
|
||||
export function useSetDomainTemplate() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, templateId }: { id: string; templateId: string | null }) => api.setDomainTemplate(id, templateId),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["domains"] }),
|
||||
})
|
||||
}
|
||||
export function useDeleteDomain() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.deleteDomain(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["domains"] }),
|
||||
})
|
||||
}
|
||||
export function useCheckDomain(id: string) {
|
||||
return useQuery({ queryKey: ["check", id], queryFn: () => api.checkDomain(id), enabled: !!id })
|
||||
}
|
||||
export function useApplyDomain(id: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: ApplyRequest) => api.applyDomain(id, body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["check", id] }),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-heading: var(--font-ui);
|
||||
--font-sans: var(--font-ui);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--color-diff-add: var(--diff-add);
|
||||
--color-diff-update: var(--diff-update);
|
||||
--color-diff-delete: var(--diff-delete);
|
||||
--color-diff-insync: var(--diff-insync);
|
||||
--color-diff-readonly: var(--diff-readonly);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Typography: distinctive UI grotesk + mono for DNS-shaped data. */
|
||||
--font-ui: "Hanken Grotesk Variable", ui-sans-serif, sans-serif;
|
||||
--font-mono: "IBM Plex Mono", ui-monospace, "SF Mono", monospace;
|
||||
|
||||
--background: oklch(0.98 0.003 260);
|
||||
--foreground: oklch(0.2 0.012 260);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.2 0.012 260);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.2 0.012 260);
|
||||
--primary: oklch(0.45 0.1 224);
|
||||
--primary-foreground: oklch(0.98 0.005 260);
|
||||
--secondary: oklch(0.94 0.006 260);
|
||||
--secondary-foreground: oklch(0.25 0.012 260);
|
||||
--muted: oklch(0.94 0.006 260);
|
||||
--muted-foreground: oklch(0.5 0.012 260);
|
||||
--accent: oklch(0.92 0.015 224);
|
||||
--accent-foreground: oklch(0.25 0.012 260);
|
||||
--destructive: oklch(0.58 0.21 20);
|
||||
--border: oklch(0.88 0.006 260);
|
||||
--input: oklch(0.88 0.006 260);
|
||||
--ring: oklch(0.6 0.1 224 / 50%);
|
||||
--chart-1: oklch(0.72 0.15 155);
|
||||
--chart-2: oklch(0.8 0.14 85);
|
||||
--chart-3: oklch(0.68 0.19 20);
|
||||
--chart-4: oklch(0.55 0.1 224);
|
||||
--chart-5: oklch(0.5 0.02 260);
|
||||
--radius: 0.375rem;
|
||||
--sidebar: oklch(0.96 0.005 260);
|
||||
--sidebar-foreground: oklch(0.2 0.012 260);
|
||||
--sidebar-primary: oklch(0.45 0.1 224);
|
||||
--sidebar-primary-foreground: oklch(0.98 0.005 260);
|
||||
--sidebar-accent: oklch(0.92 0.015 224);
|
||||
--sidebar-accent-foreground: oklch(0.25 0.012 260);
|
||||
--sidebar-border: oklch(0.88 0.006 260);
|
||||
--sidebar-ring: oklch(0.6 0.1 224 / 50%);
|
||||
|
||||
/* Diff semantics — used across the domain-diff console (Task 3-6). */
|
||||
--diff-add: oklch(0.72 0.15 155); /* emerald */
|
||||
--diff-update: oklch(0.8 0.14 85); /* amber */
|
||||
--diff-delete: oklch(0.68 0.19 20); /* rose */
|
||||
--diff-insync: oklch(0.55 0.02 260); /* muted */
|
||||
--diff-readonly: oklch(0.5 0.02 260); /* dimmed */
|
||||
}
|
||||
|
||||
/* "Refined technical console" — dark by default (html.dark). Cool slate
|
||||
surfaces, a single steel-cyan accent (never purple), hairline borders,
|
||||
and a faint drafting-table grid for atmosphere instead of noise/blur. */
|
||||
.dark {
|
||||
--background: oklch(0.16 0.011 258);
|
||||
--foreground: oklch(0.94 0.006 258);
|
||||
--card: oklch(0.195 0.012 258);
|
||||
--card-foreground: oklch(0.94 0.006 258);
|
||||
--popover: oklch(0.185 0.012 258);
|
||||
--popover-foreground: oklch(0.94 0.006 258);
|
||||
--primary: oklch(0.74 0.11 224);
|
||||
--primary-foreground: oklch(0.16 0.02 258);
|
||||
--secondary: oklch(0.26 0.012 258);
|
||||
--secondary-foreground: oklch(0.94 0.006 258);
|
||||
--muted: oklch(0.23 0.011 258);
|
||||
--muted-foreground: oklch(0.64 0.016 258);
|
||||
--accent: oklch(0.28 0.03 224);
|
||||
--accent-foreground: oklch(0.94 0.006 258);
|
||||
--destructive: oklch(0.68 0.19 20);
|
||||
--border: oklch(1 0 0 / 9%);
|
||||
--input: oklch(1 0 0 / 12%);
|
||||
--ring: oklch(0.74 0.11 224 / 45%);
|
||||
--chart-1: oklch(0.72 0.15 155);
|
||||
--chart-2: oklch(0.8 0.14 85);
|
||||
--chart-3: oklch(0.68 0.19 20);
|
||||
--chart-4: oklch(0.74 0.11 224);
|
||||
--chart-5: oklch(0.55 0.02 258);
|
||||
--sidebar: oklch(0.135 0.012 258);
|
||||
--sidebar-foreground: oklch(0.88 0.008 258);
|
||||
--sidebar-primary: oklch(0.74 0.11 224);
|
||||
--sidebar-primary-foreground: oklch(0.16 0.02 258);
|
||||
--sidebar-accent: oklch(0.22 0.014 258);
|
||||
--sidebar-accent-foreground: oklch(0.94 0.006 258);
|
||||
--sidebar-border: oklch(1 0 0 / 8%);
|
||||
--sidebar-ring: oklch(0.74 0.11 224 / 45%);
|
||||
|
||||
--diff-add: oklch(0.72 0.15 155);
|
||||
--diff-update: oklch(0.8 0.14 85);
|
||||
--diff-delete: oklch(0.68 0.19 20);
|
||||
--diff-insync: oklch(0.55 0.02 258);
|
||||
--diff-readonly: oklch(0.42 0.014 258);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
html {
|
||||
@apply font-sans;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: var(--font-ui);
|
||||
background-image:
|
||||
linear-gradient(to right, oklch(1 0 0 / 3%) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, oklch(1 0 0 / 3%) 1px, transparent 1px);
|
||||
background-size: 32px 32px;
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: oklch(0.74 0.11 224 / 30%);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* Thin, muted scrollbars — a console tool doesn't shout. */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
}
|
||||
*::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border);
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
}
|
||||
|
||||
/* DNS-shaped data (record names, TTLs, IPs, zone files) reads in mono. */
|
||||
.font-dns {
|
||||
font-family: var(--font-mono);
|
||||
font-feature-settings: "tnum" 1, "zero" 1;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const DEFAULT_PROJECT_ID = "00000000-0000-0000-0000-000000000002"
|
||||
export const API_BASE = `/api/v1/projects/${DEFAULT_PROJECT_ID}`
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import "@fontsource-variable/hanken-grotesk/index.css"
|
||||
import "@fontsource/ibm-plex-mono/400.css"
|
||||
import "@fontsource/ibm-plex-mono/500.css"
|
||||
import "./index.css"
|
||||
import React from "react"
|
||||
import ReactDOM from "react-dom/client"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { BrowserRouter } from "react-router-dom"
|
||||
import { App } from "./App"
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,103 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react"
|
||||
import userEvent from "@testing-library/user-event"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { AccountsPage } from "./AccountsPage"
|
||||
import { api } from "@/api/client"
|
||||
import { vi, beforeEach, test, expect } from "vitest"
|
||||
import type { Account } from "@/api/types"
|
||||
|
||||
const accounts: Account[] = [
|
||||
{ id: "acc1", provider: "selectel", comment: "Main" },
|
||||
{ id: "acc2", provider: "selectel", comment: "Backup" },
|
||||
]
|
||||
|
||||
function renderPage() {
|
||||
const qc = new QueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter initialEntries={["/accounts"]}>
|
||||
<AccountsPage />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(api, "listAccounts").mockResolvedValue(accounts)
|
||||
})
|
||||
|
||||
test("отрисовывает список учёток без секрета", async () => {
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByText("Main")).toBeInTheDocument()
|
||||
expect(screen.getByText("Backup")).toBeInTheDocument()
|
||||
expect(screen.getAllByText("selectel").length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("форма создания вызывает api.createAccount с введённым secret и не показывает его после", async () => {
|
||||
const createSpy = vi.spyOn(api, "createAccount").mockResolvedValue({
|
||||
id: "acc3",
|
||||
provider: "selectel",
|
||||
comment: "New account",
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
await screen.findByText("Main")
|
||||
|
||||
const secretInput = screen.getByLabelText(/api-ключ/i)
|
||||
expect(secretInput).toHaveAttribute("type", "password")
|
||||
|
||||
await user.type(secretInput, "super-secret-token-123")
|
||||
await user.type(screen.getByLabelText(/комментарий/i), "New account")
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /добавить учётку/i }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(createSpy).toHaveBeenCalledWith({
|
||||
provider: "selectel",
|
||||
secret: "super-secret-token-123",
|
||||
comment: "New account",
|
||||
}),
|
||||
)
|
||||
|
||||
await waitFor(() => expect(secretInput).toHaveValue(""))
|
||||
expect(screen.queryByText("super-secret-token-123")).not.toBeInTheDocument()
|
||||
expect(screen.queryByDisplayValue("super-secret-token-123")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test("содержит ссылку-инструкцию получения ключа Selectel", async () => {
|
||||
renderPage()
|
||||
await screen.findByText("Main")
|
||||
|
||||
const link = screen.getByRole("link", { name: /selectel/i })
|
||||
expect(link).toHaveAttribute("href", expect.stringContaining("selectel.ru"))
|
||||
})
|
||||
|
||||
test("ошибка создания учётки отображается пользователю", async () => {
|
||||
vi.spyOn(api, "createAccount").mockRejectedValue(new Error("Не удалось создать учётку"))
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
await screen.findByText("Main")
|
||||
|
||||
await user.type(screen.getByLabelText(/api-ключ/i), "token-xyz")
|
||||
await user.type(screen.getByLabelText(/комментарий/i), "Test")
|
||||
await user.click(screen.getByRole("button", { name: /добавить учётку/i }))
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Не удалось создать учётку")
|
||||
})
|
||||
|
||||
test("удаление учётки вызывает api.deleteAccount", async () => {
|
||||
const deleteSpy = vi.spyOn(api, "deleteAccount").mockResolvedValue(undefined)
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true)
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
await screen.findByText("Main")
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /удалить.*main/i }))
|
||||
|
||||
await waitFor(() => expect(deleteSpy).toHaveBeenCalledWith("acc1"))
|
||||
})
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useId } from "react"
|
||||
import { Controller, useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { z } from "zod"
|
||||
import { ExternalLink, Inbox, KeyRound, Loader2, Plus, Trash2 } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldSet,
|
||||
} from "@/components/ui/field"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { useAccounts, useCreateAccount, useDeleteAccount } from "@/hooks/useApi"
|
||||
|
||||
const createAccountSchema = z.object({
|
||||
provider: z.literal("selectel"),
|
||||
secret: z.string().min(1, "Укажите API-ключ"),
|
||||
comment: z.string().min(1, "Укажите комментарий"),
|
||||
})
|
||||
|
||||
type CreateAccountForm = z.infer<typeof createAccountSchema>
|
||||
|
||||
export function AccountsPage() {
|
||||
const accounts = useAccounts()
|
||||
const createAccount = useCreateAccount()
|
||||
const deleteAccount = useDeleteAccount()
|
||||
const secretFieldId = useId()
|
||||
const commentFieldId = useId()
|
||||
|
||||
const accountList = accounts.data ?? []
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<CreateAccountForm>({
|
||||
resolver: zodResolver(createAccountSchema),
|
||||
defaultValues: { provider: "selectel", secret: "", comment: "" },
|
||||
})
|
||||
|
||||
function onSubmit(values: CreateAccountForm) {
|
||||
createAccount.mutate(values, {
|
||||
onSuccess: () => reset({ provider: "selectel", secret: "", comment: "" }),
|
||||
})
|
||||
}
|
||||
|
||||
function onDelete(id: string, comment: string) {
|
||||
if (window.confirm(`Удалить учётную запись «${comment}»? Действие необратимо.`)) {
|
||||
deleteAccount.mutate(id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
|
||||
<header className="flex flex-col gap-1">
|
||||
<span className="font-dns text-[11px] tracking-wider text-muted-foreground uppercase">
|
||||
providers
|
||||
</span>
|
||||
<h1 className="text-xl font-semibold tracking-tight text-foreground">Учётные записи</h1>
|
||||
</header>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4 rounded-xl border border-border bg-card/60 p-4"
|
||||
>
|
||||
<FieldSet className="gap-3">
|
||||
<FieldGroup className="gap-3 sm:flex-row sm:items-start">
|
||||
<Field className="sm:max-w-56">
|
||||
<FieldLabel htmlFor={secretFieldId}>API-ключ Selectel</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="secret"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
id={secretFieldId}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder="••••••••••••"
|
||||
aria-invalid={!!errors.secret}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[errors.secret]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor={commentFieldId}>Комментарий</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="comment"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
id={commentFieldId}
|
||||
placeholder="Например, основной аккаунт"
|
||||
aria-invalid={!!errors.comment}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[errors.comment]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
<FieldDescription className="flex items-start gap-2 rounded-lg border border-border/60 bg-background/40 px-3 py-2.5">
|
||||
<KeyRound className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" strokeWidth={1.75} />
|
||||
<span>
|
||||
Провайдер: <span className="font-dns text-foreground">selectel</span>. Ключ создаётся
|
||||
в панели управления Selectel — раздел «API-ключи» — и используется только для
|
||||
запроса, храниться в открытом виде он не будет.{" "}
|
||||
<a
|
||||
href="https://my.selectel.ru/profile/apikeys"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1"
|
||||
>
|
||||
Получить ключ на my.selectel.ru
|
||||
<ExternalLink className="size-3" strokeWidth={1.75} />
|
||||
</a>
|
||||
</span>
|
||||
</FieldDescription>
|
||||
</FieldSet>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border pt-3">
|
||||
{createAccount.isError && (
|
||||
<span role="alert" className="font-dns text-xs text-destructive">
|
||||
{createAccount.error.message}
|
||||
</span>
|
||||
)}
|
||||
<Button type="submit" disabled={createAccount.isPending} className="ml-auto">
|
||||
{createAccount.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
|
||||
) : (
|
||||
<Plus className="size-4" strokeWidth={1.75} />
|
||||
)}
|
||||
Добавить учётку
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{deleteAccount.isError && (
|
||||
<span role="alert" className="font-dns text-xs text-destructive">
|
||||
{deleteAccount.error.message}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{accountList.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 rounded-xl border border-dashed border-border px-4 py-12 text-center text-sm text-muted-foreground">
|
||||
<Inbox className="size-6" strokeWidth={1.5} />
|
||||
Учётных записей пока нет — добавьте первую выше.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Провайдер</TableHead>
|
||||
<TableHead>Комментарий</TableHead>
|
||||
<TableHead className="text-right">Действия</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{accountList.map((a) => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell className="font-dns">{a.provider}</TableCell>
|
||||
<TableCell>{a.comment}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon-sm"
|
||||
aria-label={`Удалить учётку ${a.comment}`}
|
||||
onClick={() => onDelete(a.id, a.comment)}
|
||||
disabled={deleteAccount.isPending}
|
||||
>
|
||||
<Trash2 className="size-3.5" strokeWidth={1.75} />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react"
|
||||
import userEvent from "@testing-library/user-event"
|
||||
import { MemoryRouter, Routes, Route } from "react-router-dom"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { DomainDiffPage } from "./DomainDiffPage"
|
||||
import { api } from "@/api/client"
|
||||
import { vi } from "vitest"
|
||||
|
||||
function renderPage() {
|
||||
const qc = new QueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter initialEntries={["/domains/d1"]}>
|
||||
<Routes><Route path="/domains/:id" element={<DomainDiffPage />} /></Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
test("apply sends applyPrunes=false by default, true only after opting in", async () => {
|
||||
vi.spyOn(api, "checkDomain").mockResolvedValue({
|
||||
updates: [{ kind: "update", type: "A", name: "a.", desired: ["1"], actual: ["2"], readOnly: false }],
|
||||
prunes: [{ kind: "delete", type: "A", name: "b.", actual: ["3"], readOnly: false }],
|
||||
readOnly: [], inSyncCount: 0,
|
||||
})
|
||||
const applySpy = vi.spyOn(api, "applyDomain").mockResolvedValue({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 })
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
const applyBtn = await screen.findByRole("button", { name: /apply/i })
|
||||
await user.click(applyBtn)
|
||||
await waitFor(() => expect(applySpy).toHaveBeenCalled())
|
||||
expect(applySpy.mock.calls[0][1]).toEqual({ applyUpdates: true, applyPrunes: false })
|
||||
|
||||
// включить prune и применить снова
|
||||
const pruneToggle = screen.getByRole("checkbox", { name: /prune|удал/i })
|
||||
await user.click(pruneToggle)
|
||||
await user.click(screen.getByRole("button", { name: /apply/i }))
|
||||
await waitFor(() => expect(applySpy).toHaveBeenCalledTimes(2))
|
||||
expect(applySpy.mock.calls[1][1]).toEqual({ applyUpdates: true, applyPrunes: true })
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useId, useState } from "react"
|
||||
import { useParams } from "react-router-dom"
|
||||
import { AlertTriangle, Loader2, Play, RefreshCw, TriangleAlert } from "lucide-react"
|
||||
import { DiffView } from "@/components/DiffView"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { useApplyDomain, useCheckDomain } from "@/hooks/useApi"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function DomainDiffPage() {
|
||||
const { id = "" } = useParams()
|
||||
const check = useCheckDomain(id)
|
||||
const apply = useApplyDomain(id)
|
||||
const [applyPrunes, setApplyPrunes] = useState(false)
|
||||
const pruneCheckboxId = useId()
|
||||
|
||||
const changeset = check.data
|
||||
const hasPrunes = (changeset?.prunes.length ?? 0) > 0
|
||||
const hasUpdates = (changeset?.updates.length ?? 0) > 0
|
||||
const pruneWarning = applyPrunes && hasPrunes
|
||||
|
||||
function onApply() {
|
||||
apply.mutate({ applyUpdates: true, applyPrunes })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
|
||||
<header className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-dns text-[11px] tracking-wider text-muted-foreground uppercase">
|
||||
domain / check
|
||||
</span>
|
||||
<h1 className="font-dns text-xl font-semibold tracking-tight text-foreground">
|
||||
{id}
|
||||
</h1>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => check.refetch()}
|
||||
disabled={check.isFetching}
|
||||
>
|
||||
<RefreshCw className={cn("size-3.5", check.isFetching && "animate-spin")} strokeWidth={1.75} />
|
||||
Recheck
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{check.isPending && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-card px-4 py-8 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
|
||||
Вычисляю дифф…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{check.isError && (
|
||||
<div className="flex items-start gap-2.5 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" strokeWidth={1.75} />
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Не удалось получить дифф</span>
|
||||
<span className="font-dns text-xs opacity-90">{check.error.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{changeset && (
|
||||
<>
|
||||
<DiffView changeset={changeset} />
|
||||
|
||||
<div className="flex flex-col gap-3 rounded-xl border border-border bg-card/60 p-4">
|
||||
<Label
|
||||
htmlFor={pruneCheckboxId}
|
||||
className="flex items-start gap-2.5 text-sm font-normal"
|
||||
>
|
||||
<Checkbox
|
||||
id={pruneCheckboxId}
|
||||
aria-label="prune — удалить лишние записи"
|
||||
checked={applyPrunes}
|
||||
onCheckedChange={(v) => setApplyPrunes(v === true)}
|
||||
className="mt-0.5"
|
||||
style={
|
||||
applyPrunes
|
||||
? ({ borderColor: "var(--diff-delete)", background: "var(--diff-delete)" } as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<span className="font-medium text-foreground">
|
||||
Prune — удалить записи, которых нет в шаблоне
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
По умолчанию выключено. Apply меняет только записи из шаблона.
|
||||
</span>
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
{pruneWarning && (
|
||||
<div
|
||||
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs"
|
||||
style={{
|
||||
color: "var(--diff-delete)",
|
||||
background: "color-mix(in oklch, var(--diff-delete), transparent 90%)",
|
||||
}}
|
||||
role="alert"
|
||||
>
|
||||
<TriangleAlert className="mt-px size-3.5 shrink-0" strokeWidth={2} />
|
||||
<span>
|
||||
Будет безвозвратно удалено записей:{" "}
|
||||
<span className="font-dns font-semibold">{changeset.prunes.length}</span>. Действие необратимо.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border pt-3">
|
||||
{apply.isError ? (
|
||||
<span className="font-dns text-xs text-destructive">{apply.error.message}</span>
|
||||
) : apply.isSuccess ? (
|
||||
<span className="font-dns text-xs" style={{ color: "var(--diff-add)" }}>
|
||||
Применено ✓
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{hasUpdates || (applyPrunes && hasPrunes)
|
||||
? "Готово к применению"
|
||||
: "Изменений для применения нет"}
|
||||
</span>
|
||||
)}
|
||||
<Button onClick={onApply} disabled={apply.isPending}>
|
||||
{apply.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
|
||||
) : (
|
||||
<Play className="size-4" strokeWidth={1.75} />
|
||||
)}
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react"
|
||||
import userEvent from "@testing-library/user-event"
|
||||
import { MemoryRouter, Routes, Route } from "react-router-dom"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { DomainsPage } from "./DomainsPage"
|
||||
import { api } from "@/api/client"
|
||||
import { vi, beforeEach, test, expect } from "vitest"
|
||||
import type { Account, Domain, Template } from "@/api/types"
|
||||
|
||||
const accounts: Account[] = [
|
||||
{ id: "acc1", provider: "selectel", comment: "Main" },
|
||||
{ id: "acc2", provider: "cloudflare", comment: "Backup" },
|
||||
]
|
||||
const templates: Template[] = [
|
||||
{ id: "t1", name: "Standard", records: [], version: 1 },
|
||||
{ id: "t2", name: "Minimal", records: [], version: 1 },
|
||||
]
|
||||
const domains: Domain[] = [
|
||||
{ id: "d1", providerAccountId: "acc1", zoneName: "example.com.", zoneId: "z1", templateId: null },
|
||||
{ id: "d2", providerAccountId: "acc2", zoneName: "test.org.", zoneId: "z2", templateId: "t1" },
|
||||
]
|
||||
|
||||
function renderPage() {
|
||||
const qc = new QueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter initialEntries={["/domains"]}>
|
||||
<Routes>
|
||||
<Route path="/domains" element={<DomainsPage />} />
|
||||
<Route path="/domains/:id" element={<div>diff page</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(api, "listDomains").mockResolvedValue(domains)
|
||||
vi.spyOn(api, "listAccounts").mockResolvedValue(accounts)
|
||||
vi.spyOn(api, "listTemplates").mockResolvedValue(templates)
|
||||
})
|
||||
|
||||
test("отрисовывает домены и ссылку на diff-страницу", async () => {
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByText("example.com.")).toBeInTheDocument()
|
||||
expect(screen.getByText("test.org.")).toBeInTheDocument()
|
||||
|
||||
const links = screen.getAllByRole("link", { name: /diff/i })
|
||||
expect(links.length).toBe(2)
|
||||
expect(links[0]).toHaveAttribute("href", "/domains/d1")
|
||||
expect(links[1]).toHaveAttribute("href", "/domains/d2")
|
||||
})
|
||||
|
||||
test("кнопка импорта вызывает api.importZones с выбранной учёткой", async () => {
|
||||
const importSpy = vi.spyOn(api, "importZones").mockResolvedValue([])
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
await screen.findByText("example.com.")
|
||||
|
||||
await user.click(screen.getByRole("combobox", { name: /учётн/i }))
|
||||
await user.click(await screen.findByRole("option", { name: /cloudflare/i }))
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /импортировать зоны/i }))
|
||||
|
||||
await waitFor(() => expect(importSpy).toHaveBeenCalledWith("acc2"))
|
||||
})
|
||||
|
||||
test("привязка шаблона в строке домена вызывает api.setDomainTemplate", async () => {
|
||||
const setTemplateSpy = vi.spyOn(api, "setDomainTemplate").mockResolvedValue(domains[0])
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
await screen.findByText("example.com.")
|
||||
|
||||
await user.click(screen.getByRole("combobox", { name: /example\.com\./i }))
|
||||
await user.click(await screen.findByRole("option", { name: /^standard$/i }))
|
||||
|
||||
await waitFor(() => expect(setTemplateSpy).toHaveBeenCalledWith("d1", "t1"))
|
||||
})
|
||||
|
||||
test("ошибка привязки шаблона отображается пользователю", async () => {
|
||||
vi.spyOn(api, "setDomainTemplate").mockRejectedValue(new Error("Не удалось привязать шаблон"))
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
await screen.findByText("example.com.")
|
||||
|
||||
await user.click(screen.getByRole("combobox", { name: /example\.com\./i }))
|
||||
await user.click(await screen.findByRole("option", { name: /^standard$/i }))
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Не удалось привязать шаблон")
|
||||
})
|
||||
|
||||
test("пустое состояние при отсутствии доменов", async () => {
|
||||
vi.spyOn(api, "listDomains").mockResolvedValue([])
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByText(/доменов пока нет/i)).toBeInTheDocument()
|
||||
})
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useState } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { Inbox, Loader2, Trash2, Upload } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import {
|
||||
useAccounts,
|
||||
useDeleteDomain,
|
||||
useDomains,
|
||||
useImportZones,
|
||||
useSetDomainTemplate,
|
||||
useTemplates,
|
||||
} from "@/hooks/useApi"
|
||||
|
||||
const NO_TEMPLATE = "__none__"
|
||||
|
||||
export function DomainsPage() {
|
||||
const domains = useDomains()
|
||||
const accounts = useAccounts()
|
||||
const templates = useTemplates()
|
||||
const importZones = useImportZones()
|
||||
const setTemplate = useSetDomainTemplate()
|
||||
const deleteDomain = useDeleteDomain()
|
||||
|
||||
const accountList = accounts.data ?? []
|
||||
const templateList = templates.data ?? []
|
||||
const domainList = domains.data ?? []
|
||||
|
||||
const [importAccountId, setImportAccountId] = useState<string | null>(null)
|
||||
const selectedImportAccount = importAccountId ?? accountList[0]?.id ?? null
|
||||
|
||||
const accountItems = accountList.map((a) => ({
|
||||
value: a.id,
|
||||
label: `${a.provider} · ${a.comment}`,
|
||||
}))
|
||||
|
||||
function accountLabel(id: string) {
|
||||
const acc = accountList.find((a) => a.id === id)
|
||||
return acc ? `${acc.provider} · ${acc.comment}` : id
|
||||
}
|
||||
|
||||
function onImport() {
|
||||
if (!selectedImportAccount) return
|
||||
importZones.mutate(selectedImportAccount)
|
||||
}
|
||||
|
||||
function onTemplateChange(domainId: string, value: unknown) {
|
||||
const templateId = value === NO_TEMPLATE ? null : (value as string)
|
||||
setTemplate.mutate({ id: domainId, templateId })
|
||||
}
|
||||
|
||||
function onDelete(domainId: string, zoneName: string) {
|
||||
if (window.confirm(`Удалить домен ${zoneName}? Действие необратимо.`)) {
|
||||
deleteDomain.mutate(domainId)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-5xl flex-col gap-6 px-6 py-8">
|
||||
<header className="flex flex-col gap-1">
|
||||
<span className="font-dns text-[11px] tracking-wider text-muted-foreground uppercase">
|
||||
zones
|
||||
</span>
|
||||
<h1 className="text-xl font-semibold tracking-tight text-foreground">Domains</h1>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3 rounded-xl border border-border bg-card/60 p-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">Учётная запись</span>
|
||||
<Select
|
||||
items={accountItems}
|
||||
value={selectedImportAccount}
|
||||
onValueChange={(v) => setImportAccountId(v as string)}
|
||||
>
|
||||
<SelectTrigger aria-label="Учётная запись для импорта" className="min-w-56">
|
||||
<SelectValue placeholder="Выберите учётку" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{accountItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={onImport} disabled={!selectedImportAccount || importZones.isPending}>
|
||||
{importZones.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
|
||||
) : (
|
||||
<Upload className="size-4" strokeWidth={1.75} />
|
||||
)}
|
||||
Импортировать зоны
|
||||
</Button>
|
||||
{importZones.isError && (
|
||||
<span className="font-dns text-xs text-destructive">{importZones.error.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{setTemplate.isError && (
|
||||
<span role="alert" className="font-dns text-xs text-destructive">
|
||||
{setTemplate.error?.message}
|
||||
</span>
|
||||
)}
|
||||
{deleteDomain.isError && (
|
||||
<span role="alert" className="font-dns text-xs text-destructive">
|
||||
{deleteDomain.error?.message}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{domainList.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 rounded-xl border border-dashed border-border px-4 py-12 text-center text-sm text-muted-foreground">
|
||||
<Inbox className="size-6" strokeWidth={1.5} />
|
||||
Доменов пока нет — импортируйте зоны из учётной записи.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Zone</TableHead>
|
||||
<TableHead>Учётка</TableHead>
|
||||
<TableHead>Шаблон</TableHead>
|
||||
<TableHead className="text-right">Действия</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{domainList.map((d) => {
|
||||
const templateItems = [
|
||||
{ value: NO_TEMPLATE, label: "Без шаблона" },
|
||||
...templateList.map((t) => ({ value: t.id, label: t.name })),
|
||||
]
|
||||
return (
|
||||
<TableRow key={d.id}>
|
||||
<TableCell className="font-dns">{d.zoneName}</TableCell>
|
||||
<TableCell className="font-dns text-xs text-muted-foreground">
|
||||
{accountLabel(d.providerAccountId)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
items={templateItems}
|
||||
value={d.templateId ?? NO_TEMPLATE}
|
||||
onValueChange={(v) => onTemplateChange(d.id, v)}
|
||||
>
|
||||
<SelectTrigger aria-label={`Шаблон: ${d.zoneName}`} size="sm" className="min-w-40">
|
||||
<SelectValue placeholder="Без шаблона" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templateItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<Button variant="outline" size="sm" render={<Link to={`/domains/${d.id}`} />}>
|
||||
Diff
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon-sm"
|
||||
aria-label={`Удалить ${d.zoneName}`}
|
||||
onClick={() => onDelete(d.id, d.zoneName)}
|
||||
disabled={deleteDomain.isPending}
|
||||
>
|
||||
<Trash2 className="size-3.5" strokeWidth={1.75} />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { render, screen, waitFor, within, fireEvent } from "@testing-library/react"
|
||||
import userEvent from "@testing-library/user-event"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { TemplatesPage } from "./TemplatesPage"
|
||||
import { api } from "@/api/client"
|
||||
import { vi, beforeEach, test, expect } from "vitest"
|
||||
import type { Template } from "@/api/types"
|
||||
|
||||
const templates: Template[] = [
|
||||
{
|
||||
id: "t1",
|
||||
name: "Standard",
|
||||
records: [{ type: "A", name: "@", ttl: 3600, values: ["1.2.3.4"] }],
|
||||
version: 1,
|
||||
},
|
||||
{ id: "t2", name: "Minimal", records: [], version: 1 },
|
||||
]
|
||||
|
||||
function renderPage() {
|
||||
const qc = new QueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter initialEntries={["/templates"]}>
|
||||
<TemplatesPage />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(api, "listTemplates").mockResolvedValue(templates)
|
||||
})
|
||||
|
||||
test("отрисовывает список шаблонов с числом записей", async () => {
|
||||
renderPage()
|
||||
|
||||
await screen.findByText("Standard")
|
||||
|
||||
const standardRow = screen.getByRole("row", { name: /standard/i })
|
||||
expect(within(standardRow).getByText("1")).toBeInTheDocument()
|
||||
|
||||
const minimalRow = screen.getByRole("row", { name: /minimal/i })
|
||||
expect(within(minimalRow).getByText("0")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test("создание шаблона с записью вызывает api.createTemplate с {name, records}", async () => {
|
||||
const createSpy = vi.spyOn(api, "createTemplate").mockResolvedValue({
|
||||
id: "t3",
|
||||
name: "New",
|
||||
records: [],
|
||||
version: 1,
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
await screen.findByText("Standard")
|
||||
|
||||
await user.type(screen.getByLabelText(/имя шаблона/i), "New")
|
||||
await user.click(screen.getByRole("button", { name: /добавить запись/i }))
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/имя записи 1/i), { target: { value: "www" } })
|
||||
fireEvent.change(screen.getByLabelText(/значения записи 1/i), { target: { value: "1.1.1.1" } })
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /сохранить шаблон/i }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(createSpy).toHaveBeenCalledWith({
|
||||
name: "New",
|
||||
records: [{ type: "A", name: "www", ttl: 3600, values: ["1.1.1.1"] }],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("редактирование шаблона вызывает api.updateTemplate с обновлённым именем", async () => {
|
||||
const updateSpy = vi.spyOn(api, "updateTemplate").mockResolvedValue(templates[0])
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
await screen.findByText("Standard")
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /редактировать standard/i }))
|
||||
|
||||
const nameInput = screen.getByLabelText(/имя шаблона/i)
|
||||
await user.clear(nameInput)
|
||||
await user.type(nameInput, "Standard v2")
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /сохранить шаблон/i }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateSpy).toHaveBeenCalledWith("t1", {
|
||||
name: "Standard v2",
|
||||
records: [{ type: "A", name: "@", ttl: 3600, values: ["1.2.3.4"] }],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("удаление шаблона вызывает api.deleteTemplate", async () => {
|
||||
const deleteSpy = vi.spyOn(api, "deleteTemplate").mockResolvedValue(undefined)
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true)
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
await screen.findByText("Standard")
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /удалить шаблон standard/i }))
|
||||
|
||||
await waitFor(() => expect(deleteSpy).toHaveBeenCalledWith("t1"))
|
||||
})
|
||||
|
||||
test("ошибка создания шаблона отображается пользователю", async () => {
|
||||
vi.spyOn(api, "createTemplate").mockRejectedValue(new Error("Не удалось создать шаблон"))
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
await screen.findByText("Standard")
|
||||
|
||||
await user.type(screen.getByLabelText(/имя шаблона/i), "Broken")
|
||||
await user.click(screen.getByRole("button", { name: /сохранить шаблон/i }))
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Не удалось создать шаблон")
|
||||
})
|
||||
|
||||
test("запись с нетронутым (пустым) values не уходит в api.createTemplate, показывается ошибка", async () => {
|
||||
const createSpy = vi.spyOn(api, "createTemplate").mockClear()
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
await screen.findByText("Standard")
|
||||
|
||||
await user.type(screen.getByLabelText(/имя шаблона/i), "New")
|
||||
await user.click(screen.getByRole("button", { name: /добавить запись/i }))
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/имя записи 1/i), { target: { value: "www" } })
|
||||
// значения записи намеренно не заполняются — остаётся дефолтная пустая строка
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /сохранить шаблон/i }))
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(/заполните имя и значения/i)
|
||||
expect(createSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("сабмит с пустым именем записи показывает ошибку и не вызывает api.createTemplate", async () => {
|
||||
const createSpy = vi.spyOn(api, "createTemplate").mockClear()
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
await screen.findByText("Standard")
|
||||
|
||||
await user.type(screen.getByLabelText(/имя шаблона/i), "New")
|
||||
await user.click(screen.getByRole("button", { name: /добавить запись/i }))
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/значения записи 1/i), { target: { value: "1.1.1.1" } })
|
||||
// имя записи намеренно оставлено пустым
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /сохранить шаблон/i }))
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(/заполните имя и значения/i)
|
||||
expect(createSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("пустое состояние при отсутствии шаблонов", async () => {
|
||||
vi.spyOn(api, "listTemplates").mockResolvedValue([])
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByText(/шаблонов пока нет/i)).toBeInTheDocument()
|
||||
})
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useId, useState } from "react"
|
||||
import { Controller, useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { z } from "zod"
|
||||
import { Inbox, Loader2, Pencil, Save, Trash2, X } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldSet,
|
||||
} from "@/components/ui/field"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { RecordEditor } from "@/components/RecordEditor"
|
||||
import { useCreateTemplate, useDeleteTemplate, useTemplates, useUpdateTemplate } from "@/hooks/useApi"
|
||||
import type { Template } from "@/api/types"
|
||||
|
||||
const recordSchema = z.object({
|
||||
type: z.string().min(1),
|
||||
name: z.string().min(1, "Укажите имя записи"),
|
||||
ttl: z.number().int("TTL — целое число").nonnegative("TTL не может быть отрицательным"),
|
||||
values: z
|
||||
.array(z.string().trim().min(1, "Значение не может быть пустым"))
|
||||
.min(1, "Добавьте хотя бы одно значение"),
|
||||
})
|
||||
|
||||
const templateFormSchema = z.object({
|
||||
name: z.string().min(1, "Укажите имя шаблона"),
|
||||
records: z.array(recordSchema),
|
||||
})
|
||||
|
||||
type TemplateForm = z.infer<typeof templateFormSchema>
|
||||
|
||||
const EMPTY_FORM: TemplateForm = { name: "", records: [] }
|
||||
|
||||
function sanitizeRecords(records: TemplateForm["records"]) {
|
||||
return records
|
||||
.map((record) => ({
|
||||
...record,
|
||||
values: record.values.map((v) => v.trim()).filter(Boolean),
|
||||
}))
|
||||
.filter((record) => record.values.length > 0)
|
||||
}
|
||||
|
||||
export function TemplatesPage() {
|
||||
const templates = useTemplates()
|
||||
const createTemplate = useCreateTemplate()
|
||||
const updateTemplate = useUpdateTemplate()
|
||||
const deleteTemplate = useDeleteTemplate()
|
||||
const nameFieldId = useId()
|
||||
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
|
||||
const templateList = templates.data ?? []
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<TemplateForm>({
|
||||
resolver: zodResolver(templateFormSchema),
|
||||
defaultValues: EMPTY_FORM,
|
||||
})
|
||||
|
||||
function onEdit(template: Template) {
|
||||
setEditingId(template.id)
|
||||
reset({ name: template.name, records: template.records })
|
||||
}
|
||||
|
||||
function onCancelEdit() {
|
||||
setEditingId(null)
|
||||
reset(EMPTY_FORM)
|
||||
}
|
||||
|
||||
function onSubmit(values: TemplateForm) {
|
||||
const input = { ...values, records: sanitizeRecords(values.records) }
|
||||
if (editingId) {
|
||||
updateTemplate.mutate(
|
||||
{ id: editingId, input },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setEditingId(null)
|
||||
reset(EMPTY_FORM)
|
||||
},
|
||||
},
|
||||
)
|
||||
} else {
|
||||
createTemplate.mutate(input, { onSuccess: () => reset(EMPTY_FORM) })
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete(id: string, name: string) {
|
||||
if (window.confirm(`Удалить шаблон «${name}»? Действие необратимо.`)) {
|
||||
if (editingId === id) onCancelEdit()
|
||||
deleteTemplate.mutate(id)
|
||||
}
|
||||
}
|
||||
|
||||
const saveMutation = editingId ? updateTemplate : createTemplate
|
||||
const hasFormErrors = !!errors.name || !!errors.records
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-4xl flex-col gap-6 px-6 py-8">
|
||||
<header className="flex flex-col gap-1">
|
||||
<span className="font-dns text-[11px] tracking-wider text-muted-foreground uppercase">
|
||||
templates
|
||||
</span>
|
||||
<h1 className="text-xl font-semibold tracking-tight text-foreground">Шаблоны</h1>
|
||||
</header>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4 rounded-xl border border-border bg-card/60 p-4"
|
||||
>
|
||||
<FieldSet className="gap-3">
|
||||
<FieldGroup className="gap-3">
|
||||
<Field className="sm:max-w-72">
|
||||
<FieldLabel htmlFor={nameFieldId}>Имя шаблона</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
id={nameFieldId}
|
||||
placeholder="Например, standard"
|
||||
aria-invalid={!!errors.name}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[errors.name]} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Записи</FieldLabel>
|
||||
<FieldContent>
|
||||
<Controller
|
||||
control={control}
|
||||
name="records"
|
||||
render={({ field }) => (
|
||||
<RecordEditor value={field.value} onChange={field.onChange} />
|
||||
)}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border pt-3">
|
||||
{hasFormErrors ? (
|
||||
<span role="alert" className="font-dns text-xs text-destructive">
|
||||
Заполните имя и значения всех записей
|
||||
</span>
|
||||
) : (
|
||||
saveMutation.isError && (
|
||||
<span role="alert" className="font-dns text-xs text-destructive">
|
||||
{saveMutation.error.message}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{editingId && (
|
||||
<Button type="button" variant="ghost" onClick={onCancelEdit}>
|
||||
<X className="size-4" strokeWidth={1.75} />
|
||||
Отменить
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" disabled={saveMutation.isPending}>
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
|
||||
) : (
|
||||
<Save className="size-4" strokeWidth={1.75} />
|
||||
)}
|
||||
Сохранить шаблон
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{deleteTemplate.isError && (
|
||||
<span role="alert" className="font-dns text-xs text-destructive">
|
||||
{deleteTemplate.error.message}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{templateList.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 rounded-xl border border-dashed border-border px-4 py-12 text-center text-sm text-muted-foreground">
|
||||
<Inbox className="size-6" strokeWidth={1.5} />
|
||||
Шаблонов пока нет — создайте первый выше.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Имя</TableHead>
|
||||
<TableHead>Записей</TableHead>
|
||||
<TableHead className="text-right">Действия</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{templateList.map((t) => (
|
||||
<TableRow key={t.id}>
|
||||
<TableCell className="font-dns">{t.name}</TableCell>
|
||||
<TableCell>{t.records.length}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label={`Редактировать ${t.name}`}
|
||||
onClick={() => onEdit(t)}
|
||||
>
|
||||
<Pencil className="size-3.5" strokeWidth={1.75} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon-sm"
|
||||
aria-label={`Удалить шаблон ${t.name}`}
|
||||
onClick={() => onDelete(t.id, t.name)}
|
||||
disabled={deleteTemplate.isPending}
|
||||
>
|
||||
<Trash2 className="size-3.5" strokeWidth={1.75} />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom"
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"paths": { "@/*": ["./src/*"] },
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client", "vitest/globals"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
],
|
||||
"compilerOptions": {
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/// <reference types="vitest/config" />
|
||||
import { defineConfig } from "vite"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
import path from "path"
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: { "@": path.resolve(__dirname, "./src") },
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": { target: "http://localhost:8080", changeOrigin: true },
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: "./src/test-setup.ts",
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user