# Phase 1C: React SPA — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. UI-задачи ДОПОЛНИТЕЛЬНО используют superpowers:frontend-design для визуального качества. Steps use checkbox (`- [ ]`) syntax. **Goal:** Полноценный React SPA поверх готового REST API (1B): управление учётками/шаблонами/доменами, импорт зон, просмотр дифа с семантической подсветкой и применение с guard на удаление; в prod вшивается в Go-бинарь. **Architecture:** `web/` — Vite + React + TS. TanStack Query для server-state, типизированный fetch-клиент к `/api/v1/projects/{DEFAULT_PROJECT_ID}`. shadcn/ui + Tailwind, тёмная «technical console» эстетика. В dev — Vite proxy на Go API; в prod — `vite build` → `dist/` вшивается через `embed.FS` в `internal/web`, отдаётся `cmd/server` с SPA-fallback (API-роуты приоритетны). **Tech Stack:** Vite, React 18+, TypeScript, react-router-dom, @tanstack/react-query v5, Tailwind CSS v4 (`@tailwindcss/vite`), shadcn/ui, react-hook-form + zod, Vitest + React Testing Library. Пакетный менеджер — **npm**. ## Global Constraints - Каталог фронта — `web/` в корне репозитория (Go-модуль `github.com/vasyakrg/dns-autoresolver`). - `DEFAULT_PROJECT_ID = "00000000-0000-0000-0000-000000000002"` (seed default project; Фаза 1C без логина). - API base: `/api/v1/projects/${DEFAULT_PROJECT_ID}`. В dev Vite проксирует `/api` → `http://localhost:8080`. - Секрет учётки — только на вход (POST accounts); НИКОГДА не отображается обратно (API его и не возвращает). - Apply: чекбокс prune **по умолчанию выключен**; включается только явным действием пользователя с визуальным предупреждением. Тело `POST apply`: `{applyUpdates:true, applyPrunes:}`. - Диф-семантика и цвета (CSS-переменные): add=emerald, update=amber, delete/prune=rose, in-sync=muted, read-only=dimmed. - Типографика: НЕ Inter/Roboto/system. UI-гротеск + моноширинный для DNS-данных/дифов. - Эстетика: тёмная refined «technical console» (см. spec, секция «Фаза 1C»). UI-задачи используют skill frontend-design. - Тесты: каждая задача с логикой завершается зелёным `npm run test` (Vitest) и коммитом. Go-задача — `go test`. - Типы фронта (`api/types.ts`) зеркалят Go DTO из `internal/api` (Account/Template/Domain/Changeset/RecordView/ApplyRequest). --- ### Task 1: Scaffold web/ (Vite + Tailwind + shadcn + router + тема) **Files:** - Create: `web/` (Vite react-ts проект), `web/vite.config.ts`, `web/tsconfig*.json`, `web/components.json` - Create: `web/src/index.css` (тема, шрифты, diff-переменные), `web/src/main.tsx`, `web/src/App.tsx` - Create: `web/src/components/Layout.tsx` - Modify: `.gitignore` (web/node_modules, web/dist) - Create: `web/src/App.test.tsx` (smoke) **Interfaces:** - Produces: рабочий dev-сервер (`npm run dev`) с proxy; alias `@`; тёмная тема; роутер-каркас со страницами-заглушками; Vitest настроен. - [ ] **Step 1: Создать Vite-проект и зависимости** ```bash cd /Users/vasyansk/Developers/MyProject/IaaC/Realmanual/dns-autoresolver npm create vite@latest web -- --template react-ts cd web npm install npm install -D @tailwindcss/vite tailwindcss npm install react-router-dom @tanstack/react-query npm install react-hook-form zod @hookform/resolvers npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom @testing-library/user-event ``` - [ ] **Step 2: Tailwind v4 + alias + proxy + vitest в конфиге** `web/src/index.css` (начало): ```css @import "tailwindcss"; ``` `web/vite.config.ts`: ```ts 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", }, }) ``` `web/src/test-setup.ts`: ```ts import "@testing-library/jest-dom" ``` `web/tsconfig.json` — добавить в `compilerOptions`: ```json { "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["./src/*"] } } } ``` (и то же `baseUrl`/`paths` в `tsconfig.app.json`, чтобы редактор/сборка резолвили `@`.) - [ ] **Step 3: Инициализировать shadcn и добавить компоненты** ```bash npx shadcn@latest init # base color: slate; CSS variables: yes; dark mode готов npx shadcn@latest add button table dialog input label form card badge checkbox sonner separator textarea select ``` - [ ] **Step 4: Тема «technical console» + шрифты + diff-переменные** Дополнить `web/src/index.css` (после shadcn-переменных): подключить характерные шрифты (не Inter/system) и mono, задать тёмную тему по умолчанию (`` в `index.html`) и diff-семантику: ```css /* Шрифты через Fontsource (устанавливаются): напр. @fontsource-variable/hanken-grotesk и @fontsource-variable/ibm-plex-mono */ :root { --font-ui: "Hanken Grotesk Variable", ui-sans-serif, sans-serif; --font-mono: "IBM Plex Mono", ui-monospace, monospace; --diff-add: oklch(0.72 0.15 155); /* emerald */ --diff-update: oklch(0.80 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.50 0.02 260); /* dimmed */ } body { font-family: var(--font-ui); } .font-dns { font-family: var(--font-mono); } ``` Установить шрифты: ```bash npm install @fontsource-variable/hanken-grotesk @fontsource-variable/ibm-plex-mono ``` и импортировать их в `web/src/main.tsx`. Реализатор: подключить skill frontend-design и довести тёмную «technical console» тему (фон-атмосфера, границы, тени) — это центральное впечатление продукта. - [ ] **Step 5: main.tsx + App.tsx + Layout + роутер-каркас** `web/src/main.tsx`: ```tsx import "@fontsource-variable/hanken-grotesk" import "@fontsource-variable/ibm-plex-mono" 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( , ) ``` `web/src/App.tsx` — роуты со страницами-заглушками (реальные страницы — задачи 3-6): ```tsx import { Routes, Route, Navigate } from "react-router-dom" import { Layout } from "@/components/Layout" function Placeholder({ name }: { name: string }) { return
{name}
} export function App() { return ( } /> } /> } /> } /> } /> ) } ``` `web/src/components/Layout.tsx` — сайдбар/навигация (Domains/Accounts/Templates), тёмная console-эстетика (реализатор дорабатывает по frontend-design). - [ ] **Step 6: Smoke-тест + .gitignore** `.gitignore` (добавить): ``` web/node_modules/ web/dist/ ``` `web/src/App.test.tsx`: ```tsx import { render, screen } 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( , ) expect(screen.getByText("Domains")).toBeInTheDocument() }) ``` Добавить в `web/package.json` scripts: `"test": "vitest run"`, `"test:watch": "vitest"`. - [ ] **Step 7: Проверки** Run: `cd web && npm run test` → PASS (smoke). Run: `cd web && npx tsc --noEmit` → без ошибок типов. Run: `cd web && npm run build` → `dist/` собирается. - [ ] **Step 8: Commit** ```bash git add web/ .gitignore git commit -m "feat(web): scaffold Vite+React+TS, Tailwind v4, shadcn, router, тёмная console-тема" ``` --- ### Task 2: API-клиент, типы и Query-хуки **Files:** - Create: `web/src/lib/config.ts`, `web/src/api/types.ts`, `web/src/api/client.ts`, `web/src/api/client.test.ts` - Create: `web/src/hooks/useApi.ts` **Interfaces:** - Consumes: Go DTO из `internal/api` (Account/Template/Domain/Changeset/RecordView/ApplyRequest — сверить с `internal/api/dto.go`, `tenant_dto.go`) - Produces: - `DEFAULT_PROJECT_ID` - типы `Account, Template, Domain, RecordView, ChangesetResponse, RecordDTO, ApplyRequest` - `api` — объект с методами: `listAccounts/createAccount/deleteAccount`, `listTemplates/createTemplate/updateTemplate/deleteTemplate`, `listDomains/createDomain/deleteDomain/importZones/setDomainTemplate`, `checkDomain/applyDomain` - хуки: `useAccounts, useCreateAccount, useDeleteAccount, useTemplates, ...`, `useDomains, useImportZones, useSetDomainTemplate, useCheckDomain, useApplyDomain` - [ ] **Step 1: Написать падающий тест клиента** `web/src/api/client.test.ts`: ```ts 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") }) }) ``` - [ ] **Step 2: Запустить — падает** Run: `cd web && npm run test -- client` → FAIL. - [ ] **Step 3: config + types + client** `web/src/lib/config.ts`: ```ts export const DEFAULT_PROJECT_ID = "00000000-0000-0000-0000-000000000002" export const API_BASE = `/api/v1/projects/${DEFAULT_PROJECT_ID}` ``` `web/src/api/types.ts` (зеркало Go DTO — сверить имена полей с internal/api): ```ts 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[] } export interface CreateTemplateInput { name: string; records: RecordDTO[] } export interface Domain { id: string providerAccountId: string zoneName: string zoneId: string templateId: string | 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 } ``` `web/src/api/client.ts`: ```ts import { API_BASE } from "@/lib/config" import type { Account, CreateAccountInput, Template, CreateTemplateInput, Domain, CreateDomainInput, ChangesetResponse, ApplyRequest, } from "./types" async function req(path: string, init?: RequestInit): Promise { 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 = 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("/accounts"), createAccount: (input: CreateAccountInput) => req("/accounts", { method: "POST", body: JSON.stringify(input) }), deleteAccount: (id: string) => req(`/accounts/${id}`, { method: "DELETE" }), listTemplates: () => req("/templates"), createTemplate: (input: CreateTemplateInput) => req