Merge branch 'docs/design-handoff-catalog-v2-richer' into 'main'
docs(admin-ui): rich design handoff Dictionary Catalog (3 views + nstart/ui patch) See merge request 2-6/2-6-4/terravault/ordinis!16
This commit is contained in:
@@ -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 / 500–600` — 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: `8–10px`
|
||||
- View padding: `24px` inside surface, `28px` page padding
|
||||
|
||||
### Radius / shadow
|
||||
|
||||
- Cards: `border-radius: 10px`, `border: 1px solid #e5e7ee`
|
||||
- Buttons: `border-radius: 8px`, `height: 32–36px`
|
||||
- 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 responsive — current 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}/> {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} · bundle: {center.bundle} · 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}/> {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 |
Reference in New Issue
Block a user