fix(admin-ui): guest nav filter + suppress drafts 401
@@ -1,394 +0,0 @@
|
||||
# Handoff: Dictionary Catalog Redesign + nstart-ui Popover Fix
|
||||
|
||||
## Overview
|
||||
|
||||
Two interconnected pieces of work for the **Ordinis admin UI** (`ordinis-admin-ui`, React 19 + TanStack Router + `@nstart/ui` 0.1.3):
|
||||
|
||||
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.
|
||||
|
||||
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`)
|
||||
|
||||
## About the Design Files
|
||||
|
||||
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.
|
||||
|
||||
**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`)
|
||||
|
||||
**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.
|
||||
|
||||
## Fidelity
|
||||
|
||||
**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.
|
||||
|
||||
The graph layout in the prototype is **statically positioned** — production must use `d3-force` (see Implementation §3.2).
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — `@nstart/ui` Patch
|
||||
|
||||
### Why
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
Three logical changes inside `dist/index.js` (or `.mjs`):
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
## Files in this bundle
|
||||
|
||||
| Path | What it is |
|
||||
|---|---|
|
||||
| `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. |
|
||||
|
||||
## Open questions to confirm with the team
|
||||
|
||||
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`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- 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.
|
||||
@@ -1,936 +0,0 @@
|
||||
|
||||
// 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 });
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,486 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,49 +0,0 @@
|
||||
// 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;
|
||||
@@ -1,72 +0,0 @@
|
||||
// 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 });
|
||||
@@ -1,180 +0,0 @@
|
||||
// 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;
|
||||
@@ -1,154 +0,0 @@
|
||||
// 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;
|
||||
@@ -1,154 +0,0 @@
|
||||
// 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,133 +0,0 @@
|
||||
# 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`.
|
||||
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,499 @@
|
||||
# Handoff: Ordinis MDM — UI redesign
|
||||
|
||||
## Overview
|
||||
|
||||
Full UI redesign for the **Ordinis MDM admin** (`ordinis-admin-ui` — React 19 + TanStack Router + Vite + Tailwind v4 + currently `@nstart/ui@0.1.3`). Covers:
|
||||
|
||||
1. **Design system** — earthy / woody light theme + Claude-style warm dark theme. Inter + JetBrains Mono + Tektur. Custom token palette.
|
||||
2. **Dictionary catalog** — bundle-grouped grid with FK chips, scope/bundle filters, search.
|
||||
3. **Dictionary editor** — tabs (Records / Relations / Fields / JSON / Events / History), keyboard-driven table, record drawer with sectioned form, time-travel viewer, status banner for draft/review/published flows.
|
||||
4. **Modals** — Create dictionary, Confirm delete, Toast notifications.
|
||||
5. **Theme switcher** — Light (earthy) / Dark (Claude-warm), persisted to `localStorage`.
|
||||
|
||||
## About the design files
|
||||
|
||||
`design/compact.html` and `design/ui-kit.html` are **single-file React prototypes** using inline Babel + CDN React 18. They are **design references**, not production code.
|
||||
|
||||
**Your task:** recreate the screens in the existing `ordinis-admin-ui` codebase using its real patterns:
|
||||
- TanStack Router routes
|
||||
- TanStack Query for data
|
||||
- Tailwind v4 utility classes (project already uses `@tailwindcss/vite`)
|
||||
- Component primitives from `@nstart/ui` (or progressively replace with **shadcn/ui** behind a barrel — see "Stack migration" below)
|
||||
- React Hook Form + AJV for forms
|
||||
- Phosphor Icons
|
||||
|
||||
**Do not** ship the prototype HTML to production. **Do not** import the prototype as a module — it uses `window.*` globals and inline Babel that won't survive the Vite build.
|
||||
|
||||
## Fidelity
|
||||
|
||||
**High-fidelity.** Colors, typography, spacing, and most interactions are decided. Pixel-perfect recreation is the goal, with these adaptations:
|
||||
- Inline styles → Tailwind classes + `@theme` tokens in `globals.css`.
|
||||
- Mock data (`SATS`, `DICTS`, `HISTORY`) → real TanStack Query.
|
||||
- Hand-built `<button>`/`<div>` chrome → `@nstart/ui` (short-term) or `shadcn/ui` primitives (long-term).
|
||||
|
||||
## Files in this bundle
|
||||
|
||||
| Path | What it is |
|
||||
|---|---|
|
||||
| `design/compact.html` | The main prototype — catalog, editor, drawer, modals, time-travel, theme switcher |
|
||||
| `design/ui-kit.html` | Design-system reference — tokens, type scale, components in isolation |
|
||||
| `screenshots/01-catalog-light.png` | Catalog (Earth theme) |
|
||||
| `screenshots/02-catalog-dark.png` | Catalog (Claude-warm dark) |
|
||||
| `screenshots/03-uikit-light.png` | Design-system reference (Earth) |
|
||||
| `screenshots/04-uikit-dark.png` | Design-system reference (Dark) |
|
||||
| `review.md` | Original code review of `ordinis-admin-ui` — bugs and improvements that drove this redesign |
|
||||
|
||||
Open both HTML files in any modern browser. Toggle Earth / Dark in the top-right corner. Editor screen and record drawer are accessed by clicking any dictionary row in the catalog.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Design tokens
|
||||
|
||||
Both themes share token names, just different values. Apply in `globals.css` via Tailwind v4's `@theme` directive.
|
||||
|
||||
### Light theme (Earth — default, `:root`)
|
||||
|
||||
```css
|
||||
@theme {
|
||||
/* text */
|
||||
--color-ink: #1a1714; /* body, headings */
|
||||
--color-ink-2: #52483d; /* secondary text, labels */
|
||||
--color-mute: #8c8276; /* placeholders, captions, mono labels */
|
||||
|
||||
/* surfaces */
|
||||
--color-bg: #fbf8ee; /* page background */
|
||||
--color-surface: #ffffff; /* cards, modals, drawer */
|
||||
--color-surface-2: #f4eedc; /* hovers, table-row stripe */
|
||||
|
||||
/* lines */
|
||||
--color-line: #e3dccb; /* hairlines, card borders */
|
||||
--color-line-2: #efe9d8; /* subtle dividers */
|
||||
|
||||
/* brand + actions */
|
||||
--color-navy: #3a2818; /* primary button bg (deep coffee) */
|
||||
--color-accent: #b05a2e; /* terracotta — links, focus, FK chips */
|
||||
--color-accent-bg: rgb(176 90 46 / 11%);
|
||||
--color-on-accent: #ffffff;
|
||||
|
||||
/* semantic */
|
||||
--color-green: #4f7038; /* "ok" / live */
|
||||
--color-green-bg: #e4ead0;
|
||||
--color-warn: #a8762a; /* warning / draft */
|
||||
--color-warn-bg: #f6e8bc;
|
||||
--color-pink: #a64636; /* error / destructive */
|
||||
--color-pink-bg: #f4d9cd;
|
||||
}
|
||||
```
|
||||
|
||||
### Dark theme (Claude-warm, `[data-theme="dark"]`)
|
||||
|
||||
```css
|
||||
[data-theme="dark"] {
|
||||
--color-ink: #f0ece4;
|
||||
--color-ink-2: #c8c1b3;
|
||||
--color-mute: #8a8474;
|
||||
--color-bg: #1c1a17;
|
||||
--color-surface: #262421;
|
||||
--color-surface-2: #2e2b27;
|
||||
--color-line: #3a3733;
|
||||
--color-line-2: #2d2a27;
|
||||
--color-navy: #d97757; /* primary becomes Claude-orange in dark */
|
||||
--color-accent: #d97757;
|
||||
--color-accent-bg: rgb(217 119 87 / 16%);
|
||||
--color-on-accent: #1c1a17;
|
||||
--color-green: #6fa97d;
|
||||
--color-green-bg: rgb(111 169 125 / 18%);
|
||||
--color-warn: #d4a653;
|
||||
--color-warn-bg: rgb(212 166 83 / 18%);
|
||||
--color-pink: #d18272;
|
||||
--color-pink-bg: rgb(209 130 114 / 20%);
|
||||
}
|
||||
```
|
||||
|
||||
### Scope colors (dictionary visibility)
|
||||
|
||||
| Scope | Light fg | Light bg | Dark behavior |
|
||||
|---|---|---|---|
|
||||
| `PUBLIC` | `--color-accent` (`#b05a2e`) | `--color-accent-bg` | inherits accent |
|
||||
| `INTERNAL` | `--color-warn` (`#a8762a`) | `--color-warn-bg` | inherits warn |
|
||||
| `TENANT` | `--color-pink` (`#a64636`) | `--color-pink-bg` | inherits pink |
|
||||
|
||||
Always pair with a 6-8px square swatch prefix (`<span class="scope-dot">`).
|
||||
|
||||
### Typography
|
||||
|
||||
| Font | Source | Used for |
|
||||
|---|---|---|
|
||||
| **Inter** 400/500/600/700 | Google Fonts | All UI text, headings, body |
|
||||
| **JetBrains Mono** 400/500/600 | Google Fonts | IDs, FK refs, dates, numerics |
|
||||
| **Tektur** 500/600/700 | Google Fonts | UPPERCASE captions (`.cap`), logo, theme switch labels |
|
||||
|
||||
`font-feature-settings: "ss01","cv11","cv02","cv03","cv04"` on body for Inter's stylistic alternates.
|
||||
|
||||
**Scale:**
|
||||
- 22px / 600 — section header (rare, e.g. modal title)
|
||||
- 17px / 600 — page title in editor
|
||||
- 15px / 600 — dictionary card title
|
||||
- 13px / 400-600 — body text, buttons, tabs (this is the workhorse)
|
||||
- 12.5px / 500 — table cell text
|
||||
- 11px Mono / 500 — IDs, dates
|
||||
- 10.5px Tektur uppercase, `letter-spacing: .10em` — captions / section labels
|
||||
|
||||
### Spacing
|
||||
|
||||
Tight, density-first. The whole UI optimizes for showing many records at once.
|
||||
|
||||
- Card padding: `12px 14px`
|
||||
- Drawer body padding: `4px 18px 24px`
|
||||
- Drawer section grid gap: `14px`
|
||||
- Modal padding: `20px 22px`
|
||||
- Toolbar height: `40px`, with `0 14px` horizontal padding
|
||||
- Page gutter: `18px` horizontal
|
||||
|
||||
### Radius / shadow
|
||||
|
||||
| Element | Value |
|
||||
|---|---|
|
||||
| Buttons, inputs | `border-radius: 6px` |
|
||||
| Cards | `border-radius: 8px` |
|
||||
| Modal, drawer | `border-radius: 8px` (drawer only on inner edge) |
|
||||
| Toast | `border-radius: 8px` |
|
||||
| Chip (badge, FK) | `border-radius: 4-5px` |
|
||||
|
||||
Shadows are minimal in light, almost absent in dark. Drawer: `-20px 0 60px -20px rgba(15,17,21,.35)`. Modal: `0 24px 48px -16px rgba(15,17,21,.30)`. Toast: `0 12px 30px -10px rgba(40,25,10,.38)`.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Screens
|
||||
|
||||
The prototype is **one page** that toggles between catalog and editor via a state machine (`view: "catalog" | "editor"`). In production these become two TanStack Router routes.
|
||||
|
||||
### Screen 1 — Catalog (`/dictionaries`)
|
||||
|
||||
**Layout (1280px+):**
|
||||
|
||||
```
|
||||
┌─ sidebar 220px ──┬─ main ─────────────────────────────────────┐
|
||||
│ logo (ORDINIS) │ header (h=56): breadcrumb · ⌕ search · usr │
|
||||
│ nav rail ├────────────────────────────────────────────┤
|
||||
│ ─ Главная │ toolbar (h=44): filter chips · "+ Создать" │
|
||||
│ ▣ Справочники 40 ├────────────────────────────────────────────┤
|
||||
│ Мои черновики │ bundle group: cuod (10) │
|
||||
│ На ревью 7 │ ┌── card ──┐ ┌── card ──┐ │
|
||||
│ Outbox 2 │ │ title │ │ title │ … │
|
||||
│ Webhooks │ │ desc │ │ desc │ │
|
||||
│ Аудит │ │ FK chips │ │ FK chips │ │
|
||||
│ Поиск │ └──────────┘ └──────────┘ │
|
||||
│ │ bundle group: geo (5) │
|
||||
│ user · profile │ … │
|
||||
└──────────────────┴────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Toolbar:**
|
||||
- Search input — `width: 280px`, placeholder "Поиск по названию, коду…", searches `id + title + desc`.
|
||||
- Scope chip group — three toggleable buttons with colored dot prefix.
|
||||
- Bundle filter — single-select chips after a small "BUNDLE" Tektur label.
|
||||
- Right side: `"+ Создать справочник"` primary button → opens modal.
|
||||
|
||||
**Card:**
|
||||
- 3px left strip in scope color.
|
||||
- Title (15px / 600 / `--color-navy`) + id below in Mono 11px gray.
|
||||
- Description (12px) clamped to 2 lines.
|
||||
- Relations row — `→ ссылается` label + chips for `fk[]`; `← N использ.` + chips for `refBy[]` (dimmed dashed border style).
|
||||
- Right column: ScopeBadge + version mono.
|
||||
- Hover: `translateY(-1px)` + soft shadow; entire card is the click target → opens editor.
|
||||
|
||||
**FK chip:** clickable button, `padding: 2px 7px`, `border-radius: 4px`, mono 11px. Active variant: `bg: --color-accent-bg`, `color: --color-accent`. Dimmed: transparent bg + dashed border. `onClick` stops propagation and navigates to that dictionary.
|
||||
|
||||
### Screen 2 — Editor (`/dictionaries/$name`)
|
||||
|
||||
**Layout:**
|
||||
|
||||
```
|
||||
┌─ sidebar (collapsed to 56px in editor) ─┬─ main ─────────────────┐
|
||||
│ ⌂ ▣ ✎ ◐ ↗ ⇆ ☷ ⌕ │ breadcrumb + actions │
|
||||
│ │ title row │
|
||||
│ │ status banner │
|
||||
│ ├────────────────────────┤
|
||||
│ │ tabs (h=40) │
|
||||
│ ├────────────────────────┤
|
||||
│ │ tab body │
|
||||
│ │ - records (table) │
|
||||
│ │ - relations (FK list)│
|
||||
│ │ - fields (schema) │
|
||||
│ │ - json (raw schema) │
|
||||
│ │ - events (audit) │
|
||||
│ │ - history (timeline) │
|
||||
│ ├────────────────────────┤
|
||||
│ │ footer toolbar │
|
||||
└──────────────────────────────────────────┴────────────────────────┘
|
||||
```
|
||||
|
||||
**Breadcrumb row:** "Справочники / **Космические аппараты**" + right-aligned: `[Export]` `[Compare]` `[⋯ menu]`.
|
||||
|
||||
**Title row:** large title (17px / 600) + scope badge + version mono + edit pencil.
|
||||
|
||||
**Status banner (sits above tabs):** 1-line card showing current workflow state.
|
||||
- Draft: `--color-warn-bg` background, left edge `--color-warn`, copy "Черновик · v1.8.0+draft · 3 правки · открыт {user}".
|
||||
- Review: `--color-accent-bg`, copy "На ревью · ждёт {reviewer} · открыто {time}".
|
||||
- Live: `--color-green-bg`, "Опубликовано · v1.8.0 · {time}".
|
||||
- Buttons on the right adapt to state: `Запросить ревью` (draft), `Approve / Request changes` (review), `Создать черновик` (live).
|
||||
|
||||
**Tab bar:** horizontal scrollable on narrow viewports; active tab has `--color-accent` bottom border, weight 600. Right side of bar has utility buttons (`Time-travel ◷` opens a popover with date picker).
|
||||
|
||||
**Records tab** — the main view:
|
||||
- Compact table, row height 32px, monospace IDs in first column.
|
||||
- Sticky header with column sort arrows and a search icon per column (filter).
|
||||
- Row hover: `--color-surface-2` background.
|
||||
- Click anywhere in a row → opens **RecordDrawer** on the right.
|
||||
- Bulk-select via leftmost checkbox column.
|
||||
- Footer toolbar: "N из M записей" · paging · "+ Запись" (right-aligned, primary).
|
||||
|
||||
**Relations tab** — two sections:
|
||||
- "Исходящие FK" — table: field · target dict · cardinality · critical / soft.
|
||||
- "Входящие ссылки" — table: source dict · field · count of records using it.
|
||||
|
||||
**Fields tab** — full JSON-schema view as an editable grid:
|
||||
- Columns: name · type · required · unique · i18n · index · description.
|
||||
- Inline edit; "+ Поле" at bottom.
|
||||
|
||||
**JSON tab** — Monaco editor with the raw `schema.json`; read-only on Live records.
|
||||
|
||||
**Events tab** — audit log filtered to this dict:
|
||||
- Filter pills: webhook · publish · review · import · retried.
|
||||
- One row per event with mono timestamp, actor avatar, target record, status badge.
|
||||
|
||||
**History tab** — vertical timeline:
|
||||
- One node per commit, current `HEAD` marked.
|
||||
- Each node shows: short SHA, message, author, diff counters (`+N -M`).
|
||||
- Click → opens diff in a slide-over.
|
||||
|
||||
### Screen 3 — RecordDrawer (right slide-over)
|
||||
|
||||
Opens on row click. **Two widths**: 520px (default) and 880px (toggle via `‹/›` button).
|
||||
|
||||
**Header (sticky):** breadcrumb "редактирование записи · satellites" caption, then `id` mono + `· name`, then `[‹/›]` `[История]` `[×]`.
|
||||
|
||||
**Toolbar (sticky):** search field for filtering schema fields by name + "только заполненные" toggle + counter "N / M".
|
||||
|
||||
**Section anchor chips:** horizontally scrolling row of section names with field-count badge; click to scroll-to-section. Active chip is filled navy/accent.
|
||||
|
||||
**Body:** sections in order with sticky section headers (`.cap` style). Each section is a 2-column grid (or 4 in wide mode). Section list — see **Record schema** below.
|
||||
|
||||
**Wide mode (`880px`):** adds a left ToC rail (180px) with the same sections as a vertical list, mirroring the chip strip.
|
||||
|
||||
**Footer (sticky):** `[Удалить]` (pink) · "не сохранено · N полей" caption · `[Отмена]` · `[Сохранить]` (primary).
|
||||
|
||||
### Screen 4 — Modals
|
||||
|
||||
**Create dictionary** — centered dialog, 560px:
|
||||
- "НОВЫЙ СПРАВОЧНИК" cap header.
|
||||
- Fields: Название (text), id (mono, slug-validated, auto-derived from title), Bundle (select), Scope (radio with colored dots), Поддерживаемые локали (multi-chip — `ru / en / zh / es / fr / de / ar`).
|
||||
- Footer: `[Отмена]` · `[Создать]`.
|
||||
|
||||
**Confirm delete** — centered dialog, 440px:
|
||||
- Pink left-accent stripe (4px).
|
||||
- "ПОДТВЕРЖДЕНИЕ" cap header.
|
||||
- Copy explains consequences (e.g. "12 справочников ссылаются на эту запись через FK").
|
||||
- Footer: `[Отмена]` · `[Удалить]` (pink danger button).
|
||||
|
||||
**Toast** — bottom-right slide-up:
|
||||
- Navy (or Claude-orange in dark) `--color-navy` background.
|
||||
- "OK" cap header in toast-cap color (orange-cream), then short message, then `×`.
|
||||
- Auto-dismiss after 4s.
|
||||
|
||||
### Screen 5 — Time-travel
|
||||
|
||||
A popover from the time-travel button in the editor tab bar. Two parts:
|
||||
- **Date picker** — calendar with 30 day range; selecting a date snapshots the dict and editor to that point.
|
||||
- **Quick presets** — "сейчас" · "1ч назад" · "вчера 18:00" · "перед последним публикованием".
|
||||
- "Применить" / "Сбросить" footer.
|
||||
|
||||
When active, all editor surfaces gain a thin amber top border and a banner: "Просмотр на {timestamp} · только чтение · [Сбросить]".
|
||||
|
||||
---
|
||||
|
||||
## Record schema (sections in drawer)
|
||||
|
||||
The drawer's section list is the canonical "what fields a record can have" definition. The prototype encodes this in `RECORD_SCHEMA` (see `design/compact.html` ~line 220).
|
||||
|
||||
| Section | Fields | Notes |
|
||||
|---|---|---|
|
||||
| **Основное** | id, active toggle, name_ru/en/zh/es, description | name_ru is the only required loc; others optional |
|
||||
| **Связи (FK)** | operator, status (enum), orbit_type, mission, launch_site, launch_vehicle, manufacturer, data_center | Each is a FK to another dict |
|
||||
| **Геометрия орбиты** | altitude_km, inclination, period_min, eccentricity, raan, arg_perigee | numerics with units |
|
||||
| **Физические параметры** | mass_kg, power_w, design_life, dimensions | |
|
||||
| **Инструменты и продукты** | instruments[], products[], ground_stations[] | tag inputs |
|
||||
| **Внешние идентификаторы** | cospar_id, norad_id, sat_cat, itu | mono text |
|
||||
| **Даты и события** | launch_date, decom_date, first_light, last_contact | |
|
||||
| **Флаги** | public, deprecated, sensitive, draft_only | toggles |
|
||||
| **Произвольные данные** | raw_json | JSON editor (Monaco) |
|
||||
| **Системные** (readonly) | created_at, updated_at, created_by, version | |
|
||||
|
||||
In production these sections must come from the dictionary's JSON-schema metadata (`x-section` per property), not be hard-coded.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Components inventory
|
||||
|
||||
Map each prototype piece to a target component file. Where possible reuse `@nstart/ui`; where it's broken, replace.
|
||||
|
||||
### Atomic
|
||||
|
||||
| Prototype | Target | Notes |
|
||||
|---|---|---|
|
||||
| `Cap` (uppercase Tektur label) | `<Cap>` in `components/ui/Cap.tsx` | Plain `<span class="cap">`, Tektur 10.5px |
|
||||
| `ScopeBadge` | `components/catalog/ScopeBadge.tsx` | Pill with colored dot + uppercase label |
|
||||
| `ScopeDot` | `components/catalog/ScopeDot.tsx` | 8×8 colored square |
|
||||
| `FkChip` | `components/catalog/FkChip.tsx` | Active + dimmed variants |
|
||||
| `StatusBadge` (DRAFT / LIVE / DEPRECATED) | `components/ui/StatusBadge.tsx` | Background = `--color-warn-bg` / `--color-green-bg` / `--color-pink-bg` |
|
||||
| `Btn` / `BtnPrimary` / `BtnDanger` / `BtnGhost` | `@nstart/ui` `Button` (variant) | Patch theme tokens; danger uses `--color-pink` |
|
||||
| `IconBtn` (28x28 with icon glyph) | `components/ui/IconButton.tsx` | |
|
||||
| `Toggle` (switch) | `@nstart/ui` `Toggle` or shadcn `Switch` | Native indeterminate not needed |
|
||||
| `TagInput` | replace `@nstart/ui` MultiSelect → **shadcn Combobox + cmdk** | See "Stack migration" |
|
||||
|
||||
### Composite
|
||||
|
||||
| Prototype piece | Target component | File |
|
||||
|---|---|---|
|
||||
| Sidebar (nav rail + collapse) | `<Sidebar>` | `components/layout/Sidebar.tsx` |
|
||||
| Header (breadcrumb + search + user) | `<TopBar>` | `components/layout/TopBar.tsx` |
|
||||
| Catalog toolbar | `<CatalogToolbar>` | `components/catalog/CatalogToolbar.tsx` |
|
||||
| Catalog card | `<DictCard>` | `components/catalog/DictCard.tsx` |
|
||||
| Catalog bundle group | `<BundleGroup>` | `components/catalog/BundleGroup.tsx` |
|
||||
| Editor tab bar | `<EditorTabs>` | `components/editor/EditorTabs.tsx` |
|
||||
| Editor status banner | `<WorkflowBanner>` | `components/editor/WorkflowBanner.tsx` (variant = draft/review/live) |
|
||||
| Records table | `<RecordsTable>` | `components/editor/RecordsTable.tsx` (TanStack Table) |
|
||||
| Record drawer | `<RecordDrawer>` | `components/editor/RecordDrawer.tsx` |
|
||||
| Drawer field renderers | `<DrawerField>` | `components/editor/DrawerField.tsx` (kind-switch: text / mono / num / date / fk / enum / toggle / tags / textarea / json) |
|
||||
| Create-dict modal | `<CreateDictModal>` | `components/modals/CreateDictModal.tsx` |
|
||||
| Confirm modal | `<ConfirmModal>` | `components/modals/ConfirmModal.tsx` |
|
||||
| Toast | `<Toast>` | use `sonner` (recommended) or `@nstart/ui` Toast |
|
||||
| Time-travel popover | `<TimeTravelPopover>` | `components/editor/TimeTravelPopover.tsx` |
|
||||
| Theme switcher | `<ThemeSwitch>` | `components/layout/ThemeSwitch.tsx`, writes `data-theme` attr + `localStorage` |
|
||||
|
||||
---
|
||||
|
||||
## Stack migration plan (`@nstart/ui` → `shadcn/ui`)
|
||||
|
||||
Three-phase migration. Don't do it all at once. Phase 1 unblocks the redesign; phases 2-3 happen over the following weeks.
|
||||
|
||||
### Phase 1 — Patch & ship (1 day)
|
||||
|
||||
1. `pnpm patch @nstart/ui` to fix three things in `dist/index.js`:
|
||||
- `Select` / `MultiSelect` / `DatePicker` popover → `createPortal(node, document.body)` with `position: fixed`, computed top/left from `triggerRef.getBoundingClientRect()`, `z-index: 9999`, listens to scroll/resize.
|
||||
- Outside-click detection: check both `triggerRef.contains(target)` and `popoverRef.contains(target)`.
|
||||
- `Checkbox`: forward ref to inner `<input>`, sync `indeterminate` prop via `useEffect`.
|
||||
2. Add `@theme` block to `globals.css` with both palettes (above).
|
||||
3. Add `<ThemeSwitch>` component → toggles `data-theme` on `<html>`.
|
||||
4. Wire `@nstart/ui` theme tokens to our `--color-*` variables (one CSS file, override their CSS vars).
|
||||
|
||||
### Phase 2 — Barrel + Radix replacements for broken components (3-4 days)
|
||||
|
||||
Create a barrel at `src/ui/index.ts`:
|
||||
|
||||
```ts
|
||||
// re-export anything from @nstart/ui that works
|
||||
export { Button, Alert, Badge, Tabs, TextInput, Drawer } from '@nstart/ui';
|
||||
|
||||
// override the broken ones with our wrappers
|
||||
export { Select, MultiSelect } from './Select'; // Radix Select + cmdk
|
||||
export { Modal } from './Modal'; // Radix Dialog
|
||||
export { Checkbox } from './Checkbox'; // Radix Checkbox
|
||||
export { Combobox } from './Combobox'; // cmdk-based
|
||||
```
|
||||
|
||||
Then a codemod / find-and-replace across the codebase: `from '@nstart/ui'` → `from '@/ui'`. Adopt **shadcn/ui** primitives inside the wrappers:
|
||||
- `npx shadcn@latest init` (Tailwind v4 mode, slate base, CSS variables = yes).
|
||||
- `npx shadcn@latest add select combobox dialog checkbox dropdown-menu popover tooltip command`.
|
||||
- Edit the generated files to use our `--color-*` tokens directly (replace shadcn's `primary` / `destructive` etc. with our names).
|
||||
|
||||
### Phase 3 — Full shadcn for new screens, gradual replace for old (2-3 weeks, parallel to feature work)
|
||||
|
||||
- All new components use shadcn directly.
|
||||
- Old `@nstart/ui` Button / Alert / Badge / Tabs / TextInput / Drawer get migrated one at a time as touched.
|
||||
- End state: `@nstart/ui` removed from `package.json`, patch deleted.
|
||||
|
||||
---
|
||||
|
||||
## Interactions & behavior
|
||||
|
||||
### Tables (all 5 — catalog list, records, fields, events, history-preview)
|
||||
- Row hover background uses `--color-surface-2`; default is `--color-surface`. **Never hardcode `#fff` / `#fafbfc`** in `onMouseEnter`/`Leave` handlers — those break dark mode (rows go white-on-white). Either use CSS `tr:hover { background: var(--color-surface-2) }` or pass theme tokens to the inline handler.
|
||||
- Row height: 32px in production; 28px in compact density mode.
|
||||
- IDs in the first column are `mono` + `--color-accent`; clickable but `stopPropagation` is not needed (row click and id click both navigate to the same place).
|
||||
|
||||
### Catalog
|
||||
- Search debounced 150ms.
|
||||
- Filter chips are URL-synced via TanStack Router `validateSearch` (`?q&scopes&bundle&onlyWithRefs`).
|
||||
- Card click → `navigate({ to: '/dictionaries/$name', params: { name: id } })`.
|
||||
|
||||
### Editor
|
||||
- Tab change → `?tab=records|relations|fields|json|events|history`.
|
||||
- Status banner → mutations (request-review, approve, publish, create-draft) hit existing API and invalidate the dict query.
|
||||
- Records table sort/filter are URL-synced.
|
||||
- Pressing `j`/`k` moves selected row; `Enter` opens drawer; `Esc` closes; `n` opens new-record drawer; `/` focuses search.
|
||||
|
||||
### Drawer
|
||||
- Width toggle persists per-user to localStorage (`ord-drawer-wide`).
|
||||
- "Только заполненные" filter only hides fields with no value (excluding active toggles set to false — those still show because the field is "configured").
|
||||
- "История" button opens the History tab of the current dict filtered to this record.
|
||||
- Save → optimistic update + toast; conflict → modal with diff.
|
||||
|
||||
### Time-travel
|
||||
- Opens via tab-bar button.
|
||||
- Sets `?asOf=2026-04-30T09:00:00Z` in search.
|
||||
- All API queries scope to that timestamp; mutations disabled (UI goes read-only with amber border).
|
||||
- "Сбросить" clears `asOf`.
|
||||
|
||||
### Theme
|
||||
- Click switch → `document.documentElement.setAttribute('data-theme', val)` + `localStorage.setItem('ord-theme', val)`.
|
||||
- Initial load reads localStorage; falls back to `prefers-color-scheme`.
|
||||
|
||||
---
|
||||
|
||||
## Responsive
|
||||
|
||||
Desktop-first (1280px+ baseline). Three breakpoints:
|
||||
|
||||
| Breakpoint | Changes |
|
||||
|---|---|
|
||||
| `≤ 1024px` | Sidebar becomes overlay drawer; hamburger in TopBar; main grid `0 1fr` |
|
||||
| `≤ 880px` | Drawer and modals go fullscreen (`100vw`, `100dvh`, `border-radius: 0`); editor body is single column |
|
||||
| `≤ 560px` | Table columns 7+ hidden via `nth-child`; toolbar wraps; sidebar always overlay |
|
||||
|
||||
---
|
||||
|
||||
## Accessibility checklist
|
||||
|
||||
- All buttons have visible focus rings — use `--color-accent` 2px outline-offset 2px.
|
||||
- Drawer + modal are focus-trapped (shadcn Dialog gives this free).
|
||||
- Drawer + modal close on `Esc`.
|
||||
- Theme switch has `aria-label`; current value is `aria-pressed`.
|
||||
- Tables: `<th scope="col">`; sortable headers are `<button>` with `aria-sort`.
|
||||
- Status badges include text, never color-only.
|
||||
- All FK chips have a `title` with the full target id.
|
||||
|
||||
---
|
||||
|
||||
## Open questions for the team
|
||||
|
||||
1. **JSON-schema FK convention** — is it `x-ref`, `$ref`, or `x-dict`? `extractFks` must match.
|
||||
2. **Backend graph endpoint** — willing to add `GET /api/v1/dictionaries/graph` so the catalog doesn't recompute on every load?
|
||||
3. **Workflow state machine** — confirm exact transitions (draft → review → live → archived?). Current banner assumes draft / review / live.
|
||||
4. **Time-travel granularity** — minute? second? day? Affects calendar UX.
|
||||
5. **Sections in record drawer** — confirm `x-section` will be added to schema metadata, or do we need a separate ordering file?
|
||||
6. **Are sensitive fields really record-level** or are they field-level (mask in UI based on user permission)?
|
||||
|
||||
---
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Mobile-first redesign — admin remains desktop-primary.
|
||||
- Bulk import / export UX — separate work.
|
||||
- Permission management UI — separate work.
|
||||
- Webhook subscription manager — separate work.
|
||||
- The form-validation bugs in `review.md` (#4 AJV errors, #12 token storage) — covered in Phase 1 patch + ongoing.
|
||||
@@ -0,0 +1,680 @@
|
||||
<!doctype html>
|
||||
<html lang="ru"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=1100"/>
|
||||
<title>Ordinis · UI Kit</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Tektur:wght@500;600;700&display=swap" rel="stylesheet"/>
|
||||
<style>
|
||||
:root{
|
||||
/* Earthy light — v2. Cleaner contrast, less muddy. */
|
||||
--ink:#1a1714; --ink-2:#52483d; --mute:#8c8276;
|
||||
--line:#e3dccb; --line-2:#efe9d8; --bg:#fbf8ee;
|
||||
--surface:#ffffff; --surface-2:#f4eedc;
|
||||
--navy:#3a2818; --accent:#b05a2e; --accent-bg:rgba(176,90,46,.11);
|
||||
--green:#4f7038; --amber:#a8762a; --pink:#a64636; --purple:#7a4a2e;
|
||||
--on-accent:#ffffff;
|
||||
--bg-public:rgba(176,90,46,.13); --bg-internal:#f6e8bc; --bg-tenant:#f4d9cd;
|
||||
--bg-draft:var(--line-2); --bg-live:#e4ead0; --bg-deprec:#f4d9cd;
|
||||
--al-info-bg:rgba(176,90,46,.09); --al-info-bd:rgba(176,90,46,.28);
|
||||
--al-warn-bg:#f6e8bc; --al-warn-bd:#e2c982;
|
||||
--al-err-bg:#f4d9cd; --al-err-bd:#dfb29e;
|
||||
--al-ok-bg:#e4ead0; --al-ok-bd:#bcc995;
|
||||
--shadow-l:0 12px 30px -16px rgba(50,30,15,.18);
|
||||
--shadow-m:0 12px 28px -12px rgba(50,30,15,.22);
|
||||
--shadow-toast:0 12px 30px -10px rgba(40,25,10,.38);
|
||||
--shadow-drawer:0 1px 2px rgba(50,30,12,.18);
|
||||
--toast-cap:#d4a07a;
|
||||
--avt-pink-bg:#f4d9cd; --avt-green-bg:#e4ead0;
|
||||
--primary:var(--navy);
|
||||
}
|
||||
|
||||
:root[data-theme="claude-light"]{
|
||||
--ink:#2c2b27; --ink-2:#5a564f; --mute:#8a8474;
|
||||
--line:#e6e1d4; --line-2:#efeae0; --bg:#faf9f5;
|
||||
--surface:#ffffff; --surface-2:#f5f3ec;
|
||||
--navy:#d97757; --accent:#d97757; --accent-bg:rgba(217,119,87,.12);
|
||||
--green:#4a7d4f; --amber:#b97c2f; --pink:#b34d6a; --purple:#a85d3e;
|
||||
--on-accent:#ffffff;
|
||||
--bg-public:rgba(217,119,87,.14); --bg-internal:#fcecc7; --bg-tenant:#f8d8de;
|
||||
--bg-draft:#efeae0; --bg-live:#dde9da; --bg-deprec:#f8d8de;
|
||||
--al-info-bg:rgba(217,119,87,.10); --al-info-bd:rgba(217,119,87,.30);
|
||||
--al-warn-bg:#fcecc7; --al-warn-bd:#ecd393;
|
||||
--al-err-bg:#f8d8de; --al-err-bd:#ecb6c2;
|
||||
--al-ok-bg:#dde9da; --al-ok-bd:#bdd3b9;
|
||||
--shadow-l:0 12px 30px -16px rgba(89,67,40,.22);
|
||||
--shadow-m:0 12px 28px -12px rgba(89,67,40,.20);
|
||||
--shadow-toast:0 14px 36px -10px rgba(89,67,40,.30);
|
||||
--shadow-drawer:0 1px 2px rgba(89,67,40,.18);
|
||||
--toast-cap:#f5c8b3;
|
||||
--avt-pink-bg:#f8d8de; --avt-green-bg:#dde9da;
|
||||
--primary:var(--accent);
|
||||
}
|
||||
|
||||
:root[data-theme="claude"]{
|
||||
--ink:#f0ece4; --ink-2:#c8c1b3; --mute:#8a8474;
|
||||
--line:#3a3733; --line-2:#2d2a27; --bg:#1c1a17;
|
||||
--surface:#262421; --surface-2:#2e2b27;
|
||||
--navy:#d97757; --accent:#d97757; --accent-bg:rgba(217,119,87,.16);
|
||||
--green:#6fa97d; --amber:#d4a653; --pink:#d18272; --purple:#a991d4;
|
||||
--on-accent:#1c1a17;
|
||||
--bg-public:rgba(217,119,87,.18); --bg-internal:rgba(212,166,83,.18); --bg-tenant:rgba(209,130,114,.20);
|
||||
--bg-draft:#2d2a27; --bg-live:rgba(111,169,125,.20); --bg-deprec:rgba(209,130,114,.20);
|
||||
--al-info-bg:rgba(217,119,87,.10); --al-info-bd:rgba(217,119,87,.35);
|
||||
--al-warn-bg:rgba(212,166,83,.10); --al-warn-bd:rgba(212,166,83,.35);
|
||||
--al-err-bg:rgba(209,130,114,.10); --al-err-bd:rgba(209,130,114,.35);
|
||||
--al-ok-bg:rgba(111,169,125,.10); --al-ok-bd:rgba(111,169,125,.35);
|
||||
--shadow-l:0 12px 30px -16px rgba(0,0,0,.6);
|
||||
--shadow-m:0 12px 28px -12px rgba(0,0,0,.55);
|
||||
--shadow-toast:0 14px 36px -10px rgba(0,0,0,.7);
|
||||
--shadow-drawer:0 1px 2px rgba(0,0,0,.5);
|
||||
--toast-cap:#f0c3ab;
|
||||
--avt-pink-bg:rgba(209,130,114,.22); --avt-green-bg:rgba(111,169,125,.22);
|
||||
--primary:var(--accent);
|
||||
}
|
||||
|
||||
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;background:var(--bg);color:var(--ink);font-family:Inter,system-ui,sans-serif;font-feature-settings:"cv11","ss01";font-size:13px;line-height:1.5}
|
||||
.mono{font-family:"JetBrains Mono",monospace;font-feature-settings:"calt","liga"}
|
||||
.cap{font-family:Tektur,Inter,sans-serif;font-size:10.5px;font-weight:600;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}
|
||||
.brand{font-family:Tektur;font-weight:600;letter-spacing:.22em;display:inline-flex;align-items:center;gap:9px;color:var(--ink)}
|
||||
.brand svg{display:block;color:var(--accent);flex-shrink:0}
|
||||
.brand .wm{display:inline-flex;align-items:baseline;gap:6px}
|
||||
.brand .wm em{font-style:normal;font-weight:400;letter-spacing:.32em;color:var(--mute);font-size:.78em}
|
||||
|
||||
.shell{max-width:1180px;margin:0 auto;padding:32px 28px 80px}
|
||||
.head{display:flex;align-items:baseline;gap:14px;border-bottom:1px solid var(--line);padding-bottom:18px;margin-bottom:28px}
|
||||
.head h1{font-family:Tektur;font-size:22px;letter-spacing:.04em;margin:0;font-weight:600}
|
||||
.head .sub{color:var(--mute);font-size:12.5px}
|
||||
.toc{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:28px}
|
||||
.toc a{height:24px;padding:0 10px;border-radius:12px;border:1px solid var(--line);background:var(--surface);color:var(--ink-2);font-size:11.5px;text-decoration:none;display:inline-flex;align-items:center;gap:6px}
|
||||
.toc a:hover{border-color:var(--navy);color:var(--navy)}
|
||||
.toc a .n{font-family:"JetBrains Mono",monospace;font-size:10px;color:var(--mute)}
|
||||
|
||||
section{margin-bottom:36px}
|
||||
section h2{display:flex;align-items:baseline;gap:10px;margin:0 0 14px;font-size:11px;font-weight:600;letter-spacing:.18em;text-transform:uppercase;color:var(--mute);font-family:Tektur}
|
||||
section h2 .num{font-family:"JetBrains Mono",monospace;color:var(--accent);letter-spacing:0}
|
||||
section h2::after{content:"";flex:1;height:1px;background:var(--line-2)}
|
||||
|
||||
.card{background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:18px}
|
||||
.row{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
|
||||
.col{display:flex;flex-direction:column;gap:10px}
|
||||
.grid{display:grid;gap:14px}
|
||||
.label{font-family:Tektur;font-size:9.5px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute);font-weight:600;margin-bottom:6px;display:block}
|
||||
|
||||
/* primitives */
|
||||
.btn{height:32px;padding:0 14px;border:1px solid var(--line);border-radius:6px;background:var(--surface);color:var(--ink);cursor:pointer;font:inherit;font-size:12.5px;display:inline-flex;align-items:center;gap:6px;transition:background .12s, border-color .12s}
|
||||
.btn:hover{border-color:var(--ink-2);background:var(--surface-2);color:var(--ink)}
|
||||
.btn-pri{background:var(--primary);border-color:var(--primary);color:var(--on-accent);font-weight:600}
|
||||
.btn-pri:hover{background:color-mix(in srgb, var(--primary) 88%, #000);border-color:color-mix(in srgb, var(--primary) 88%, #000);color:var(--on-accent)}
|
||||
.btn-danger{color:var(--pink);border-color:var(--line);background:var(--surface)}
|
||||
.btn-danger:hover{border-color:var(--pink);background:var(--al-err-bg);color:var(--pink)}
|
||||
.btn-ghost{border-color:transparent;background:transparent;color:var(--ink-2)}
|
||||
.btn-ghost:hover{background:var(--line-2);color:var(--ink);border-color:transparent}
|
||||
.btn-sm{height:26px;padding:0 10px;font-size:11.5px}
|
||||
.btn-xs{height:22px;padding:0 8px;font-size:11px}
|
||||
.icon{width:32px;height:32px;border:1px solid var(--line);border-radius:6px;background:var(--surface);color:var(--ink-2);cursor:pointer;display:inline-flex;align-items:center;justify-content:center;font-size:14px;transition:background .12s, border-color .12s, color .12s}
|
||||
.icon:hover{border-color:var(--ink-2);color:var(--ink);background:var(--surface-2)}
|
||||
|
||||
.inp{height:32px;padding:0 10px;border:1px solid var(--line);border-radius:6px;font:inherit;font-size:12.5px;background:var(--surface);color:var(--ink);outline:none}
|
||||
.inp:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-bg)}
|
||||
.inp-mono{font-family:"JetBrains Mono",monospace;font-size:12px}
|
||||
.inp-err{border-color:var(--pink);box-shadow:0 0 0 3px var(--al-err-bg)}
|
||||
|
||||
.chk{appearance:none;width:16px;height:16px;border:1px solid var(--line);border-radius:3px;cursor:pointer;position:relative;background:var(--surface);vertical-align:middle}
|
||||
.chk:checked{background:var(--primary);border-color:var(--primary)}
|
||||
.chk:checked::after{content:"✓";position:absolute;top:-1px;left:2px;color:var(--on-accent);font-size:12px;line-height:1}
|
||||
|
||||
.rad{appearance:none;width:16px;height:16px;border:1px solid var(--line);border-radius:50%;cursor:pointer;position:relative;background:var(--surface)}
|
||||
.rad:checked{border-color:var(--primary)}
|
||||
.rad:checked::after{content:"";position:absolute;inset:3px;border-radius:50%;background:var(--primary)}
|
||||
|
||||
.swt{width:32px;height:18px;border-radius:9px;background:var(--line);position:relative;cursor:pointer;display:inline-block;transition:background .15s}
|
||||
.swt::after{content:"";position:absolute;left:2px;top:2px;width:14px;height:14px;border-radius:50%;background:var(--surface);transition:left .15s;box-shadow:var(--shadow-drawer)}
|
||||
.swt.on{background:var(--green)}
|
||||
.swt.on::after{left:16px}
|
||||
|
||||
.badge{padding:1px 7px;border-radius:3px;font-size:10.5px;font-family:"JetBrains Mono",monospace;display:inline-block}
|
||||
.bg-public{background:var(--bg-public);color:var(--navy)}
|
||||
.bg-internal{background:var(--bg-internal);color:var(--amber)}
|
||||
.bg-tenant{background:var(--bg-tenant);color:var(--pink)}
|
||||
.bg-draft{background:var(--bg-draft);color:var(--mute)}
|
||||
.bg-live{background:var(--bg-live);color:var(--green)}
|
||||
.bg-deprec{background:var(--bg-deprec);color:var(--pink)}
|
||||
|
||||
.chip{height:24px;padding:0 9px;border-radius:12px;border:1px solid var(--line);background:var(--surface);color:var(--ink-2);font-size:11.5px;display:inline-flex;align-items:center;gap:6px;cursor:pointer}
|
||||
.chip.on{background:var(--primary);border-color:var(--primary);color:var(--on-accent)}
|
||||
.chip .x{opacity:.6;cursor:pointer}
|
||||
|
||||
.tag{padding:3px 8px;border-radius:4px;background:var(--accent-bg);color:var(--accent);font-family:"JetBrains Mono",monospace;font-size:11px;display:inline-flex;align-items:center;gap:6px}
|
||||
|
||||
.dot{width:6px;height:6px;border-radius:50%;display:inline-block}
|
||||
.status{display:inline-flex;align-items:center;gap:5px;font-size:11.5px}
|
||||
.s-op{color:var(--green)}.s-pl{color:var(--mute)}.s-dc{color:var(--pink)}.s-ls{color:var(--amber)}
|
||||
|
||||
.tab-bar{display:flex;gap:0;border-bottom:1px solid var(--line)}
|
||||
.tab{border:none;background:transparent;padding:9px 12px;font:inherit;font-size:12.5px;cursor:pointer;color:var(--mute);border-bottom:2px solid transparent;margin-bottom:-1px}
|
||||
.tab.on{color:var(--ink);font-weight:600;border-bottom-color:var(--navy)}
|
||||
|
||||
.seg{display:inline-flex;border:1px solid var(--line);border-radius:6px;overflow:hidden}
|
||||
.seg button{height:28px;padding:0 12px;border:none;background:var(--surface);color:var(--ink-2);font:inherit;font-size:12px;cursor:pointer;border-right:1px solid var(--line)}
|
||||
.seg button:last-child{border-right:none}
|
||||
.seg button.on{background:var(--primary);color:var(--on-accent)}
|
||||
|
||||
.alert{padding:10px 14px;border-radius:7px;display:flex;gap:10px;align-items:flex-start;font-size:12.5px;border:1px solid}
|
||||
.alert .ico{font-family:Tektur;font-weight:700;width:18px;text-align:center}
|
||||
.al-info{background:var(--al-info-bg);border-color:var(--al-info-bd);color:var(--ink)}
|
||||
.al-info .ico{color:var(--accent)}
|
||||
.al-warn{background:var(--al-warn-bg);border-color:var(--al-warn-bd);color:var(--ink)}
|
||||
.al-warn .ico{color:var(--amber)}
|
||||
.al-err{background:var(--al-err-bg);border-color:var(--al-err-bd);color:var(--ink)}
|
||||
.al-err .ico{color:var(--pink)}
|
||||
.al-ok{background:var(--al-ok-bg);border-color:var(--al-ok-bd);color:var(--ink)}
|
||||
.al-ok .ico{color:var(--green)}
|
||||
|
||||
.table{width:100%;border-collapse:collapse;font-size:12.5px}
|
||||
.table th{text-align:left;padding:7px 12px;font-size:10px;color:var(--mute);font-weight:600;font-family:Tektur;letter-spacing:.16em;text-transform:uppercase;border-bottom:1px solid var(--line);background:var(--surface)}
|
||||
.table td{padding:7px 12px;border-bottom:1px solid var(--line-2)}
|
||||
.table tr:hover td{background:var(--surface-2)}
|
||||
|
||||
.kbd{font-family:"JetBrains Mono",monospace;font-size:10.5px;padding:1px 5px;border:1px solid var(--line);border-radius:3px;background:var(--surface-2);color:var(--ink-2);box-shadow:0 1px 0 var(--line)}
|
||||
|
||||
/* type scale */
|
||||
.t-row{display:grid;grid-template-columns:120px 1fr 80px;gap:14px;align-items:baseline;padding:8px 0;border-bottom:1px solid var(--line-2)}
|
||||
.t-row:last-child{border-bottom:none}
|
||||
.t-row .name{color:var(--mute);font-size:11px;font-family:"JetBrains Mono",monospace}
|
||||
.t-row .sz{color:var(--mute);font-size:11px;font-family:"JetBrains Mono",monospace;text-align:right}
|
||||
|
||||
/* color swatch */
|
||||
.sw{display:flex;flex-direction:column;gap:4px}
|
||||
.sw .chip-c{height:56px;border-radius:6px;border:1px solid rgba(0,0,0,.05)}
|
||||
.sw .nm{font-size:11.5px}
|
||||
.sw .vl{font-family:"JetBrains Mono",monospace;font-size:11px;color:var(--mute)}
|
||||
|
||||
.modal-preview{width:100%;background:var(--surface);border:1px solid var(--line);border-radius:8px;overflow:hidden;box-shadow:var(--shadow-l)}
|
||||
.mp-hd{padding:12px 16px;border-bottom:1px solid var(--line-2);display:flex;align-items:center;gap:10px}
|
||||
.mp-bd{padding:16px}
|
||||
.mp-ft{padding:10px 16px;border-top:1px solid var(--line-2);background:var(--surface-2);display:flex;gap:8px;justify-content:flex-end}
|
||||
|
||||
.field{display:flex;flex-direction:column;gap:5px}
|
||||
.hint{font-size:11px;color:var(--mute)}
|
||||
|
||||
.toast{background:var(--primary);color:var(--on-accent);padding:9px 13px;border-radius:8px;font-size:12.5px;display:inline-flex;align-items:center;gap:10px;box-shadow:var(--shadow-toast)}
|
||||
.toast .cap{color:var(--toast-cap)!important}
|
||||
|
||||
.empty{padding:30px;text-align:center;border:1px dashed var(--line);border-radius:8px;color:var(--mute)}
|
||||
.empty .ttl{color:var(--ink);font-size:14px;margin-bottom:4px;font-weight:600}
|
||||
|
||||
.loading{height:8px;border-radius:4px;background:linear-gradient(90deg,var(--line-2) 0,var(--line) 50%,var(--line-2) 100%);background-size:200% 100%;animation:shimmer 1.4s infinite}
|
||||
@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}
|
||||
.skl{display:flex;flex-direction:column;gap:8px}
|
||||
|
||||
.timeline{position:relative;padding-left:18px}
|
||||
.timeline::before{content:"";position:absolute;left:5px;top:6px;bottom:6px;width:1px;background:var(--line)}
|
||||
.tl-item{position:relative;padding-bottom:14px}
|
||||
.tl-item::before{content:"";position:absolute;left:-17px;top:5px;width:11px;height:11px;border-radius:50%;background:var(--surface);border:2px solid var(--accent)}
|
||||
.tl-meta{display:flex;align-items:center;gap:8px;flex-wrap:nowrap;white-space:nowrap;overflow:hidden}
|
||||
.tl-meta .ts{margin-left:auto;font-family:"JetBrains Mono",monospace;font-size:11px;color:var(--mute)}
|
||||
|
||||
.bar{height:6px;border-radius:3px;background:var(--line-2);overflow:hidden}
|
||||
.bar > span{display:block;height:100%;background:var(--accent)}
|
||||
|
||||
.menu{background:var(--surface);border:1px solid var(--line);border-radius:7px;box-shadow:var(--shadow-m);padding:5px;min-width:200px}
|
||||
.menu .it{padding:7px 10px;border-radius:5px;font-size:12.5px;color:var(--ink-2);display:flex;align-items:center;gap:10px;cursor:pointer}
|
||||
.menu .it:hover{background:var(--line-2);color:var(--ink)}
|
||||
.menu .it .k{margin-left:auto;font-family:"JetBrains Mono",monospace;font-size:10.5px;color:var(--mute)}
|
||||
.menu hr{border:none;border-top:1px solid var(--line-2);margin:4px 0}
|
||||
.menu .it.danger{color:var(--pink)}
|
||||
|
||||
.tt{position:relative;display:inline-block}
|
||||
.tt::after{content:attr(data-tip);position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);background:var(--ink);color:var(--bg);padding:4px 8px;border-radius:5px;font-size:11px;white-space:nowrap;opacity:0;pointer-events:none;transition:opacity .12s}
|
||||
.tt:hover::after{opacity:1}
|
||||
|
||||
.avt{width:24px;height:24px;border-radius:50%;background:var(--accent-bg);color:var(--accent);display:inline-flex;align-items:center;justify-content:center;font-size:10.5px;font-weight:600;font-family:Tektur;letter-spacing:.04em}
|
||||
|
||||
/* theme switcher */
|
||||
.theme-switch{position:fixed;top:18px;right:18px;z-index:50;display:inline-flex;border:1px solid var(--line);border-radius:8px;background:var(--surface);box-shadow:var(--shadow-m);overflow:hidden;font-family:Tektur;font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;font-weight:600}
|
||||
.theme-switch button{height:30px;padding:0 11px;border:none;background:transparent;color:var(--mute);cursor:pointer;font:inherit;font-size:9.5px;letter-spacing:.14em;border-right:1px solid var(--line);display:inline-flex;align-items:center;gap:6px}
|
||||
.theme-switch button:last-child{border-right:none}
|
||||
.theme-switch button.on{background:var(--primary);color:var(--on-accent)}
|
||||
.theme-switch .sw-dot{width:8px;height:8px;border-radius:50%;display:inline-block;border:1px solid rgba(0,0,0,.1)}
|
||||
html,body{transition:background .2s, color .2s}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="theme-switch" id="theme-switch">
|
||||
<button data-th="claude-light" class="on"><span class="sw-dot" style="background:#faf9f5;border-color:#e6e1d4"></span>Warm</button>
|
||||
<button data-th="claude"><span class="sw-dot" style="background:#262421"></span>Dark</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
const sw = document.getElementById('theme-switch');
|
||||
const saved = localStorage.getItem('ord-theme') || 'claude-light';
|
||||
apply(saved);
|
||||
sw.addEventListener('click', e => {
|
||||
const b = e.target.closest('button[data-th]');
|
||||
if(!b) return;
|
||||
apply(b.dataset.th);
|
||||
localStorage.setItem('ord-theme', b.dataset.th);
|
||||
});
|
||||
function apply(th){
|
||||
document.documentElement.setAttribute('data-theme', th);
|
||||
sw.querySelectorAll('button').forEach(b => b.classList.toggle('on', b.dataset.th === th));
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<div class="shell">
|
||||
|
||||
<header class="head">
|
||||
<span class="brand" style="font-size:18px">
|
||||
<svg width="24" height="24" viewBox="0 0 32 32" fill="none" aria-hidden="true">
|
||||
<circle cx="16" cy="16" r="13" stroke="currentColor" stroke-width="1.6"/>
|
||||
<circle cx="16" cy="16" r="6" stroke="currentColor" stroke-width="1" opacity=".45"/>
|
||||
<circle cx="22.5" cy="16" r="2.2" fill="currentColor"/>
|
||||
</svg>
|
||||
<span class="wm">ORDINIS<em>MDM</em></span>
|
||||
</span>
|
||||
<h1>UI Kit</h1>
|
||||
<div style="flex:1"></div>
|
||||
<div class="sub">v1.0 · admin design system · 2026</div>
|
||||
</header>
|
||||
|
||||
<nav class="toc">
|
||||
<a href="#type"><span class="cap">01</span> Type</a>
|
||||
<a href="#color"><span class="cap">02</span> Color</a>
|
||||
<a href="#btn"><span class="cap">03</span> Buttons</a>
|
||||
<a href="#form"><span class="cap">04</span> Form</a>
|
||||
<a href="#sel"><span class="cap">05</span> Select & Chips</a>
|
||||
<a href="#status"><span class="cap">06</span> Badges & Status</a>
|
||||
<a href="#nav"><span class="cap">07</span> Tabs & Nav</a>
|
||||
<a href="#alert"><span class="cap">08</span> Alerts</a>
|
||||
<a href="#table"><span class="cap">09</span> Tables</a>
|
||||
<a href="#empty"><span class="cap">10</span> Empty & Loading</a>
|
||||
<a href="#overlay"><span class="cap">11</span> Overlays</a>
|
||||
<a href="#timeline"><span class="cap">12</span> Timeline & Meta</a>
|
||||
</nav>
|
||||
|
||||
<!-- TYPE -->
|
||||
<section id="type">
|
||||
<h2><span class="num">01</span> Typography</h2>
|
||||
<div class="card">
|
||||
<div class="t-row"><span class="name">Tektur 22/600</span><span style="font-family:Tektur;font-weight:600;font-size:22px;letter-spacing:.04em">ORDINIS — каталоги мастер-данных</span><span class="sz">page title</span></div>
|
||||
<div class="t-row"><span class="name">Inter 16/600</span><span style="font-size:16px;font-weight:600">Космические аппараты</span><span class="sz">card title</span></div>
|
||||
<div class="t-row"><span class="name">Inter 13/400</span><span>Каталог космических аппаратов ДЗЗ-миссий, включая планируемые и действующие.</span><span class="sz">body</span></div>
|
||||
<div class="t-row"><span class="name">Inter 12.5/500</span><span style="font-size:12.5px">Кнопки и плотный UI, чтобы помещалось много данных.</span><span class="sz">ui</span></div>
|
||||
<div class="t-row"><span class="name">JetBrains 12/400</span><span class="mono" style="font-size:12px">RES-P-1 · satellites.v1.8.0 · 2026-04-30T09:11Z</span><span class="sz">code/id</span></div>
|
||||
<div class="t-row"><span class="name">Tektur 10/600</span><span class="cap">всё что заглавными — это лейблы и микрокопия</span><span class="sz">cap</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- COLOR -->
|
||||
<section id="color">
|
||||
<h2><span class="num">02</span> Color</h2>
|
||||
<div class="card">
|
||||
<div class="label">Ink & surfaces</div>
|
||||
<div class="grid" style="grid-template-columns:repeat(6,1fr)">
|
||||
<div class="sw"><div class="chip-c" style="background:var(--ink)"></div><div class="nm">ink</div><div class="vl mono">--ink</div></div>
|
||||
<div class="sw"><div class="chip-c" style="background:var(--ink-2)"></div><div class="nm">ink-2</div><div class="vl mono">--ink-2</div></div>
|
||||
<div class="sw"><div class="chip-c" style="background:var(--mute)"></div><div class="nm">mute</div><div class="vl mono">--mute</div></div>
|
||||
<div class="sw"><div class="chip-c" style="background:var(--line)"></div><div class="nm">line</div><div class="vl mono">--line</div></div>
|
||||
<div class="sw"><div class="chip-c" style="background:var(--line-2)"></div><div class="nm">line-2</div><div class="vl mono">--line-2</div></div>
|
||||
<div class="sw"><div class="chip-c" style="background:var(--bg)"></div><div class="nm">bg</div><div class="vl mono">--bg</div></div>
|
||||
</div>
|
||||
<div class="label" style="margin-top:18px">Brand & semantic</div>
|
||||
<div class="grid" style="grid-template-columns:repeat(6,1fr)">
|
||||
<div class="sw"><div class="chip-c" style="background:var(--navy)"></div><div class="nm">navy</div><div class="vl mono">--navy</div></div>
|
||||
<div class="sw"><div class="chip-c" style="background:var(--accent)"></div><div class="nm">accent</div><div class="vl mono">--accent</div></div>
|
||||
<div class="sw"><div class="chip-c" style="background:var(--green)"></div><div class="nm">green</div><div class="vl mono">--green</div></div>
|
||||
<div class="sw"><div class="chip-c" style="background:var(--amber)"></div><div class="nm">amber</div><div class="vl mono">--amber</div></div>
|
||||
<div class="sw"><div class="chip-c" style="background:var(--pink)"></div><div class="nm">pink</div><div class="vl mono">--pink</div></div>
|
||||
<div class="sw"><div class="chip-c" style="background:var(--purple)"></div><div class="nm">purple</div><div class="vl mono">--purple</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- BUTTONS -->
|
||||
<section id="btn">
|
||||
<h2><span class="num">03</span> Buttons</h2>
|
||||
<div class="card col" style="gap:18px">
|
||||
<div>
|
||||
<div class="label">Variants</div>
|
||||
<div class="row">
|
||||
<button class="btn btn-pri">Сохранить</button>
|
||||
<button class="btn">Отмена</button>
|
||||
<button class="btn btn-danger">Удалить</button>
|
||||
<button class="btn btn-ghost">Сбросить</button>
|
||||
<button class="btn" disabled style="opacity:.4;cursor:default">Disabled</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">Sizes</div>
|
||||
<div class="row">
|
||||
<button class="btn btn-pri">Default 32</button>
|
||||
<button class="btn btn-pri btn-sm">Small 26</button>
|
||||
<button class="btn btn-pri btn-xs">Mini 22</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">Icon buttons</div>
|
||||
<div class="row">
|
||||
<button class="icon">⚙</button>
|
||||
<button class="icon">⌕</button>
|
||||
<button class="icon">↻</button>
|
||||
<button class="icon">⊞</button>
|
||||
<button class="icon">⋯</button>
|
||||
<button class="icon">⊕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">With label + counter</div>
|
||||
<div class="row">
|
||||
<button class="btn">Записи <span class="badge bg-public mono">127</span></button>
|
||||
<button class="btn">Поля <span class="badge bg-public mono">12</span></button>
|
||||
<button class="btn btn-pri">+ Создать</button>
|
||||
<button class="btn">⤓ Экспорт</button>
|
||||
<button class="btn">⟲ Time-travel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FORM -->
|
||||
<section id="form">
|
||||
<h2><span class="num">04</span> Form fields</h2>
|
||||
<div class="card grid" style="grid-template-columns:1fr 1fr;gap:18px">
|
||||
<div class="field"><span class="cap">текст</span><input class="inp" placeholder="Resurs-P 1"/><span class="hint">обычная строка</span></div>
|
||||
<div class="field"><span class="cap">id (mono)</span><input class="inp inp-mono" value="RES-P-1" readonly/><span class="hint">readonly</span></div>
|
||||
<div class="field"><span class="cap">число</span><input class="inp inp-mono" value="475"/><span class="hint">км</span></div>
|
||||
<div class="field"><span class="cap">дата</span><input class="inp inp-mono" type="date" value="2013-06-25"/></div>
|
||||
<div class="field"><span class="cap">ошибка</span><input class="inp inp-err" value="RES P 1"/><span class="hint" style="color:var(--pink)">пробелы недопустимы — только snake-kebab</span></div>
|
||||
<div class="field"><span class="cap">select</span><select class="inp"><option>planned</option><option>operational</option><option>decommissioned</option><option>lost</option></select></div>
|
||||
<div class="field" style="grid-column:span 2"><span class="cap">textarea</span><textarea class="inp" style="height:60px;padding:8px 10px;resize:vertical;font-family:inherit">Каталог космических аппаратов ДЗЗ-миссий.</textarea></div>
|
||||
<div class="field" style="grid-column:span 2"><span class="cap">json</span><textarea class="inp inp-mono" style="height:90px;padding:8px 10px;background:var(--surface-2);resize:vertical;line-height:1.55">{
|
||||
"tle": { "line1": "1 39186U …", "line2": "2 39186 …" },
|
||||
"tags": ["eo","optical","high-res"]
|
||||
}</textarea></div>
|
||||
<div class="field"><span class="cap">checkbox</span>
|
||||
<div class="row" style="gap:14px">
|
||||
<label class="row" style="gap:6px"><input type="checkbox" class="chk" checked/> Активен</label>
|
||||
<label class="row" style="gap:6px"><input type="checkbox" class="chk"/> Только в draft</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field"><span class="cap">radio</span>
|
||||
<div class="row" style="gap:14px">
|
||||
<label class="row" style="gap:6px"><input type="radio" name="r" class="rad" checked/> PUBLIC</label>
|
||||
<label class="row" style="gap:6px"><input type="radio" name="r" class="rad"/> INTERNAL</label>
|
||||
<label class="row" style="gap:6px"><input type="radio" name="r" class="rad"/> TENANT</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field"><span class="cap">switch</span>
|
||||
<div class="row" style="gap:10px"><span class="swt on"></span><span style="font-size:12.5px;color:var(--ink-2)">видна потребителям</span></div>
|
||||
</div>
|
||||
<div class="field"><span class="cap">range</span>
|
||||
<div class="row" style="gap:10px;width:100%"><input type="range" min="0" max="100" value="62" style="flex:1;accent-color:var(--accent)"/><span class="mono" style="font-size:11.5px;color:var(--mute);width:32px;text-align:right">62%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SELECT & CHIPS -->
|
||||
<section id="sel">
|
||||
<h2><span class="num">05</span> Selects, chips, tags</h2>
|
||||
<div class="card col" style="gap:18px">
|
||||
<div>
|
||||
<div class="label">Multi-select (тэги)</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:6px;padding:6px;border:1px solid var(--line);border-radius:6px;background:var(--surface)">
|
||||
<span class="tag">MSU-GS <span style="opacity:.6;cursor:pointer">×</span></span>
|
||||
<span class="tag">Geoton-L1 <span style="opacity:.6;cursor:pointer">×</span></span>
|
||||
<span class="tag">KShMSA-VR <span style="opacity:.6;cursor:pointer">×</span></span>
|
||||
<input class="inp" style="border:none;height:24px;padding:0;font-size:12px;background:transparent;flex:1;min-width:80px" placeholder="добавить…"/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">Filter chips</div>
|
||||
<div class="row">
|
||||
<button class="chip on">все <span class="mono" style="opacity:.7">12</span></button>
|
||||
<button class="chip">webhooks <span class="mono" style="opacity:.7">2</span></button>
|
||||
<button class="chip">publish <span class="mono" style="opacity:.7">1</span></button>
|
||||
<button class="chip">review <span class="mono" style="opacity:.7">1</span></button>
|
||||
<button class="chip">edit <span class="mono" style="opacity:.7">1</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">Segmented control</div>
|
||||
<div class="row" style="gap:16px">
|
||||
<div class="seg"><button class="on">версия</button><button>день</button></div>
|
||||
<div class="seg"><button class="on">все</button><button>draft</button><button>review</button><button>live</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- BADGES -->
|
||||
<section id="status">
|
||||
<h2><span class="num">06</span> Badges & status</h2>
|
||||
<div class="card col" style="gap:14px">
|
||||
<div>
|
||||
<div class="label">Scope</div>
|
||||
<div class="row">
|
||||
<span class="badge bg-public">PUBLIC</span>
|
||||
<span class="badge bg-internal">INTERNAL</span>
|
||||
<span class="badge bg-tenant">TENANT</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">Lifecycle</div>
|
||||
<div class="row">
|
||||
<span class="badge bg-draft">DRAFT</span>
|
||||
<span class="badge bg-internal">REVIEW</span>
|
||||
<span class="badge bg-live">LIVE</span>
|
||||
<span class="badge bg-deprec">DEPRECATED</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">Inline status</div>
|
||||
<div class="row" style="gap:18px">
|
||||
<span class="status s-op"><span class="dot" style="background:var(--green)"></span>operational</span>
|
||||
<span class="status s-pl"><span class="dot" style="background:var(--mute)"></span>planned</span>
|
||||
<span class="status s-dc"><span class="dot" style="background:var(--pink)"></span>decommissioned</span>
|
||||
<span class="status s-ls"><span class="dot" style="background:var(--amber)"></span>lost</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">Avatars & people</div>
|
||||
<div class="row">
|
||||
<span class="avt">EM</span>
|
||||
<span class="avt" style="background:var(--avt-pink-bg);color:var(--pink)">AK</span>
|
||||
<span class="avt" style="background:var(--avt-green-bg);color:var(--green)">VP</span>
|
||||
<span style="font-size:12.5px;color:var(--ink-2)">e.morozov, a.korovin, v.petrov</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- TABS -->
|
||||
<section id="nav">
|
||||
<h2><span class="num">07</span> Tabs & sidebar nav</h2>
|
||||
<div class="grid" style="grid-template-columns:1fr 280px;gap:14px">
|
||||
<div class="card">
|
||||
<div class="tab-bar">
|
||||
<button class="tab on">Записи <span class="badge bg-draft mono">127</span></button>
|
||||
<button class="tab">Связи <span class="badge bg-draft mono">6</span></button>
|
||||
<button class="tab">Поля <span class="badge bg-draft mono">12</span></button>
|
||||
<button class="tab">JSON</button>
|
||||
<button class="tab">События</button>
|
||||
<button class="tab">История</button>
|
||||
</div>
|
||||
<div class="row" style="padding:14px 4px 0;gap:6px">
|
||||
<button class="btn btn-sm">operational ▾</button>
|
||||
<button class="btn btn-sm">оператор ▾</button>
|
||||
<input class="inp" placeholder="Поиск по записям…" style="height:28px;flex:1;max-width:260px"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="padding:0">
|
||||
<div style="padding:10px 12px;border-bottom:1px solid var(--line-2)"><span class="cap">справочники</span></div>
|
||||
<div style="padding:6px">
|
||||
<div style="padding:7px 10px;border-radius:5px;background:var(--accent-bg);color:var(--navy);font-weight:600;font-size:12.5px;display:flex;justify-content:space-between"><span>Главная</span></div>
|
||||
<div style="padding:7px 10px;border-radius:5px;color:var(--ink-2);font-size:12.5px;display:flex;justify-content:space-between"><span>Справочники</span><span class="mono" style="color:var(--mute)">48</span></div>
|
||||
<div style="padding:7px 10px;border-radius:5px;color:var(--ink-2);font-size:12.5px;display:flex;justify-content:space-between"><span>Мои черновики</span><span class="mono" style="color:var(--mute)">3</span></div>
|
||||
<div style="padding:7px 10px;border-radius:5px;color:var(--ink-2);font-size:12.5px;display:flex;justify-content:space-between"><span>На ревью</span><span class="mono" style="color:var(--amber)">7</span></div>
|
||||
<div style="padding:7px 10px;border-radius:5px;color:var(--ink-2);font-size:12.5px;display:flex;justify-content:space-between"><span>Outbox</span><span class="mono" style="color:var(--pink)">2</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ALERTS -->
|
||||
<section id="alert">
|
||||
<h2><span class="num">08</span> Alerts</h2>
|
||||
<div class="card col">
|
||||
<div class="alert al-info"><span class="ico">i</span><div><strong>Новая версия в работе.</strong> v1.9.0-draft создаётся в фоне на основе текущего состояния.</div></div>
|
||||
<div class="alert al-ok"><span class="ico">✓</span><div><strong>Опубликовано.</strong> v1.8.0 ушла в production. 3 потребителя получили webhook.</div></div>
|
||||
<div class="alert al-warn"><span class="ico">!</span><div><strong>Поле «design_life» изменило тип.</strong> Старые значения автоматически приведены к number — проверьте записи.</div></div>
|
||||
<div class="alert al-err"><span class="ico">×</span><div><strong>Webhook legacy-erp вернул 504.</strong> Поставлен на ретрай (попытка 3/5). <a href="#" style="color:var(--pink)">Смотреть лог →</a></div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- TABLES -->
|
||||
<section id="table">
|
||||
<h2><span class="num">09</span> Tables</h2>
|
||||
<div class="card" style="padding:0;overflow:hidden">
|
||||
<table class="table">
|
||||
<thead><tr><th style="width:30px"></th><th>ID</th><th>Название</th><th>Оператор</th><th>Статус</th><th>Орбита</th><th>Запуск</th><th style="width:30px"></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><input type="checkbox" class="chk"/></td><td class="mono" style="color:var(--accent)">RES-P-1</td><td>Ресурс-П №1</td><td>Роскосмос</td><td><span class="status s-op"><span class="dot" style="background:var(--green)"></span>operational</span></td><td class="mono">SSO</td><td class="mono" style="color:var(--mute)">2013-06-25</td><td style="color:var(--mute)">⋯</td></tr>
|
||||
<tr><td><input type="checkbox" class="chk" checked/></td><td class="mono" style="color:var(--accent)">SNT-1B</td><td>Sentinel-1B</td><td>ESA</td><td><span class="status s-dc"><span class="dot" style="background:var(--pink)"></span>decommissioned</span></td><td class="mono">SSO</td><td class="mono" style="color:var(--mute)">2016-04-25</td><td style="color:var(--mute)">⋯</td></tr>
|
||||
<tr><td><input type="checkbox" class="chk"/></td><td class="mono" style="color:var(--accent)">LST-9</td><td>Landsat 9</td><td>NASA</td><td><span class="status s-op"><span class="dot" style="background:var(--green)"></span>operational</span></td><td class="mono">SSO</td><td class="mono" style="color:var(--mute)">2021-09-27</td><td style="color:var(--mute)">⋯</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- EMPTY / LOADING -->
|
||||
<section id="empty">
|
||||
<h2><span class="num">10</span> Empty & loading</h2>
|
||||
<div class="grid" style="grid-template-columns:1fr 1fr;gap:14px">
|
||||
<div class="empty">
|
||||
<div class="ttl">Здесь пока ничего нет</div>
|
||||
<div style="font-size:12.5px;color:var(--mute);margin-bottom:14px">Создайте первый справочник, чтобы начать работу.</div>
|
||||
<button class="btn btn-pri btn-sm">+ Создать справочник</button>
|
||||
</div>
|
||||
<div class="card skl">
|
||||
<div class="loading" style="width:30%"></div>
|
||||
<div class="loading"></div>
|
||||
<div class="loading" style="width:80%"></div>
|
||||
<div class="loading" style="width:60%"></div>
|
||||
<div class="loading" style="width:90%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="margin-top:14px">
|
||||
<div class="label">Progress</div>
|
||||
<div class="col">
|
||||
<div class="row" style="gap:10px"><div class="bar" style="flex:1"><span style="width:24%"></span></div><span class="mono" style="font-size:11px;color:var(--mute);width:40px;text-align:right">24%</span></div>
|
||||
<div class="row" style="gap:10px"><div class="bar" style="flex:1"><span style="width:68%;background:var(--green)"></span></div><span class="mono" style="font-size:11px;color:var(--mute);width:40px;text-align:right">68%</span></div>
|
||||
<div class="row" style="gap:10px"><div class="bar" style="flex:1"><span style="width:92%;background:var(--amber)"></span></div><span class="mono" style="font-size:11px;color:var(--mute);width:40px;text-align:right">92%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- OVERLAYS -->
|
||||
<section id="overlay">
|
||||
<h2><span class="num">11</span> Modals, menus, tooltips, toasts</h2>
|
||||
<div class="grid" style="grid-template-columns:1fr 1fr;gap:14px;align-items:start">
|
||||
<div>
|
||||
<div class="label">Modal</div>
|
||||
<div class="modal-preview" style="max-width:480px">
|
||||
<div class="mp-hd"><span class="cap" style="color:var(--accent)">новый справочник</span><div style="flex:1"></div><span style="color:var(--mute);cursor:pointer">×</span></div>
|
||||
<div class="mp-bd" style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div class="field" style="grid-column:span 2"><span class="cap">название</span><input class="inp" value="Космические аппараты"/></div>
|
||||
<div class="field"><span class="cap">id</span><input class="inp inp-mono" value="satellites"/></div>
|
||||
<div class="field"><span class="cap">bundle</span><select class="inp"><option>cuod</option></select></div>
|
||||
</div>
|
||||
<div class="mp-ft"><button class="btn btn-sm">Отмена</button><button class="btn btn-pri btn-sm">Создать</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">Dropdown menu</div>
|
||||
<div class="menu" style="max-width:240px">
|
||||
<div class="it">Открыть в новой вкладке <span class="k">⌘↵</span></div>
|
||||
<div class="it">Дублировать <span class="k">⌘D</span></div>
|
||||
<div class="it">Экспорт CSV…</div>
|
||||
<hr/>
|
||||
<div class="it">Поставить на ревью</div>
|
||||
<div class="it">Time-travel…</div>
|
||||
<hr/>
|
||||
<div class="it danger">Удалить запись</div>
|
||||
</div>
|
||||
|
||||
<div class="label" style="margin-top:18px">Toast & tooltip</div>
|
||||
<div class="row" style="gap:24px">
|
||||
<div class="toast"><span class="cap">ok</span><span>Справочник создан как draft v0.1.0</span></div>
|
||||
<div class="tt" data-tip="последнее изменение 2 часа назад"><button class="btn btn-sm">наведи</button></div>
|
||||
<div class="row" style="gap:4px"><span class="kbd">⌘</span><span class="kbd">K</span><span style="font-size:11.5px;color:var(--mute);margin-left:6px">палитра действий</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- TIMELINE / META -->
|
||||
<section id="timeline">
|
||||
<h2><span class="num">12</span> Timeline, meta, callouts</h2>
|
||||
<div class="grid" style="grid-template-columns:1fr 1fr;gap:14px;align-items:start">
|
||||
<div class="card">
|
||||
<div class="label">Timeline (история записи)</div>
|
||||
<div class="timeline">
|
||||
<div class="tl-item">
|
||||
<div class="tl-meta"><span class="cap" style="color:var(--accent)">изменено</span><span class="mono" style="font-size:11px;color:var(--accent)">v1.8.0</span><span class="ts">30.04.26</span></div>
|
||||
<div style="margin-top:3px">+ raw_json (TLE данные)</div>
|
||||
<div style="font-size:11.5px;color:var(--mute);margin-top:2px">автор · <span class="mono">e.morozov</span></div>
|
||||
</div>
|
||||
<div class="tl-item">
|
||||
<div class="tl-meta"><span class="cap" style="color:var(--amber)">переименование</span><span class="mono" style="font-size:11px;color:var(--accent)">v1.2.0</span><span class="ts">22.05.25</span></div>
|
||||
<div style="margin-top:3px">оператор: РКА → Роскосмос</div>
|
||||
<div style="font-size:11.5px;color:var(--mute);margin-top:2px">автор · <span class="mono">e.morozov</span></div>
|
||||
</div>
|
||||
<div class="tl-item">
|
||||
<div class="tl-meta"><span class="cap" style="color:var(--green)">создано</span><span class="mono" style="font-size:11px;color:var(--accent)">v1.0.0</span><span class="ts">12.01.25</span></div>
|
||||
<div style="margin-top:3px">запись создана</div>
|
||||
<div style="font-size:11.5px;color:var(--mute);margin-top:2px">автор · <span class="mono">e.morozov</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Meta-grid (сайдбар записи)</div>
|
||||
<div class="grid" style="grid-template-columns:1fr 1fr;gap:10px">
|
||||
<div><span class="cap">bundle</span><div class="mono" style="margin-top:2px">cuod</div></div>
|
||||
<div><span class="cap">версия</span><div class="mono" style="margin-top:2px">1.8.0</div></div>
|
||||
<div><span class="cap">записей</span><div class="mono" style="margin-top:2px">127</div></div>
|
||||
<div><span class="cap">локали</span><div class="mono" style="margin-top:2px">ru, en</div></div>
|
||||
</div>
|
||||
<div style="border-top:1px solid var(--line-2);margin-top:14px;padding-top:12px">
|
||||
<span class="cap">связи (6)</span>
|
||||
<div style="display:flex;flex-direction:column;gap:4px;margin-top:6px;font-size:11.5px">
|
||||
<div style="color:var(--mute)">→ ссылается</div>
|
||||
<a class="mono" style="color:var(--accent);text-decoration:none">operators</a>
|
||||
<a class="mono" style="color:var(--accent);text-decoration:none">satellite_statuses</a>
|
||||
<div style="color:var(--mute);margin-top:6px">← используют</div>
|
||||
<a class="mono" style="color:var(--accent);text-decoration:none">acquisitions</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div style="margin-top:48px;padding-top:18px;border-top:1px solid var(--line);display:flex;align-items:baseline;gap:14px;color:var(--mute);font-size:11.5px">
|
||||
<span class="brand" style="font-size:12px">
|
||||
<svg width="16" height="16" viewBox="0 0 32 32" fill="none" aria-hidden="true">
|
||||
<circle cx="16" cy="16" r="13" stroke="currentColor" stroke-width="1.8"/>
|
||||
<circle cx="22.5" cy="16" r="2.4" fill="currentColor"/>
|
||||
</svg>
|
||||
ORDINIS
|
||||
</span> <span>UI Kit v1.0</span><span style="flex:1"></span>
|
||||
<span class="mono">Inter · JetBrains Mono · Tektur</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 30 KiB |
@@ -3,6 +3,7 @@ import type { QueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { LanguageSwitch } from '@nstart/ui'
|
||||
import { AuthBadge } from '@/auth/AuthBadge'
|
||||
import { useCanMutate } from '@/auth/useCanMutate'
|
||||
import { UpdateBanner } from '@/components/version/UpdateBanner'
|
||||
import { VersionBadge } from '@/components/version/VersionBadge'
|
||||
|
||||
@@ -17,6 +18,11 @@ const LANG_OPTIONS = [
|
||||
|
||||
function RootLayout() {
|
||||
const { t, i18n } = useTranslation()
|
||||
// Гость (anonymous) видит только ссылку на /dictionaries и /search.
|
||||
// Аудит, outbox, webhooks, drafts, reviews — admin-only страницы, требуют
|
||||
// JWT (backend SecurityConfig + admin role). Скрываем из top-nav чтобы
|
||||
// user не получал 401 toast при навигации.
|
||||
const canMutate = useCanMutate()
|
||||
return (
|
||||
<div className="min-h-full flex flex-col bg-white">
|
||||
<UpdateBanner />
|
||||
@@ -33,27 +39,6 @@ function RootLayout() {
|
||||
>
|
||||
{t('nav.dictionaries')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/audit"
|
||||
className="text-carbon hover:text-ultramarain"
|
||||
activeProps={{ className: 'text-ultramarain font-medium' }}
|
||||
>
|
||||
{t('nav.audit')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/outbox"
|
||||
className="text-carbon hover:text-ultramarain"
|
||||
activeProps={{ className: 'text-ultramarain font-medium' }}
|
||||
>
|
||||
{t('nav.outbox')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/webhooks"
|
||||
className="text-carbon hover:text-ultramarain"
|
||||
activeProps={{ className: 'text-ultramarain font-medium' }}
|
||||
>
|
||||
{t('nav.webhooks')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/search"
|
||||
className="text-carbon hover:text-ultramarain"
|
||||
@@ -61,20 +46,45 @@ function RootLayout() {
|
||||
>
|
||||
{t('nav.search')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/reviews"
|
||||
className="text-carbon hover:text-ultramarain"
|
||||
activeProps={{ className: 'text-ultramarain font-medium' }}
|
||||
>
|
||||
{t('nav.reviews')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/my-drafts"
|
||||
className="text-carbon hover:text-ultramarain"
|
||||
activeProps={{ className: 'text-ultramarain font-medium' }}
|
||||
>
|
||||
{t('nav.myDrafts')}
|
||||
</Link>
|
||||
{canMutate && (
|
||||
<>
|
||||
<Link
|
||||
to="/audit"
|
||||
className="text-carbon hover:text-ultramarain"
|
||||
activeProps={{ className: 'text-ultramarain font-medium' }}
|
||||
>
|
||||
{t('nav.audit')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/outbox"
|
||||
className="text-carbon hover:text-ultramarain"
|
||||
activeProps={{ className: 'text-ultramarain font-medium' }}
|
||||
>
|
||||
{t('nav.outbox')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/webhooks"
|
||||
className="text-carbon hover:text-ultramarain"
|
||||
activeProps={{ className: 'text-ultramarain font-medium' }}
|
||||
>
|
||||
{t('nav.webhooks')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/reviews"
|
||||
className="text-carbon hover:text-ultramarain"
|
||||
activeProps={{ className: 'text-ultramarain font-medium' }}
|
||||
>
|
||||
{t('nav.reviews')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/my-drafts"
|
||||
className="text-carbon hover:text-ultramarain"
|
||||
activeProps={{ className: 'text-ultramarain font-medium' }}
|
||||
>
|
||||
{t('nav.myDrafts')}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<VersionBadge />
|
||||
|
||||
@@ -136,8 +136,10 @@ function DictionaryDetail() {
|
||||
* "На review" в каждой строке records table. Polled 30s. Bypass'нем если
|
||||
* dict не approval-required (нет смысла грузить).
|
||||
*/
|
||||
// Гость (anonymous) — drafts polling требует JWT (401 каждые 30s в console).
|
||||
// Disable если не авторизован.
|
||||
const pendingDraftsQuery = useDictPendingDrafts(
|
||||
detailQuery.data?.approvalRequired ? name : undefined,
|
||||
canMutate && detailQuery.data?.approvalRequired ? name : undefined,
|
||||
)
|
||||
const pendingByKey = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
|
||||