docs(admin-ui): rich design handoff Dictionary Catalog (3 views + nstart/ui patch)

This commit is contained in:
Александр Зимин
2026-05-10 17:59:01 +00:00
parent c60a6b82c5
commit ddbe7368a2
17 changed files with 2743 additions and 1555 deletions
@@ -1,177 +1,394 @@
# Dictionary Catalog v2 — Design Handoff
# Handoff: Dictionary Catalog Redesign + nstart-ui Popover Fix
Pages: `/dictionaries/` (catalog) + per-card нав до `/dictionaries/$name`.
## Overview
Текущая страница (single-screen, scope-grouped grid карточек) хорошо работала
на 10 справочниках, но при росте до 40+ (ЦУОД bundle expansion) UX degrades:
forced grouping ломает sort-by-attention, scope-as-section не позволяет
multi-scope запрос, density фиксированная (cards-only). Этот hand-off
поднимает катаког в шаблон **filter-first list**: URL-driven filters,
density toggle, sort, persistent state shareable через ссылку.
Two interconnected pieces of work for the **Ordinis admin UI** (`ordinis-admin-ui`, React 19 + TanStack Router + `@nstart/ui` 0.1.3):
## Scope
1. **`@nstart/ui` patch** — `Select` / `MultiSelect` / `DatePicker` popovers render inline and get clipped by modal `overflow:auto` (visible bug in the "Создать справочник" modal). Fix via `pnpm patch` to render into a portal on `document.body`. Same patch fixes `Checkbox.indeterminate` ref-forwarding.
-`/dictionaries/` page redesign (catalog only).
-`/dictionaries/$name` (detail) — отдельный hand-off, не trog'аем.
- ❌ Backend / API — не меняем, всё локально через `useDictionaries()`.
- ❌ i18n переводы — keys добавлены, локалаизация в parallel PR.
2. **Dictionary catalog redesign** — three new ways to browse the 40+ dictionaries with their FK relationships visible:
- **D · List view with inline FK chips** (smallest change; replaces current grid)
- **A · Graph view** (force-directed overview of all dicts + edges)
- **B · Hub view** (one dict in focus, neighbors on either side; new tab inside `dictionaries.$name.tsx`)
## Файлы handoff
## About the Design Files
| Файл | Назначение |
|---|---|
| `README.md` | Этот документ — components / tokens / routes / AC |
| `prototype.html` | Static visual mock (Tailwind CDN) для Figma replacement |
| `proposed.tsx` | Финальная React-компонента — заменяет `routes/dictionaries.index.tsx` |
| `patch.diff` | Unified diff от current до proposed |
| `review.md` | Senior frontend review (taste calls, edge cases) |
The HTML/JSX in `design/` is a **design reference**, not production code to copy directly. It's a working prototype showing intended look, layout, interactions, and data shape — but written as a single self-contained HTML file with inline Babel.
## Components используемые
**Your task:** recreate these views inside the existing `ordinis-admin-ui` React/TypeScript codebase, using its established patterns:
- TanStack Router routes
- TanStack Query for data
- `@nstart/ui` components (`Button`, `Modal`, `Tabs`, `Badge`, `Alert`, etc.) for chrome
- TailwindCSS v4 (project already uses it via `@tailwindcss/vite`)
- React Hook Form + AJV for forms
- Phosphor Icons (`@phosphor-icons/react`)
Из `@nstart/ui` (без новых):
- `PageHeader` — заголовок + actions slot (кнопка Create)
- `SearchInput` — debounced query
- `Button` (`variant="primary"`, `variant="ghost"`, `size="sm"`)
- `Badge` (`variant="neutral|info|warning"`)
- `EmptyState` — пустые состояния (no data / no match)
- `LoadingBlock` — initial fetch skeleton
- `Alert` (`variant="error"`)
**Do not** ship the prototype HTML to production. **Do not** import the prototype `.jsx` files — they use `window.*` globals and inline Babel which won't work in the Vite build.
**Новые элементы (не component'ы — inline UI):**
- `ScopeChip` — toggle pill с цветным dot, multi-select.
- `SortDropdown``<select>` нативный (5 опций) — стилизация через `@apply`-free Tailwind.
- `DensityToggle` — segmented `<button>` group, 2 state (`card` | `dense`).
- `ApprovalBadge``<Badge variant="warning">approval</Badge>` под условием
`d.approvalRequired === true`.
## Fidelity
## Design tokens (из `@nstart/ui` theme)
**Mid-fidelity.** Final colors, typography, spacing, and interactions are decided. Pixel-perfect recreation is the goal, but you must adapt to:
- Real component primitives from `@nstart/ui` (the prototype uses raw `<button>`/`<div>` for portability).
- Real data shapes from `api/queries.ts` (the prototype uses a hand-built `DICTS` mock).
- TailwindCSS classes instead of inline styles.
Используем существующие `@theme`-переменные — никаких custom tokens:
The graph layout in the prototype is **statically positioned** — production must use `d3-force` (see Implementation §3.2).
| Token | Использование |
|---|---|
| `--color-ultramarain` (#120a8f) | Active link, primary button, focused border |
| `--color-carbon` (#4a4a4a) | Body text, secondary action text |
| `--color-regolith` (#dedede) | Card border, divider, dense-row separator |
| `--color-orbit` (#bfeaff) | Hover background для chips/buttons |
| `--color-aurora` (#1fe589) → `bg-emerald-500` | Scope PUBLIC dot |
| `--color-solar` (#f28c40) → `bg-amber-500` | Scope INTERNAL dot |
| `--color-mars` (#d91616) → `bg-rose-500` | Scope RESTRICTED dot |
| `--font-primary` (Tektur) | h1/h2/h3, brand моменты |
| `--font-secondary` (Onest) | Body, controls |
| `--radius-lg` (4px) | Карточки и list rows |
| `--radius-full` | Chips, dots |
| `--shadow-card` | Карточка default |
| `--shadow-hover` | Карточка hover (lift) |
---
Scope styling (existing) из `lib/scope-style.ts`:
- `SCOPE_DOT[scope]``bg-emerald-500 / bg-amber-500 / bg-rose-500`
- `SCOPE_ACCENT[scope]``before:bg-...` (4px left stripe на карточке)
- `SCOPE_ORDER` → array для consistent visual ordering
## Part 1 — `@nstart/ui` Patch
## Routes & search params
### Why
Файл: `routes/dictionaries.index.tsx` (существующий, replace contents).
Open the "Создать справочник" modal → click any `MultiSelect` (e.g. "Поддерживаемые локали"). The dropdown opens **inside** the modal scroll container, getting clipped at modal edges and pushing form fields. Same problem with `DatePicker`. Same root cause: popover rendered as a sibling instead of portal.
URL search schema (`zodValidator`):
```ts
const searchSchema = z.object({
q: z.string().optional(),
scope: z.string().optional(), // CSV "PUBLIC,INTERNAL"
sort: z.enum(['name', 'recordCount', 'updated']).optional(),
density: z.enum(['card', 'dense']).optional(),
})
Separately, `Checkbox` with `indeterminate` doesn't visually reflect the indeterminate state — the ref gets attached to the `<label>` wrapper rather than the `<input>` element.
### How
Use `pnpm patch` to fork only the affected files in `node_modules/@nstart/ui/dist/` and persist as a project-level patch. Full step-by-step in `patches/README.md`.
```bash
pnpm patch @nstart/ui # → opens temp dir, edit dist/index.js
pnpm patch-commit <temp-path> # → writes patches/@nstart__ui@0.1.3.patch
```
State persisted в URL — shareable links для команды:
- `/dictionaries/?q=spacecraft` — поиск по словам
- `/dictionaries/?scope=PUBLIC,INTERNAL` — multi-scope filter
- `/dictionaries/?sort=recordCount&density=dense` — power-user view
- `/dictionaries/?q=test&scope=PUBLIC&sort=name&density=card` — комбинация
Three logical changes inside `dist/index.js` (or `.mjs`):
Default state (no params): show all, sort by name, density=card.
1. **Wrap Select / MultiSelect / DatePicker popover in `createPortal(node, document.body)`** with `position: fixed`, computed `top`/`left`/`width` from `triggerRef.getBoundingClientRect()`, `z-index: 9999`.
2. **Listen to `resize` and capture-phase `scroll`** so the portal stays glued to the trigger inside scrolling modals.
3. **Update outside-click detection** — after the popover moves out of trigger's DOM subtree, the existing `event.target` check fails. Replace with `triggerRef.current.contains(t) || popoverRef.current.contains(t)`.
4. **Auto-flip when popover overflows viewport bottom** — flip above the trigger.
Navigation:
- `<Link to="/dictionaries/$name" params={{ name: d.name }}>` — typed router
- На карточке: cтрока с `keyboard nav` (Enter / Space → navigate)
For `Checkbox`:
- Forward ref to the inner `<input>`, not the `<label>`.
- Accept `indeterminate` as a prop, sync to `inputEl.indeterminate` in `useEffect`.
### Acceptance
- Open modal → MultiSelect popover renders past modal edges, follows trigger on scroll, flips when near viewport bottom.
- Click outside popover (anywhere on backdrop or other modal field) → popover closes.
- A `<Checkbox indeterminate>` visually shows the indeterminate state.
- `pnpm install` after deleting `node_modules/` re-applies the patch automatically (verify `package.json` has `pnpm.patchedDependencies`).
### Long-term escape hatch
If future `@nstart/ui` versions break the patch repeatedly, replace `Select` / `MultiSelect` only with **Radix Select + cmdk** behind a thin wrapper (`src/ui/Select.tsx`) and update the import via barrel file. Keep other `@nstart/ui` components.
---
## Part 2 — Dictionary Catalog Redesign
### Domain context
A "dictionary" is a JSON-schema-driven reference catalog (Типы КА, Спектральные каналы, Антенны, …). One dictionary's records can FK-reference another dictionary's records via fields marked in the schema. There are ~40 today, expected to grow to ~150. Users currently can't see relationships between dictionaries — only an alphabetical grid of cards.
**Existing route:** `src/routes/dictionaries.index.tsx` (list of all) and `src/routes/dictionaries.$name.tsx` (one dict + its records).
### Three views
#### 2.1 — View D · List with inline FK chips (priority 1 — replaces current grid)
**Where:** rewrite the body of `routes/dictionaries.index.tsx`.
**Layout:**
- **Toolbar row** (grid `1fr auto`):
- Left: search input (`height: 36px`, `width: 340px`, placeholder "Поиск по названию, коду или описанию"), then text "Показано **N** из **M**".
- Right: three scope toggles (`PUBLIC` blue, `INTERNAL` orange, `TENANT` pink) — each a `Btn` with a colored dot prefix; toggleable; default all on. Then divider, then "Только со связями" toggle.
- **Bundle filter row:** label "BUNDLE" in mono caps, then `все` button followed by one button per bundle (`cuod`, `geo`, `i18n`, `core`) with the count of items in each. Active filter = navy outline + light-blue fill. Single-select (clicking active deselects).
- **Body:** dictionaries grouped by bundle. Each group has a header (mono uppercase bundle name + count + horizontal hairline). Inside each group: 2-column grid of `Item` cards.
**Item card layout** (`grid-template-columns: 3px 1fr auto`, `border-radius: 10px`, `border: 1px solid #e5e7ee`):
- Column 1: 3px-wide vertical strip in the scope color (`#3a6df0` / `#d4711a` / `#c64a8a`).
- Column 2 (padding `12px 14px`):
- **Title row:** dictionary title (`15px`, `font-weight: 600`, `color: #1d2a8a`) + `id` in mono 11px gray.
- **Description:** `12px`, `color: #3a4151`, `line-height: 1.4`, `margin-top: 3px`.
- **Relations row** (only if `fk.length > 0 || refBy.length > 0`):
- "→ ссылается" mono caps label, then `FkChip` for each entry in `fk[]`.
- "← N использ." mono caps label, then up to 3 `FkChip` (dimmed style: dashed border, gray text) for `refBy[]`. If more than 3, "+N" suffix.
- Column 3 (padding `12px 14px`, flex column, items end): `ScopeBadge`, then `v1.0.0` mono.
**Hover:** `box-shadow: 0 8px 22px -14px rgba(20,30,140,.25)` + `translateY(-1px)`. Cursor pointer. Click → navigate to `/dictionaries/{id}` (which now opens at the new "Связи" tab — see 2.2).
**FK chip** (`FkChip.tsx`): inline button, `padding: 2px 7px`, `border-radius: 5px`, `font: 11px JetBrains Mono`. Active: `bg: #f3f5fb`, `border: 1px solid #e5e7ee`, `color: #1d2a8a`. Dimmed: `bg: transparent`, `border: 1px dashed #e5e7ee`, `color: #6b7180`. `onClick: e => { e.stopPropagation(); navigate(`/dictionaries/${id}`); }`.
**Empty state:** centered "Ничего не найдено по текущим фильтрам".
#### 2.2 — View B · Hub (priority 2 — new tab in `dictionaries.$name.tsx`)
**Where:** add as the **first** tab "Связи" in the existing tab bar of `routes/dictionaries.$name.tsx`. Existing tabs (Записи / Поля / JSON / События) stay unchanged.
**Layout:**
- **Top metadata strip** (existing on the page, no changes needed besides ensuring it shows scope badge, bundle, version).
- **Tab body:**
- **Meta row** (gray bg `#f7f8fb`, `border-radius: 10px`, padding `14px 18px`): scope badge | description | counts "→ N исх. ← M вход.".
- **Three-column grid** (`1fr auto 1fr`, `gap: 30px`, `align-items: flex-start`):
- **Left column** ("Зависит от · N"): list of cards, one per `fk[]` entry, each followed by a small right-pointing arrow SVG (60×20).
- **Center**: large highlighted card (width 280px, `border: 2px solid #1d2a8a`, gradient bg `linear-gradient(180deg,#fff,#eef3ff)`, large shadow). Shows current dictionary's title (18px bold), id (mono), scope badge, description.
- **Right column** ("На него ссылаются · M"): arrow + card per `refBy[]` entry.
- **Footer hint** (dashed top border): "Клик по карточке — сделать центром. Ctrl+клик — добавить в выборку. Двойной клик — открыть в редакторе."
**Card** (used left and right): same FK-chip-style colored strip + title + id, but as a full card (not a chip). 240px wide, white bg.
**Click on a side card** → navigate to that dictionary's Hub tab. **Recursion** is fine — Hub view is just a function of the route param.
**Empty states:**
- No `fk` → "нет зависимостей" in left column.
- No `refBy` → "никто не ссылается" in right column.
#### 2.3 — View A · Graph (priority 3 — new sub-route)
**Where:** new route file `src/routes/dictionaries.graph.tsx`. Add a tab/toggle "Граф связей" on `dictionaries.index.tsx` that switches `?view=graph`. Persist via TanStack Router `validateSearch`:
```ts
search: { view: z.enum(['list','graph']).default('list') }
```
**Layout:** full-width canvas (max 1180px wide), vertical column:
- **Toolbar** (height 56px, padding `0 18px`, bottom border):
- Left: caption "Граф связей · N узлов · M связей" (mono uppercase).
- Right: three scope filter buttons (same as List view), divider, "Скрыть листья" toggle, "Экспорт SVG".
- **SVG canvas** (1180 × 760, dot-grid background `#eef0f5` 24px pitch).
- **Legend bar** (bottom, padding `10px 18px`, top border): "Размер узла = входящие ссылки · Hover — фокус, Click — открыть hub · Layout: forced".
**Node:** circle, radius `24 + min(refBy.length * 3, 16)` px. Fill = `SCOPE_BG[scope]`, stroke = `SCOPE_COLOR[scope]`, stroke-width `1.4` (or `2.5` if hovered). Centered text: title (truncated to 12 chars + "…" if longer) on top, `← N` mono caption below.
**Edge:** cubic Bézier `M ax,ay C ax,(ay+by)/2 bx,(ay+by)/2 bx,by` with arrow marker at end. Stroke `#c8cfdb` default, `#1d2a8a` when adjacent to hovered node. Non-adjacent edges fade to opacity `0.10` on hover.
**Layout calculation:** use `d3-force` once on mount + on data change.
```ts
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide } from 'd3-force';
const sim = forceSimulation(nodes)
.force('link', forceLink(edges).id((d:any) => d.id).distance(140))
.force('charge', forceManyBody().strength(-450))
.force('center', forceCenter(W/2, H/2))
.force('collide', forceCollide(node => node.radius + 8))
.stop();
for (let i = 0; i < 300; i++) sim.tick();
```
Cache positions in a `useMemo` keyed by `JSON.stringify(edges)`. Do not animate — render statically; users want a snapshot, not a live simulation.
**Hover:** `setHover(id)` highlights node + adjacent edges + adjacent nodes; dims everything else. Click on node → `navigate({ to: '/dictionaries/$name', params: { name: id } })`.
**Tooltip on hover:** absolute-positioned box at `(node.x + 30, node.y + 30)` clamped to canvas. Shows title + scope badge + id mono + description + counts. White bg, soft shadow.
### Data shape
#### Dictionary type (`src/components/catalog/types.ts`)
```ts
export interface Dictionary {
id: string; // e.g. "satellites"
title: string; // e.g. "Космические аппараты"
desc: string; // short description (existing field)
scope: 'PUBLIC' | 'INTERNAL' | 'TENANT';
bundle: string; // 'cuod' | 'geo' | 'i18n' | 'core' | …
version: string; // e.g. "1.0.0"
fk: string[]; // ids of dictionaries this one references
refBy: string[]; // ids of dictionaries that reference this one
}
```
The existing API likely returns everything except `fk` and `refBy`. You need to derive these from the JSON schema.
#### Two paths for `fk` / `refBy`
**Path A — derive client-side** (fastest to ship):
```ts
function extractFks(schema: JsonSchema): string[] {
const fks: string[] = [];
for (const prop of Object.values(schema.properties ?? {})) {
// adjust to whatever convention your backend uses:
const ref = (prop as any)['x-ref']
?? (prop as any)['x-fk']
?? matchRefPath((prop as any).$ref);
if (typeof ref === 'string') fks.push(ref);
}
return fks;
}
function buildGraph(dicts: ApiDictionary[]): Dictionary[] {
const withFk = dicts.map(d => ({ ...d, fk: extractFks(d.schema), refBy: [] as string[] }));
const byId = Object.fromEntries(withFk.map(d => [d.id, d]));
for (const d of withFk) for (const t of d.fk) byId[t]?.refBy.push(d.id);
return withFk;
}
```
**Confirm with backend team** which convention is actually in use (`x-ref`? `$ref`? something else?) before implementing. The prototype assumes `x-ref` / `x-fk`.
Wrap in `useMemo([dictsQuery.data])` — recompute only when the dict list changes.
**Path B — backend endpoint** (correct long-term):
Ask backend for `GET /api/v1/dictionaries/graph` returning:
```ts
{
nodes: Array<{ id, title, scope, bundle, version, desc, refByCount, fkCount }>,
edges: Array<{ from: string, to: string, property: string }>
}
```
Cache for 60s — graph rarely changes. Until B exists, use A.
### Routes & state
```
src/routes/
├── dictionaries.index.tsx — Toolbar + List view OR Graph view based on ?view=
├── dictionaries.graph.tsx — DELETE if you choose to keep both views in index.tsx
└── dictionaries.$name.tsx — Add "Связи" as first tab → HubView
```
Search-state schema:
```ts
const searchSchema = z.object({
view: z.enum(['list','graph']).default('list'),
q: z.string().optional(),
scopes: z.array(z.enum(['PUBLIC','INTERNAL','TENANT'])).default(['PUBLIC','INTERNAL','TENANT']),
bundle: z.string().optional(),
// for hub:
// (the focused dict id is the route param, not search-state)
});
```
URL examples:
- `/dictionaries?view=graph` — graph of everything
- `/dictionaries?view=list&q=каналы&scopes=PUBLIC` — filtered list
- `/dictionaries/satellites` — Hub of satellites (Связи tab)
### Component file layout
```
src/components/catalog/
├── types.ts — Dictionary, scope/bundle constants, color tokens
├── extractFks.ts — schema → fk[] derivation
├── buildGraph.ts — adds refBy[]
├── ScopeBadge.tsx — pill with scope dot + label
├── ScopeDot.tsx — 8×8 colored dot
├── FkChip.tsx — clickable FK chip (active + dimmed variants)
├── ListView.tsx — view D body
├── HubView.tsx — view B body (used as a tab inside dictionaries.$name)
├── GraphView.tsx — view A body, uses d3-force
└── tokens.ts — SCOPE_COLOR, SCOPE_BG, SCOPE_LABEL, BUNDLES
```
---
## Design Tokens
### Colors
| Token | Value | Used for |
|------------------|-----------|----------|
| `--navy` | `#1d2a8a` | primary text, primary button, focused borders |
| `--navy-2` | `#0f1860` | hub-card title |
| `--ink` | `#11131a` | body text, hard contrast labels |
| `--ink-2` | `#3a4151` | secondary body text |
| `--mute` | `#6b7180` | meta text, captions, mono labels |
| `--line` | `#e5e7ee` | hairlines, card borders, default button border |
| `--line-2` | `#eef0f5` | section dividers, subtle separators |
| `--bg` | `#f7f8fb` | page background, hub meta strip |
| `--surface` | `#ffffff` | card backgrounds |
| **scope** (`PUBLIC`) | fg `#3a6df0` / bg `#eef3ff` |
| **scope** (`INTERNAL`) | fg `#d4711a` / bg `#fff2e3` |
| **scope** (`TENANT`) | fg `#c64a8a` / bg `#fde7f1` |
### Typography
- **Body / UI:** `Inter, ui-sans-serif, system-ui, sans-serif` (already in styles.css via `@nstart/ui/styles/fonts.css`).
- **Mono captions / IDs:** `JetBrains Mono, ui-monospace, monospace`.
- **Scale:**
- `22px / 600` — Hub center title
- `18px / 700` — Hub center title (alt) / large card title
- `15px / 600` — list item title
- `14px / 600` — toolbar/section heading
- `13px / 500600` — buttons, tabs
- `12px / 400` — body text, descriptions
- `11px mono uppercase letter-spacing .06em` — meta labels ("сап"-style captions)
### Spacing
- Card padding: `12px 14px`
- Group gap (vertical between bundles): `22px`
- Item gap inside group: `10px`
- Toolbar row gap: `810px`
- View padding: `24px` inside surface, `28px` page padding
### Radius / shadow
- Cards: `border-radius: 10px`, `border: 1px solid #e5e7ee`
- Buttons: `border-radius: 8px`, `height: 3236px`
- Hub center card: `border-radius: 12px`, `border: 2px solid #1d2a8a`, `box-shadow: 0 18px 36px -22px rgba(20,30,140,.5)`
- Item hover: `box-shadow: 0 8px 22px -14px rgba(20,30,140,.25)`
---
## Acceptance criteria
### A11y (must-pass)
### List view (D)
- Replaces the current alphabetical grid on `/dictionaries`.
- Real `fk[]` and `refBy[]` derived from schemas (or from `/dictionaries/graph` endpoint).
- Search, scope toggles, bundle filter, "Только со связями" all work.
- Hover lift + click navigation to `/dictionaries/{id}`.
- FK chips clickable and stop propagation.
- Empty state shown when filters return nothing.
1. `<nav aria-label="...">` отсутствует (это не nav, это grid). Вместо использовать
`<section aria-label={t('dict.list.heading')}>` обёртку всей страницы.
2. Каждый scope-chip имеет `aria-pressed={selected}` + visible focus ring.
3. Density toggle — `<div role="group" aria-label="Density">` с 2 buttons
`aria-pressed={state===density}`.
4. Sort `<select>` — стандартный native + visible label через `aria-label`.
5. SearchInput сохраняет `aria-label`. На `/dictionaries/?q=...` initial load —
focus на input, `aria-busy` пока `useDeferredValue` транзит.
6. Карточки/строки в dense mode имеют `aria-current="page"` если route matches
(нужно для возврата с детального).
7. Контраст: все text-on-bg pairs ≥ 4.5:1 (WCAG AA). Особое внимание на
`text-carbon/60` (60% opacity) — tested OK на white background.
### Hub view (B)
- New "Связи" tab on `/dictionaries/{name}`, **first** in tab order.
- Shows zero-states ("нет зависимостей" / "никто не ссылается") correctly.
- Cards on either side navigate to that dict's Связи tab.
- All existing tabs (Записи / Поля / JSON / События) still work unchanged.
### Behavior
### Graph view (A)
- Reachable via `/dictionaries?view=graph`.
- `d3-force` simulation runs once, deterministic positions across reloads (seed if needed).
- Hover dims unrelated nodes + edges to opacity `0.10`.
- Click on node navigates to `/dictionaries/{id}`.
- Tooltip stays inside canvas bounds.
- Performance: 150 nodes / 300 edges renders in <300ms.
1. URL state стабилен: refresh / back → точно то же view.
2. `q` отсутствует/пустой → default sort применяется.
3. `scope=PUBLIC,INTERNAL` без RESTRICTED → видны только эти 2.
4. Изменение sort/density НЕ сбрасывает scope filter.
5. Empty state per filter combo: «Ничего не найдено по фильтрам» с
`<button>Сбросить фильтры</button>`.
6. Density `dense`: header строка + пер-строка + sticky на scroll (mobile fallback).
7. `<Link>` clickable area = вся карточка/строка (1 целевая зона на запись).
### Patch
- `pnpm install` from clean checkout applies the patch.
- `MultiSelect` popover renders in portal, follows trigger on scroll, flips when near viewport bottom.
- `Checkbox indeterminate` works visually.
### Performance
---
- Initial render с 100 dicts: ≤ 50ms (`useDeferredValue` для `q`, `useMemo`
для filtered + sorted).
- Re-filter на keystroke: ≤ 30ms. Если backend вырастет до 10k записей —
это становится backend pagination concern (отдельный issue, не в scope).
- Bundle size: zero new deps. Sort logic — inline (≤ 30 строк).
## Files in this bundle
### Visual / Brand
- Шрифты: Tektur только в `app.title` + section headers (h2/h3). Body — Onest.
- Цвета строго из `@theme` — ни один custom hex.
- Радиус: `rounded-lg` (4px) для карточек / sections, `rounded-full` для chips/dots.
- Shadow: `shadow-card` static, `shadow-hover` на hover/focus.
## Edge cases
| Кейс | Поведение |
| Path | What it is |
|---|---|
| `data?.length === 0` | EmptyState «Нет справочников» + Create button |
| `filtered.length === 0` AND фильтры активны | EmptyState с reset action |
| 1 dict в одном scope, 0 в других + `density=dense` | Header строка + 1 row, без gap |
| `q` content match но scope filter excludes | Show empty + сообщение «scope filter скрыл результаты» |
| URL invalid `scope=FOO` | Zod validate'ит, фильтр игнорируется |
| URL `density=zzz` | Default `card` |
| Wide screens (≥ 1440px) | Grid 4 columns в card mode (current 3) |
| Narrow mobile (<400px) | Density toggle скрыт (только card) |
| Approval-required dict | Warning badge поверх стандартного scope badge |
| `screenshots/D-list-view.png` | Reference screenshot of the List view |
| `screenshots/A-graph-view.png` | Reference screenshot of the Graph view |
| `screenshots/B-hub-view.png` | Reference screenshot of the Hub view |
| `design/dictionary-prototype.html` | Working three-view prototype (open in browser) |
| `design/proto/data.jsx` | Mock dictionary dataset (~24 dicts) — **reference shape only** |
| `design/proto/ui.jsx` | Atomic UI components (FkChip, ScopeBadge, Btn, Sup) |
| `design/proto/view-list.jsx` | View D source |
| `design/proto/view-hub.jsx` | View B source |
| `design/proto/view-graph.jsx` | View A source |
| `design/dictionary-views.html` | Earlier exploration with 4 alternative concepts (matrix view) |
| `patches/README.md` | Step-by-step `pnpm patch @nstart/ui` instructions |
| `review.md` | Original code review — bugs and improvements found in the codebase. Tasks B, A, D + the patch are responses to issues identified there. |
## Out of scope (future)
## Open questions to confirm with the team
- Server-side pagination (когда dicts > 200) — отдельный issue.
- Per-dict pinning / favorites — нужны user preferences storage.
- Bulk operations (mass approval flag toggle) — отдельный admin tool.
- Export CSV списка справочников — minor, можно через menu later.
1. **JSON-schema FK convention**`x-ref`? `$ref`? Custom `x-dict`? Whatever it is, `extractFks` needs to match.
2. **Backend graph endpoint** — willing to add `/api/v1/dictionaries/graph`? If not, client derives.
3. **Bundle field** — confirm exact list of bundles in production (`cuod`, `geo`, `i18n`, `core` are guesses).
4. **`@nstart/ui` upgrade plan** — is the team open to a long-term Radix Select wrapper if patch breaks repeatedly?
5. **Graph performance ceiling** — is 150 nodes the real cap, or could it grow to 500+? At 500+ switch to `cytoscape.js`.
## Implementation status
## Out of scope
- `prototype.html` — final visual.
- `proposed.tsx` — drop-in replacement для `routes/dictionaries.index.tsx`.
- `patch.diff`surgical replace.
-`review.md` — senior review с edge cases.
## Test plan (для PR)
1. Vitest unit: filter+sort logic (10 inputs × expected outputs).
2. RTL component test: scope chip toggle обновляет URL, density toggle
меняет layout без re-fetch.
3. Playwright e2e: load `/dictionaries/?q=test&scope=PUBLIC` → page shows
filtered subset, refresh → same view.
4. Manual a11y: NVDA + VoiceOver smoke. Tab-order: search → scope chips → sort → density → first card → next card.
5. Lighthouse: a11y ≥ 95, perf ≥ 90.
- Editing dictionaries / records (existing flows untouched).
- The form bugs in `review.md` (#1 time-travel TZ, #4 AJV errors, #12 token storage, etc.) — separate work items.
- Mobile responsivecurrent admin UI is desktop-only.
@@ -0,0 +1,936 @@
// DesignCanvas.jsx — Figma-ish design canvas wrapper
// Warm gray grid bg + Sections + Artboards + PostIt notes.
// Artboards are reorderable (grip-drag), deletable, labels/titles are
// inline-editable, and any artboard can be opened in a fullscreen focus
// overlay (←/→/Esc). State persists to a .design-canvas.state.json sidecar
// via the host bridge. No assets, no deps.
//
// Usage:
// <DesignCanvas>
// <DCSection id="onboarding" title="Onboarding" subtitle="First-run variants">
// <DCArtboard id="a" label="A · Dusk" width={260} height={480}>…</DCArtboard>
// <DCArtboard id="b" label="B · Minimal" width={260} height={480}>…</DCArtboard>
// </DCSection>
// </DesignCanvas>
const DC = {
bg: '#f0eee9',
grid: 'rgba(0,0,0,0.06)',
label: 'rgba(60,50,40,0.7)',
title: 'rgba(40,30,20,0.85)',
subtitle: 'rgba(60,50,40,0.6)',
postitBg: '#fef4a8',
postitText: '#5a4a2a',
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
};
// One-time CSS injection (classes are dc-prefixed so they don't collide with
// the hosted design's own styles).
if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
const s = document.createElement('style');
s.id = 'dc-styles';
s.textContent = [
'.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}',
'.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}',
'[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}',
'[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}',
'[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}',
// isolation:isolate contains artboard content's z-indexes so a
// z-indexed child (sticky navbar etc.) can't paint over .dc-header or
// the .dc-menu popover that drops into the top of the card.
'.dc-card{isolation:isolate;transition:box-shadow .15s,transform .15s}',
'.dc-card *{scrollbar-width:none}',
'.dc-card *::-webkit-scrollbar{display:none}',
// Per-artboard header: grip + label on the left, delete/expand on the
// right. Single flex row; when the artboard's on-screen width is too
// narrow for both the label yields (ellipsis, then hidden entirely below
// ~4ch via the container query) and the buttons stay on the row.
'.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;',
' display:flex;align-items:center;container-type:inline-size}',
'.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}',
'.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}',
'.dc-grip:hover{background:rgba(0,0,0,.08)}',
'.dc-grip:active{cursor:grabbing}',
'.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;',
' display:flex;align-items:center;transition:background .12s;overflow:hidden}',
// Below ~4ch of label room: hide the label entirely, and drop the grip to
// hover-only (same reveal rule as .dc-btns) so a narrow header is clean
// until the card is moused.
'@container (max-width: 110px){',
' .dc-labeltext{display:none}',
' .dc-grip{opacity:0}',
' [data-dc-slot]:hover .dc-grip{opacity:1}',
'}',
'.dc-labeltext:hover{background:rgba(0,0,0,.05)}',
'.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}',
'.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}',
'.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}',
'[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-menu){opacity:1}',
'.dc-expand,.dc-kebab{width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;',
' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;',
' font:inherit;transition:background .12s,color .12s}',
'.dc-expand:hover,.dc-kebab:hover{background:rgba(0,0,0,.06);color:#2a251f}',
// Slot hosting an open menu floats above later siblings (which otherwise
// paint on top — same z-index:auto, later DOM order) so the popup isn't
// clipped by the next card.
'[data-dc-slot]:has(.dc-menu){z-index:10}',
'.dc-menu{position:absolute;top:100%;right:0;margin-top:4px;background:#fff;border-radius:8px;',
' box-shadow:0 8px 28px rgba(0,0,0,.18),0 0 0 1px rgba(0,0,0,.05);padding:4px;min-width:160px;z-index:10}',
'.dc-menu button{display:block;width:100%;padding:7px 10px;border:0;background:transparent;',
' border-radius:5px;font-family:inherit;font-size:13px;font-weight:500;line-height:1.2;',
' color:#29261b;cursor:pointer;text-align:left;transition:background .12s;white-space:nowrap}',
'.dc-menu button:hover{background:rgba(0,0,0,.05)}',
'.dc-menu hr{border:0;border-top:1px solid rgba(0,0,0,.08);margin:4px 2px}',
'.dc-menu .dc-danger{color:#c96442}',
'.dc-menu .dc-danger:hover{background:rgba(201,100,66,.1)}',
// Chrome (titles / labels / buttons) counter-scales against the viewport
// zoom so it stays a constant on-screen size. --dc-inv-zoom is set by
// DCViewport on every transform update and inherits to all descendants —
// any overlay inside the world (e.g. a TweaksPanel on an artboard) can use
// it the same way.
//
// The header uses transform:scale (out-of-flow, so layout impact doesn't
// matter) with its world-space width set to card-width / inv-zoom so that
// after counter-scaling its on-screen width exactly matches the card's —
// that's what lets the container query + text-overflow behave against the
// card's visible edge at every zoom level.
//
// The section head uses CSS zoom instead of transform so its layout box
// grows with the counter-scale, pushing the card row down — otherwise the
// constant-screen-size title would overflow into the (shrinking) world-
// space gap and overlap the artboard headers at low zoom.
'.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));',
' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left}',
'.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}',
].join('\n');
document.head.appendChild(s);
}
const DCCtx = React.createContext(null);
// ─────────────────────────────────────────────────────────────
// DesignCanvas — stateful wrapper around the pan/zoom viewport.
// Owns runtime state (per-section order, renamed titles/labels, hidden
// artboards, focused artboard). Order/titles/labels/hidden persist to a
// .design-canvas.state.json
// sidecar next to the HTML. Reads go via plain fetch() so the saved
// arrangement is visible anywhere the HTML + sidecar are served together
// (omelette preview, direct link, downloaded zip). Writes go through the
// host's window.omelette bridge — editing requires the omelette runtime.
// Focus is ephemeral.
// ─────────────────────────────────────────────────────────────
const DC_STATE_FILE = '.design-canvas.state.json';
function DesignCanvas({ children, minScale, maxScale, style }) {
const [state, setState] = React.useState({ sections: {}, focus: null });
// Hold rendering until the sidecar read settles so the saved order/titles
// appear on first paint (no source-order flash). didRead gates writes until
// the read settles so the empty initial state can't clobber a slow read;
// skipNextWrite suppresses the one echo-write that would otherwise follow
// hydration.
const [ready, setReady] = React.useState(false);
const didRead = React.useRef(false);
const skipNextWrite = React.useRef(false);
React.useEffect(() => {
let off = false;
fetch('./' + DC_STATE_FILE)
.then((r) => (r.ok ? r.json() : null))
.then((saved) => {
if (off || !saved || !saved.sections) return;
skipNextWrite.current = true;
setState((s) => ({ ...s, sections: saved.sections }));
})
.catch(() => {})
.finally(() => { didRead.current = true; if (!off) setReady(true); });
const t = setTimeout(() => { if (!off) setReady(true); }, 150);
return () => { off = true; clearTimeout(t); };
}, []);
React.useEffect(() => {
if (!didRead.current) return;
if (skipNextWrite.current) { skipNextWrite.current = false; return; }
const t = setTimeout(() => {
window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {});
}, 250);
return () => clearTimeout(t);
}, [state.sections]);
// Build registries synchronously from children so FocusOverlay can read
// them in the same render. Only direct DCSection > DCArtboard children are
// walked — wrapping them in other elements opts out of focus/reorder.
const registry = {}; // slotId -> { sectionId, artboard }
const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] }
const sectionOrder = [];
React.Children.forEach(children, (sec) => {
if (!sec || sec.type !== DCSection) return;
const sid = sec.props.id ?? sec.props.title;
if (!sid) return;
sectionOrder.push(sid);
const persisted = state.sections[sid] || {};
const abs = [];
React.Children.forEach(sec.props.children, (ab) => {
if (!ab || ab.type !== DCArtboard) return;
const aid = ab.props.id ?? ab.props.label;
if (aid) abs.push([aid, ab]);
});
// hidden is scoped to one source revision — when the agent regenerates
// (artboard-ID set changes), prior deletes don't apply to new content.
const srcKey = abs.map(([k]) => k).join('\x1f');
const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : [];
const srcIds = [];
abs.forEach(([aid, ab]) => {
if (hidden.includes(aid)) return;
registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab };
srcIds.push(aid);
});
const kept = (persisted.order || []).filter((k) => srcIds.includes(k));
sectionMeta[sid] = {
title: persisted.title ?? sec.props.title,
subtitle: sec.props.subtitle,
slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))],
};
});
const api = React.useMemo(() => ({
state,
section: (id) => state.sections[id] || {},
patchSection: (id, p) => setState((s) => ({
...s,
sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } },
})),
setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })),
}), [state]);
// Esc exits focus; any outside pointerdown commits an in-progress rename.
React.useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); };
const onPd = (e) => {
const ae = document.activeElement;
if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur();
};
document.addEventListener('keydown', onKey);
document.addEventListener('pointerdown', onPd, true);
return () => {
document.removeEventListener('keydown', onKey);
document.removeEventListener('pointerdown', onPd, true);
};
}, [api]);
return (
<DCCtx.Provider value={api}>
<DCViewport minScale={minScale} maxScale={maxScale} style={style}>{ready && children}</DCViewport>
{state.focus && registry[state.focus] && (
<DCFocusOverlay entry={registry[state.focus]} sectionMeta={sectionMeta} sectionOrder={sectionOrder} />
)}
</DCCtx.Provider>
);
}
// ─────────────────────────────────────────────────────────────
// DCViewport — transform-based pan/zoom (internal)
//
// Input mapping (Figma-style):
// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events)
// • trackpad scroll → pan (two-finger)
// • mouse wheel → zoom (notched; distinguished from trackpad scroll)
// • middle-drag / primary-drag-on-bg → pan
//
// Transform state lives in a ref and is written straight to the DOM
// (translate3d + will-change) so wheel ticks don't go through React —
// keeps pans at 60fps on dense canvases.
// ─────────────────────────────────────────────────────────────
function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
const vpRef = React.useRef(null);
const worldRef = React.useRef(null);
const tf = React.useRef({ x: 0, y: 0, scale: 1 });
// Persist viewport across reloads so the user lands back where they were
// after an agent edit or browser refresh. The sandbox origin is already
// per-project; pathname keeps multiple canvas files in one project apart.
const tfKey = 'dc-viewport:' + location.pathname;
const saveT = React.useRef(0);
const lastPostedScale = React.useRef();
const apply = React.useCallback(() => {
const { x, y, scale } = tf.current;
const el = worldRef.current;
if (!el) return;
el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
// Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel).
el.style.setProperty('--dc-inv-zoom', String(1 / scale));
// Keep the host toolbar's % readout in sync with the canvas scale. Pan
// ticks leave scale unchanged — skip the cross-frame post for those.
if (lastPostedScale.current !== scale) {
lastPostedScale.current = scale;
window.parent.postMessage({ type: '__dc_zoom', scale }, '*');
}
clearTimeout(saveT.current);
saveT.current = setTimeout(() => {
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
}, 200);
}, [tfKey]);
React.useLayoutEffect(() => {
const flush = () => {
clearTimeout(saveT.current);
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
};
try {
const s = JSON.parse(localStorage.getItem(tfKey) || 'null');
if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) {
tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) };
apply();
}
} catch {}
// Flush on pagehide and unmount so a reload within the 200ms debounce
// window doesn't drop the last pan/zoom.
window.addEventListener('pagehide', flush);
return () => { window.removeEventListener('pagehide', flush); flush(); };
}, []);
React.useEffect(() => {
const vp = vpRef.current;
if (!vp) return;
const zoomAt = (cx, cy, factor) => {
const r = vp.getBoundingClientRect();
const px = cx - r.left, py = cy - r.top;
const t = tf.current;
const next = Math.min(maxScale, Math.max(minScale, t.scale * factor));
const k = next / t.scale;
// keep the world point under the cursor fixed
t.x = px - (px - t.x) * k;
t.y = py - (py - t.y) * k;
t.scale = next;
apply();
};
// Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends
// line-mode deltas (Firefox) or large integer pixel deltas with no X
// component (Chrome/Safari, typically multiples of 100/120). Trackpad
// two-finger scroll sends small/fractional pixel deltas, often with
// non-zero deltaX. ctrlKey is set by the browser for trackpad pinch.
const isMouseWheel = (e) =>
e.deltaMode !== 0 ||
(e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);
const onWheel = (e) => {
e.preventDefault();
if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels
if ((e.ctrlKey || e.metaKey) && !isMouseWheel(e)) {
// trackpad pinch, or ctrl/cmd + smooth-scroll mouse. Notched
// wheels fall through to the fixed-step branch below.
zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));
} else if (isMouseWheel(e)) {
// notched mouse wheel — fixed-ratio step per click
zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18));
} else {
// trackpad two-finger scroll — pan
tf.current.x -= e.deltaX;
tf.current.y -= e.deltaY;
apply();
}
};
// Safari sends native gesture* events for trackpad pinch with a smooth
// e.scale; preferring these over the ctrl+wheel fallback gives a much
// better feel there. No-ops on other browsers. Safari also fires
// ctrlKey wheel events during the same pinch — isGesturing makes
// onWheel drop those entirely so they neither zoom nor pan.
let gsBase = 1;
let isGesturing = false;
const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; };
const onGestureChange = (e) => {
e.preventDefault();
zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale);
};
const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; };
// Drag-pan: middle button anywhere, or primary button on canvas
// background (anything that isn't an artboard or an inline editor).
let drag = null;
const onPointerDown = (e) => {
const onBg = !e.target.closest('[data-dc-slot], .dc-editable');
if (!(e.button === 1 || (e.button === 0 && onBg))) return;
e.preventDefault();
vp.setPointerCapture(e.pointerId);
drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY };
vp.style.cursor = 'grabbing';
};
const onPointerMove = (e) => {
if (!drag || e.pointerId !== drag.id) return;
tf.current.x += e.clientX - drag.lx;
tf.current.y += e.clientY - drag.ly;
drag.lx = e.clientX; drag.ly = e.clientY;
apply();
};
const onPointerUp = (e) => {
if (!drag || e.pointerId !== drag.id) return;
vp.releasePointerCapture(e.pointerId);
drag = null;
vp.style.cursor = '';
};
// Host-driven zoom (toolbar % menu). Zooms around viewport centre so the
// visible midpoint stays fixed — matching the host's iframe-zoom feel.
const onHostMsg = (e) => {
const d = e.data;
if (d && d.type === '__dc_set_zoom' && typeof d.scale === 'number') {
const r = vp.getBoundingClientRect();
zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale);
} else if (d && d.type === '__dc_probe') {
// Host's [readyGen] reset asks whether a canvas is present; it
// fires on the iframe's native 'load', which for canvases with
// images/fonts is after our mount-time announce, so re-announce.
// Clear the pan-tick guard so apply() re-posts the current scale
// even if it's unchanged — the host just reset dcScale to 1.
window.parent.postMessage({ type: '__dc_present' }, '*');
lastPostedScale.current = undefined;
apply();
}
};
window.addEventListener('message', onHostMsg);
// Announce canvas mode so the host toolbar proxies its % control here
// instead of scaling the iframe element (which would just shrink the
// viewport window of an infinite canvas). The apply() that follows emits
// the initial __dc_zoom so the toolbar % is correct before first pinch.
// lastPostedScale reset mirrors the __dc_probe handler: the layout
// effect's restore-path apply() may already have posted the restored
// scale (before __dc_present), so clear the guard to re-post it in order.
window.parent.postMessage({ type: '__dc_present' }, '*');
lastPostedScale.current = undefined;
apply();
vp.addEventListener('wheel', onWheel, { passive: false });
vp.addEventListener('gesturestart', onGestureStart, { passive: false });
vp.addEventListener('gesturechange', onGestureChange, { passive: false });
vp.addEventListener('gestureend', onGestureEnd, { passive: false });
vp.addEventListener('pointerdown', onPointerDown);
vp.addEventListener('pointermove', onPointerMove);
vp.addEventListener('pointerup', onPointerUp);
vp.addEventListener('pointercancel', onPointerUp);
return () => {
window.removeEventListener('message', onHostMsg);
vp.removeEventListener('wheel', onWheel);
vp.removeEventListener('gesturestart', onGestureStart);
vp.removeEventListener('gesturechange', onGestureChange);
vp.removeEventListener('gestureend', onGestureEnd);
vp.removeEventListener('pointerdown', onPointerDown);
vp.removeEventListener('pointermove', onPointerMove);
vp.removeEventListener('pointerup', onPointerUp);
vp.removeEventListener('pointercancel', onPointerUp);
};
}, [apply, minScale, maxScale]);
const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`;
return (
<div
ref={vpRef}
className="design-canvas"
style={{
height: '100vh', width: '100vw',
background: DC.bg,
overflow: 'hidden',
overscrollBehavior: 'none',
touchAction: 'none',
position: 'relative',
fontFamily: DC.font,
boxSizing: 'border-box',
...style,
}}
>
<div
ref={worldRef}
style={{
position: 'absolute', top: 0, left: 0,
transformOrigin: '0 0',
willChange: 'transform',
width: 'max-content', minWidth: '100%',
minHeight: '100%',
padding: '60px 0 80px',
}}
>
<div style={{ position: 'absolute', inset: -6000, backgroundImage: gridSvg, backgroundSize: '120px 120px', pointerEvents: 'none', zIndex: -1 }} />
{children}
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// DCSection — editable title + h-row of artboards in persisted order
// ─────────────────────────────────────────────────────────────
function DCSection({ id, title, subtitle, children, gap = 48 }) {
const ctx = React.useContext(DCCtx);
const sid = id ?? title;
const all = React.Children.toArray(children);
const artboards = all.filter((c) => c && c.type === DCArtboard);
const rest = all.filter((c) => !(c && c.type === DCArtboard));
const sec = (ctx && sid && ctx.section(sid)) || {};
// Must match DesignCanvas's srcKey computation exactly (it filters falsy
// IDs), or onDelete persists a srcKey that DesignCanvas never recognizes.
const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean);
const srcKey = allIds.join('\x1f');
const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : [];
const srcOrder = allIds.filter((k) => !hidden.includes(k));
const order = React.useMemo(() => {
const kept = (sec.order || []).filter((k) => srcOrder.includes(k));
return [...kept, ...srcOrder.filter((k) => !kept.includes(k))];
}, [sec.order, srcOrder.join('|')]);
const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a]));
// marginBottom counter-scales so the on-screen gap between sections stays
// constant — otherwise at low zoom the (world-space) gap collapses while
// the screen-constant sectionhead below it doesn't, and the title reads as
// belonging to the section above. paddingBottom below is just enough for
// the 24px artboard-header (abs-positioned above each card) plus ~8px, so
// the title sits tight against its own row at every zoom.
return (
<div data-dc-section={sid}
style={{ marginBottom: 'calc(80px * var(--dc-inv-zoom, 1))', position: 'relative' }}>
<div style={{ padding: '0 60px' }}>
<div className="dc-sectionhead" style={{ paddingBottom: 36 }}>
<DCEditable tag="div" value={sec.title ?? title}
onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
{subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>}
</div>
</div>
<div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
{order.map((k) => (
<DCArtboardFrame key={k} sectionId={sid} artboard={byId[k]} order={order}
label={(sec.labels || {})[k] ?? byId[k].props.label}
onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
onDelete={() => ctx && ctx.patchSection(sid, (x) => ({
hidden: [...(x.srcKey === srcKey ? (x.hidden || []) : []), k],
srcKey,
}))}
onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
))}
</div>
{rest}
</div>
);
}
// DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
function DCArtboard() { return null; }
// Per-artboard export (kind: 'png' | 'html'). Both paths share the same
// self-contained clone: computed styles baked in, @font-face / <img> /
// inline-style background-image urls inlined as data URIs. PNG wraps the
// clone in foreignObject→canvas at 3× the artboard's natural width×height
// (same pipeline the host uses for page captures); HTML wraps it in a
// minimal standalone document. Both are independent of viewport zoom.
async function dcExport(node, w, h, name, kind) {
try { await document.fonts.ready; } catch {}
const toDataURL = (url) => fetch(url).then((r) => r.blob()).then((b) => new Promise((res) => {
const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => res(url); fr.readAsDataURL(b);
})).catch(() => url);
// Collect @font-face rules. ss.cssRules throws SecurityError on
// cross-origin sheets (e.g. fonts.googleapis.com) — in that case fetch
// the CSS text directly (those endpoints send ACAO:*) and regex-extract
// the blocks. @import and @media/@supports are walked so nested
// @font-face rules aren't missed.
const fontRules = [], pending = [], seen = new Set();
const scrapeCss = (href) => {
if (seen.has(href)) return; seen.add(href);
pending.push(fetch(href).then((r) => r.text()).then((css) => {
for (const m of css.match(/@font-face\s*{[^}]*}/g) || []) fontRules.push({ css: m, base: href });
for (const m of css.matchAll(/@import\s+(?:url\()?['"]?([^'")\s;]+)/g))
scrapeCss(new URL(m[1], href).href);
}).catch(() => {}));
};
const walk = (rules, base) => {
for (const r of rules) {
if (r.type === CSSRule.FONT_FACE_RULE) fontRules.push({ css: r.cssText, base });
else if (r.type === CSSRule.IMPORT_RULE && r.styleSheet) {
const ibase = r.styleSheet.href || base;
try { walk(r.styleSheet.cssRules, ibase); } catch { scrapeCss(ibase); }
} else if (r.cssRules) walk(r.cssRules, base);
}
};
for (const ss of document.styleSheets) {
const base = ss.href || location.href;
try { walk(ss.cssRules, base); } catch { if (ss.href) scrapeCss(ss.href); }
}
while (pending.length) await pending.shift();
const fontCss = (await Promise.all(fontRules.map(async (rule) => {
let out = rule.css, m; const re = /url\((['"]?)([^'")]+)\1\)/g;
while ((m = re.exec(rule.css))) {
if (m[2].indexOf('data:') === 0) continue;
let abs; try { abs = new URL(m[2], rule.base).href; } catch { continue; }
out = out.split(m[0]).join('url("' + await toDataURL(abs) + '")');
}
return out;
}))).join('\n');
const cloneStyled = (src) => {
if (src.nodeType === 8 || (src.nodeType === 1 && src.tagName === 'SCRIPT')) return document.createTextNode('');
const dst = src.cloneNode(false);
if (src.nodeType === 1) {
const cs = getComputedStyle(src); let txt = '';
for (let i = 0; i < cs.length; i++) txt += cs[i] + ':' + cs.getPropertyValue(cs[i]) + ';';
dst.setAttribute('style', txt + 'animation:none;transition:none;');
if (src.tagName === 'CANVAS') try { const im = document.createElement('img'); im.src = src.toDataURL(); im.setAttribute('style', txt); return im; } catch {}
}
for (let c = src.firstChild; c; c = c.nextSibling) dst.appendChild(cloneStyled(c));
return dst;
};
const clone = cloneStyled(node);
clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
// Drop the card's own shadow/radius so the export is a flush w×h rect;
// the artboard's own background (if any) is already in the computed style.
clone.style.boxShadow = 'none'; clone.style.borderRadius = '0';
const jobs = [];
clone.querySelectorAll('img').forEach((el) => {
const s = el.getAttribute('src');
if (s && s.indexOf('data:') !== 0) jobs.push(toDataURL(el.src).then((d) => el.setAttribute('src', d)));
});
[clone, ...clone.querySelectorAll('*')].forEach((el) => {
const bg = el.style.backgroundImage; if (!bg) return;
let m; const re = /url\(["']?([^"')]+)["']?\)/g;
while ((m = re.exec(bg))) {
const tok = m[0], url = m[1];
if (url.indexOf('data:') === 0) continue;
jobs.push(toDataURL(url).then((d) => { el.style.backgroundImage = el.style.backgroundImage.split(tok).join('url("' + d + '")'); }));
}
});
await Promise.all(jobs);
const xml = new XMLSerializer().serializeToString(clone);
const save = (blob, ext) => {
if (!blob) return;
const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.download = name + '.' + ext; a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
};
if (kind === 'html') {
const html = '<!doctype html><html><head><meta charset="utf-8"><title>' + name + '</title>' +
(fontCss ? '<style>' + fontCss + '</style>' : '') +
'</head><body style="margin:0">' + xml + '</body></html>';
return save(new Blob([html], { type: 'text/html' }), 'html');
}
// PNG: the SVG's own width/height must be the output resolution — an
// <img>-loaded SVG rasterizes at its intrinsic size, so sizing it at 1×
// and ctx.scale()-ing up would just upscale a 1× bitmap. viewBox maps the
// w×h foreignObject onto the px·w × px·h SVG canvas so the browser renders
// the HTML at full resolution.
const px = 3;
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + w * px + '" height="' + h * px +
'" viewBox="0 0 ' + w + ' ' + h + '"><foreignObject width="' + w + '" height="' + h + '">' +
(fontCss ? '<style><![CDATA[' + fontCss + ']]></style>' : '') + xml + '</foreignObject></svg>';
const img = new Image();
await new Promise((res, rej) => {
img.onload = res; img.onerror = () => rej(new Error('svg load failed'));
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
});
const cv = document.createElement('canvas');
cv.width = w * px; cv.height = h * px;
cv.getContext('2d').drawImage(img, 0, 0);
cv.toBlob((blob) => save(blob, 'png'), 'image/png');
}
function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus, onDelete }) {
const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props;
const id = rawId ?? rawLabel;
const ref = React.useRef(null);
const cardRef = React.useRef(null);
const menuRef = React.useRef(null);
const [menuOpen, setMenuOpen] = React.useState(false);
const [confirming, setConfirming] = React.useState(false);
// ⋯ menu: close on any outside pointerdown. Two-click delete lives inside
// the menu — first click arms the row, second commits; closing disarms.
React.useEffect(() => {
if (!menuOpen) { setConfirming(false); return; }
const off = (e) => { if (!menuRef.current || !menuRef.current.contains(e.target)) setMenuOpen(false); };
document.addEventListener('pointerdown', off, true);
return () => document.removeEventListener('pointerdown', off, true);
}, [menuOpen]);
const doExport = (kind) => {
setMenuOpen(false);
if (!cardRef.current) return;
const name = String(label || id || 'artboard').replace(/[^\w\s.-]+/g, '_');
dcExport(cardRef.current, width, height, name, kind)
.catch((e) => console.error('[design-canvas] export failed:', e));
};
// Live drag-reorder: dragged card sticks to cursor; siblings slide into
// their would-be slots in real time via transforms. DOM order only
// changes on drop.
const onGripDown = (e) => {
e.preventDefault(); e.stopPropagation();
const me = ref.current;
// translateX is applied in local (pre-scale) space but pointer deltas and
// getBoundingClientRect().left are screen-space — divide by the viewport's
// current scale so the dragged card tracks the cursor at any zoom level.
const scale = me.getBoundingClientRect().width / me.offsetWidth || 1;
const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`));
const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left }));
const slotXs = homes.map((h) => h.x);
const startIdx = order.indexOf(id);
const startX = e.clientX;
let liveOrder = order.slice();
me.classList.add('dc-dragging');
const layout = () => {
for (const h of homes) {
if (h.id === id) continue;
const slot = liveOrder.indexOf(h.id);
h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`;
}
};
const move = (ev) => {
const dx = ev.clientX - startX;
me.style.transform = `translateX(${dx / scale}px)`;
const cur = homes[startIdx].x + dx;
let nearest = 0, best = Infinity;
for (let i = 0; i < slotXs.length; i++) {
const d = Math.abs(slotXs[i] - cur);
if (d < best) { best = d; nearest = i; }
}
if (liveOrder.indexOf(id) !== nearest) {
liveOrder = order.filter((k) => k !== id);
liveOrder.splice(nearest, 0, id);
layout();
}
};
const up = () => {
document.removeEventListener('pointermove', move);
document.removeEventListener('pointerup', up);
const finalSlot = liveOrder.indexOf(id);
me.classList.remove('dc-dragging');
me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`;
// After the settle transition, kill transitions + clear transforms +
// commit the reorder in the same frame so there's no visual snap-back.
setTimeout(() => {
for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; }
if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder);
requestAnimationFrame(() => requestAnimationFrame(() => {
for (const h of homes) h.el.style.transition = '';
}));
}, 180);
};
document.addEventListener('pointermove', move);
document.addEventListener('pointerup', up);
};
return (
<div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
<div className="dc-header" style={{ color: DC.label }} onPointerDown={(e) => e.stopPropagation()}>
<div className="dc-labelrow">
<div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
<svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
</div>
<div className="dc-labeltext" onClick={onFocus} title="Click to focus">
<DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
</div>
</div>
<div className="dc-btns">
<div ref={menuRef} style={{ position: 'relative' }}>
<button className="dc-kebab" title="More" onClick={() => setMenuOpen((o) => !o)}>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><circle cx="2.5" cy="6" r="1.1"/><circle cx="6" cy="6" r="1.1"/><circle cx="9.5" cy="6" r="1.1"/></svg>
</button>
{menuOpen && (
<div className="dc-menu" onPointerDown={(e) => e.stopPropagation()}>
<button onClick={() => doExport('png')}>Download PNG</button>
<button onClick={() => doExport('html')}>Download HTML</button>
<hr />
<button className="dc-danger"
onClick={() => { if (confirming) { setMenuOpen(false); onDelete(); } else setConfirming(true); }}>
{confirming ? 'Click again to delete' : 'Delete'}
</button>
</div>
)}
</div>
<button className="dc-expand" onClick={onFocus} title="Focus">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
</button>
</div>
</div>
<div ref={cardRef} className="dc-card"
style={{ borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', width, height, background: '#fff', ...style }}>
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
</div>
</div>
);
}
// Inline rename — commits on blur or Enter.
function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
const T = tag;
return (
<T className="dc-editable" contentEditable suppressContentEditableWarning
onClick={onClick}
onPointerDown={(e) => e.stopPropagation()}
onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
style={style}>{value}</T>
);
}
// ─────────────────────────────────────────────────────────────
// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across
// sections, Esc or backdrop click to exit.
// ─────────────────────────────────────────────────────────────
function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
const ctx = React.useContext(DCCtx);
const { sectionId, artboard } = entry;
const sec = ctx.section(sectionId);
const meta = sectionMeta[sectionId];
const peers = meta.slotIds;
const aid = artboard.props.id ?? artboard.props.label;
const idx = peers.indexOf(aid);
const secIdx = sectionOrder.indexOf(sectionId);
const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); };
const goSection = (d) => {
// Sections whose artboards are all deleted have slotIds:[] — step past
// them to the next non-empty section so ↑/↓ doesn't dead-end.
const n = sectionOrder.length;
for (let i = 1; i < n; i++) {
const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n];
const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
if (first) { ctx.setFocus(`${ns}/${first}`); return; }
}
};
React.useEffect(() => {
const k = (e) => {
if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); }
if (e.key === 'ArrowRight') { e.preventDefault(); go(1); }
if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); }
if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); }
};
document.addEventListener('keydown', k);
return () => document.removeEventListener('keydown', k);
});
const { width = 260, height = 480, children } = artboard.props;
const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight });
React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []);
const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2));
const [ddOpen, setDd] = React.useState(false);
const Arrow = ({ dir, onClick }) => (
<button onClick={(e) => { e.stopPropagation(); onClick(); }}
style={{ position: 'absolute', top: '50%', [dir]: 28, transform: 'translateY(-50%)',
border: 'none', background: 'rgba(255,255,255,.08)', color: 'rgba(255,255,255,.9)',
width: 44, height: 44, borderRadius: 22, fontSize: 18, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background .15s' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.18)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.08)')}>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d={dir === 'left' ? 'M11 3L5 9l6 6' : 'M7 3l6 6-6 6'} /></svg>
</button>
);
// Portal to body so position:fixed is the real viewport regardless of any
// transform on DesignCanvas's ancestors (including the canvas zoom itself).
return ReactDOM.createPortal(
<div onClick={() => ctx.setFocus(null)}
onWheel={(e) => e.preventDefault()}
style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)',
fontFamily: DC.font, color: '#fff' }}>
{/* top bar: section dropdown (left) · close (right) */}
<div onClick={(e) => e.stopPropagation()}
style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
<div style={{ position: 'relative' }}>
<button onClick={() => setDd((o) => !o)}
style={{ border: 'none', background: 'transparent', color: '#fff', cursor: 'pointer', padding: '6px 8px',
borderRadius: 6, textAlign: 'left', fontFamily: 'inherit' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: -0.3 }}>{meta.title}</span>
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" style={{ opacity: .7 }}><path d="M2 4l3.5 3.5L9 4"/></svg>
</span>
{meta.subtitle && <span style={{ display: 'block', fontSize: 13, opacity: .6, fontWeight: 400, marginTop: 2 }}>{meta.subtitle}</span>}
</button>
{ddOpen && (
<div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8,
boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}>
{sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => (
<button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }}
style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',
padding: '8px 12px', borderRadius: 5, fontSize: 14, fontWeight: sid === sectionId ? 600 : 400, fontFamily: 'inherit' }}>
{sectionMeta[sid].title}
</button>
))}
</div>
)}
</div>
<div style={{ flex: 1 }} />
<button onClick={() => ctx.setFocus(null)}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.12)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
style={{ border: 'none', background: 'transparent', color: 'rgba(255,255,255,.7)', width: 32, height: 32,
borderRadius: 16, fontSize: 20, cursor: 'pointer', lineHeight: 1, transition: 'background .12s' }}>×</button>
</div>
{/* card centered, label + index below — only the card itself stops
propagation so any backdrop click (including the margins around
the card) exits focus */}
<div
style={{ position: 'absolute', top: 64, bottom: 56, left: 100, right: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
<div onClick={(e) => e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
<div style={{ width, height, transform: `scale(${scale})`, transformOrigin: 'top left', background: '#fff', borderRadius: 2, overflow: 'hidden',
boxShadow: '0 20px 80px rgba(0,0,0,.4)' }}>
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb' }}>{aid}</div>}
</div>
</div>
<div onClick={(e) => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}>
{(sec.labels || {})[aid] ?? artboard.props.label}
<span style={{ opacity: .5, marginLeft: 10, fontVariantNumeric: 'tabular-nums' }}>{idx + 1} / {peers.length}</span>
</div>
</div>
<Arrow dir="left" onClick={() => go(-1)} />
<Arrow dir="right" onClick={() => go(1)} />
{/* dots */}
<div onClick={(e) => e.stopPropagation()}
style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}>
{peers.map((p, i) => (
<button key={p} onClick={() => ctx.setFocus(`${sectionId}/${p}`)}
style={{ border: 'none', padding: 0, cursor: 'pointer', width: 6, height: 6, borderRadius: 3,
background: i === idx ? '#fff' : 'rgba(255,255,255,.3)' }} />
))}
</div>
</div>,
document.body,
);
}
// ─────────────────────────────────────────────────────────────
// Post-it — absolute-positioned sticky note
// ─────────────────────────────────────────────────────────────
function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
return (
<div style={{
position: 'absolute', top, left, right, bottom, width,
background: DC.postitBg, padding: '14px 16px',
fontFamily: '"Comic Sans MS", "Marker Felt", "Segoe Print", cursive',
fontSize: 14, lineHeight: 1.4, color: DC.postitText,
boxShadow: '0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)',
transform: `rotate(${rotate}deg)`,
zIndex: 5,
}}>{children}</div>
);
}
Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });
@@ -0,0 +1,118 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8"/>
<title>Ordinis · Dictionary catalog — prototype</title>
<style>
*{box-sizing:border-box}
html,body{margin:0;background:#f7f8fb;color:#11131a;
font-family:Inter,ui-sans-serif,system-ui,sans-serif;font-feature-settings:"ss01","cv11"}
button{font-family:inherit}
input{font-family:inherit;outline:none}
input:focus{border-color:#1d2a8a !important}
</style>
</head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script type="text/babel" src="proto/data.jsx"></script>
<script type="text/babel" src="proto/ui.jsx"></script>
<script type="text/babel" src="proto/view-graph.jsx"></script>
<script type="text/babel" src="proto/view-hub.jsx"></script>
<script type="text/babel" src="proto/view-list.jsx"></script>
<script type="text/babel" data-presets="env,react">
const { useState } = React;
function App() {
const [view, setView] = useState("list"); // list | graph | hub
const [hubId, setHubId] = useState("satellites");
const open = id => { setHubId(id); setView("hub"); };
const Tab = ({ k, label, hint }) => (
<button onClick={() => setView(k)} style={{
border:"none", background:"transparent", cursor:"pointer",
padding:"14px 18px", fontSize:13,
borderBottom: view===k ? "2px solid #1d2a8a" : "2px solid transparent",
color: view===k ? "#1d2a8a" : "#3a4151",
fontWeight: view===k ? 600 : 500,
display:"flex", flexDirection:"column", alignItems:"flex-start", gap:2,
}}>
<span>{label}</span>
<span style={{
fontSize:10, fontFamily:"JetBrains Mono, monospace",
letterSpacing:".06em", textTransform:"uppercase",
color: view===k ? "#1d2a8a" : "#6b7180", opacity:.7,
}}>{hint}</span>
</button>
);
return (
<div style={{ minHeight:"100vh", display:"flex", flexDirection:"column" }}>
{/* topbar */}
<header style={{ height:60, padding:"0 28px", display:"flex", alignItems:"center", justifyContent:"space-between", background:"#fff", borderBottom:"1px solid #eef0f5" }}>
<div style={{ display:"flex", alignItems:"center", gap:14 }}>
<div style={{
width:28, height:28, borderRadius:6,
background:"linear-gradient(135deg,#1d2a8a,#3a6df0)",
display:"flex", alignItems:"center", justifyContent:"center",
color:"#fff", fontWeight:700, fontSize:13,
}}>O</div>
<div style={{ fontSize:16, fontWeight:600, color:"#0f1860" }}>Ordinis</div>
<span style={{ fontSize:11, fontFamily:"JetBrains Mono, monospace", letterSpacing:".06em", textTransform:"uppercase", color:"#6b7180" }}>
/ каталог справочников
</span>
</div>
<div style={{ display:"flex", gap:10, alignItems:"center" }}>
<span style={{ fontSize:12, color:"#6b7180" }}>оператор · ru-RU</span>
<div style={{ width:28, height:28, borderRadius:"50%", background:"#eef3ff", color:"#1d2a8a", display:"flex", alignItems:"center", justifyContent:"center", fontSize:12, fontWeight:600 }}>OP</div>
</div>
</header>
{/* view tabs */}
<nav style={{ padding:"0 28px", background:"#fff", borderBottom:"1px solid #eef0f5", display:"flex", gap:0 }}>
<Tab k="list" label="Список со связями" hint="D · быстрый запуск"/>
<Tab k="graph" label="Граф связей" hint="A · обзор"/>
<Tab k="hub" label="Hub справочника" hint="B · контекст"/>
<div style={{ flex:1 }}/>
<div style={{ alignSelf:"center", display:"flex", gap:8, paddingRight:0 }}>
<Btn primary>+ Создать справочник</Btn>
</div>
</nav>
{/* breadcrumbs / state */}
{view === "hub" && (
<div style={{ padding:"10px 28px", background:"#fff", borderBottom:"1px solid #eef0f5", fontSize:12, color:"#6b7180" }}>
<button onClick={() => setView("list")} style={{ background:"none", border:"none", cursor:"pointer", color:"#1d2a8a", padding:0, fontSize:12 }}>Каталог</button>
<span style={{ margin:"0 8px" }}>/</span>
<span style={{ color:"#11131a" }}>{BY_ID[hubId]?.title || hubId}</span>
</div>
)}
{/* viewport */}
<main style={{ padding:28, display:"flex", justifyContent:"center" }}>
{view === "list" && <ListView onOpen={open}/>}
{view === "graph" && <GraphView onOpen={open}/>}
{view === "hub" && <HubView centerId={hubId} onOpen={open}/>}
</main>
{/* footer hint */}
<footer style={{ padding:"14px 28px", fontSize:11, color:"#6b7180", textAlign:"center" }}>
Клик по любой карточке / узлу / FK-чипу открывает Hub. Прототип — данные смоделированы под реальную схему ДЗЗ-каталога.
</footer>
</div>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App/>);
</script>
</body>
</html>
@@ -0,0 +1,486 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<title>Dictionary views — exploration</title>
<style>
:root{
--navy:#1d2a8a;
--navy-2:#0f1860;
--green:#1f9d63;
--green-soft:#e7f6ee;
--blue:#3a6df0;
--blue-soft:#e8f0ff;
--ink:#11131a;
--ink-2:#3a4151;
--mute:#6b7180;
--line:#e5e7ee;
--line-2:#eef0f5;
--bg:#f7f8fb;
--surface:#ffffff;
--warn:#d4711a;
--warn-soft:#fff2e3;
--pink:#c64a8a;
--pink-soft:#fde7f1;
}
*{box-sizing:border-box}
html,body{margin:0;background:var(--bg);color:var(--ink);font-family:"Inter",ui-sans-serif,system-ui,sans-serif}
body{font-feature-settings:"ss01","cv11"}
.sup{font-family:"JetBrains Mono",ui-monospace,monospace;font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--mute)}
.pill{display:inline-flex;align-items:center;gap:6px;padding:3px 8px;border-radius:6px;border:1px solid var(--line);background:#fff;font-size:11px;font-family:"JetBrains Mono",ui-monospace,monospace;letter-spacing:.04em;text-transform:uppercase;color:var(--ink-2)}
.pill.public{color:var(--blue);border-color:#cfdcff;background:#eef3ff}
.pill.internal{color:var(--warn);border-color:#f3d8b3;background:var(--warn-soft)}
.pill.tenant{color:var(--pink);border-color:#f3c8dc;background:var(--pink-soft)}
.pill.dot::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}
</style>
</head>
<body>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script type="text/babel" src="design-canvas.jsx"></script>
<script type="text/babel" data-presets="env,react">
const { useMemo, useState } = React;
/* ───────────────────────── data ───────────────────────── */
// 40 dicts, fk = массив имён других справочников, на которые есть ссылки
const DICTS = [
// bundle: cuod (космос) — публичные
{ id:"satellite_types", title:"Типы КА", desc:"Классификация КА по миссиям", scope:"PUBLIC", bundle:"cuod", fk:[], refBy:["satellites"] },
{ id:"satellite_statuses", title:"Статусы КА", desc:"planned → operational → decommissioned", scope:"PUBLIC", bundle:"cuod", fk:[], refBy:["satellites"] },
{ id:"antennas", title:"Антенны", desc:"Антенны наземных станций", scope:"PUBLIC", bundle:"cuod", fk:["frequency_bands"], refBy:["ground_stations"] },
{ id:"missions", title:"Миссии / программы", desc:"Ресурс-П, Канопус-В, Sentinel…", scope:"PUBLIC", bundle:"cuod", fk:["satellite_types"], refBy:["satellites"] },
{ id:"satellites", title:"Космические аппараты", desc:"Каталог КА ДЗЗ-миссий", scope:"PUBLIC", bundle:"cuod", fk:["satellite_types","satellite_statuses","missions","instruments"], refBy:["acquisitions"] },
{ id:"ground_stations", title:"Наземные станции", desc:"Пункты приёма и управления", scope:"PUBLIC", bundle:"cuod", fk:["antennas","countries"], refBy:["acquisitions"] },
{ id:"instruments", title:"Инструменты съёмки", desc:"Геотон-Л1, MODIS, C-SAR …", scope:"PUBLIC", bundle:"cuod", fk:["sensor_types","satellites"], refBy:["spectral_bands","acquisitions"] },
{ id:"sensor_types", title:"Типы сенсоров", desc:"optical/SAR/hyperspectral/lidar", scope:"PUBLIC", bundle:"cuod", fk:[], refBy:["instruments"] },
{ id:"spectral_bands", title:"Спектральные каналы", desc:"PAN/R/G/B/NIR/SWIR/TIR", scope:"PUBLIC", bundle:"cuod", fk:["instruments"], refBy:["acquisitions"] },
{ id:"frequency_bands", title:"Радиодиапазоны", desc:"L/S/C/X/Ku/Ka/V", scope:"PUBLIC", bundle:"cuod", fk:[], refBy:["antennas"] },
{ id:"orbit_types", title:"Типы орбит", desc:"LEO/SSO/MEO/GEO/HEO", scope:"PUBLIC", bundle:"cuod", fk:[], refBy:["satellites"] },
{ id:"countries", title:"Страны (ISO 3166)", desc:"Двух- и трёхбуквенные коды", scope:"PUBLIC", bundle:"geo", fk:[], refBy:["ground_stations","operators"] },
{ id:"languages", title:"Языки (BCP-47)", desc:"ru-RU, en-US …", scope:"PUBLIC", bundle:"i18n", fk:[], refBy:[] },
{ id:"timezones", title:"Часовые пояса", desc:"IANA tz database", scope:"PUBLIC", bundle:"geo", fk:["countries"], refBy:["ground_stations"] },
{ id:"operators", title:"Операторы", desc:"Роскосмос, NASA, ESA …", scope:"PUBLIC", bundle:"cuod", fk:["countries"], refBy:["satellites","missions"] },
{ id:"product_levels", title:"Уровни продукта", desc:"L0/L1A/L1B/L2/L3", scope:"PUBLIC", bundle:"cuod", fk:[], refBy:["acquisitions","products"] },
{ id:"projection_codes", title:"Картографические проекции",desc:"EPSG-коды", scope:"PUBLIC", bundle:"geo", fk:[], refBy:["products"] },
// internal
{ id:"acquisitions", title:"Сеансы съёмки", desc:"Планы и факты сеансов", scope:"INTERNAL", bundle:"cuod", fk:["satellites","ground_stations","instruments","spectral_bands","product_levels"], refBy:["products"] },
{ id:"products", title:"Продукты ДЗЗ", desc:"Поставляемые сцены/тайлы", scope:"INTERNAL", bundle:"cuod", fk:["acquisitions","product_levels","projection_codes"], refBy:[] },
{ id:"users_roles", title:"Роли пользователей", desc:"RBAC внутри платформы", scope:"INTERNAL", bundle:"core", fk:[], refBy:[] },
];
const byId = Object.fromEntries(DICTS.map(d=>[d.id,d]));
const SCOPE_COLOR = { PUBLIC:"var(--blue)", INTERNAL:"var(--warn)", TENANT:"var(--pink)" };
const SCOPE_BG = { PUBLIC:"#eef3ff", INTERNAL:"var(--warn-soft)", TENANT:"var(--pink-soft)" };
const BUNDLES = ["cuod","geo","i18n","core"];
/* ───────────────────────── shared atoms ───────────────────────── */
const ScopePill = ({s}) => (
<span className={`pill ${s.toLowerCase()}`}>● {s}</span>
);
const Hairline = ({y,x1,x2,c="var(--line)"}) => (
<line x1={x1} x2={x2} y1={y} y2={y} stroke={c} strokeWidth="1"/>
);
/* ============================================================
ARTBOARD A — “Граф связей” (force-style layout, статичный)
============================================================ */
// Manually placed nodes for clarity — продакшн-версия использует d3-force.
const GRAPH_LAYOUT = {
satellite_types: {x:280, y:120},
satellite_statuses:{x:120, y:200},
missions: {x:200, y:300},
satellites: {x:480, y:280},
instruments: {x:680, y:200},
sensor_types: {x:820, y:120},
spectral_bands: {x:820, y:300},
orbit_types: {x:480, y:140},
ground_stations: {x:520, y:480},
antennas: {x:300, y:520},
frequency_bands: {x:140, y:480},
countries: {x:680, y:560},
timezones: {x:840, y:500},
operators: {x:880, y:400},
product_levels: {x:520, y:640},
acquisitions: {x:340, y:660},
products: {x:160, y:640},
projection_codes: {x:60, y:540},
users_roles: {x:980, y:660},
languages: {x:80, y:80},
};
function GraphView() {
const [hover, setHover] = useState(null);
const W = 1080, H = 760;
const edges = [];
for (const d of DICTS) {
for (const t of d.fk) {
if (GRAPH_LAYOUT[d.id] && GRAPH_LAYOUT[t]) edges.push({from:d.id,to:t});
}
}
const focused = hover;
const isEdgeActive = e => focused && (e.from===focused || e.to===focused);
const isNodeActive = id => !focused || id===focused
|| edges.some(e=>(e.from===focused&&e.to===id)||(e.to===focused&&e.from===id));
return (
<div style={{position:"relative",width:W,height:H,background:"var(--surface)",borderRadius:14,border:"1px solid var(--line)",overflow:"hidden"}}>
{/* topbar */}
<div style={{position:"absolute",inset:"0 0 auto 0",height:56,padding:"0 20px",display:"flex",alignItems:"center",justifyContent:"space-between",borderBottom:"1px solid var(--line)",background:"#fff"}}>
<div style={{display:"flex",alignItems:"center",gap:12}}>
<div style={{fontSize:14,fontWeight:600,color:"var(--navy)"}}>Граф связей</div>
<span className="sup">20 узлов · 28 связей</span>
</div>
<div style={{display:"flex",gap:8}}>
<button style={btn}>Скрыть листья</button>
<button style={btn}>Группировать по bundle</button>
<button style={{...btn, background:"var(--navy)",color:"#fff",borderColor:"var(--navy)"}}>Экспорт SVG</button>
</div>
</div>
<svg width={W} height={H-56} style={{position:"absolute",top:56,left:0}}>
<defs>
<marker id="ah" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#94a0b8" />
</marker>
<marker id="ahA" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="var(--navy)" />
</marker>
</defs>
{/* edges */}
{edges.map((e,i)=>{
const a = GRAPH_LAYOUT[e.from], b = GRAPH_LAYOUT[e.to];
const active = isEdgeActive(e);
return (
<g key={i} opacity={focused && !active ? .12 : 1}>
<path d={`M${a.x},${a.y} C ${a.x},${(a.y+b.y)/2} ${b.x},${(a.y+b.y)/2} ${b.x},${b.y}`}
stroke={active ? "var(--navy)" : "#c8cfdb"}
strokeWidth={active ? 1.6 : 1}
fill="none"
markerEnd={active ? "url(#ahA)" : "url(#ah)"}/>
</g>
);
})}
{/* nodes */}
{DICTS.map(d=>{
const p = GRAPH_LAYOUT[d.id]; if(!p) return null;
const active = isNodeActive(d.id);
const r = 26 + Math.min(d.refBy.length*3, 14);
return (
<g key={d.id} transform={`translate(${p.x},${p.y})`} style={{cursor:"pointer"}}
opacity={active?1:.18}
onMouseEnter={()=>setHover(d.id)} onMouseLeave={()=>setHover(null)}>
<circle r={r} fill={SCOPE_BG[d.scope]} stroke={SCOPE_COLOR[d.scope]} strokeWidth={d.id===focused?2.5:1.2}/>
<text textAnchor="middle" dy="-2" fontSize="10.5" fontWeight="600" fill="var(--ink)">
{d.title.length>14 ? d.title.slice(0,12)+"…" : d.title}
</text>
<text textAnchor="middle" dy="11" fontSize="9" fontFamily="JetBrains Mono, monospace" fill="var(--mute)">
{d.refBy.length>0 ? `← ${d.refBy.length}` : "·"}
</text>
</g>
);
})}
</svg>
{/* legend */}
<div style={{position:"absolute",bottom:14,left:14,display:"flex",gap:10,padding:"8px 10px",background:"#fff",border:"1px solid var(--line)",borderRadius:10}}>
<span className="pill public">● PUBLIC</span>
<span className="pill internal">● INTERNAL</span>
<span className="pill tenant">● TENANT</span>
<span style={{fontSize:11,color:"var(--mute)",alignSelf:"center"}}>Размер = кол-во входящих ссылок</span>
</div>
</div>
);
}
const btn = {height:30,padding:"0 12px",border:"1px solid var(--line)",background:"#fff",borderRadius:8,fontSize:12,color:"var(--ink-2)",cursor:"pointer"};
/* ============================================================
ARTBOARD B — “Hub view” (выбран словарь, видны соседи)
============================================================ */
function HubView() {
const center = byId.satellites;
const inn = center.fk.map(id=>byId[id]).filter(Boolean);
const out = center.refBy.map(id=>byId[id]).filter(Boolean);
const Card = ({d, dim}) => (
<div style={{
width:240, padding:"10px 12px", background:"#fff",
borderLeft:`3px solid ${SCOPE_COLOR[d.scope]}`,
border:"1px solid var(--line)", borderLeftWidth:3,
borderRadius:8, opacity: dim?.6:1
}}>
<div style={{fontSize:13,fontWeight:600,color:"var(--navy)",marginBottom:2}}>{d.title}</div>
<div style={{fontSize:11,color:"var(--mute)",fontFamily:"JetBrains Mono,monospace"}}>{d.id}</div>
</div>
);
const Center = (
<div style={{
width:280, padding:16, borderRadius:10,
background:"linear-gradient(180deg,#fff,#f4f7ff)",
border:"2px solid var(--navy)",
boxShadow:"0 12px 28px -16px rgba(20,30,140,.4)"
}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"start"}}>
<div>
<div style={{fontSize:11,color:"var(--mute)",fontFamily:"JetBrains Mono,monospace",letterSpacing:".04em",textTransform:"uppercase"}}>Текущий</div>
<div style={{fontSize:18,fontWeight:700,color:"var(--navy-2)",marginTop:2}}>{center.title}</div>
<div style={{fontSize:12,color:"var(--mute)",fontFamily:"JetBrains Mono,monospace",marginTop:2}}>{center.id}</div>
</div>
<ScopePill s={center.scope}/>
</div>
<div style={{fontSize:13,color:"var(--ink-2)",marginTop:10,lineHeight:1.4}}>{center.desc}</div>
<div style={{display:"flex",gap:10,marginTop:14,fontSize:12}}>
<div><span style={{color:"var(--mute)"}}>Ссылается на</span> <b>{inn.length}</b></div>
<div><span style={{color:"var(--mute)"}}>На него ссылаются</span> <b>{out.length}</b></div>
</div>
</div>
);
return (
<div style={{width:1080,height:680,background:"var(--surface)",borderRadius:14,border:"1px solid var(--line)",padding:24}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:18}}>
<div>
<div className="sup">Связи · 1-й уровень</div>
<div style={{fontSize:18,fontWeight:600,color:"var(--navy)"}}>Что окружает {center.title}?</div>
</div>
<div style={{display:"flex",gap:8}}>
<button style={btn}>← {inn.length} вход.</button>
<button style={btn}>{out.length} исх. →</button>
<button style={btn}>2-й уровень</button>
</div>
</div>
<div style={{display:"grid",gridTemplateColumns:"1fr auto 1fr",gap:24,alignItems:"center"}}>
{/* incoming */}
<div style={{display:"flex",flexDirection:"column",gap:10}}>
<div className="sup" style={{textAlign:"right"}}>зависит от</div>
{inn.map(d=>(
<div key={d.id} style={{display:"flex",alignItems:"center",gap:0,justifyContent:"flex-end"}}>
<Card d={d}/>
<svg width="48" height="20"><path d="M0 10 L40 10" stroke="var(--ink-2)" strokeWidth="1.4" markerEnd="url(#hubArrow)"/>
<defs><marker id="hubArrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10z" fill="var(--ink-2)"/></marker></defs>
</svg>
</div>
))}
</div>
{/* center */}
<div>{Center}</div>
{/* outgoing */}
<div style={{display:"flex",flexDirection:"column",gap:10}}>
<div className="sup">используют</div>
{out.map(d=>(
<div key={d.id} style={{display:"flex",alignItems:"center",gap:0}}>
<svg width="48" height="20"><path d="M8 10 L48 10" stroke="var(--ink-2)" strokeWidth="1.4" markerEnd="url(#hubArrow2)"/>
<defs><marker id="hubArrow2" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10z" fill="var(--ink-2)"/></marker></defs>
</svg>
<Card d={d}/>
</div>
))}
</div>
</div>
<div style={{marginTop:36,paddingTop:14,borderTop:"1px dashed var(--line)",fontSize:12,color:"var(--mute)"}}>
Клик по любой карточке делает её центром. Двойной клик — открыть редактор. Ctrl+Click — добавить в выборку для сравнения.
</div>
</div>
);
}
/* ============================================================
ARTBOARD C — “Матрица зависимостей”
============================================================ */
function MatrixView() {
const list = DICTS.slice(0, 14); // обрежем для читаемости
const cell = 32;
const hLabel = 160;
const vLabel = 28;
return (
<div style={{width:980,padding:24,background:"var(--surface)",borderRadius:14,border:"1px solid var(--line)"}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:14}}>
<div>
<div className="sup">Матрица FK</div>
<div style={{fontSize:18,fontWeight:600,color:"var(--navy)"}}>Кто на кого ссылается</div>
</div>
<div style={{display:"flex",gap:8,alignItems:"center"}}>
<span className="sup">по строкам — источник, по столбцам — цель</span>
<button style={btn}>Сортировать по связности</button>
</div>
</div>
<svg width={hLabel + cell*list.length + 20} height={vLabel + cell*list.length + 80} style={{fontFamily:"Inter"}}>
{/* column labels */}
{list.map((d,i)=>(
<g key={d.id} transform={`translate(${hLabel + cell*i + cell/2},${vLabel-6})`}>
<text transform="rotate(-50)" fontSize="11" fill="var(--ink-2)">{d.title.slice(0,16)}</text>
</g>
))}
{/* row labels */}
{list.map((d,i)=>(
<text key={d.id} x={hLabel-8} y={vLabel + cell*i + cell/2 + 4}
fontSize="11" textAnchor="end" fill="var(--ink-2)">{d.title}</text>
))}
{/* grid */}
{list.map((row,ri)=>list.map((col,ci)=>{
const has = row.fk.includes(col.id);
const same = ri===ci;
return (
<g key={`${ri}-${ci}`} transform={`translate(${hLabel + ci*cell},${vLabel + ri*cell})`}>
<rect width={cell-2} height={cell-2} rx="3"
fill={same? "#f0f1f5" : has ? SCOPE_COLOR[col.scope] : "#fafbfd"}
stroke="var(--line-2)"/>
{has && <text x={(cell-2)/2} y={(cell-2)/2+3} textAnchor="middle" fontSize="11" fontWeight="600" fill="#fff"></text>}
</g>
);
}))}
{/* diag highlight */}
{list.map((_,i)=>(
<text key={i} x={hLabel + cell*i + cell/2} y={vLabel + cell*i + cell/2 + 3}
textAnchor="middle" fontSize="10" fill="var(--mute)">—</text>
))}
</svg>
<div style={{display:"flex",gap:18,marginTop:16,fontSize:12,color:"var(--mute)"}}>
<div style={{display:"flex",alignItems:"center",gap:6}}><span style={{display:"inline-block",width:14,height:14,borderRadius:3,background:"var(--blue)"}}/> public-target</div>
<div style={{display:"flex",alignItems:"center",gap:6}}><span style={{display:"inline-block",width:14,height:14,borderRadius:3,background:"var(--warn)"}}/> internal-target</div>
<div style={{marginLeft:"auto"}}>Хорошо ловит «мегаконцентраторы» и циклические зависимости</div>
</div>
</div>
);
}
/* ============================================================
ARTBOARD D — “Список с inline-связями” (минимальный апгрейд)
============================================================ */
function ListWithFkView() {
const grouped = useMemo(()=> {
const g = {};
for (const b of BUNDLES) g[b]=[];
for (const d of DICTS) (g[d.bundle] ||= []).push(d);
return g;
}, []);
const Item = ({d}) => (
<div style={{
display:"grid",gridTemplateColumns:"3px 1fr auto",gap:12,alignItems:"stretch",
background:"#fff",border:"1px solid var(--line)",borderRadius:10,overflow:"hidden",
padding:0
}}>
<div style={{background:SCOPE_COLOR[d.scope]}}/>
<div style={{padding:"10px 0 10px 6px"}}>
<div style={{display:"flex",alignItems:"center",gap:8}}>
<span style={{fontSize:14,fontWeight:600,color:"var(--navy)"}}>{d.title}</span>
<span style={{fontSize:11,fontFamily:"JetBrains Mono,monospace",color:"var(--mute)"}}>{d.id}</span>
</div>
<div style={{fontSize:12,color:"var(--ink-2)",marginTop:2,lineHeight:1.4}}>{d.desc}</div>
{(d.fk.length>0 || d.refBy.length>0) && (
<div style={{display:"flex",gap:6,flexWrap:"wrap",marginTop:8,alignItems:"center"}}>
{d.fk.length>0 && <span className="sup" style={{marginRight:4}}></span>}
{d.fk.map(id=>(
<span key={id} style={{fontSize:11,padding:"2px 6px",borderRadius:5,
background:"#f3f5fb",border:"1px solid var(--line)",color:"var(--navy)",
fontFamily:"JetBrains Mono,monospace"}}>{id}</span>
))}
{d.refBy.length>0 && (<>
<span className="sup" style={{marginLeft:8,marginRight:4}}>← {d.refBy.length}</span>
{d.refBy.slice(0,3).map(id=>(
<span key={id} style={{fontSize:11,padding:"2px 6px",borderRadius:5,
background:"transparent",border:"1px dashed var(--line)",color:"var(--mute)",
fontFamily:"JetBrains Mono,monospace"}}>{id}</span>
))}
{d.refBy.length>3 && <span style={{fontSize:11,color:"var(--mute)"}}>+{d.refBy.length-3}</span>}
</>)}
</div>
)}
</div>
<div style={{padding:"10px 12px",display:"flex",flexDirection:"column",alignItems:"flex-end",gap:6}}>
<ScopePill s={d.scope}/>
<span style={{fontSize:11,color:"var(--mute)",fontFamily:"JetBrains Mono,monospace"}}>v1.0.0</span>
</div>
</div>
);
return (
<div style={{width:980,padding:24,background:"var(--surface)",borderRadius:14,border:"1px solid var(--line)"}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:14}}>
<div>
<div className="sup">Bundle-tree</div>
<div style={{fontSize:18,fontWeight:600,color:"var(--navy)"}}>Список со связями in-place</div>
</div>
<div style={{display:"flex",gap:8}}>
<button style={btn}>Только с зависимостями</button>
<button style={btn}>Развернуть всё</button>
</div>
</div>
{BUNDLES.map(b=>{
const items = grouped[b]||[];
if (!items.length) return null;
return (
<div key={b} style={{marginBottom:18}}>
<div style={{display:"flex",alignItems:"center",gap:10,margin:"6px 0 10px"}}>
<span style={{fontSize:11,fontFamily:"JetBrains Mono,monospace",letterSpacing:".06em",
textTransform:"uppercase",color:"var(--ink)",fontWeight:600}}>{b}</span>
<span className="sup">{items.length} справочн.</span>
<div style={{flex:1,height:1,background:"var(--line-2)"}}/>
</div>
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:8}}>
{items.map(d=><Item key={d.id} d={d}/>)}
</div>
</div>
);
})}
</div>
);
}
/* ───────────────────────── canvas root ───────────────────────── */
function App() {
return (
<DesignCanvas title="Dictionary catalog — варианты представления" subtitle="40+ справочников + связи через FK">
<DCSection id="explore" title="Идеи">
<DCArtboard id="graph" label="A · Граф связей" width={1080} height={760}
notes="Глобальный обзор. Показывает hubs (satellites, acquisitions) и листья. Хорош для онбординга и архитектурных ревью.">
<GraphView/>
</DCArtboard>
<DCArtboard id="hub" label="B · Hub view (одна точка фокуса)" width={1080} height={680}
notes="Заменяет страницу справочника: контекст в один взгляд, без графа всей схемы. Хорошо ложится на навигацию через клики.">
<HubView/>
</DCArtboard>
<DCArtboard id="matrix" label="C · Матрица зависимостей" width={980} height={620}
notes="Power-user view. Показывает плотность связей и циклы лучше всех; хуже всех читается на 100+ словарях.">
<MatrixView/>
</DCArtboard>
<DCArtboard id="list" label="D · Список + inline FK (минимум изменений)" width={980} height={1060}
notes="Эволюция текущей страницы: те же карточки, плюс ряд FK-чипов и группировка по bundle. Самый быстрый к запуску.">
<ListWithFkView/>
</DCArtboard>
</DCSection>
</DesignCanvas>
);
}
const root = ReactDOM.createRoot(document.getElementById('root') || (()=>{const r=document.createElement('div');r.id='root';document.body.appendChild(r);return r;})());
root.render(<App/>);
</script>
</body>
</html>
@@ -0,0 +1,49 @@
// Shared data for dictionary prototype.
// Каждый dict: id, title, desc, scope, bundle, fk[]. refBy вычисляется.
const RAW_DICTS = [
{ id:"satellite_types", title:"Типы КА", desc:"Классификация КА по миссиям и характеристикам", scope:"PUBLIC", bundle:"cuod", fk:[] },
{ id:"satellite_statuses", title:"Статусы КА", desc:"Жизненный цикл: planned → operational → decommissioned/lost", scope:"PUBLIC", bundle:"cuod", fk:[] },
{ id:"antennas", title:"Антенны", desc:"Антенны наземных станций с G/T и поддерживаемыми диапазонами", scope:"PUBLIC", bundle:"cuod", fk:["frequency_bands"] },
{ id:"missions", title:"Миссии / программы", desc:"Программы ДЗЗ — Ресурс-П, Канопус-В, Sentinel и т.д.", scope:"PUBLIC", bundle:"cuod", fk:["satellite_types","operators"] },
{ id:"satellites", title:"Космические аппараты", desc:"Каталог КА (космических аппаратов) ДЗЗ-миссий", scope:"PUBLIC", bundle:"cuod", fk:["satellite_types","satellite_statuses","missions","operators","orbit_types"] },
{ id:"ground_stations", title:"Наземные станции", desc:"Каталог наземных пунктов приёма и управления", scope:"PUBLIC", bundle:"cuod", fk:["antennas","countries","timezones"] },
{ id:"instruments", title:"Инструменты съёмки", desc:"Полезная нагрузка / сенсоры на борту КА", scope:"PUBLIC", bundle:"cuod", fk:["sensor_types","satellites"] },
{ id:"sensor_types", title:"Типы сенсоров", desc:"Класс ПН: optical / SAR / hyperspectral / lidar / thermal", scope:"PUBLIC", bundle:"cuod", fk:[] },
{ id:"spectral_bands", title:"Спектральные каналы", desc:"Стандартные оптические/ИК каналы (PAN/R/G/B/NIR/SWIR/TIR)", scope:"PUBLIC", bundle:"cuod", fk:["instruments"] },
{ id:"frequency_bands", title:"Радиодиапазоны", desc:"Радио-диапазоны: L/S/C/X/Ku/Ka/V", scope:"PUBLIC", bundle:"cuod", fk:[] },
{ id:"orbit_types", title:"Типы орбит", desc:"LEO / SSO / MEO / GEO / HEO", scope:"PUBLIC", bundle:"cuod", fk:[] },
{ id:"product_levels", title:"Уровни продукта", desc:"L0 / L1A / L1B / L2 / L3", scope:"PUBLIC", bundle:"cuod", fk:[] },
{ id:"projection_codes", title:"Картографические проекции",desc:"EPSG-коды для картографии", scope:"PUBLIC", bundle:"geo", fk:[] },
{ id:"countries", title:"Страны (ISO 3166)", desc:"ISO 3166: двух- и трёхбуквенные коды, имена", scope:"PUBLIC", bundle:"geo", fk:[] },
{ id:"timezones", title:"Часовые пояса", desc:"IANA tz database", scope:"PUBLIC", bundle:"geo", fk:["countries"] },
{ id:"languages", title:"Языки (BCP-47)", desc:"ru-RU, en-US, … — справочник локалей", scope:"PUBLIC", bundle:"i18n", fk:[] },
{ id:"operators", title:"Операторы", desc:"Роскосмос, NASA, ESA — операторы программ", scope:"PUBLIC", bundle:"cuod", fk:["countries"] },
{ id:"acquisitions", title:"Сеансы съёмки", desc:"Планы и факты сеансов съёмки", scope:"INTERNAL", bundle:"cuod", fk:["satellites","ground_stations","instruments","spectral_bands","product_levels"] },
{ id:"products", title:"Продукты ДЗЗ", desc:"Поставляемые сцены и тайлы", scope:"INTERNAL", bundle:"cuod", fk:["acquisitions","product_levels","projection_codes"] },
{ id:"users_roles", title:"Роли пользователей", desc:"RBAC внутри платформы", scope:"INTERNAL", bundle:"core", fk:[] },
{ id:"webhooks_topics", title:"Webhook topics", desc:"Темы событий для исходящих webhook-ов", scope:"INTERNAL", bundle:"core", fk:[] },
{ id:"audit_event_types", title:"Типы аудит-событий", desc:"Каталог типов событий аудита", scope:"INTERNAL", bundle:"core", fk:[] },
{ id:"tenant_quotas", title:"Квоты тенантов", desc:"Лимиты на ресурсы по тенантам", scope:"TENANT", bundle:"core", fk:[] },
{ id:"tenant_branding", title:"Брендинг тенантов", desc:"Цвета, логотипы, домены", scope:"TENANT", bundle:"core", fk:["languages"] },
];
// Compute refBy
const _byId = Object.fromEntries(RAW_DICTS.map(d => [d.id, { ...d, refBy: [] }]));
for (const d of RAW_DICTS) {
for (const t of d.fk) if (_byId[t]) _byId[t].refBy.push(d.id);
}
const DICTS = Object.values(_byId);
const BY_ID = _byId;
const SCOPE_COLOR = { PUBLIC:"#3a6df0", INTERNAL:"#d4711a", TENANT:"#c64a8a" };
const SCOPE_BG = { PUBLIC:"#eef3ff", INTERNAL:"#fff2e3", TENANT:"#fde7f1" };
const SCOPE_LABEL = { PUBLIC:"Публичные", INTERNAL:"Внутренние", TENANT:"Тенант" };
const BUNDLES = ["cuod","geo","i18n","core"];
window.DICTS = DICTS;
window.BY_ID = BY_ID;
window.SCOPE_COLOR = SCOPE_COLOR;
window.SCOPE_BG = SCOPE_BG;
window.SCOPE_LABEL = SCOPE_LABEL;
window.BUNDLES = BUNDLES;
@@ -0,0 +1,72 @@
// Shared atomic UI for the dictionary prototype.
const { useState, useRef, useEffect, useLayoutEffect, useMemo } = React;
function ScopeDot({ scope }) {
return (
<span style={{
display:"inline-block", width:8, height:8, borderRadius:"50%",
background: SCOPE_COLOR[scope]
}}/>
);
}
function ScopeBadge({ scope }) {
return (
<span style={{
display:"inline-flex", alignItems:"center", gap:6,
padding:"3px 8px", borderRadius:6,
fontFamily:"JetBrains Mono, ui-monospace, monospace",
fontSize:11, letterSpacing:".04em", textTransform:"uppercase",
color: SCOPE_COLOR[scope],
background: SCOPE_BG[scope],
border:`1px solid ${SCOPE_COLOR[scope]}33`,
}}>
<ScopeDot scope={scope}/> {scope}
</span>
);
}
function FkChip({ id, dim, onClick }) {
const d = BY_ID[id];
return (
<button onClick={(e) => { e.stopPropagation(); onClick && onClick(id); }}
title={d ? d.title : id}
style={{
display:"inline-flex", alignItems:"center", gap:5,
padding:"2px 7px", borderRadius:5,
fontSize:11, fontFamily:"JetBrains Mono, ui-monospace, monospace",
background: dim ? "transparent" : "#f3f5fb",
border: dim ? "1px dashed #e5e7ee" : "1px solid #e5e7ee",
color: dim ? "#6b7180" : "#1d2a8a",
cursor:"pointer",
}}>
{d ? d.title : id}
</button>
);
}
function Btn({ children, primary, onClick, active }) {
return (
<button onClick={onClick} style={{
height:32, padding:"0 14px", borderRadius:8,
fontSize:13, cursor:"pointer",
border:`1px solid ${primary ? "#1d2a8a" : active ? "#1d2a8a" : "#e5e7ee"}`,
background: primary ? "#1d2a8a" : active ? "#eef3ff" : "#fff",
color: primary ? "#fff" : active ? "#1d2a8a" : "#3a4151",
fontWeight: primary || active ? 600 : 400,
}}>{children}</button>
);
}
function Sup({ children, style }) {
return (
<div style={{
fontFamily:"JetBrains Mono, ui-monospace, monospace",
fontSize:11, letterSpacing:".06em", textTransform:"uppercase",
color:"#6b7180",
...style,
}}>{children}</div>
);
}
Object.assign(window, { ScopeDot, ScopeBadge, FkChip, Btn, Sup });
@@ -0,0 +1,180 @@
// View A Граф связей. Force-style static layout с интерактивностью.
const { useState: useStateA, useMemo: useMemoA } = React;
const GRAPH_LAYOUT = {
satellite_types: {x:300, y:140},
satellite_statuses: {x:140, y:230},
missions: {x:230, y:330},
satellites: {x:500, y:300},
instruments: {x:700, y:220},
sensor_types: {x:840, y:140},
spectral_bands: {x:840, y:320},
orbit_types: {x:500, y:160},
ground_stations: {x:540, y:500},
antennas: {x:320, y:540},
frequency_bands: {x:160, y:500},
countries: {x:700, y:580},
timezones: {x:860, y:520},
operators: {x:900, y:420},
product_levels: {x:540, y:660},
acquisitions: {x:360, y:680},
products: {x:180, y:660},
projection_codes: {x:80, y:580},
users_roles: {x:1000,y:680},
webhooks_topics: {x:1000,y:140},
audit_event_types: {x:1000,y:240},
tenant_quotas: {x:60, y:140},
tenant_branding: {x:60, y:340},
languages: {x:80, y:60},
};
function GraphView({ onOpen }) {
const [hover, setHover] = useStateA(null);
const [showLeaves, setShowLeaves] = useStateA(true);
const [scopeFilter, setScopeFilter] = useStateA(new Set(["PUBLIC","INTERNAL","TENANT"]));
const visible = useMemoA(() => DICTS.filter(d => {
if (!scopeFilter.has(d.scope)) return false;
if (!showLeaves && d.fk.length === 0 && d.refBy.length === 0) return false;
return true;
}), [scopeFilter, showLeaves]);
const visIds = useMemoA(() => new Set(visible.map(d => d.id)), [visible]);
const edges = useMemoA(() => {
const out = [];
for (const d of visible) for (const t of d.fk) if (visIds.has(t)) out.push({ from:d.id, to:t });
return out;
}, [visible, visIds]);
const focus = hover;
const isEdgeActive = e => focus && (e.from === focus || e.to === focus);
const isNodeActive = id => !focus || id === focus
|| edges.some(e => (e.from === focus && e.to === id) || (e.to === focus && e.from === id));
const W = 1180, svgH = 760;
const toggleScope = s => {
const n = new Set(scopeFilter);
n.has(s) ? n.delete(s) : n.add(s);
setScopeFilter(n);
};
return (
<div style={{ width:W, background:"#fff", borderRadius:12, border:"1px solid #e5e7ee", overflow:"hidden" }}>
{/* toolbar */}
<div style={{ height:56, padding:"0 18px", display:"flex", alignItems:"center", justifyContent:"space-between", borderBottom:"1px solid #eef0f5" }}>
<div style={{ display:"flex", alignItems:"center", gap:14 }}>
<Sup>Граф связей · {visible.length} узлов · {edges.length} связей</Sup>
</div>
<div style={{ display:"flex", gap:8 }}>
{["PUBLIC","INTERNAL","TENANT"].map(s => (
<Btn key={s} active={scopeFilter.has(s)} onClick={() => toggleScope(s)}>
<ScopeDot scope={s}/> &nbsp;{SCOPE_LABEL[s]}
</Btn>
))}
<div style={{ width:1, background:"#eef0f5", margin:"0 4px" }}/>
<Btn active={!showLeaves} onClick={() => setShowLeaves(!showLeaves)}>Скрыть листья</Btn>
<Btn>Экспорт SVG</Btn>
</div>
</div>
<div style={{ position:"relative" }}>
<svg width={W} height={svgH}>
<defs>
<marker id="ah" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#94a0b8"/>
</marker>
<marker id="ahA" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#1d2a8a"/>
</marker>
<pattern id="dotgrid" width="24" height="24" patternUnits="userSpaceOnUse">
<circle cx="1" cy="1" r="1" fill="#eef0f5"/>
</pattern>
</defs>
<rect width={W} height={svgH} fill="url(#dotgrid)"/>
{edges.map((e, i) => {
const a = GRAPH_LAYOUT[e.from], b = GRAPH_LAYOUT[e.to];
if (!a || !b) return null;
const active = isEdgeActive(e);
const opacity = focus && !active ? 0.10 : 1;
return (
<path key={i}
d={`M${a.x},${a.y} C ${a.x},${(a.y+b.y)/2} ${b.x},${(a.y+b.y)/2} ${b.x},${b.y}`}
stroke={active ? "#1d2a8a" : "#c8cfdb"}
strokeWidth={active ? 1.8 : 1}
fill="none"
opacity={opacity}
markerEnd={active ? "url(#ahA)" : "url(#ah)"}/>
);
})}
{visible.map(d => {
const p = GRAPH_LAYOUT[d.id]; if (!p) return null;
const active = isNodeActive(d.id);
const r = 24 + Math.min(d.refBy.length * 3, 16);
return (
<g key={d.id} transform={`translate(${p.x},${p.y})`} style={{ cursor:"pointer" }}
opacity={active ? 1 : .15}
onMouseEnter={() => setHover(d.id)}
onMouseLeave={() => setHover(null)}
onClick={() => onOpen(d.id)}>
<circle r={r}
fill={SCOPE_BG[d.scope]}
stroke={SCOPE_COLOR[d.scope]}
strokeWidth={d.id === focus ? 2.5 : 1.4}/>
<text textAnchor="middle" dy="-2" fontSize="10.5" fontWeight="600" fill="#11131a" style={{ pointerEvents:"none" }}>
{d.title.length > 14 ? d.title.slice(0, 12) + "…" : d.title}
</text>
<text textAnchor="middle" dy="11" fontSize="9" fontFamily="JetBrains Mono, monospace" fill="#6b7180" style={{ pointerEvents:"none" }}>
{d.refBy.length > 0 ? `${d.refBy.length}` : "·"}
</text>
</g>
);
})}
</svg>
{/* hover-tooltip */}
{hover && (() => {
const d = BY_ID[hover];
const p = GRAPH_LAYOUT[hover];
return (
<div style={{
position:"absolute",
left: Math.min(W - 280, p.x + 30),
top: Math.min(svgH - 120, p.y + 30),
width: 260, padding:"10px 12px",
background:"#fff", border:"1px solid #e5e7ee", borderRadius:10,
boxShadow:"0 14px 28px -16px rgba(20,30,140,.3)",
pointerEvents:"none",
}}>
<div style={{ display:"flex", justifyContent:"space-between", alignItems:"start", gap:8 }}>
<div style={{ fontSize:13, fontWeight:600, color:"#1d2a8a" }}>{d.title}</div>
<ScopeBadge scope={d.scope}/>
</div>
<div style={{ fontSize:11, fontFamily:"JetBrains Mono, monospace", color:"#6b7180", marginTop:2 }}>
{d.id} · v1.0.0
</div>
<div style={{ fontSize:12, color:"#3a4151", marginTop:6, lineHeight:1.4 }}>{d.desc}</div>
<div style={{ marginTop:8, display:"flex", gap:14, fontSize:11, color:"#6b7180" }}>
<span> ссылается: <b style={{ color:"#11131a" }}>{d.fk.length}</b></span>
<span> используют: <b style={{ color:"#11131a" }}>{d.refBy.length}</b></span>
</div>
</div>
);
})()}
</div>
{/* legend bar */}
<div style={{ borderTop:"1px solid #eef0f5", padding:"10px 18px", display:"flex", alignItems:"center", gap:18, fontSize:12, color:"#6b7180" }}>
<span>Размер узла = входящие ссылки</span>
<span>·</span>
<span>Hover фокус, Click открыть hub</span>
<span style={{ marginLeft:"auto" }}>Layout: forced (статический snapshot)</span>
</div>
</div>
);
}
window.GraphView = GraphView;
@@ -0,0 +1,154 @@
// View B Hub view. Один справочник в фокусе, соседи по сторонам.
const { useState: useStateB, useMemo: useMemoB } = React;
function HubView({ centerId, onOpen }) {
const center = BY_ID[centerId] || DICTS[0];
const inn = center.fk.map(id => BY_ID[id]).filter(Boolean);
const out = center.refBy.map(id => BY_ID[id]).filter(Boolean);
const [tab, setTab] = useStateB("links"); // links | fields | preview
const Card = ({ d, side }) => (
<div onClick={() => onOpen(d.id)}
style={{
cursor:"pointer", display:"flex", alignItems:"stretch",
background:"#fff", border:"1px solid #e5e7ee", borderRadius:8,
overflow:"hidden", transition:"transform .12s, box-shadow .12s",
}}
onMouseEnter={e => { e.currentTarget.style.transform = side==="left"?"translateX(-2px)":"translateX(2px)"; e.currentTarget.style.boxShadow="0 6px 16px -10px rgba(20,30,140,.25)"; }}
onMouseLeave={e => { e.currentTarget.style.transform = "none"; e.currentTarget.style.boxShadow="none"; }}>
<div style={{ width:3, background: SCOPE_COLOR[d.scope] }}/>
<div style={{ padding:"10px 12px", flex:1 }}>
<div style={{ fontSize:13, fontWeight:600, color:"#1d2a8a" }}>{d.title}</div>
<div style={{ fontSize:11, fontFamily:"JetBrains Mono, monospace", color:"#6b7180", marginTop:2 }}>{d.id}</div>
</div>
</div>
);
const Arrow = ({ dir }) => (
<svg width="60" height="20">
<defs>
<marker id={`hubArr-${dir}`} viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10z" fill="#3a4151"/>
</marker>
</defs>
{dir === "right"
? <path d="M4 10 L56 10" stroke="#3a4151" strokeWidth="1.4" markerEnd={`url(#hubArr-${dir})`}/>
: <path d="M56 10 L4 10" stroke="#3a4151" strokeWidth="1.4" markerEnd={`url(#hubArr-${dir})`}/>
}
</svg>
);
return (
<div style={{ width:1180, background:"#fff", borderRadius:12, border:"1px solid #e5e7ee", padding:24 }}>
{/* topline */}
<div style={{ display:"flex", justifyContent:"space-between", alignItems:"flex-start", marginBottom:18 }}>
<div>
<Sup>Hub · контекст справочника</Sup>
<div style={{ fontSize:22, fontWeight:600, color:"#1d2a8a", marginTop:4 }}>{center.title}</div>
<div style={{ fontSize:12, fontFamily:"JetBrains Mono, monospace", color:"#6b7180", marginTop:2 }}>
{center.id} &middot; bundle: {center.bundle} &middot; v1.0.0
</div>
</div>
<div style={{ display:"flex", gap:8 }}>
<Btn> Назад</Btn>
<Btn>История</Btn>
<Btn primary>Открыть редактор</Btn>
</div>
</div>
{/* tabs */}
<div style={{ display:"flex", gap:24, borderBottom:"1px solid #eef0f5", marginBottom:24 }}>
{[
["links","Связи",`${inn.length+out.length}`],
["fields","Поля","12"],
["preview","Записи","248"],
].map(([k,label,count]) => (
<button key={k} onClick={() => setTab(k)} style={{
border:"none", background:"transparent", cursor:"pointer",
padding:"10px 0", borderBottom: tab===k ? "2px solid #1d2a8a" : "2px solid transparent",
fontSize:13, fontWeight: tab===k ? 600 : 500,
color: tab===k ? "#1d2a8a" : "#6b7180",
display:"flex", alignItems:"center", gap:6,
}}>
{label}
<span style={{
fontSize:11, padding:"1px 6px", borderRadius:4,
background: tab===k ? "#eef3ff" : "#f5f6fa",
color: tab===k ? "#1d2a8a" : "#6b7180",
fontFamily:"JetBrains Mono, monospace",
}}>{count}</span>
</button>
))}
</div>
{tab === "links" && (
<>
{/* meta strip */}
<div style={{ display:"flex", gap:24, padding:"14px 18px", background:"#f7f8fb", borderRadius:10, marginBottom:24 }}>
<ScopeBadge scope={center.scope}/>
<div style={{ fontSize:13, color:"#3a4151", flex:1 }}>{center.desc}</div>
<div style={{ display:"flex", gap:18, fontSize:12, color:"#6b7180" }}>
<span> <b style={{ color:"#11131a" }}>{inn.length}</b> исх.</span>
<span> <b style={{ color:"#11131a" }}>{out.length}</b> вход.</span>
</div>
</div>
<div style={{ display:"grid", gridTemplateColumns:"1fr auto 1fr", gap:30, alignItems:"flex-start" }}>
{/* incoming dependencies (this dict depends on these) */}
<div>
<Sup style={{ textAlign:"right", marginBottom:10 }}>Зависит от · {inn.length}</Sup>
<div style={{ display:"flex", flexDirection:"column", gap:10 }}>
{inn.length === 0 && <div style={{ fontSize:12, color:"#6b7180", textAlign:"right", padding:"20px 0" }}>нет зависимостей</div>}
{inn.map(d => (
<div key={d.id} style={{ display:"grid", gridTemplateColumns:"1fr auto", alignItems:"center" }}>
<Card d={d} side="left"/>
<Arrow dir="right"/>
</div>
))}
</div>
</div>
{/* center */}
<div style={{ width:280, padding:18, borderRadius:12,
background:"linear-gradient(180deg,#fff,#eef3ff)",
border:"2px solid #1d2a8a",
boxShadow:"0 18px 36px -22px rgba(20,30,140,.5)" }}>
<Sup>Текущий</Sup>
<div style={{ fontSize:18, fontWeight:700, color:"#0f1860", marginTop:6 }}>{center.title}</div>
<div style={{ fontSize:11, fontFamily:"JetBrains Mono, monospace", color:"#6b7180", marginTop:2 }}>{center.id}</div>
<div style={{ marginTop:12 }}><ScopeBadge scope={center.scope}/></div>
<div style={{ fontSize:12, color:"#3a4151", marginTop:10, lineHeight:1.45 }}>{center.desc}</div>
</div>
{/* outgoing (these depend on this) */}
<div>
<Sup style={{ marginBottom:10 }}>На него ссылаются · {out.length}</Sup>
<div style={{ display:"flex", flexDirection:"column", gap:10 }}>
{out.length === 0 && <div style={{ fontSize:12, color:"#6b7180", padding:"20px 0" }}>никто не ссылается</div>}
{out.map(d => (
<div key={d.id} style={{ display:"grid", gridTemplateColumns:"auto 1fr", alignItems:"center" }}>
<Arrow dir="right"/>
<Card d={d} side="right"/>
</div>
))}
</div>
</div>
</div>
<div style={{ marginTop:30, paddingTop:14, borderTop:"1px dashed #e5e7ee", fontSize:12, color:"#6b7180" }}>
Клик по карточке сделать центром. Ctrl+клик добавить в выборку для сравнения. Двойной клик открыть в редакторе.
</div>
</>
)}
{tab !== "links" && (
<div style={{ padding:"60px 0", textAlign:"center", color:"#6b7180" }}>
<Sup>placeholder</Sup>
<div style={{ marginTop:6, fontSize:14 }}>Содержимое вкладки «{tab === "fields" ? "Поля" : "Записи"}» без изменений к текущей реализации.</div>
</div>
)}
</div>
);
}
window.HubView = HubView;
@@ -0,0 +1,154 @@
// View D Список со связями inline. Bundle-grouped, FK chips clickable.
const { useState: useStateD, useMemo: useMemoD } = React;
function ListView({ onOpen }) {
const [q, setQ] = useStateD("");
const [withDepsOnly, setWithDepsOnly] = useStateD(false);
const [scopes, setScopes] = useStateD(new Set(["PUBLIC","INTERNAL","TENANT"]));
const [bundleFilter, setBundleFilter] = useStateD(null); // null = все
const filtered = useMemoD(() => {
const ql = q.trim().toLowerCase();
return DICTS.filter(d => {
if (!scopes.has(d.scope)) return false;
if (bundleFilter && d.bundle !== bundleFilter) return false;
if (withDepsOnly && d.fk.length === 0 && d.refBy.length === 0) return false;
if (ql && !d.title.toLowerCase().includes(ql) && !d.id.toLowerCase().includes(ql) && !d.desc.toLowerCase().includes(ql)) return false;
return true;
});
}, [q, withDepsOnly, scopes, bundleFilter]);
const grouped = useMemoD(() => {
const g = {};
for (const b of BUNDLES) g[b] = [];
for (const d of filtered) (g[d.bundle] = g[d.bundle] || []).push(d);
return g;
}, [filtered]);
const toggleScope = s => {
const n = new Set(scopes); n.has(s) ? n.delete(s) : n.add(s); setScopes(n);
};
const Item = ({ d }) => (
<div onClick={() => onOpen(d.id)}
style={{
display:"grid", gridTemplateColumns:"3px 1fr auto",
background:"#fff", border:"1px solid #e5e7ee", borderRadius:10,
overflow:"hidden", cursor:"pointer", transition:"box-shadow .12s, transform .12s",
}}
onMouseEnter={e => { e.currentTarget.style.boxShadow="0 8px 22px -14px rgba(20,30,140,.25)"; e.currentTarget.style.transform="translateY(-1px)"; }}
onMouseLeave={e => { e.currentTarget.style.boxShadow="none"; e.currentTarget.style.transform="none"; }}>
<div style={{ background: SCOPE_COLOR[d.scope] }}/>
<div style={{ padding:"12px 14px" }}>
<div style={{ display:"flex", alignItems:"baseline", gap:10 }}>
<span style={{ fontSize:15, fontWeight:600, color:"#1d2a8a" }}>{d.title}</span>
<span style={{ fontSize:11, fontFamily:"JetBrains Mono, monospace", color:"#6b7180" }}>{d.id}</span>
</div>
<div style={{ fontSize:12, color:"#3a4151", marginTop:3, lineHeight:1.4 }}>{d.desc}</div>
{(d.fk.length > 0 || d.refBy.length > 0) && (
<div style={{ display:"flex", gap:6, flexWrap:"wrap", marginTop:10, alignItems:"center" }}>
{d.fk.length > 0 && (
<>
<Sup style={{ marginRight:2 }}> ссылается</Sup>
{d.fk.map(id => <FkChip key={id} id={id} onClick={onOpen}/>)}
</>
)}
{d.refBy.length > 0 && (
<>
<Sup style={{ marginLeft:8, marginRight:2 }}> {d.refBy.length} использ.</Sup>
{d.refBy.slice(0,3).map(id => <FkChip key={id} id={id} dim onClick={onOpen}/>)}
{d.refBy.length > 3 && (
<span style={{ fontSize:11, color:"#6b7180", fontFamily:"JetBrains Mono, monospace" }}>
+{d.refBy.length - 3}
</span>
)}
</>
)}
</div>
)}
</div>
<div style={{ padding:"12px 14px", display:"flex", flexDirection:"column", alignItems:"flex-end", gap:6 }}>
<ScopeBadge scope={d.scope}/>
<span style={{ fontSize:11, color:"#6b7180", fontFamily:"JetBrains Mono, monospace" }}>v1.0.0</span>
</div>
</div>
);
return (
<div style={{ width:1180, background:"#fff", borderRadius:12, border:"1px solid #e5e7ee", padding:24 }}>
{/* toolbar */}
<div style={{ display:"grid", gridTemplateColumns:"1fr auto", gap:14, alignItems:"center", marginBottom:18 }}>
<div style={{ display:"flex", alignItems:"center", gap:10 }}>
<input
placeholder="Поиск по названию, коду или описанию"
value={q} onChange={e => setQ(e.target.value)}
style={{
height:36, width:340, padding:"0 14px",
border:"1px solid #e5e7ee", borderRadius:8,
fontSize:13, color:"#11131a",
}}/>
<span style={{ fontSize:12, color:"#6b7180" }}>
Показано <b style={{ color:"#11131a" }}>{filtered.length}</b> из {DICTS.length}
</span>
</div>
<div style={{ display:"flex", gap:8 }}>
{["PUBLIC","INTERNAL","TENANT"].map(s => (
<Btn key={s} active={scopes.has(s)} onClick={() => toggleScope(s)}>
<ScopeDot scope={s}/> &nbsp;{SCOPE_LABEL[s]}
</Btn>
))}
<div style={{ width:1, background:"#eef0f5", margin:"0 4px" }}/>
<Btn active={withDepsOnly} onClick={() => setWithDepsOnly(!withDepsOnly)}>
Только со связями
</Btn>
</div>
</div>
{/* bundle pills */}
<div style={{ display:"flex", gap:8, marginBottom:18, flexWrap:"wrap", alignItems:"center" }}>
<Sup>bundle</Sup>
<Btn active={bundleFilter===null} onClick={() => setBundleFilter(null)}>все</Btn>
{BUNDLES.map(b => {
const n = filtered.filter(d => d.bundle === b).length;
return (
<Btn key={b} active={bundleFilter===b} onClick={() => setBundleFilter(bundleFilter===b ? null : b)}>
{b} <span style={{ fontSize:11, color:"#6b7180", marginLeft:4 }}>{n}</span>
</Btn>
);
})}
</div>
{/* grouped list */}
{BUNDLES.map(b => {
const items = grouped[b];
if (!items || items.length === 0) return null;
return (
<div key={b} style={{ marginBottom:22 }}>
<div style={{ display:"flex", alignItems:"center", gap:10, margin:"6px 0 12px" }}>
<span style={{
fontSize:12, fontFamily:"JetBrains Mono, monospace",
letterSpacing:".06em", textTransform:"uppercase",
color:"#11131a", fontWeight:600,
}}>{b}</span>
<span style={{ fontSize:11, color:"#6b7180" }}>· {items.length} справочн.</span>
<div style={{ flex:1, height:1, background:"#eef0f5" }}/>
</div>
<div style={{ display:"grid", gridTemplateColumns:"1fr 1fr", gap:10 }}>
{items.map(d => <Item key={d.id} d={d}/>)}
</div>
</div>
);
})}
{filtered.length === 0 && (
<div style={{ padding:"60px 20px", textAlign:"center", color:"#6b7180" }}>
<Sup>пусто</Sup>
<div style={{ marginTop:6, fontSize:14 }}>Ничего не найдено по текущим фильтрам</div>
</div>
)}
</div>
);
}
window.ListView = ListView;
@@ -1,495 +0,0 @@
--- ordinis-admin-ui/src/routes/dictionaries.index.tsx 2026-05-06 12:49:46
+++ ordinis-admin-ui/design_handoff_dictionary_catalog/proposed.tsx 2026-05-10 19:33:10
@@ -1,6 +1,21 @@
-import { useDeferredValue, useMemo, useState } from 'react'
+/**
+ * Dictionary Catalog v2 — proposed.tsx
+ *
+ * Drop-in replacement для `routes/dictionaries.index.tsx`.
+ *
+ * Что нового vs v1:
+ * - URL search params (q / scope / sort / density) — shareable + survives refresh.
+ * - Multi-scope filter (chips) вместо forced grouping.
+ * - Sort dropdown (name / recordCount / updated).
+ * - Density toggle (card / dense list).
+ * - Approval-required badge на карточках.
+ *
+ * См. design_handoff_dictionary_catalog/README.md для полного спека.
+ */
+import { useDeferredValue, useMemo } from 'react'
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
+import { z } from 'zod'
import {
Alert,
Badge,
@@ -16,10 +31,34 @@
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
import { SCOPE_ACCENT, SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style'
+const SORT_OPTIONS = ['name', 'recordCount', 'updated'] as const
+type SortKey = (typeof SORT_OPTIONS)[number]
+const DENSITY_OPTIONS = ['card', 'dense'] as const
+type Density = (typeof DENSITY_OPTIONS)[number]
+
+const searchSchema = z.object({
+ q: z.string().optional(),
+ // CSV "PUBLIC,INTERNAL" — TanStack Router validate'ит и normalize'ит до Set.
+ scope: z.string().optional(),
+ sort: z.enum(SORT_OPTIONS).optional(),
+ density: z.enum(DENSITY_OPTIONS).optional(),
+})
+
export const Route = createFileRoute('/dictionaries/')({
+ validateSearch: searchSchema,
component: DictionariesPage,
})
+const parseScopeFilter = (csv: string | undefined): Set<DataScope> => {
+ if (!csv) return new Set()
+ const valid: DataScope[] = ['PUBLIC', 'INTERNAL', 'RESTRICTED']
+ const found = csv
+ .split(',')
+ .map((s) => s.trim().toUpperCase())
+ .filter((s): s is DataScope => valid.includes(s as DataScope))
+ return new Set(found)
+}
+
const matchesQuery = (d: DictionaryDefinition, q: string): boolean => {
if (!q) return true
const haystack = [d.name, d.displayName ?? '', d.description ?? '']
@@ -28,30 +67,90 @@
return haystack.includes(q)
}
+const sortDicts = (
+ list: DictionaryDefinition[],
+ key: SortKey,
+): DictionaryDefinition[] => {
+ const copy = [...list]
+ switch (key) {
+ case 'name':
+ return copy.sort((a, b) =>
+ (a.displayName ?? a.name).localeCompare(b.displayName ?? b.name),
+ )
+ case 'recordCount':
+ return copy.sort((a, b) => (b.recordCount ?? 0) - (a.recordCount ?? 0))
+ case 'updated':
+ return copy.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
+ }
+}
+
function DictionariesPage() {
const { t } = useTranslation()
- const navigate = useNavigate()
+ const navigate = useNavigate({ from: '/dictionaries/' })
+ const search = Route.useSearch()
const { data, isLoading, error } = useDictionaries()
- const [createOpen, setCreateOpen] = useState(false)
- const [query, setQuery] = useState('')
- const deferredQuery = useDeferredValue(query)
+ const q = (search.q ?? '').trim().toLowerCase()
+ const deferredQuery = useDeferredValue(q)
+ const scopeFilter = useMemo(() => parseScopeFilter(search.scope), [search.scope])
+ const sortKey: SortKey = search.sort ?? 'name'
+ const density: Density = search.density ?? 'card'
+
+ // create modal — local state, не URL (модалки — transient, refresh-сбрасываем).
+ // Использовать useState напрямую без useState import — переписали через
+ // search-state pattern был бы over-engineered.
+ const createOpen = search.q === '__create__' // sentinel — нет, используем state ниже
+ // Note: для modal'а оставляем useState, на URL не выносим (см. README "Out of scope").
+ // (В продакшен-версии: createOpen + setCreateOpen в useState).
+
const filtered = useMemo(() => {
if (!data) return []
- const q = deferredQuery.trim().toLowerCase()
- return data.filter((d) => matchesQuery(d, q))
- }, [data, deferredQuery])
+ const byScope =
+ scopeFilter.size === 0
+ ? data
+ : data.filter((d) => scopeFilter.has(d.scope))
+ const byQuery = byScope.filter((d) => matchesQuery(d, deferredQuery))
+ return sortDicts(byQuery, sortKey)
+ }, [data, deferredQuery, scopeFilter, sortKey])
- const groupedByScope = useMemo(() => {
- const map: Record<DataScope, DictionaryDefinition[]> = {
- PUBLIC: [],
- INTERNAL: [],
- RESTRICTED: [],
- }
- for (const d of filtered) map[d.scope].push(d)
- return map
- }, [filtered])
+ const scopeCounts = useMemo(() => {
+ const out: Record<DataScope, number> = { PUBLIC: 0, INTERNAL: 0, RESTRICTED: 0 }
+ if (data) for (const d of data) out[d.scope]++
+ return out
+ }, [data])
+ const setSearch = (next: Partial<z.infer<typeof searchSchema>>) => {
+ navigate({
+ search: (prev) => {
+ const merged = { ...prev, ...next }
+ // Убираем default values чтобы URL был чистым.
+ if (!merged.q) delete merged.q
+ if (!merged.scope) delete merged.scope
+ if (merged.sort === 'name') delete merged.sort
+ if (merged.density === 'card') delete merged.density
+ return merged
+ },
+ })
+ }
+
+ const toggleScope = (scope: DataScope) => {
+ const next = new Set(scopeFilter)
+ if (next.has(scope)) next.delete(scope)
+ else next.add(scope)
+ setSearch({
+ scope: next.size === 0 ? undefined : Array.from(next).join(','),
+ })
+ }
+
+ const resetFilters = () => {
+ navigate({ search: {} })
+ }
+
+ const filtersActive = Boolean(q) || scopeFilter.size > 0
+
+ // Modal state via useState pattern — keep local, not URL (per spec).
+ const [createOpenLocal, setCreateOpen] = useLocalToggle(false)
+
const createButton = (
<Button
type="button"
@@ -85,7 +184,7 @@
/>
<EmptyState title={t('dict.empty')} />
<DictionaryEditorDialog
- open={createOpen}
+ open={createOpenLocal}
mode={{ kind: 'create' }}
onClose={() => setCreateOpen(false)}
onSuccess={(name) => {
@@ -98,92 +197,127 @@
}
return (
- <div className="space-y-6">
+ <section
+ aria-label={t('dict.list.heading', { defaultValue: 'Каталог справочников' })}
+ className="space-y-6"
+ >
<PageHeader
title={t('nav.dictionaries')}
description={t('dict.list.subtitle')}
actions={createButton}
/>
- <div className="flex flex-col sm:flex-row sm:items-center gap-3">
- <div className="flex-1 max-w-md">
+ {/* Row 1: search + scope chips */}
+ <div className="flex flex-wrap items-center gap-3">
+ <div className="flex-1 min-w-[280px] max-w-md">
<SearchInput
- value={query}
- onChange={(e) => setQuery(e.target.value)}
+ value={search.q ?? ''}
+ onChange={(e) => setSearch({ q: e.target.value })}
placeholder={t('dict.list.search.placeholder')}
aria-label={t('dict.list.search.placeholder')}
/>
</div>
- <span className="text-sm text-carbon/70">
- {t('dict.list.found', { shown: filtered.length, total: data.length })}
- </span>
- </div>
- {filtered.length === 0 ? (
- <EmptyState title={t('dict.list.search.empty')} />
- ) : (
- <div className="space-y-8">
+ <div
+ role="group"
+ aria-label={t('dict.list.filter.scope', { defaultValue: 'Фильтр по области' })}
+ className="flex items-center gap-1.5 flex-wrap"
+ >
{SCOPE_ORDER.map((scope) => {
- const items = groupedByScope[scope]
- if (items.length === 0) return null
+ const selected = scopeFilter.has(scope)
+ const count = scopeCounts[scope]
return (
- <section key={scope} className="space-y-3">
- <h2 className="flex items-center gap-2 text-2xs uppercase tracking-label text-carbon/70">
- <span
- className={`inline-block size-2 rounded-full ${SCOPE_DOT[scope]}`}
- aria-hidden="true"
- />
- <span className="font-primary text-sm normal-case tracking-normal text-ultramarain">
- {t(`dict.list.section.${scope}`)}
- </span>
- <span className="text-carbon/50">· {items.length}</span>
- </h2>
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
- {items.map((d) => (
- <Link
- key={d.id}
- to="/dictionaries/$name"
- params={{ name: d.name }}
- className={`relative block bg-white border border-regolith rounded-lg p-4 pl-5 transition shadow-card hover:shadow-hover hover:border-ultramarain/40 before:absolute before:left-0 before:top-0 before:bottom-0 before:w-1 before:rounded-l-lg ${SCOPE_ACCENT[d.scope]}`}
- >
- <div className="flex items-start justify-between mb-2 gap-2">
- <h3 className="font-primary text-base text-ultramarain">
- {d.displayName ?? d.name}
- </h3>
- <Badge variant="info">{d.scope}</Badge>
- </div>
- {d.description && (
- <p className="text-sm text-carbon line-clamp-2 mb-3">
- {d.description}
- </p>
- )}
- <div className="flex items-center justify-between gap-2 text-2xs uppercase tracking-label text-carbon/60">
- <div className="flex gap-2 min-w-0">
- <span className="truncate">v{d.schemaVersion}</span>
- <span>·</span>
- <span className="truncate">{d.bundle}</span>
- <span>·</span>
- <span className="truncate">
- {d.supportedLocales.join(', ')}
- </span>
- </div>
- {typeof d.recordCount === 'number' && (
- <Badge variant="neutral">
- {t('dict.list.recordCount', { count: d.recordCount })}
- </Badge>
- )}
- </div>
- </Link>
- ))}
- </div>
- </section>
+ <button
+ key={scope}
+ type="button"
+ onClick={() => toggleScope(scope)}
+ aria-pressed={selected}
+ className={`px-3 py-1.5 rounded-full text-2xs uppercase tracking-label flex items-center gap-1.5 border transition focus:outline-none focus:ring-2 focus:ring-ultramarain/40 ${
+ selected
+ ? 'border-ultramarain bg-orbit/40 text-ultramarain'
+ : 'border-regolith hover:border-ultramarain/60'
+ }`}
+ >
+ <span
+ className={`inline-block size-2 rounded-full ${SCOPE_DOT[scope]}`}
+ aria-hidden="true"
+ />
+ {t(`dict.list.section.${scope}`)} · {count}
+ </button>
)
})}
</div>
+ </div>
+
+ {/* Row 2: sort + density + counts */}
+ <div className="flex flex-wrap items-center justify-between gap-3 text-sm">
+ <div className="flex items-center gap-3">
+ <label className="flex items-center gap-2">
+ <span className="text-2xs uppercase tracking-label text-carbon/70">
+ {t('dict.list.sort.label', { defaultValue: 'Сорт' })}
+ </span>
+ <select
+ value={sortKey}
+ onChange={(e) =>
+ setSearch({ sort: e.target.value as SortKey })
+ }
+ className="border border-regolith rounded-sm py-1 px-2 text-sm focus:border-ultramarain focus:outline-none focus:ring-2 focus:ring-ultramarain/40"
+ aria-label={t('dict.list.sort.label', { defaultValue: 'Сорт' })}
+ >
+ <option value="name">{t('dict.list.sort.name')}</option>
+ <option value="recordCount">{t('dict.list.sort.recordCount')}</option>
+ <option value="updated">{t('dict.list.sort.updated')}</option>
+ </select>
+ </label>
+
+ <div
+ role="group"
+ aria-label={t('dict.list.density.label', { defaultValue: 'Плотность' })}
+ className="hidden sm:flex border border-regolith rounded-sm overflow-hidden"
+ >
+ {DENSITY_OPTIONS.map((d) => (
+ <button
+ key={d}
+ type="button"
+ onClick={() => setSearch({ density: d })}
+ aria-pressed={d === density}
+ className={`px-3 py-1 text-2xs uppercase tracking-label transition focus:outline-none focus:ring-2 focus:ring-ultramarain/40 ${
+ d === density
+ ? 'bg-orbit/40 text-ultramarain'
+ : 'hover:bg-orbit/20'
+ }`}
+ >
+ {t(`dict.list.density.${d}`)}
+ </button>
+ ))}
+ </div>
+ </div>
+
+ <span className="text-2xs uppercase tracking-label text-carbon/70">
+ {t('dict.list.found', { shown: filtered.length, total: data.length })}
+ </span>
+ </div>
+
+ {/* Results */}
+ {filtered.length === 0 ? (
+ <div className="border border-regolith rounded-lg p-12 text-center bg-orbit/10">
+ <p className="font-primary text-lg text-ultramarain mb-2">
+ {t('dict.list.search.empty')}
+ </p>
+ {filtersActive && (
+ <Button type="button" variant="ghost" onClick={resetFilters}>
+ {t('dict.list.search.reset', { defaultValue: 'Сбросить фильтры' })}
+ </Button>
+ )}
+ </div>
+ ) : density === 'card' ? (
+ <CardGrid items={filtered} />
+ ) : (
+ <DenseList items={filtered} t={t} />
)}
<DictionaryEditorDialog
- open={createOpen}
+ open={createOpenLocal}
mode={{ kind: 'create' }}
onClose={() => setCreateOpen(false)}
onSuccess={(name) => {
@@ -191,6 +325,124 @@
navigate({ to: '/dictionaries/$name', params: { name } })
}}
/>
+ </section>
+ )
+}
+
+// ===== Subcomponents =====
+
+const CardGrid = ({ items }: { items: DictionaryDefinition[] }) => {
+ const { t } = useTranslation()
+ return (
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
+ {items.map((d) => (
+ <Link
+ key={d.id}
+ to="/dictionaries/$name"
+ params={{ name: d.name }}
+ className={`relative block bg-white border border-regolith rounded-lg p-4 pl-5 transition shadow-card hover:shadow-hover hover:border-ultramarain/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ultramarain before:absolute before:left-0 before:top-0 before:bottom-0 before:w-1 before:rounded-l-lg ${SCOPE_ACCENT[d.scope]}`}
+ >
+ <div className="flex items-start justify-between mb-2 gap-2">
+ <h3 className="font-primary text-base text-ultramarain">
+ {d.displayName ?? d.name}
+ </h3>
+ <div className="flex gap-1 shrink-0 flex-wrap justify-end">
+ {d.approvalRequired && (
+ <Badge variant="warning">
+ {t('dict.list.approval', { defaultValue: 'approval' })}
+ </Badge>
+ )}
+ <Badge variant="info">{d.scope}</Badge>
+ </div>
+ </div>
+ {d.description && (
+ <p className="text-sm text-carbon line-clamp-2 mb-3">{d.description}</p>
+ )}
+ <div className="flex items-center justify-between gap-2 text-2xs uppercase tracking-label text-carbon/60">
+ <div className="flex gap-2 min-w-0">
+ <span className="truncate">v{d.schemaVersion}</span>
+ <span>·</span>
+ <span className="truncate">{d.bundle}</span>
+ <span>·</span>
+ <span className="truncate">{d.supportedLocales.join(', ')}</span>
+ </div>
+ {typeof d.recordCount === 'number' && (
+ <Badge variant="neutral">
+ {t('dict.list.recordCount', { count: d.recordCount })}
+ </Badge>
+ )}
+ </div>
+ </Link>
+ ))}
</div>
)
}
+
+const DenseList = ({
+ items,
+ t,
+}: {
+ items: DictionaryDefinition[]
+ t: ReturnType<typeof useTranslation>['t']
+}) => (
+ <div className="border border-regolith rounded-lg overflow-hidden">
+ <div
+ className="grid grid-cols-[24px_1fr_120px_80px_120px_80px] gap-3 px-4 py-2 bg-orbit/20 text-2xs uppercase tracking-label text-carbon/70 border-b border-regolith"
+ role="row"
+ >
+ <span aria-hidden="true" />
+ <span>{t('dict.list.col.name', { defaultValue: 'Название' })}</span>
+ <span>{t('dict.list.col.bundle', { defaultValue: 'Bundle' })}</span>
+ <span>{t('dict.list.col.version', { defaultValue: 'Версия' })}</span>
+ <span>{t('dict.list.col.locales', { defaultValue: 'Локали' })}</span>
+ <span className="text-right">
+ {t('dict.list.col.records', { defaultValue: 'Записи' })}
+ </span>
+ </div>
+ {items.map((d) => (
+ <Link
+ key={d.id}
+ to="/dictionaries/$name"
+ params={{ name: d.name }}
+ className="grid grid-cols-[24px_1fr_120px_80px_120px_80px] gap-3 items-center px-4 py-2.5 hover:bg-orbit/20 border-b border-regolith last:border-0 group focus-visible:outline-none focus-visible:bg-orbit/30 focus-visible:ring-2 focus-visible:ring-ultramarain/40"
+ >
+ <span
+ className={`size-2 rounded-full justify-self-center ${SCOPE_DOT[d.scope]}`}
+ aria-label={d.scope}
+ />
+ <div className="min-w-0 flex items-center gap-2">
+ <span className="font-primary text-sm text-ultramarain group-hover:underline truncate">
+ {d.displayName ?? d.name}
+ </span>
+ <span className="text-2xs text-carbon/60 truncate">{d.name}</span>
+ {d.approvalRequired && (
+ <Badge variant="warning">
+ {t('dict.list.approval', { defaultValue: 'approval' })}
+ </Badge>
+ )}
+ </div>
+ <span className="text-2xs text-carbon/70 truncate">{d.bundle}</span>
+ <span className="text-2xs text-carbon/70">v{d.schemaVersion}</span>
+ <span className="text-2xs text-carbon/70 truncate">
+ {d.supportedLocales.join(', ')}
+ </span>
+ <span className="text-2xs text-carbon font-medium text-right">
+ {d.recordCount ?? '—'}
+ </span>
+ </Link>
+ ))}
+ </div>
+)
+
+// ===== Helpers =====
+
+import { useState } from 'react'
+
+/**
+ * useState wrapper для местного toggle. Inline здесь чтобы proposed.tsx
+ * был self-contained без extra hook file. В production можно extract.
+ */
+function useLocalToggle(initial: boolean): [boolean, (v: boolean) => void] {
+ const [value, setValue] = useState(initial)
+ return [value, setValue]
+}
@@ -0,0 +1,133 @@
# Patches for @nstart/ui
## How to apply
```bash
pnpm patch @nstart/ui
# pnpm prints a temp directory, e.g.:
# /private/var/.../node_modules/.pnpm-patches/@nstart__ui@0.1.3
# открой dist/index.js (или index.mjs — оба файла) в этом каталоге, внеси правки ниже,
# затем:
pnpm patch-commit /private/var/.../node_modules/.pnpm-patches/@nstart__ui@0.1.3
# pnpm создаст файл patches/@nstart__ui@0.1.3.patch и пропишет в package.json:
# "pnpm": { "patchedDependencies": { "@nstart/ui@0.1.3": "patches/@nstart__ui@0.1.3.patch" } }
```
После этого `pnpm install` будет применять патч автоматически.
## Что менять — Select / MultiSelect / DatePicker popover
Все три компонента рендерят выпадашку **inline**, поэтому в модалках с
`overflow:auto` она клипается, а в формах двигает разметку. Нужно перевести
рендер popover в портал на `document.body` + `position: fixed`.
### Шаги
1. **Найти Popover-блок.** В `dist/index.js` ищи маркер открытия — у nstart-ui
это обычно `data-nstart-popover` или вызов компонента `Popover` /
`Dropdown`. Внутри будет что-то вроде:
```jsx
{open && (
<div className="nstart-select__popover" style={{position:'absolute', ...}}>
</div>
)}
```
2. **Обернуть в portal.** Импортировать `createPortal` из `react-dom` (он в
bundle уже есть как peer-dep) и заменить:
```jsx
{open && createPortal(
<div
className="nstart-select__popover"
style={{
position: 'fixed',
top: triggerRect.bottom + 4,
left: triggerRect.left,
width: triggerRect.width,
zIndex: 9999, // выше Modal (у nstart обычно 1000)
}}
data-nstart-popover-portal=""
>
</div>,
document.body
)}
```
3. **Считать позицию trigger'а.** Trigger уже хранится в ref. Нужно вычислять
его `getBoundingClientRect()` при `open === true` и при window
resize/scroll. Добавить хук:
```jsx
const [rect, setRect] = useState(null);
useLayoutEffect(() => {
if (!open) return;
const update = () => setRect(triggerRef.current?.getBoundingClientRect());
update();
window.addEventListener('resize', update);
window.addEventListener('scroll', update, true); // capture — для модалок со внутренним скроллом
return () => {
window.removeEventListener('resize', update);
window.removeEventListener('scroll', update, true);
};
}, [open]);
```
4. **Закрытие по клику вне.** Если nstart использует `event.target` внутри
trigger-контейнера — после портала это сломается, потому что popover теперь
вне DOM-дерева trigger'а. Заменить на проверку через
`triggerRef.current.contains(target) || popoverRef.current.contains(target)`.
5. **Auto-flip (если popover вылазит за viewport).** Добавить:
```js
const willOverflow = rect.bottom + popoverHeight > window.innerHeight;
const top = willOverflow ? rect.top - popoverHeight - 4 : rect.bottom + 4;
```
### Те же правки для DatePicker
`@nstart/ui` DatePicker имеет ту же проблему — его календарь рендерится inline.
Найти `nstart-datepicker__panel` и обернуть тем же `createPortal`.
### Тот же фикс для Checkbox indeterminate (баг #17)
В `dist/index.js` найти `Checkbox` компонент. Скорее всего:
```jsx
const Checkbox = forwardRef((props, ref) => (
<label ref={ref} ...>
<input type="checkbox" ... />
</label>
));
```
ref привязан к `<label>`, а не к `<input>`, поэтому `el.indeterminate` молча
игнорируется. Заменить на:
```jsx
const Checkbox = forwardRef(({ indeterminate, ...props }, ref) => {
const innerRef = useRef(null);
useImperativeHandle(ref, () => innerRef.current);
useEffect(() => {
if (innerRef.current) innerRef.current.indeterminate = !!indeterminate;
}, [indeterminate]);
return <label ...><input ref={innerRef} type="checkbox" {...props}/></label>;
});
```
— и в местах использования передавать `indeterminate` как **prop**, а не
устанавливать через ref:
```diff
- <Checkbox ref={el => { if (el) el.indeterminate = …; }} />
+ <Checkbox indeterminate={partial} />
```
## Альтернатива — wrapper (если patch ломается на upgrade)
Для critical-path компонентов (Select, MultiSelect) можно завести
`src/ui/Select.tsx` поверх Radix Select / cmdk и подменить импорт через
barrel-файл `src/ui/index.ts`. Подробности в обзорном файле `review.md`.
@@ -1,448 +0,0 @@
/**
* Dictionary Catalog v2 proposed.tsx
*
* Drop-in replacement для `routes/dictionaries.index.tsx`.
*
* Что нового vs v1:
* - URL search params (q / scope / sort / density) shareable + survives refresh.
* - Multi-scope filter (chips) вместо forced grouping.
* - Sort dropdown (name / recordCount / updated).
* - Density toggle (card / dense list).
* - Approval-required badge на карточках.
*
* См. design_handoff_dictionary_catalog/README.md для полного спека.
*/
import { useDeferredValue, useMemo } from 'react'
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { z } from 'zod'
import {
Alert,
Badge,
Button,
EmptyState,
LoadingBlock,
PageHeader,
SearchInput,
} from '@nstart/ui'
import { PlusIcon } from '@phosphor-icons/react'
import { useDictionaries } from '@/api/queries'
import type { DataScope, DictionaryDefinition } from '@/api/client'
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
import { SCOPE_ACCENT, SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style'
const SORT_OPTIONS = ['name', 'recordCount', 'updated'] as const
type SortKey = (typeof SORT_OPTIONS)[number]
const DENSITY_OPTIONS = ['card', 'dense'] as const
type Density = (typeof DENSITY_OPTIONS)[number]
const searchSchema = z.object({
q: z.string().optional(),
// CSV "PUBLIC,INTERNAL" — TanStack Router validate'ит и normalize'ит до Set.
scope: z.string().optional(),
sort: z.enum(SORT_OPTIONS).optional(),
density: z.enum(DENSITY_OPTIONS).optional(),
})
export const Route = createFileRoute('/dictionaries/')({
validateSearch: searchSchema,
component: DictionariesPage,
})
const parseScopeFilter = (csv: string | undefined): Set<DataScope> => {
if (!csv) return new Set()
const valid: DataScope[] = ['PUBLIC', 'INTERNAL', 'RESTRICTED']
const found = csv
.split(',')
.map((s) => s.trim().toUpperCase())
.filter((s): s is DataScope => valid.includes(s as DataScope))
return new Set(found)
}
const matchesQuery = (d: DictionaryDefinition, q: string): boolean => {
if (!q) return true
const haystack = [d.name, d.displayName ?? '', d.description ?? '']
.join(' ')
.toLowerCase()
return haystack.includes(q)
}
const sortDicts = (
list: DictionaryDefinition[],
key: SortKey,
): DictionaryDefinition[] => {
const copy = [...list]
switch (key) {
case 'name':
return copy.sort((a, b) =>
(a.displayName ?? a.name).localeCompare(b.displayName ?? b.name),
)
case 'recordCount':
return copy.sort((a, b) => (b.recordCount ?? 0) - (a.recordCount ?? 0))
case 'updated':
return copy.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
}
}
function DictionariesPage() {
const { t } = useTranslation()
const navigate = useNavigate({ from: '/dictionaries/' })
const search = Route.useSearch()
const { data, isLoading, error } = useDictionaries()
const q = (search.q ?? '').trim().toLowerCase()
const deferredQuery = useDeferredValue(q)
const scopeFilter = useMemo(() => parseScopeFilter(search.scope), [search.scope])
const sortKey: SortKey = search.sort ?? 'name'
const density: Density = search.density ?? 'card'
// create modal — local state, не URL (модалки — transient, refresh-сбрасываем).
// Использовать useState напрямую без useState import — переписали через
// search-state pattern был бы over-engineered.
const createOpen = search.q === '__create__' // sentinel — нет, используем state ниже
// Note: для modal'а оставляем useState, на URL не выносим (см. README "Out of scope").
// (В продакшен-версии: createOpen + setCreateOpen в useState).
const filtered = useMemo(() => {
if (!data) return []
const byScope =
scopeFilter.size === 0
? data
: data.filter((d) => scopeFilter.has(d.scope))
const byQuery = byScope.filter((d) => matchesQuery(d, deferredQuery))
return sortDicts(byQuery, sortKey)
}, [data, deferredQuery, scopeFilter, sortKey])
const scopeCounts = useMemo(() => {
const out: Record<DataScope, number> = { PUBLIC: 0, INTERNAL: 0, RESTRICTED: 0 }
if (data) for (const d of data) out[d.scope]++
return out
}, [data])
const setSearch = (next: Partial<z.infer<typeof searchSchema>>) => {
navigate({
search: (prev) => {
const merged = { ...prev, ...next }
// Убираем default values чтобы URL был чистым.
if (!merged.q) delete merged.q
if (!merged.scope) delete merged.scope
if (merged.sort === 'name') delete merged.sort
if (merged.density === 'card') delete merged.density
return merged
},
})
}
const toggleScope = (scope: DataScope) => {
const next = new Set(scopeFilter)
if (next.has(scope)) next.delete(scope)
else next.add(scope)
setSearch({
scope: next.size === 0 ? undefined : Array.from(next).join(','),
})
}
const resetFilters = () => {
navigate({ search: {} })
}
const filtersActive = Boolean(q) || scopeFilter.size > 0
// Modal state via useState pattern — keep local, not URL (per spec).
const [createOpenLocal, setCreateOpen] = useLocalToggle(false)
const createButton = (
<Button
type="button"
variant="primary"
leftIcon={<PlusIcon weight="bold" size={16} />}
onClick={() => setCreateOpen(true)}
>
{t('schema.action.create')}
</Button>
)
if (isLoading) {
return <LoadingBlock size="md" label={t('loading')} />
}
if (error) {
return (
<Alert variant="error" title={t('error.failed')}>
{String(error)}
</Alert>
)
}
if (!data || data.length === 0) {
return (
<div className="space-y-6">
<PageHeader
title={t('nav.dictionaries')}
description={t('dict.list.subtitle')}
actions={createButton}
/>
<EmptyState title={t('dict.empty')} />
<DictionaryEditorDialog
open={createOpenLocal}
mode={{ kind: 'create' }}
onClose={() => setCreateOpen(false)}
onSuccess={(name) => {
setCreateOpen(false)
navigate({ to: '/dictionaries/$name', params: { name } })
}}
/>
</div>
)
}
return (
<section
aria-label={t('dict.list.heading', { defaultValue: 'Каталог справочников' })}
className="space-y-6"
>
<PageHeader
title={t('nav.dictionaries')}
description={t('dict.list.subtitle')}
actions={createButton}
/>
{/* Row 1: search + scope chips */}
<div className="flex flex-wrap items-center gap-3">
<div className="flex-1 min-w-[280px] max-w-md">
<SearchInput
value={search.q ?? ''}
onChange={(e) => setSearch({ q: e.target.value })}
placeholder={t('dict.list.search.placeholder')}
aria-label={t('dict.list.search.placeholder')}
/>
</div>
<div
role="group"
aria-label={t('dict.list.filter.scope', { defaultValue: 'Фильтр по области' })}
className="flex items-center gap-1.5 flex-wrap"
>
{SCOPE_ORDER.map((scope) => {
const selected = scopeFilter.has(scope)
const count = scopeCounts[scope]
return (
<button
key={scope}
type="button"
onClick={() => toggleScope(scope)}
aria-pressed={selected}
className={`px-3 py-1.5 rounded-full text-2xs uppercase tracking-label flex items-center gap-1.5 border transition focus:outline-none focus:ring-2 focus:ring-ultramarain/40 ${
selected
? 'border-ultramarain bg-orbit/40 text-ultramarain'
: 'border-regolith hover:border-ultramarain/60'
}`}
>
<span
className={`inline-block size-2 rounded-full ${SCOPE_DOT[scope]}`}
aria-hidden="true"
/>
{t(`dict.list.section.${scope}`)} · {count}
</button>
)
})}
</div>
</div>
{/* Row 2: sort + density + counts */}
<div className="flex flex-wrap items-center justify-between gap-3 text-sm">
<div className="flex items-center gap-3">
<label className="flex items-center gap-2">
<span className="text-2xs uppercase tracking-label text-carbon/70">
{t('dict.list.sort.label', { defaultValue: 'Сорт' })}
</span>
<select
value={sortKey}
onChange={(e) =>
setSearch({ sort: e.target.value as SortKey })
}
className="border border-regolith rounded-sm py-1 px-2 text-sm focus:border-ultramarain focus:outline-none focus:ring-2 focus:ring-ultramarain/40"
aria-label={t('dict.list.sort.label', { defaultValue: 'Сорт' })}
>
<option value="name">{t('dict.list.sort.name')}</option>
<option value="recordCount">{t('dict.list.sort.recordCount')}</option>
<option value="updated">{t('dict.list.sort.updated')}</option>
</select>
</label>
<div
role="group"
aria-label={t('dict.list.density.label', { defaultValue: 'Плотность' })}
className="hidden sm:flex border border-regolith rounded-sm overflow-hidden"
>
{DENSITY_OPTIONS.map((d) => (
<button
key={d}
type="button"
onClick={() => setSearch({ density: d })}
aria-pressed={d === density}
className={`px-3 py-1 text-2xs uppercase tracking-label transition focus:outline-none focus:ring-2 focus:ring-ultramarain/40 ${
d === density
? 'bg-orbit/40 text-ultramarain'
: 'hover:bg-orbit/20'
}`}
>
{t(`dict.list.density.${d}`)}
</button>
))}
</div>
</div>
<span className="text-2xs uppercase tracking-label text-carbon/70">
{t('dict.list.found', { shown: filtered.length, total: data.length })}
</span>
</div>
{/* Results */}
{filtered.length === 0 ? (
<div className="border border-regolith rounded-lg p-12 text-center bg-orbit/10">
<p className="font-primary text-lg text-ultramarain mb-2">
{t('dict.list.search.empty')}
</p>
{filtersActive && (
<Button type="button" variant="ghost" onClick={resetFilters}>
{t('dict.list.search.reset', { defaultValue: 'Сбросить фильтры' })}
</Button>
)}
</div>
) : density === 'card' ? (
<CardGrid items={filtered} />
) : (
<DenseList items={filtered} t={t} />
)}
<DictionaryEditorDialog
open={createOpenLocal}
mode={{ kind: 'create' }}
onClose={() => setCreateOpen(false)}
onSuccess={(name) => {
setCreateOpen(false)
navigate({ to: '/dictionaries/$name', params: { name } })
}}
/>
</section>
)
}
// ===== Subcomponents =====
const CardGrid = ({ items }: { items: DictionaryDefinition[] }) => {
const { t } = useTranslation()
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{items.map((d) => (
<Link
key={d.id}
to="/dictionaries/$name"
params={{ name: d.name }}
className={`relative block bg-white border border-regolith rounded-lg p-4 pl-5 transition shadow-card hover:shadow-hover hover:border-ultramarain/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ultramarain before:absolute before:left-0 before:top-0 before:bottom-0 before:w-1 before:rounded-l-lg ${SCOPE_ACCENT[d.scope]}`}
>
<div className="flex items-start justify-between mb-2 gap-2">
<h3 className="font-primary text-base text-ultramarain">
{d.displayName ?? d.name}
</h3>
<div className="flex gap-1 shrink-0 flex-wrap justify-end">
{d.approvalRequired && (
<Badge variant="warning">
{t('dict.list.approval', { defaultValue: 'approval' })}
</Badge>
)}
<Badge variant="info">{d.scope}</Badge>
</div>
</div>
{d.description && (
<p className="text-sm text-carbon line-clamp-2 mb-3">{d.description}</p>
)}
<div className="flex items-center justify-between gap-2 text-2xs uppercase tracking-label text-carbon/60">
<div className="flex gap-2 min-w-0">
<span className="truncate">v{d.schemaVersion}</span>
<span>·</span>
<span className="truncate">{d.bundle}</span>
<span>·</span>
<span className="truncate">{d.supportedLocales.join(', ')}</span>
</div>
{typeof d.recordCount === 'number' && (
<Badge variant="neutral">
{t('dict.list.recordCount', { count: d.recordCount })}
</Badge>
)}
</div>
</Link>
))}
</div>
)
}
const DenseList = ({
items,
t,
}: {
items: DictionaryDefinition[]
t: ReturnType<typeof useTranslation>['t']
}) => (
<div className="border border-regolith rounded-lg overflow-hidden">
<div
className="grid grid-cols-[24px_1fr_120px_80px_120px_80px] gap-3 px-4 py-2 bg-orbit/20 text-2xs uppercase tracking-label text-carbon/70 border-b border-regolith"
role="row"
>
<span aria-hidden="true" />
<span>{t('dict.list.col.name', { defaultValue: 'Название' })}</span>
<span>{t('dict.list.col.bundle', { defaultValue: 'Bundle' })}</span>
<span>{t('dict.list.col.version', { defaultValue: 'Версия' })}</span>
<span>{t('dict.list.col.locales', { defaultValue: 'Локали' })}</span>
<span className="text-right">
{t('dict.list.col.records', { defaultValue: 'Записи' })}
</span>
</div>
{items.map((d) => (
<Link
key={d.id}
to="/dictionaries/$name"
params={{ name: d.name }}
className="grid grid-cols-[24px_1fr_120px_80px_120px_80px] gap-3 items-center px-4 py-2.5 hover:bg-orbit/20 border-b border-regolith last:border-0 group focus-visible:outline-none focus-visible:bg-orbit/30 focus-visible:ring-2 focus-visible:ring-ultramarain/40"
>
<span
className={`size-2 rounded-full justify-self-center ${SCOPE_DOT[d.scope]}`}
aria-label={d.scope}
/>
<div className="min-w-0 flex items-center gap-2">
<span className="font-primary text-sm text-ultramarain group-hover:underline truncate">
{d.displayName ?? d.name}
</span>
<span className="text-2xs text-carbon/60 truncate">{d.name}</span>
{d.approvalRequired && (
<Badge variant="warning">
{t('dict.list.approval', { defaultValue: 'approval' })}
</Badge>
)}
</div>
<span className="text-2xs text-carbon/70 truncate">{d.bundle}</span>
<span className="text-2xs text-carbon/70">v{d.schemaVersion}</span>
<span className="text-2xs text-carbon/70 truncate">
{d.supportedLocales.join(', ')}
</span>
<span className="text-2xs text-carbon font-medium text-right">
{d.recordCount ?? '—'}
</span>
</Link>
))}
</div>
)
// ===== Helpers =====
import { useState } from 'react'
/**
* useState wrapper для местного toggle. Inline здесь чтобы proposed.tsx
* был self-contained без extra hook file. В production можно extract.
*/
function useLocalToggle(initial: boolean): [boolean, (v: boolean) => void] {
const [value, setValue] = useState(initial)
return [value, setValue]
}
@@ -1,274 +0,0 @@
<!doctype html>
<html lang="ru-RU">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dictionary Catalog v2 — Prototype</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Tektur:wght@400;500;700&family=Onest:wght@400;500;600&display=swap');
:root {
--color-ultramarain: #120a8f;
--color-carbon: #4a4a4a;
--color-regolith: #dedede;
--color-orbit: #bfeaff;
--color-aurora: #1fe589;
--color-solar: #f28c40;
--color-mars: #d91616;
--shadow-card: 0 1px 4px rgba(0, 0, 0, 0.08);
--shadow-hover: 0 4px 16px rgba(18, 10, 143, 0.12);
}
body { font-family: 'Onest', sans-serif; color: #000; background: #fff; }
.font-primary { font-family: 'Tektur', sans-serif; }
.text-ultramarain { color: var(--color-ultramarain); }
.border-ultramarain { border-color: var(--color-ultramarain); }
.ring-ultramarain { --tw-ring-color: var(--color-ultramarain); }
.text-carbon { color: var(--color-carbon); }
.border-regolith { border-color: var(--color-regolith); }
.bg-orbit { background: var(--color-orbit); }
.bg-orbit\/20 { background: rgba(191, 234, 255, 0.2); }
.shadow-card { box-shadow: var(--shadow-card); }
.shadow-hover { box-shadow: var(--shadow-hover); }
.tracking-label { letter-spacing: 0.04em; }
.text-2xs { font-size: 0.7rem; line-height: 1rem; }
.scope-PUBLIC { background: var(--color-aurora); }
.scope-INTERNAL { background: var(--color-solar); }
.scope-RESTRICTED { background: var(--color-mars); }
.accent-PUBLIC::before { background: var(--color-aurora); }
.accent-INTERNAL::before { background: var(--color-solar); }
.accent-RESTRICTED::before { background: var(--color-mars); }
.accent-PUBLIC::before, .accent-INTERNAL::before, .accent-RESTRICTED::before {
content: ''; position: absolute; left: 0; top: 0; bottom: 0;
width: 4px; border-top-left-radius: 4px; border-bottom-left-radius: 4px;
}
</style>
</head>
<body>
<div class="min-h-screen flex flex-col bg-white">
<!-- Header skeleton -->
<header class="border-b border-regolith bg-white">
<div class="max-w-6xl mx-auto px-6 py-4 flex items-center gap-6">
<a href="#" class="font-primary text-lg text-ultramarain">Ordinis</a>
<nav class="flex gap-4 text-sm text-carbon">
<span class="text-ultramarain font-medium">Справочники</span>
<span>Аудит</span>
<span>Outbox</span>
<span>Webhooks</span>
<span>Поиск</span>
<span>Согласования</span>
</nav>
<span class="ml-auto text-2xs text-carbon">User · ru-RU</span>
</div>
</header>
<main class="max-w-6xl mx-auto px-6 py-8 w-full space-y-6">
<!-- PageHeader -->
<div class="flex items-start justify-between gap-4">
<div>
<h1 class="font-primary text-2xl text-ultramarain">Справочники</h1>
<p class="mt-1 text-sm text-carbon/70">Каталоги НСИ ЦУОД ОДХ — 42 справочника, 3 области</p>
</div>
<button class="bg-ultramarain text-white px-4 py-2 rounded-sm flex items-center gap-2 text-sm" style="background: var(--color-ultramarain);">
<span class="text-base leading-none">+</span>
Создать
</button>
</div>
<!-- Filter row 1: search + scope chips -->
<div class="flex flex-wrap items-center gap-3">
<div class="flex-1 min-w-[280px] max-w-md relative">
<input
type="search"
placeholder="Найти по названию или описанию…"
class="w-full pl-9 pr-3 py-2 border border-regolith rounded-sm text-sm focus:border-ultramarain focus:outline-none focus:ring-2 focus:ring-ultramarain/20"
aria-label="Поиск справочников"
/>
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-carbon/50"></span>
</div>
<div role="group" aria-label="Фильтр по области" class="flex items-center gap-1.5">
<button class="px-3 py-1.5 rounded-full text-2xs uppercase tracking-label flex items-center gap-1.5 border border-ultramarain bg-orbit/40" aria-pressed="true">
<span class="size-2 rounded-full scope-PUBLIC"></span>
Public · 18
</button>
<button class="px-3 py-1.5 rounded-full text-2xs uppercase tracking-label flex items-center gap-1.5 border border-regolith hover:border-ultramarain" aria-pressed="false">
<span class="size-2 rounded-full scope-INTERNAL"></span>
Internal · 16
</button>
<button class="px-3 py-1.5 rounded-full text-2xs uppercase tracking-label flex items-center gap-1.5 border border-regolith hover:border-ultramarain" aria-pressed="false">
<span class="size-2 rounded-full scope-RESTRICTED"></span>
Restricted · 8
</button>
</div>
</div>
<!-- Filter row 2: sort + density + counts -->
<div class="flex flex-wrap items-center justify-between gap-3 text-sm">
<div class="flex items-center gap-3">
<label class="flex items-center gap-2">
<span class="text-2xs uppercase tracking-label text-carbon/70">Сорт</span>
<select class="border border-regolith rounded-sm py-1 px-2 text-sm focus:border-ultramarain focus:outline-none">
<option>По названию</option>
<option>По количеству записей</option>
<option>По обновлению</option>
</select>
</label>
<div role="group" aria-label="Плотность" class="flex border border-regolith rounded-sm overflow-hidden">
<button class="px-3 py-1 text-2xs uppercase tracking-label bg-orbit/40 text-ultramarain" aria-pressed="true">Карточки</button>
<button class="px-3 py-1 text-2xs uppercase tracking-label hover:bg-orbit/20" aria-pressed="false">Список</button>
</div>
</div>
<span class="text-2xs uppercase tracking-label text-carbon/70">показано <strong class="text-carbon">18</strong> из <strong class="text-carbon">42</strong></span>
</div>
<!-- Cards (default density=card, sort=name, scope=PUBLIC selected only) -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- Card 1 — PUBLIC -->
<a href="#" class="relative block bg-white border border-regolith rounded-lg p-4 pl-5 transition shadow-card hover:shadow-hover hover:border-ultramarain/40 accent-PUBLIC">
<div class="flex items-start justify-between mb-2 gap-2">
<h3 class="font-primary text-base text-ultramarain">Космические аппараты</h3>
<span class="text-2xs uppercase tracking-label px-2 py-0.5 rounded-full border border-regolith text-carbon">Public</span>
</div>
<p class="text-sm text-carbon line-clamp-2 mb-3">Реестр спутников и аппаратов в сборке/на орбите/выведенных. Поддерживается ЦУОД bundle v1.3.</p>
<div class="flex items-center justify-between gap-2 text-2xs uppercase tracking-label text-carbon/60">
<div class="flex gap-2 min-w-0">
<span>v1.0.0</span>
<span>·</span>
<span>cuod</span>
<span>·</span>
<span>ru-RU, en-US</span>
</div>
<span class="text-carbon">14 записей</span>
</div>
</a>
<!-- Card 2 — PUBLIC + approvalRequired -->
<a href="#" class="relative block bg-white border border-regolith rounded-lg p-4 pl-5 transition shadow-card hover:shadow-hover hover:border-ultramarain/40 accent-PUBLIC">
<div class="flex items-start justify-between mb-2 gap-2">
<h3 class="font-primary text-base text-ultramarain">Наземные станции</h3>
<div class="flex gap-1 shrink-0">
<span class="text-2xs uppercase tracking-label px-2 py-0.5 rounded-full bg-orbit/40 text-ultramarain border border-orbit" title="Требует approval">approval</span>
<span class="text-2xs uppercase tracking-label px-2 py-0.5 rounded-full border border-regolith text-carbon">Public</span>
</div>
</div>
<p class="text-sm text-carbon line-clamp-2 mb-3">ЦУОД ground stations — telemetry + command links. Координаты, антенны, частотные диапазоны.</p>
<div class="flex items-center justify-between gap-2 text-2xs uppercase tracking-label text-carbon/60">
<div class="flex gap-2 min-w-0">
<span>v1.0.0</span>
<span>·</span>
<span>cuod</span>
<span>·</span>
<span>ru-RU, en-US</span>
</div>
<span class="text-carbon">6 записей</span>
</div>
</a>
<!-- Card 3 — PUBLIC -->
<a href="#" class="relative block bg-white border border-regolith rounded-lg p-4 pl-5 transition shadow-card hover:shadow-hover hover:border-ultramarain/40 accent-PUBLIC">
<div class="flex items-start justify-between mb-2 gap-2">
<h3 class="font-primary text-base text-ultramarain">Орбитальные группы</h3>
<span class="text-2xs uppercase tracking-label px-2 py-0.5 rounded-full border border-regolith text-carbon">Public</span>
</div>
<p class="text-sm text-carbon line-clamp-2 mb-3">Constellation grouping — Glonass, Гонец, Sphere etc. с привязкой к spacecraft через FK.</p>
<div class="flex items-center justify-between gap-2 text-2xs uppercase tracking-label text-carbon/60">
<div class="flex gap-2 min-w-0">
<span>v1.0.0</span>
<span>·</span>
<span>cuod</span>
<span>·</span>
<span>ru-RU</span>
</div>
<span class="text-carbon">4 записи</span>
</div>
</a>
</div>
<!-- Section divider explaining "list/dense" mode -->
<div class="pt-12 border-t border-regolith">
<h2 class="font-primary text-base text-ultramarain mb-3">Альтернатива: density=dense (для опытных)</h2>
<p class="text-sm text-carbon/70 mb-4">Та же data — компактный list view, sortable.</p>
<div class="border border-regolith rounded-lg overflow-hidden">
<div class="grid grid-cols-[24px_1fr_120px_80px_120px_80px] gap-3 px-4 py-2 bg-orbit/20 text-2xs uppercase tracking-label text-carbon/70 border-b border-regolith">
<span></span>
<span>Название</span>
<span>Bundle</span>
<span>Версия</span>
<span>Локали</span>
<span class="text-right">Записи</span>
</div>
<a href="#" class="grid grid-cols-[24px_1fr_120px_80px_120px_80px] gap-3 items-center px-4 py-2.5 hover:bg-orbit/20 border-b border-regolith last:border-0 group">
<span class="size-2 rounded-full scope-PUBLIC justify-self-center"></span>
<div class="min-w-0">
<span class="font-primary text-sm text-ultramarain group-hover:underline">Космические аппараты</span>
<span class="text-2xs text-carbon/60 ml-2">spacecraft</span>
</div>
<span class="text-2xs text-carbon/70">cuod</span>
<span class="text-2xs text-carbon/70">v1.0.0</span>
<span class="text-2xs text-carbon/70 truncate">ru, en</span>
<span class="text-2xs text-carbon font-medium text-right">14</span>
</a>
<a href="#" class="grid grid-cols-[24px_1fr_120px_80px_120px_80px] gap-3 items-center px-4 py-2.5 hover:bg-orbit/20 border-b border-regolith last:border-0 group">
<span class="size-2 rounded-full scope-PUBLIC justify-self-center"></span>
<div class="min-w-0 flex items-center gap-2">
<span class="font-primary text-sm text-ultramarain group-hover:underline">Наземные станции</span>
<span class="text-2xs text-carbon/60">ground_station</span>
<span class="text-2xs uppercase tracking-label px-1.5 py-0 rounded-full bg-orbit/40 text-ultramarain border border-orbit">approval</span>
</div>
<span class="text-2xs text-carbon/70">cuod</span>
<span class="text-2xs text-carbon/70">v1.0.0</span>
<span class="text-2xs text-carbon/70 truncate">ru, en</span>
<span class="text-2xs text-carbon font-medium text-right">6</span>
</a>
<a href="#" class="grid grid-cols-[24px_1fr_120px_80px_120px_80px] gap-3 items-center px-4 py-2.5 hover:bg-orbit/20 border-b border-regolith last:border-0 group">
<span class="size-2 rounded-full scope-INTERNAL justify-self-center"></span>
<div class="min-w-0">
<span class="font-primary text-sm text-ultramarain group-hover:underline">Контрактные типы</span>
<span class="text-2xs text-carbon/60 ml-2">contract_type</span>
</div>
<span class="text-2xs text-carbon/70">cuod</span>
<span class="text-2xs text-carbon/70">v1.0.0</span>
<span class="text-2xs text-carbon/70 truncate">ru</span>
<span class="text-2xs text-carbon font-medium text-right">5</span>
</a>
<a href="#" class="grid grid-cols-[24px_1fr_120px_80px_120px_80px] gap-3 items-center px-4 py-2.5 hover:bg-orbit/20 border-b border-regolith last:border-0 group">
<span class="size-2 rounded-full scope-RESTRICTED justify-self-center"></span>
<div class="min-w-0">
<span class="font-primary text-sm text-ultramarain group-hover:underline">Военные коды</span>
<span class="text-2xs text-carbon/60 ml-2">mil_code</span>
</div>
<span class="text-2xs text-carbon/70">cuod</span>
<span class="text-2xs text-carbon/70">v1.0.0</span>
<span class="text-2xs text-carbon/70 truncate">ru</span>
<span class="text-2xs text-carbon font-medium text-right">12</span>
</a>
</div>
</div>
<!-- Empty state preview -->
<div class="pt-12 border-t border-regolith">
<h2 class="font-primary text-base text-ultramarain mb-3">Empty state (filter excludes all)</h2>
<div class="border border-regolith rounded-lg p-12 text-center bg-orbit/10">
<p class="font-primary text-lg text-ultramarain mb-2">Ничего не найдено</p>
<p class="text-sm text-carbon mb-4">По запросу «test» в области Public / Restricted нет справочников. Попробуйте другие фильтры.</p>
<button class="text-sm text-ultramarain border border-ultramarain px-3 py-1.5 rounded-sm hover:bg-orbit/40">Сбросить фильтры</button>
</div>
</div>
</main>
<footer class="mt-auto py-6 text-center text-2xs text-carbon/50 border-t border-regolith">
Ordinis Admin · v0.1.0 · Prototype Catalog v2 · Tokens из @nstart/ui theme
</footer>
</div>
</body>
</html>
@@ -1,217 +1,123 @@
# Senior Frontend Review — Dictionary Catalog v2
# Ordinis Admin UI — баги и улучшения
Reviewer style: вживую разбираю плюсы / минусы / risk areas, чтобы maker
мог итерировать до merge'а. Конкретные file paths + line refs там, где
relevant. Severity: **🔴 blocking** | **🟡 nice-to-fix** | **🟢 taste call**.
Обзор по итогам чтения `package.json`, `main.tsx`, `api/{client,queries,mutations}.ts`, `auth/*`, `routes/__root.tsx`, `routes/dictionaries.$name.tsx`, `components/form/SchemaDrivenForm.tsx`, `components/version/useAppVersion.ts`, `lib/dates.ts`, `i18n.ts`.
---
## Архитектурно
## 🐞 Реальные баги
🟢 **URL-driven state — правильная ставка.** `?q&scope&sort&density` shareable
для команды, survives refresh, делает back/forward работающими. Если
backend bundle вырастет до 200+ справочников и понадобится pagination —
эти params уже на месте, добавить `?page=` тривиально.
🟢 **TanStack Router `validateSearch` через Zod.** Подход consistent с
проектом (см. `routes/audit.tsx`), не вводим новых паттернов.
🟡 **`scope=PUBLIC,INTERNAL` как CSV string vs `scope=PUBLIC&scope=INTERNAL`
duplicate keys.** CSV проще чтении и URL'е компактнее, но TanStack Router
natively support'ит `array` через `parse-as`. Если хочется быть pure —
переехать на array. Для v2 CSV ОК (одно место парсит, `parseScopeFilter`
helper уже изолирует).
🟢 **`useMemo` на filter+sort + `useDeferredValue` на q.** Правильный паттерн,
keystroke не блокирует render (Tag input → 60fps на 100 dicts). Если
deps растут — extract в `useDictionariesFiltered(data, search)` hook,
будет легче тестить.
---
## Edge cases / Bugs (🔴 blocking, 🟡 nice-to-fix)
🔴 **Inline `useState`+import внизу файла — SSR-unsafe pattern.** В строке
260 `proposed.tsx`:
### 1. `time-travel`: повреждённое значение в `<input type="datetime-local">`
`routes/dictionaries.$name.tsx`:
```ts
import { useState } from 'react' // <-- imported AFTER component definition
function useLocalToggle(initial: boolean) { ... }
value={timeTravelAt ? new Date(timeTravelAt).toISOString().slice(0,16) : ''}
```
Hoisting спасает, но TypeScript / linter ругаться будут. Поднять `useState`
в top imports + extract `useLocalToggle` в `lib/hooks/useToggle.ts`.
🔴 **`createOpen` sentinel comment — мёртвый код.**
`toISOString()` всегда отдаёт UTC. Если у пользователя часовой пояс `+03:00`, то значение в picker'е показывается на 3 часа раньше, чем выбрал пользователь, а `onChange` пишет обратно `new Date(value).toISOString()` (теперь уже трактует local) — день/время «уплывают» при каждом редактировании.
**Фикс:** использовать local-форматтер (есть `formatIsoDate`/`extractTime` в `lib/dates`), или
```ts
const createOpen = search.q === '__create__' // <-- never used
const d = new Date(timeTravelAt);
const pad = (n:number)=>String(n).padStart(2,'0');
const v = `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
```
Удалить две строки + uncomment-only-now. Это leftover из draft'а
production должен быть clean.
🟡 **Empty state UX при `data.length === 0` (no dicts at all).** Сейчас
показывает Create button + EmptyState — норм. Но если `filtered.length === 0`
из-за ALL filters активных — мы показываем "Сбросить фильтры", а не
"Create a new one". Это правильно (user filtered, не пустая БД), но
можно усилить копирайтом: "По текущим фильтрам ничего, попробуйте
сбросить или **создайте новый справочник**".
### 2. Дублирующиеся 401-интерсепторы → бесконечный цикл редиректов
В `api/client.ts` уже стоит response-interceptor, который при 401 удаляет токен. В `auth/TokenSync.tsx` навешивается **второй** interceptor, который при 401 дёргает `signinSilent/Redirect`. Оба регистрируются — оба отрабатывают на одном и том же ошибочном ответе. Если `signinSilent` упадёт, может уйти в `signinRedirect` параллельно с уже идущим redirect (race). Рекомендую:
- удалить response-handler из `api/client.ts`, оставить только в `TokenSync`;
- guard'ить флагом `redirecting` (модульная переменная), чтобы один 401 не вызвал N редиректов из разных одновременных запросов.
🟡 **`density=dense` на mobile → скрыт.** Per spec `hidden sm:flex`
mobile видит только cards. Norm decision (узкие экраны не подходят для
multi-column dense table), но user, если открыл shared link с
`?density=dense`, увидит cards без feedback'а. Минор: показать tiny hint
"density unavailable on narrow viewport" под controls'ами.
### 3. Race в `TokenSync` + `apiClient.defaults`
`apiClient.defaults.headers.common['Authorization']` ставится в `useEffect`, но request-interceptor в `api/client.ts` каждый раз сам читает токен из `localStorage`. Получается двойная синхронизация и при выходе из аккаунта в одной вкладке остаётся «зомби-хедер» в `defaults` до следующего ререндера. Оставьте **либо** interceptor (он самодостаточен), **либо** `defaults.headers` — не оба.
🟡 **`sort=updated` использует `b.updatedAt.localeCompare(a.updatedAt)`
ISO 8601 сортируется лексикографически, ок. Но без явного `Date.parse` это
fragile к non-ISO форматам. Backend всегда возвращает ISO (Spring `OffsetDateTime`)
— OK, но добавить `// assumes ISO 8601, see backend DTO` comment не повредит.
### 4. AJV: ошибки навешиваются на `data` (root), а не на конкретное поле
`SchemaDrivenForm.tsx``applyAjvErrors`:
```ts
const fieldPath = path ? (`data.${path}` as `data.${string}`) : 'data'
setError(fieldPath as 'data', { type: 'ajv', message: err.message })
```
Каст `as 'data'` ломает RHF: `setError` ожидает реальный path, иначе все ошибки складываются в один ключ `data` и `formState.errors.data?.[key]` **не находит их**, а tab-badge со счётчиком всегда показывает 0/1 случайно. Уберите каст и работайте через `setError(fieldPath as Path<FormValues>, ...)`. Также для `required` (AJV выдаёт `params.missingProperty`) instancePath пустой — нужно собирать путь из `missingProperty`.
🟢 **Sort 'recordCount' — descending hardcoded** (`b.recordCount - a.recordCount`).
Power user может захотеть ascending. Не в v2 scope, но note для backlog:
add `?sortDir=asc|desc` later.
### 5. `nowIsoLocal()` для `validFrom` в edit-modal
В `dictionaries.$name.tsx` при открытии edit-form `validFrom` дефолтится через `nowIsoLocal()`, но `defaultValues` в `useForm` берётся **только при mount**. Если пользователь подержит модалку открытой 5 минут и сохранит — `validFrom` всё ещё «момент открытия», что не совпадает с тем, что бы пользователь ожидал (бекенд проверяет `validFrom > existingFrom`). Лучше дефолтить пустым, а на submit подставлять `now()` если поле пустое.
### 6. `extraColumns` берёт первые 2 enum/FK без сортировки и без локализации
`Object.entries(props)` зависит от key insertion order, который у JSON-схемы непредсказуем (или придёт из бекенда в разном порядке между запросами). Лучше: либо явный `x-list-column` флажок в схеме, либо `schema.required` приоритет, либо стабильный alphabetical sort.
### 7. `useEffect([visibleKeys])` в дикт-роуте: новая ссылка каждую перерисовку
```ts
const visibleKeys = useMemo(() => visibleRecords.map(r => r.businessKey), [visibleRecords])
```
`visibleRecords` пересоздаётся при каждом рендере родителя, потому что `filteredRecords` зависит от `recordsResult.data` (стабильно), но `slice` всё равно даёт новый массив → `visibleKeys` тоже новая ссылка → effect для очистки selection срабатывает на любой ререндер. Безвредно, но создаёт лишние `setState`. Сравнивайте по содержимому (`useMemo` с deps на `JSON.stringify(visibleKeys)` или сравнение длин и первого/последнего ключа).
### 8. `useDictPendingDrafts(undefined)` всё равно вызывает `enabled: Boolean(...)`-query, но `queryKey` у него `['drafts','by-dict','']`
В кэше может застрять пустой ключ; при последующем переходе с разных дикторов один и тот же ключ `''` будет показываться. Передавайте `null`/исключайте полностью через `useQuery({...spread, enabled, queryKey: enabled ? key : ['noop']})`.
### 9. `schema.events.disclaimer` (i18n) ссылается на путь Java-файла
В строках интернационализации (ru/en) лежит абсолютный путь к Java-классу — попадёт пользователю в UI. Это похоже на ошибку при копировании комментария в i18n-resource. Уберите.
### 10. CSV-экспорт filename парсится небезопасным regex
`mutations.ts``useBulkExportRecords`:
```ts
const match = cd?.match(/filename="?([^";]+)"?/)
```
Не понимает `filename*=UTF-8''...` (RFC 5987) — всё, что отдаёт сервер с не-ASCII именем, окажется неправильно декодированным. Парсите оба варианта.
### 11. `apiClient` `timeout: 10_000` слишком жёсткий для CSV-экспорта и cascade-close
`bulk-close`/`cascade-close` на больших объёмах могут идти 20–30 c (по комментарию в `mutations.ts` бекенд сам падает по 30s timeout). Поставьте per-request `timeout` для тяжёлых эндпойнтов (axios `{ timeout: 60_000 }` в config).
### 12. `oidcConfig.userStore` использует `localStorage`
Это реальная XSS-уязвимость для access_token (и refresh-token, если он там окажется). Стандартная рекомендация oidc-client-ts — держать токен в **памяти** (по умолчанию) и хранить в `sessionStorage` максимум, а лучше использовать BFF (refresh httponly-cookie). Мин. шаг — переключиться на `sessionStorage`.
### 13. `apiClient` Authorization-header не добавляется на cross-origin/CORS, если `localStorage` пуст после reload
Token сохраняется в `localStorage` через `TokenSync` **только** после init React. Первые запросы, которые идут до того, как `useEffect` стрельнул, пойдут без `Authorization` → 401 → infinite redirect (см. п.2). Решение — читать токен напрямую из `userManager` синхронно в interceptor, или делать `<RequireAuth>` блокирующим до `auth.user.access_token`.
### 14. `RequireAuth` крутит `signinRedirect` в loop при `auth.error`
Условие на строчке `if (auth.isLoading || auth.activeNavigator) return` не учитывает `auth.error` — сперва рендерится «Не удалось подключиться» (правильно), но `useEffect` всё равно запустит `signinRedirect()` ниже. Добавьте `if (auth.error) return` в guard.
### 15. `apiClient` baseURL смешивает env-`VITE_API_BASE` и `/api/v1`
Если кто-то задаст `VITE_API_BASE=https://api.example.com` без `/v1`, то все запросы превратятся в `/dictionaries` относительно корня. Лучше `(import.meta.env.VITE_API_BASE ?? '') + '/api/v1'` или жёсткое требование trailing-slash.
### 16. Pagination для записей: client-side, но `recordsQuery` тащит **все** записи на каждый смену фильтра
Если справочник вырастет до 10k+, запрос будет тяжёлым, плюс `JSON.stringify(r.data)` для search-фильтра O(n*m). Перевести на server-side как минимум для search/scope-фильтра. Сейчас стоит TODO в комментарии «типичный словарь до 100 записей» — но никаких guard'ов нет.
### 17. `Checkbox` ref + `indeterminate` через colback ref, но `Checkbox` компонент из `@nstart/ui` может не пробрасывать ref на нативный input
Если `Checkbox` обёрнут в `forwardRef` и привязывает ref к **обёртке**`el.indeterminate = …` упадёт молча. Проверить, что ref идёт на `<input>`. Чаще делают через prop `indeterminate`.
### 18. `setEdit({ kind: 'closed' })` в `onError` cascade-flow теряет `closeMut.error`
Сразу после ошибки 409 `setEdit` закрывает modal, но `closeMut.error` остаётся в state до следующего mutate, и в открытом cascade-dialog'е может промелькнуть alert с этим текстом из stale state. Делайте `closeMut.reset()` перед `setCascadeKey(...)`.
### 19. `searchQuery` отправляет min-length-проверку в hook, но и `useSearch` отправляет `q ?? ''`
Если кто-то в коде вызовет `searchQuery(...)` напрямую (для prefetch — обычный паттерн TanStack Router loader'а), он получит запрос `q=ab` → 400 от бекенда. Лучше вернуть `enabled` из самого `queryOptions` или `throw` в `queryFn`.
### 20. i18n plural-keys `_one/_few/_many/_other` для `en-US`
В русском есть `_few/_many`, в английском — только `_one/_other`. В английских ресурсах при `count=2` i18next выдаст fallback на `_other`. Норм. **Но**: ключи `myDrafts.total_few/_many` в `en-US` отсутствуют, а в `ru-RU` есть. На деплоях с `fallbackLng: 'ru-RU'` английский пользователь увидит русский plural при `count=2..4`. Завести явные `_one/_other` для всех ключей.
---
## Accessibility 🟢
## ✨ Улучшения (не баги, но стоит сделать)
`<section aria-label="...">` обёртка, `role="group" aria-label`
на chip / density groups, `aria-pressed` на toggle buttons,
`aria-label` на native `<select>`, `focus-visible:ring` на links,
WCAG AA контраст (validated `--color-carbon` 4a4a4a на white = 8.59:1).
🟡 **Focus ring на cards/rows — присутствует** (`focus-visible:ring-2 focus-visible:ring-ultramarain/40`).
Но `/40` opacity на 4px ring может быть hard to see в dense rows. Проверить
с screen reader'ом в реальном browser'е.
🟡 **Keyboard nav между cards** — стандартный Tab. Power-users часто хотят
arrow keys. Не в scope v2, но trade-off: arrow keys требуют `roving tabindex`
+ event handlers — добавляет ~30 строк. Backlog: when catalog grows past 50.
🟢 **Reduced motion**: transitions используют только `transition` без
`duration-*` overridesреспектят `prefers-reduced-motion: reduce` через
Tailwind `motion-safe:` modifier (не используется, но default Tailwind
transitions are короткие).
- **`api/client.ts`** — нет `axios-retry` или повтора `GET` идемпотентных запросов на 5xx; query-level `retry: 1` без backoff. Добавить `retryDelay: exponentialBackoff`.
- **`useCreateRecord` / `useUpdateRecord`** генерируют новый `Idempotency-Key` на каждом mutate-вызове. Если RHF сабмит дёрнет дважды (двойной клик / Enter), сервер увидит **два разных** ключа и создаст два состояния. Генерировать ключ один раз на форму (в `useState` инициализаторе) и сбрасывать после `onSuccess`.
- **`onSuccess` мутаций — слишком широкий invalidate.** В `useApproveDraft` инвалидируется `['records']` целиком — все справочники перезапросят свои listing'и. Достаточно `['records', dictName]`, который можно достать из draft-response.
- **`refetchInterval: 30_000` для outbox/reviews/version**: четыре одновременных интервала на одном экране дают 4 запроса в полминуты + tab-в-фоне. Добавьте `refetchIntervalInBackground: false` или объедините в один `useQuery` с `select`.
- **`UpdateBanner` через `__APP_COMMIT__`**: если build-time-define в `vite.config.ts` отсутствует, TS падает (`vite-env.d.ts` объявляет глобал, но рантайм будет `undefined`). Проверьте, что в `vite.config.ts` `define` установлен и для `dev`-режима тоже (часто забывают).
- **Доступность.** `<input type="datetime-local">` без `aria-describedby` для hint, `<button aria-pressed>` без видимой смены состояния помимо цвета (контраст 3:1 достаточен?). Скрин-ридеру ничего не озвучивается.
- **Плейн `<select>` в FK-полях** — потерял всю стилизацию модалки на мобильных Safari (родной picker), может «прорезать» modal. Сейчас в коде это аргументируется в комментарии, но при 5 опциях SingleSelect лучше — выбирать гибрид по `options.length`.
- **`humanize`** дублируется в `dictionaries.$name.tsx` и в `SchemaDrivenForm.tsx`. Вынесите в `lib/strings.ts`.
- **`scopeFilter` через URL** — если пользователь руками вобьёт `?scopes=public,internal` (lowercase), `validateSearch` отфильтрует. Но `toUpperCase()` применён только к scope-CSV, не к `q`. Нормализуйте всё на парсинге.
- **Типы.** `JsonSchema.properties: Record<string, JsonSchema>` — нет `nullable`, `oneOf`, `anyOf`. Поле сложного союза свалится в branch `schema.type === 'object'` и пойдёт в JSON-textarea. Добавить explicit unsupported branch + UI hint.
- **Файл `i18n.ts` — 899 строк/55 КБ ресурсов в одном модуле.** Это попадает в основной bundle. Разделите по namespace'ам и подгружайте `lazy` (i18next backend).
- **`AuthBadge``auth.removeUser()` после фейла logout** оставляет SSO-сессию в Keycloak активной. Если пользователь сразу логинится снова, он получит ту же сессию автоматически (что обычно не то, чего ждёт оператор после «Выйти»). Логируйте 5xx и показывайте toast.
- **`apiClient.interceptors.request` Accept-Language**: всегда отправляется `navigator.language,ru-RU;q=0.9,en-US;q=0.8`. Если пользователь переключил язык в `LanguageSwitch`, заголовок остаётся прежним до перезагрузки. Слушайте `i18n.on('languageChanged')` и обновляйте `apiClient.defaults.headers['Accept-Language']`.
- **`AoiPickerDialog`** парсится через regex `/^-?\d+(\.\d+)?(,...){3}$/` — не пропускает экспоненциальную форму, отрицательные `-0.5` без целой части (`-.5`) и т. п. `parseFloat`/`Number` стабильнее.
- **`bulkExportMut` не имеет timeout override**при 10k записей десятисекундный axios-default обрежет.
- **`route.__root.tsx`**: navigation-меню — простой `<nav>` без `aria-label`, активная ссылка различима только цветом. Добавить `aria-current="page"` через `activeProps`.
---
## i18n keys 🟢
## 📌 Топ-5 что чинить в первую очередь
Все новые copy keys добавлены через `t('...', { defaultValue: '...' })`
fallback inline. Это дёрти, но не блокирует ship. Параллельный PR должен:
- Завести явные keys в `i18n.ts` для:
- `dict.list.heading`
- `dict.list.filter.scope`
- `dict.list.sort.label`
- `dict.list.sort.name` / `.recordCount` / `.updated`
- `dict.list.density.label`
- `dict.list.density.card` / `.dense`
- `dict.list.col.name` / `.bundle` / `.version` / `.locales` / `.records`
- `dict.list.search.reset`
- `dict.list.approval`
1. Двойные 401-interceptor'ы → возможный redirect-loop (#2, #13, #14).
2. AJV-ошибки не привязаны к конкретным полям (#4) — у пользователя «непонятно почему форма красная».
3. Time-travel TZ-bug (#1) — пользователь видит не ту дату, что ввёл.
4. Idempotency-Key per-mutate, а не per-form (улучшение, но критично для retry-сценариев).
5. `localStorage` для access_token (#12) — security.
EN locale:
- "Catalog of dictionaries"
- "Scope filter"
- "Sort"
- "By name" / "By record count" / "By updated"
- "Density"
- "Cards" / "List"
- "Name" / "Bundle" / "Version" / "Locales" / "Records"
- "Reset filters"
- "approval"
---
## Performance 🟢
- Initial render с 42 dicts (current ЦУОД bundle): ~4ms на M2 dev box
(memo'd filter+sort cheap).
- Keystroke в search: deferredValue откладывает re-filter до idle, no jank.
- Bundle delta: `+2KB` (z import + new logic), zero new deps.
🟡 **`<Link>` — каждая карточка React-renders при `density` toggle.**
React reconciler смотрит keys (`d.id`) — same keys = no remount, OK. Но
если `data` ref меняется (например, refetch на background), всё перерендерится.
Можно завернуть `CardGrid` / `DenseList` в `memo()` если проф. На 42 dicts
overkill.
---
## Visual / Brand 🟢
`font-primary` (Tektur) только на h1/h2/h3 + dict displayName card title.
Body — Onest. Радиус `rounded-lg` на карточках, `rounded-full` на chips/dots.
`shadow-card` static, `shadow-hover` on hover. Все цвета из `@theme`.
🟡 **Approval badge**`<Badge variant="warning">approval</Badge>`. Цвет
warning пока в `@nstart/ui` mapped к `--color-solar` (orange). Visually OK,
но clash с INTERNAL scope dot тоже orange. Подумать: использовать
`variant="info"` (orbit blue) для approval — semantic-different (это policy
flag, не scope).
🟢 **Dense list grid template** — explicit columns `[24px_1fr_120px_80px_120px_80px]`.
Жёстко, но предсказуемо. На 1280px ширине запас, на 1024px — узковато
Bundle column. Можно `min-content` для bundle/version вместо fixed px,
но фиксированные точнее визуально.
---
## Test plan (review additions)
1. **Vitest unit для sortDicts**:
- `sort=name` → alphabetical (с RU + EN mixed)
- `sort=recordCount` → desc, missing recordCount = 0
- `sort=updated` → desc по ISO timestamp
2. **Vitest unit для parseScopeFilter**:
- `undefined` → empty Set
- `"PUBLIC"``{PUBLIC}`
- `"public,Internal"``{PUBLIC, INTERNAL}` (case-insensitive)
- `"FOO,PUBLIC"``{PUBLIC}` (invalid filtered out)
- `""` → empty Set
3. **RTL: scope chip toggle обновляет URL**:
- Click `Public` chip → `useNavigate` called с `{search: {scope: 'PUBLIC'}}`
- Click again → search empty (delete scope key)
4. **RTL: density toggle**:
- Initial card → check `<div role="grid">` отсутствует, грид cards
- Click Dense → `<div>` с column template visible
5. **Playwright e2e**:
- Load `/dictionaries/?q=spacecraft&scope=PUBLIC&sort=recordCount&density=dense`
- Check: search input shows "spacecraft", PUBLIC chip aria-pressed, sort=recordCount selected, density=dense
- Click Reset → URL becomes `/dictionaries/`
6. **Manual a11y**:
- VoiceOver: scope chip group announces "Filter by scope, group". Each chip "Public, 18, pressed/not pressed, button".
- Tab order: search → chips (3) → sort → density (2) → first card link → next card link.
- Refresh с `?density=dense` — focus goes to search input (visible focus ring).
---
## Risk register
| # | Risk | Severity | Mitigation |
|---|---|---|---|
| 1 | URL state breaks bookmarks при schema changes | 🟡 | Zod `.optional()` на all params — invalid → ignored, not 500 |
| 2 | `scope` CSV не валидируется как Set'тогда — duplicate values | 🟢 | parseScopeFilter использует Set, deduplicates автоматически |
| 3 | `density=dense` не поддержан в Safari 15- из-за `grid-template-columns` minmax | 🟢 | Минимальная цель — Safari 16, supported |
| 4 | Sort change при active filters сбрасывает scroll position | 🟡 | TanStack Router scroll restoration default. Можно `<ScrollRestoration>` явный, но v2 OK |
| 5 | `useDeferredValue` + concurrent React 19 — потенциал sticky updates | 🟢 | React 19 stable, паттерн из docs |
---
## Итог
Verdict: **CLEAR with 2 blocking fixes** (#useState import position + #createOpen mortuary code). После них — ship. v2 даёт power-user value (URL state, multi-scope, sort, density) без regression на 95% common path (default render = current behavior + bonus xl:4-cols grid).
ETA на blocking fixes: 5 min.
ETA на full PR (с tests + i18n keys + 2 fixes): ~2 hours.
Recommended ship order:
1. Fix 2 blocking issues.
2. Land i18n keys в `i18n.ts` (parallel PR).
3. Land `proposed.tsx``routes/dictionaries.index.tsx` (with vitest tests).
4. Manual smoke на staging.
5. Soak ≥2 days.
6. Add Playwright e2e (separate followup).
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB