Merge branch 'fix/loop-globeloader-error-boundary' into 'main'
fix(ui): infinite-loop + GlobeLoader + AppErrorBoundary + a11y modals See merge request 2-6/2-6-4/terravault/ordinis!94
This commit is contained in:
@@ -0,0 +1,354 @@
|
|||||||
|
# Pending endpoints — frontend wishlist
|
||||||
|
|
||||||
|
Эти 5 endpoint'ов нужны admin-UI для завершения UI-фич, заплейсхолженных в
|
||||||
|
sprint UI polish (MR !74→!93, mai-2026). Каждый блокирует конкретный
|
||||||
|
component на frontend — сегодня там показывается placeholder ("backend
|
||||||
|
changelog pending" notice / disabled button / `0` count).
|
||||||
|
|
||||||
|
Frontend готов подключить через Orval — нужны только OpenAPI спеки.
|
||||||
|
Приоритет в порядке user-impact: 1 (highest) → 5 (nice-to-have).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Schema versions timeline — `GET /api/v1/dictionaries/{name}/changelog`
|
||||||
|
|
||||||
|
**Purpose:** History tab в editor (`/dictionaries/$name?tab=history`) и
|
||||||
|
Time-travel modal — список всех schema versions с change summaries.
|
||||||
|
|
||||||
|
**Frontend usage:**
|
||||||
|
|
||||||
|
- `HistoryTabContent` (`routes/dictionaries.$name.tsx:HistoryTabContent`)
|
||||||
|
показывает HEAD row + N исторических versions с `v{N} +X ~Y −Z`
|
||||||
|
change summary, who/when, diff link.
|
||||||
|
- `TimeTravelModal` использует timestamps versions для slider marks.
|
||||||
|
|
||||||
|
**Route:** `GET /api/v1/dictionaries/{name}/changelog`
|
||||||
|
|
||||||
|
**Query params:**
|
||||||
|
|
||||||
|
| param | type | default | description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `limit` | int | 50 | max entries (cap 200) |
|
||||||
|
| `cursor` | string | — | opaque pagination token (next-page link) |
|
||||||
|
| `from` | ISO datetime | — | filter versions с `publishedAt >= from` |
|
||||||
|
| `to` | ISO datetime | — | filter versions с `publishedAt <= to` |
|
||||||
|
|
||||||
|
**Response 200:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dictionary": "satellites",
|
||||||
|
"currentVersion": "1.4.0",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"version": "1.4.0",
|
||||||
|
"publishedAt": "2026-04-12T10:23:00Z",
|
||||||
|
"publishedBy": { "sub": "u-42", "name": "zimin.an" },
|
||||||
|
"headers": "x-references массив + поле altitude",
|
||||||
|
"diff": {
|
||||||
|
"added": ["properties.altitude"],
|
||||||
|
"removed": [],
|
||||||
|
"changed": ["properties.operator (enum +1 value)"]
|
||||||
|
},
|
||||||
|
"recordsAffected": 0,
|
||||||
|
"isCurrent": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.3.2",
|
||||||
|
"publishedAt": "2026-03-30T08:11:00Z",
|
||||||
|
"publishedBy": { "sub": "u-17", "name": "petrov.iv" },
|
||||||
|
"headers": "fix description перевод EN",
|
||||||
|
"diff": { "added": [], "removed": [], "changed": ["description.en"] },
|
||||||
|
"recordsAffected": 0,
|
||||||
|
"isCurrent": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nextCursor": "eyJ2IjoiMS4zLjEiLCJ0IjoiMjAyNi0wMy0xNVQwMDowMDowMFoifQ=="
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errors:** 404 dict not found, 401 unauth.
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- `diff` — best-effort summary, не полный JSON-patch. Если backend
|
||||||
|
считает дорого — допустим только counts: `{ "added": 3, "removed": 0, "changed": 2 }`.
|
||||||
|
- `headers` ≈ commit message от автора schema edit. Optional; покажем
|
||||||
|
"—" если empty.
|
||||||
|
|
||||||
|
**Complexity:** medium. Нужна таблица `dictionary_schema_history` (если
|
||||||
|
ещё нет — версия + JSON snapshot + author + timestamp).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Records snapshots — `GET /api/v1/dictionaries/{name}/snapshots`
|
||||||
|
|
||||||
|
**Purpose:** Time-travel modal slider marks — точки во времени где
|
||||||
|
дествительно произошёл change в records (не каждая секунда). UX:
|
||||||
|
slider snaps к "интересным" моментам.
|
||||||
|
|
||||||
|
**Frontend usage:**
|
||||||
|
|
||||||
|
- `TimeTravelModal` (`components/timetravel/TimeTravelModal.tsx`) рендерит
|
||||||
|
marks на slider — каждая mark = snapshot point. Сейчас используем
|
||||||
|
`recordTimestamps` derived из `validFrom` — но это не учитывает closes.
|
||||||
|
|
||||||
|
**Route:** `GET /api/v1/dictionaries/{name}/snapshots`
|
||||||
|
|
||||||
|
**Query params:**
|
||||||
|
|
||||||
|
| param | type | default | description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `from` | ISO datetime | now − 30d | start of window |
|
||||||
|
| `to` | ISO datetime | now | end of window |
|
||||||
|
| `granularity` | string | `day` | `hour` / `day` / `week` — bucket size |
|
||||||
|
|
||||||
|
**Response 200:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dictionary": "satellites",
|
||||||
|
"from": "2026-04-01T00:00:00Z",
|
||||||
|
"to": "2026-05-11T00:00:00Z",
|
||||||
|
"snapshots": [
|
||||||
|
{
|
||||||
|
"at": "2026-04-01T00:00:00Z",
|
||||||
|
"totalRecords": 124,
|
||||||
|
"changedSinceLast": 0,
|
||||||
|
"label": "1 апр"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"at": "2026-04-15T14:00:00Z",
|
||||||
|
"totalRecords": 127,
|
||||||
|
"changedSinceLast": 3,
|
||||||
|
"label": "15 апр"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"at": "2026-05-11T00:00:00Z",
|
||||||
|
"totalRecords": 130,
|
||||||
|
"changedSinceLast": 5,
|
||||||
|
"label": "сейчас",
|
||||||
|
"isCurrent": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Backend free выбрать какие точки маркировать — main критерий ≥1 change
|
||||||
|
с прошлой snapshot. Пустые часы/дни skip.
|
||||||
|
- `label` — backend-formatted RU label для UI (избегаем locale logic
|
||||||
|
на front).
|
||||||
|
|
||||||
|
**Complexity:** medium. Можно делать `SELECT DISTINCT date_trunc('day', valid_from)`
|
||||||
|
+ count(*) over window. Кеш ~5min на горячих dict'ах.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Workflow state machine — workflow API
|
||||||
|
|
||||||
|
**Purpose:** Workflow buttons в editor toolbar (`Request review`,
|
||||||
|
`Approve`, `Publish`, `Create draft`). Сейчас показываются placeholder /
|
||||||
|
hidden когда `approvalRequired=true` потому что нет actions.
|
||||||
|
|
||||||
|
**Frontend usage:**
|
||||||
|
|
||||||
|
- `WorkflowBanner` (`components/editor/WorkflowBanner.tsx`) показывает
|
||||||
|
"live" / "pending review" / "draft" badge.
|
||||||
|
- Action buttons прячутся пока нет API. После shipping — wire'им
|
||||||
|
кнопки в banner / editor toolbar.
|
||||||
|
|
||||||
|
**Routes (4 endpoints, RESTful):**
|
||||||
|
|
||||||
|
### 3.1 `POST /api/v1/dictionaries/{name}/drafts`
|
||||||
|
|
||||||
|
Create draft от текущего HEAD. Returns draft id.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "fromVersion": "1.4.0", "reason": "обновить altitude диапазон" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{ "draftId": "d-42a7", "branchedFrom": "1.4.0", "status": "draft" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 `POST /api/v1/dictionaries/{name}/drafts/{draftId}/review`
|
||||||
|
|
||||||
|
Submit draft for review.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "reviewers": ["u-17", "u-92"], "note": "проверьте altitude rounding" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Response: 202 Accepted, `{ "status": "review_pending" }`.
|
||||||
|
|
||||||
|
### 3.3 `POST /api/v1/dictionaries/{name}/drafts/{draftId}/decision`
|
||||||
|
|
||||||
|
Reviewer's decision.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "decision": "approve", "comment": "OK" }
|
||||||
|
```
|
||||||
|
|
||||||
|
`decision`: `approve` | `request_changes` | `reject`. Response 200 с new status.
|
||||||
|
|
||||||
|
### 3.4 `POST /api/v1/dictionaries/{name}/drafts/{draftId}/publish`
|
||||||
|
|
||||||
|
Publish approved draft → becomes new HEAD. Optimistic-lock на текущую version.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "expectedHeadVersion": "1.4.0", "publishNote": "v1.5.0 — altitude диапазон" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Response 200:
|
||||||
|
```json
|
||||||
|
{ "newVersion": "1.5.0", "publishedAt": "2026-05-11T22:00:00Z" }
|
||||||
|
```
|
||||||
|
|
||||||
|
409 если кто-то другой опубликовал свой draft первым (concurrent publish race).
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- State machine: `draft` → `review_pending` → (`approved` | `changes_requested` | `rejected`) → (publish | discard).
|
||||||
|
- Все 4 endpoint'а должны вернуть updated draft state — frontend invalidate'ит
|
||||||
|
query keys по dictionary name.
|
||||||
|
- RBAC: только approvers могут вызвать 3.3 с decision=approve.
|
||||||
|
|
||||||
|
**Complexity:** high. Нужны таблицы `drafts`, `draft_reviews`, state transition
|
||||||
|
guards. Webhook event hooks (frontend ожидает `dict.draft.*` events).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. PATCH schema fields — `PATCH /api/v1/dictionaries/{name}/schema`
|
||||||
|
|
||||||
|
**Purpose:** Inline edit single field в Fields tab без открытия full
|
||||||
|
DictionaryEditorDialog. UX: clickable field row → inline form → save →
|
||||||
|
HEAD bumps minor version.
|
||||||
|
|
||||||
|
**Frontend usage:**
|
||||||
|
|
||||||
|
- `FieldsTabContent` (`routes/dictionaries.$name.tsx:FieldsTabContent`)
|
||||||
|
сейчас read-only. После endpoint — кнопка edit на каждой row.
|
||||||
|
|
||||||
|
**Route:** `PATCH /api/v1/dictionaries/{name}/schema`
|
||||||
|
|
||||||
|
**Body (RFC 6902 JSON-patch subset или structured ops — backend выбирает):**
|
||||||
|
|
||||||
|
Option A — JSON Patch:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"expectedHeadVersion": "1.4.0",
|
||||||
|
"ops": [
|
||||||
|
{
|
||||||
|
"op": "replace",
|
||||||
|
"path": "/properties/altitude/description",
|
||||||
|
"value": { "ru": "Высота орбиты в км", "en": "Orbit altitude in km" }
|
||||||
|
},
|
||||||
|
{ "op": "add", "path": "/properties/altitude/minimum", "value": 0 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Option B — structured (более ergonomic для UI):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"expectedHeadVersion": "1.4.0",
|
||||||
|
"field": "altitude",
|
||||||
|
"changes": {
|
||||||
|
"description": { "ru": "...", "en": "..." },
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Constraints:** допустимые ops — non-breaking changes только (description,
|
||||||
|
title, minimum/maximum bounds expansion, enum value добавление). Breaking
|
||||||
|
ops (rename, type change, remove) → 422 с `code: "breaking_change_requires_draft"`.
|
||||||
|
|
||||||
|
**Response 200:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"newVersion": "1.4.1",
|
||||||
|
"schemaJson": { /* full updated schema */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errors:**
|
||||||
|
|
||||||
|
| code | status | meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `breaking_change_requires_draft` | 422 | op требует workflow draft, не inline patch |
|
||||||
|
| `version_mismatch` | 409 | someone else patched schema first (refetch) |
|
||||||
|
| `validation_failed` | 422 | op breaks existing record validation |
|
||||||
|
|
||||||
|
**Complexity:** medium-high. Нужен validator который проверяет что patch
|
||||||
|
не breaks существующие записи (semver-minor только).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Batch outgoing FK counts — `GET /api/v1/dictionaries/graph/outgoing`
|
||||||
|
|
||||||
|
**Purpose:** Catalog list (`/dictionaries`) — колонка `→` показывает
|
||||||
|
сколько FK rel'ов идёт ОТ каждого dict'а. Сейчас на front считаем
|
||||||
|
loop'ом per-row через `extractOutgoingFkCount(schemaJson)` — работает,
|
||||||
|
но требует все schemaJson на клиенте.
|
||||||
|
|
||||||
|
**Frontend usage:**
|
||||||
|
|
||||||
|
- `routes/dictionaries.index.tsx` catalog table — `→` column показывает
|
||||||
|
outgoing FK count. Если backend вернёт батчом — frontend сделает 1
|
||||||
|
request вместо включения schemaJson в каждую row useDictionaries.
|
||||||
|
|
||||||
|
**Route:** `GET /api/v1/dictionaries/graph/outgoing?dicts=satellites,operators,...`
|
||||||
|
|
||||||
|
**Query params:**
|
||||||
|
|
||||||
|
| param | type | default | description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `dicts` | comma-separated string | all visible | optional filter |
|
||||||
|
| `scope` | comma-separated string | all | `PUBLIC,INTERNAL` etc |
|
||||||
|
|
||||||
|
**Response 200:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"outgoing": {
|
||||||
|
"satellites": { "fkCount": 3, "targets": ["operators", "launch_sites", "missions"] },
|
||||||
|
"operators": { "fkCount": 0, "targets": [] },
|
||||||
|
"missions": { "fkCount": 2, "targets": ["operators", "rockets"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- `targets` опционально — если дорого, можно вернуть только `fkCount`.
|
||||||
|
- Скорость: вся таблица обычно ≤200 dict'ов — простой SELECT с
|
||||||
|
`jsonb_array_length(schema_json->'x-references')` или `LATERAL`
|
||||||
|
enumeration. Кеш 60s.
|
||||||
|
|
||||||
|
**Complexity:** low. Это derived data из существующих schemaJson —
|
||||||
|
просто batch SELECT + aggregate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend integration timeline
|
||||||
|
|
||||||
|
| Endpoint | Component | Estimate after backend ship |
|
||||||
|
|---|---|---|
|
||||||
|
| 1. Changelog | `HistoryTabContent`, `TimeTravelModal` | ~2h (Orval hook + render entries) |
|
||||||
|
| 2. Snapshots | `TimeTravelModal` slider marks | ~1h (replace mock derivation) |
|
||||||
|
| 3. Workflow API | `WorkflowBanner` + new buttons in toolbar | ~6h (4 mutations + state UI) |
|
||||||
|
| 4. PATCH schema | `FieldsTabContent` inline edit | ~4h (form + diff preview) |
|
||||||
|
| 5. Batch graph | `dictionaries.index` `→` column | ~30min (single query swap) |
|
||||||
|
|
||||||
|
Total frontend work post-backend: **~14h** (1.5 sprint days). Per
|
||||||
|
endpoint frontend готов в течение часа после OpenAPI freeze.
|
||||||
|
|
||||||
|
## Conventions reminder
|
||||||
|
|
||||||
|
- Все endpoints должны быть Authenticated (Keycloak JWT, см. [auth.md](auth.md)).
|
||||||
|
- Errors следуют [errors.md](errors.md) — `{ code, message, details? }`.
|
||||||
|
- Optimistic-lock 409 везде где есть concurrent edits.
|
||||||
|
- Webhook events для async state changes — см. [webhooks.md](webhooks.md).
|
||||||
|
- Idempotency-Key header на mutating endpoints (Time-travel race
|
||||||
|
safety per existing API guidelines).
|
||||||
@@ -29,6 +29,8 @@ items:
|
|||||||
href: integration/api/best-practices.md
|
href: integration/api/best-practices.md
|
||||||
- name: Bundle справочников cuod
|
- name: Bundle справочников cuod
|
||||||
href: integration/api/bundle-cuod.md
|
href: integration/api/bundle-cuod.md
|
||||||
|
- name: Pending endpoints (admin-UI wishlist)
|
||||||
|
href: integration/api/pending-endpoints.md
|
||||||
|
|
||||||
- name: Прикладные сценарии
|
- name: Прикладные сценарии
|
||||||
items:
|
items:
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"d3-force": "^3.0.0",
|
"d3-force": "^3.0.0",
|
||||||
|
"d3-geo": "^3.1.1",
|
||||||
"i18next": "^26.0.3",
|
"i18next": "^26.0.3",
|
||||||
"i18next-browser-languagedetector": "^8.0.2",
|
"i18next-browser-languagedetector": "^8.0.2",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
@@ -44,7 +45,9 @@
|
|||||||
"react-oidc-context": "^3.3.1",
|
"react-oidc-context": "^3.3.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tw-animate-css": "^1.4.0"
|
"topojson-client": "^3.1.0",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"world-atlas": "^2.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@semantic-release/changelog": "^6.0.3",
|
"@semantic-release/changelog": "^6.0.3",
|
||||||
@@ -59,9 +62,12 @@
|
|||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/d3-force": "^3.0.10",
|
"@types/d3-force": "^3.0.10",
|
||||||
|
"@types/d3-geo": "^3.1.0",
|
||||||
|
"@types/geojson": "^7946.0.16",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@types/topojson-client": "^3.1.5",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"conventional-changelog-conventionalcommits": "^9.3.1",
|
"conventional-changelog-conventionalcommits": "^9.3.1",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
|
|||||||
Generated
+80
@@ -68,6 +68,9 @@ importers:
|
|||||||
d3-force:
|
d3-force:
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.0.0
|
version: 3.0.0
|
||||||
|
d3-geo:
|
||||||
|
specifier: ^3.1.1
|
||||||
|
version: 3.1.1
|
||||||
i18next:
|
i18next:
|
||||||
specifier: ^26.0.3
|
specifier: ^26.0.3
|
||||||
version: 26.0.8(typescript@5.9.3)
|
version: 26.0.8(typescript@5.9.3)
|
||||||
@@ -107,9 +110,15 @@ importers:
|
|||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.6.0
|
specifier: ^3.6.0
|
||||||
version: 3.6.0
|
version: 3.6.0
|
||||||
|
topojson-client:
|
||||||
|
specifier: ^3.1.0
|
||||||
|
version: 3.1.0
|
||||||
tw-animate-css:
|
tw-animate-css:
|
||||||
specifier: ^1.4.0
|
specifier: ^1.4.0
|
||||||
version: 1.4.0
|
version: 1.4.0
|
||||||
|
world-atlas:
|
||||||
|
specifier: ^2.0.2
|
||||||
|
version: 2.0.2
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@semantic-release/changelog':
|
'@semantic-release/changelog':
|
||||||
specifier: ^6.0.3
|
specifier: ^6.0.3
|
||||||
@@ -147,6 +156,12 @@ importers:
|
|||||||
'@types/d3-force':
|
'@types/d3-force':
|
||||||
specifier: ^3.0.10
|
specifier: ^3.0.10
|
||||||
version: 3.0.10
|
version: 3.0.10
|
||||||
|
'@types/d3-geo':
|
||||||
|
specifier: ^3.1.0
|
||||||
|
version: 3.1.0
|
||||||
|
'@types/geojson':
|
||||||
|
specifier: ^7946.0.16
|
||||||
|
version: 7946.0.16
|
||||||
'@types/leaflet':
|
'@types/leaflet':
|
||||||
specifier: ^1.9.21
|
specifier: ^1.9.21
|
||||||
version: 1.9.21
|
version: 1.9.21
|
||||||
@@ -156,6 +171,9 @@ importers:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.3(@types/react@19.2.14)
|
version: 19.2.3(@types/react@19.2.14)
|
||||||
|
'@types/topojson-client':
|
||||||
|
specifier: ^3.1.5
|
||||||
|
version: 3.1.5
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^4.3.4
|
specifier: ^4.3.4
|
||||||
version: 4.7.0(vite@6.4.2(jiti@2.6.1)(lightningcss@1.32.0))
|
version: 4.7.0(vite@6.4.2(jiti@2.6.1)(lightningcss@1.32.0))
|
||||||
@@ -1619,6 +1637,9 @@ packages:
|
|||||||
'@types/d3-force@3.0.10':
|
'@types/d3-force@3.0.10':
|
||||||
resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
|
resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
|
||||||
|
|
||||||
|
'@types/d3-geo@3.1.0':
|
||||||
|
resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==}
|
||||||
|
|
||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
@@ -1642,6 +1663,12 @@ packages:
|
|||||||
'@types/react@19.2.14':
|
'@types/react@19.2.14':
|
||||||
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
|
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
|
||||||
|
|
||||||
|
'@types/topojson-client@3.1.5':
|
||||||
|
resolution: {integrity: sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==}
|
||||||
|
|
||||||
|
'@types/topojson-specification@1.0.5':
|
||||||
|
resolution: {integrity: sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==}
|
||||||
|
|
||||||
'@vitejs/plugin-react@4.7.0':
|
'@vitejs/plugin-react@4.7.0':
|
||||||
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
|
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
|
||||||
engines: {node: ^14.18.0 || >=16.0.0}
|
engines: {node: ^14.18.0 || >=16.0.0}
|
||||||
@@ -1898,6 +1925,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
commander@2.20.3:
|
||||||
|
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
|
||||||
|
|
||||||
compare-func@2.0.0:
|
compare-func@2.0.0:
|
||||||
resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==}
|
resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==}
|
||||||
|
|
||||||
@@ -1966,6 +1996,10 @@ packages:
|
|||||||
csstype@3.2.3:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
|
d3-array@3.2.4:
|
||||||
|
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
d3-dispatch@3.0.1:
|
d3-dispatch@3.0.1:
|
||||||
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
|
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -1974,6 +2008,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==}
|
resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-geo@3.1.1:
|
||||||
|
resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
d3-quadtree@3.0.1:
|
d3-quadtree@3.0.1:
|
||||||
resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==}
|
resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2407,6 +2445,10 @@ packages:
|
|||||||
ini@1.3.8:
|
ini@1.3.8:
|
||||||
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
|
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
|
||||||
|
|
||||||
|
internmap@2.0.3:
|
||||||
|
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
is-arrayish@0.2.1:
|
is-arrayish@0.2.1:
|
||||||
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
|
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
|
||||||
|
|
||||||
@@ -3422,6 +3464,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||||
engines: {node: '>=8.0'}
|
engines: {node: '>=8.0'}
|
||||||
|
|
||||||
|
topojson-client@3.1.0:
|
||||||
|
resolution: {integrity: sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
tough-cookie@6.0.1:
|
tough-cookie@6.0.1:
|
||||||
resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
|
resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
@@ -3677,6 +3723,9 @@ packages:
|
|||||||
wordwrap@1.0.0:
|
wordwrap@1.0.0:
|
||||||
resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==}
|
resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==}
|
||||||
|
|
||||||
|
world-atlas@2.0.2:
|
||||||
|
resolution: {integrity: sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ==}
|
||||||
|
|
||||||
wrap-ansi@7.0.0:
|
wrap-ansi@7.0.0:
|
||||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -5017,6 +5066,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/d3-force@3.0.10': {}
|
'@types/d3-force@3.0.10': {}
|
||||||
|
|
||||||
|
'@types/d3-geo@3.1.0':
|
||||||
|
dependencies:
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
'@types/geojson@7946.0.16': {}
|
'@types/geojson@7946.0.16': {}
|
||||||
@@ -5037,6 +5090,15 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
|
|
||||||
|
'@types/topojson-client@3.1.5':
|
||||||
|
dependencies:
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
'@types/topojson-specification': 1.0.5
|
||||||
|
|
||||||
|
'@types/topojson-specification@1.0.5':
|
||||||
|
dependencies:
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
|
||||||
'@vitejs/plugin-react@4.7.0(vite@6.4.2(jiti@2.6.1)(lightningcss@1.32.0))':
|
'@vitejs/plugin-react@4.7.0(vite@6.4.2(jiti@2.6.1)(lightningcss@1.32.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.29.0
|
'@babel/core': 7.29.0
|
||||||
@@ -5317,6 +5379,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
delayed-stream: 1.0.0
|
delayed-stream: 1.0.0
|
||||||
|
|
||||||
|
commander@2.20.3: {}
|
||||||
|
|
||||||
compare-func@2.0.0:
|
compare-func@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
array-ify: 1.0.0
|
array-ify: 1.0.0
|
||||||
@@ -5386,6 +5450,10 @@ snapshots:
|
|||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
|
d3-array@3.2.4:
|
||||||
|
dependencies:
|
||||||
|
internmap: 2.0.3
|
||||||
|
|
||||||
d3-dispatch@3.0.1: {}
|
d3-dispatch@3.0.1: {}
|
||||||
|
|
||||||
d3-force@3.0.0:
|
d3-force@3.0.0:
|
||||||
@@ -5394,6 +5462,10 @@ snapshots:
|
|||||||
d3-quadtree: 3.0.1
|
d3-quadtree: 3.0.1
|
||||||
d3-timer: 3.0.1
|
d3-timer: 3.0.1
|
||||||
|
|
||||||
|
d3-geo@3.1.1:
|
||||||
|
dependencies:
|
||||||
|
d3-array: 3.2.4
|
||||||
|
|
||||||
d3-quadtree@3.0.1: {}
|
d3-quadtree@3.0.1: {}
|
||||||
|
|
||||||
d3-timer@3.0.1: {}
|
d3-timer@3.0.1: {}
|
||||||
@@ -5856,6 +5928,8 @@ snapshots:
|
|||||||
|
|
||||||
ini@1.3.8: {}
|
ini@1.3.8: {}
|
||||||
|
|
||||||
|
internmap@2.0.3: {}
|
||||||
|
|
||||||
is-arrayish@0.2.1: {}
|
is-arrayish@0.2.1: {}
|
||||||
|
|
||||||
is-binary-path@2.1.0:
|
is-binary-path@2.1.0:
|
||||||
@@ -6697,6 +6771,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-number: 7.0.0
|
is-number: 7.0.0
|
||||||
|
|
||||||
|
topojson-client@3.1.0:
|
||||||
|
dependencies:
|
||||||
|
commander: 2.20.3
|
||||||
|
|
||||||
tough-cookie@6.0.1:
|
tough-cookie@6.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
tldts: 7.0.30
|
tldts: 7.0.30
|
||||||
@@ -6888,6 +6966,8 @@ snapshots:
|
|||||||
|
|
||||||
wordwrap@1.0.0: {}
|
wordwrap@1.0.0: {}
|
||||||
|
|
||||||
|
world-atlas@2.0.2: {}
|
||||||
|
|
||||||
wrap-ansi@7.0.0:
|
wrap-ansi@7.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Dialog, DialogContent } from '@/ui'
|
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/ui'
|
||||||
import type { DictionaryDetail, FlattenedRecord } from '@/api/client'
|
import type { DictionaryDetail, FlattenedRecord } from '@/api/client'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
@@ -175,6 +175,16 @@ export function ExportModal({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
<DialogContent size="xl" className="!p-0 gap-0">
|
<DialogContent size="xl" className="!p-0 gap-0">
|
||||||
|
{/* Radix a11y: visually-hidden Title + Description. Visible heading
|
||||||
|
* рендерится ниже как кастомный layout. */}
|
||||||
|
<DialogTitle className="sr-only">
|
||||||
|
{t('export.title', { defaultValue: 'Экспорт' })} — {detail.name}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">
|
||||||
|
{t('export.description', {
|
||||||
|
defaultValue: 'Выбор формата, объёма и колонок для выгрузки.',
|
||||||
|
})}
|
||||||
|
</DialogDescription>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-6 py-4 border-b border-line">
|
<div className="px-6 py-4 border-b border-line">
|
||||||
<div className="text-cap text-mute tracking-[0.18em] uppercase">
|
<div className="text-cap text-mute tracking-[0.18em] uppercase">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useForm, Controller, type Path, type SubmitHandler } from 'react-hook-form'
|
import { useForm, Controller, type Path, type SubmitHandler } from 'react-hook-form'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import Ajv, { type ErrorObject } from 'ajv'
|
import Ajv, { type ErrorObject } from 'ajv'
|
||||||
@@ -70,6 +70,10 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ajv = new Ajv({ allErrors: true, strict: false })
|
const ajv = new Ajv({ allErrors: true, strict: false })
|
||||||
|
|
||||||
|
// Stable empty-object literal — используется как default для dataValues
|
||||||
|
// чтобы не создавать новый `{}` на каждый render (deps memo invalidation).
|
||||||
|
const EMPTY_DATA: Record<string, unknown> = Object.freeze({})
|
||||||
addFormats(ajv)
|
addFormats(ajv)
|
||||||
|
|
||||||
const isLocalized = (s: JsonSchema): boolean => Boolean(s['x-localized'])
|
const isLocalized = (s: JsonSchema): boolean => Boolean(s['x-localized'])
|
||||||
@@ -149,7 +153,12 @@ export const SchemaDrivenForm = ({
|
|||||||
// search: substring match (case-insensitive) против key OR labelOf(key)
|
// search: substring match (case-insensitive) против key OR labelOf(key)
|
||||||
// onlyFilled: hide fields где data[key] empty (null/undefined/''/false/[]/0)
|
// onlyFilled: hide fields где data[key] empty (null/undefined/''/false/[]/0)
|
||||||
// Применяется только в sections layout (drawer use case).
|
// Применяется только в sections layout (drawer use case).
|
||||||
const dataValues = (mode === 'edit' ? defaultValues?.data : undefined) ?? {}
|
// useMemo чтобы default {} не создавал new object literal каждый render —
|
||||||
|
// иначе isFieldVisible useMemo ниже invalidate'ится → каскад re-renders.
|
||||||
|
const dataValues = useMemo(
|
||||||
|
() => (mode === 'edit' ? defaultValues?.data : undefined) ?? EMPTY_DATA,
|
||||||
|
[mode, defaultValues?.data],
|
||||||
|
)
|
||||||
const isFieldVisible = useMemo(() => {
|
const isFieldVisible = useMemo(() => {
|
||||||
const search = (searchFilter ?? '').trim().toLowerCase()
|
const search = (searchFilter ?? '').trim().toLowerCase()
|
||||||
return (key: string): boolean => {
|
return (key: string): boolean => {
|
||||||
@@ -183,9 +192,18 @@ export const SchemaDrivenForm = ({
|
|||||||
const totalFieldCount = buckets.identity.length + buckets.description.length + buckets.extra.length
|
const totalFieldCount = buckets.identity.length + buckets.description.length + buckets.extra.length
|
||||||
const visibleFieldCount =
|
const visibleFieldCount =
|
||||||
filteredBuckets.identity.length + filteredBuckets.description.length + filteredBuckets.extra.length
|
filteredBuckets.identity.length + filteredBuckets.description.length + filteredBuckets.extra.length
|
||||||
|
// Ref-pattern для callback: parent часто передаёт inline arrow
|
||||||
|
// (() => setState(...)) — identity меняется каждый render. Если включить
|
||||||
|
// callback в effect deps → effect fires → setState → re-render → loop
|
||||||
|
// ("Maximum update depth exceeded"). Держим latest callback в ref и
|
||||||
|
// вызываем по value-deps только. (review #infinite-loop-2026-05-11)
|
||||||
|
const onCounterChangeRef = useRef(onCounterChange)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onCounterChange?.(visibleFieldCount, totalFieldCount)
|
onCounterChangeRef.current = onCounterChange
|
||||||
}, [visibleFieldCount, totalFieldCount, onCounterChange])
|
})
|
||||||
|
useEffect(() => {
|
||||||
|
onCounterChangeRef.current?.(visibleFieldCount, totalFieldCount)
|
||||||
|
}, [visibleFieldCount, totalFieldCount])
|
||||||
|
|
||||||
const submit: SubmitHandler<FormValues> = (values) => {
|
const submit: SubmitHandler<FormValues> = (values) => {
|
||||||
if (values.validFrom && values.validTo) {
|
if (values.validFrom && values.validTo) {
|
||||||
@@ -243,9 +261,15 @@ export const SchemaDrivenForm = ({
|
|||||||
: 0
|
: 0
|
||||||
return top + dataDirty
|
return top + dataDirty
|
||||||
}, [formState.dirtyFields])
|
}, [formState.dirtyFields])
|
||||||
|
// Same ref-pattern as onCounterChange: parent's inline callback identity
|
||||||
|
// changes every render → loop. Subscribe to value (dirtyCount) only.
|
||||||
|
const onDirtyChangeRef = useRef(onDirtyChange)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onDirtyChange?.(dirtyCount)
|
onDirtyChangeRef.current = onDirtyChange
|
||||||
}, [dirtyCount, onDirtyChange])
|
})
|
||||||
|
useEffect(() => {
|
||||||
|
onDirtyChangeRef.current?.(dirtyCount)
|
||||||
|
}, [dirtyCount])
|
||||||
|
|
||||||
// Section visibility — в 'sections' layout все buckets рендерятся подряд,
|
// Section visibility — в 'sections' layout все buckets рендерятся подряд,
|
||||||
// в 'tabs' — только active. Sections layout per handoff Screen 3 prototype.
|
// в 'tabs' — только active. Sections layout per handoff Screen 3 prototype.
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useRouter } from '@tanstack/react-router'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from '@/ui'
|
||||||
|
import { GlobeLoader } from '@/ui/components/globe-loader'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AppErrorBoundary — friendly UI который рендерится через TanStack Router
|
||||||
|
* `errorComponent` когда route выкидывает unhandled exception (включая
|
||||||
|
* "Maximum update depth exceeded" infinite loops).
|
||||||
|
*
|
||||||
|
* Юзер видит:
|
||||||
|
* - illustrated GlobeLoader (статичный paused)
|
||||||
|
* - "Что-то пошло не так. Попробуйте обновить страницу"
|
||||||
|
* - кнопки [Обновить] / [На главную]
|
||||||
|
* - dev-only collapsible с raw error (только в dev mode, не в prod)
|
||||||
|
*
|
||||||
|
* НЕ показывает raw stack trace в prod — это утечка деталей реализации
|
||||||
|
* и тревожит юзера.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AppErrorBoundaryProps = {
|
||||||
|
error: Error
|
||||||
|
reset?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppErrorBoundary({ error, reset }: AppErrorBoundaryProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const router = useRouter()
|
||||||
|
const [showDetails, setShowDetails] = useState(false)
|
||||||
|
// Vite dev mode → import.meta.env.DEV — показываем raw error для разработки.
|
||||||
|
const isDev = import.meta.env.DEV
|
||||||
|
|
||||||
|
const handleRetry = () => {
|
||||||
|
if (reset) reset()
|
||||||
|
else router.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHome = () => {
|
||||||
|
router.navigate({ to: '/dictionaries' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="min-h-[60vh] flex flex-col items-center justify-center px-6 py-12 text-center"
|
||||||
|
>
|
||||||
|
<div className="opacity-40 mb-6">
|
||||||
|
<GlobeLoader size={120} />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-title-xl text-ink font-semibold mb-2">
|
||||||
|
{t('error.boundary.title', { defaultValue: 'Что-то пошло не так' })}
|
||||||
|
</h1>
|
||||||
|
<p className="text-body text-mute max-w-md mb-6">
|
||||||
|
{t('error.boundary.description', {
|
||||||
|
defaultValue:
|
||||||
|
'Произошла непредвиденная ошибка при отображении страницы. Попробуйте обновить — обычно это помогает. Если повторится, сообщите нам.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="primary" size="sm" onClick={handleRetry}>
|
||||||
|
{t('error.boundary.retry', { defaultValue: 'Обновить' })}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleHome}>
|
||||||
|
{t('error.boundary.home', { defaultValue: 'На главную' })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{isDev && (
|
||||||
|
<div className="mt-8 w-full max-w-2xl text-left">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowDetails((v) => !v)}
|
||||||
|
className="text-cell text-mute hover:text-ink-2 underline underline-offset-2"
|
||||||
|
>
|
||||||
|
{showDetails ? 'Скрыть детали (dev)' : 'Показать детали (dev)'}
|
||||||
|
</button>
|
||||||
|
{showDetails && (
|
||||||
|
<pre className="mt-2 rounded-md border border-line bg-surface-2 p-3 text-mono text-cell text-ink-2 overflow-auto max-h-64 whitespace-pre-wrap">
|
||||||
|
{error.message}
|
||||||
|
{error.stack ? `\n\n${error.stack}` : ''}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ArrowsClockwiseIcon, WarningIcon } from '@phosphor-icons/react'
|
import { ArrowsClockwiseIcon, WarningIcon } from '@phosphor-icons/react'
|
||||||
import { Dialog, DialogContent } from '@/ui'
|
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/ui'
|
||||||
import { useRecordRaw } from '@/api/queries'
|
import { useRecordRaw } from '@/api/queries'
|
||||||
import type { CreateRecordRequest } from '@/api/client'
|
import type { CreateRecordRequest } from '@/api/client'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -75,6 +75,17 @@ export function ConflictDiffModal({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
<DialogContent size="lg" className="!p-0 gap-0 max-h-[85vh]">
|
<DialogContent size="lg" className="!p-0 gap-0 max-h-[85vh]">
|
||||||
|
{/* Radix a11y: visually-hidden Title + Description satisfy
|
||||||
|
* DialogContent requirements. Visible header — custom layout ниже. */}
|
||||||
|
<DialogTitle className="sr-only">
|
||||||
|
{t('conflict.title', { defaultValue: 'Запись изменена другим пользователем' })}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">
|
||||||
|
{t('conflict.description', {
|
||||||
|
defaultValue:
|
||||||
|
'Сравнение локальных правок с актуальным состоянием на сервере.',
|
||||||
|
})}
|
||||||
|
</DialogDescription>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-start gap-3 px-6 py-4 border-b border-line">
|
<div className="flex items-start gap-3 px-6 py-4 border-b border-line">
|
||||||
<div className="size-8 rounded-md bg-warn-bg inline-flex items-center justify-center shrink-0">
|
<div className="size-8 rounded-md bg-warn-bg inline-flex items-center justify-center shrink-0">
|
||||||
|
|||||||
@@ -156,6 +156,12 @@ export function RecordDrawer({
|
|||||||
<DialogPrimitive.Title id={titleId} className="text-cap text-mute truncate">
|
<DialogPrimitive.Title id={titleId} className="text-cap text-mute truncate">
|
||||||
{caption}
|
{caption}
|
||||||
</DialogPrimitive.Title>
|
</DialogPrimitive.Title>
|
||||||
|
{/* sr-only Description чтобы Radix не ругался на missing
|
||||||
|
* `aria-describedby` (a11y warning). Visible description нам не
|
||||||
|
* нужен — caption + recordId уже описывают drawer. */}
|
||||||
|
<DialogPrimitive.Description className="sr-only">
|
||||||
|
{caption}{recordId ? ` (${recordId})` : ''}
|
||||||
|
</DialogPrimitive.Description>
|
||||||
{recordId && (
|
{recordId && (
|
||||||
<div className="text-mono text-ink-2 truncate mt-0.5">{recordId}</div>
|
<div className="text-mono text-ink-2 truncate mt-0.5">{recordId}</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ClockCounterClockwiseIcon, CaretLeftIcon, CaretRightIcon, XIcon } from '@phosphor-icons/react'
|
import { ClockCounterClockwiseIcon, CaretLeftIcon, CaretRightIcon, XIcon } from '@phosphor-icons/react'
|
||||||
import { Dialog, DialogContent } from '@/ui'
|
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/ui'
|
||||||
import type { DictionaryDetail } from '@/api/client'
|
import type { DictionaryDetail } from '@/api/client'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
@@ -127,6 +127,16 @@ export function TimeTravelModal({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
<DialogContent size="full" className="!p-0 gap-0 max-h-[95vh]" hideClose>
|
<DialogContent size="full" className="!p-0 gap-0 max-h-[95vh]" hideClose>
|
||||||
|
{/* Radix a11y: sr-only Title + Description. Visible header — custom layout ниже. */}
|
||||||
|
<DialogTitle className="sr-only">
|
||||||
|
{t('timeTravel.title', { defaultValue: 'Состояние справочника на момент времени' })}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">
|
||||||
|
{t('timeTravel.description', {
|
||||||
|
defaultValue:
|
||||||
|
'Сравнение текущего состояния справочника с выбранной точкой во времени.',
|
||||||
|
})}
|
||||||
|
</DialogDescription>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-start gap-3 px-6 py-4 border-b border-line">
|
<div className="flex items-start gap-3 px-6 py-4 border-b border-line">
|
||||||
<div className="size-8 rounded-md bg-surface-2 inline-flex items-center justify-center shrink-0">
|
<div className="size-8 rounded-md bg-surface-2 inline-flex items-center justify-center shrink-0">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState } from 'react'
|
|||||||
import { Sidebar, MobileSidebar } from '@/components/layout/Sidebar'
|
import { Sidebar, MobileSidebar } from '@/components/layout/Sidebar'
|
||||||
import { TopBar } from '@/components/layout/TopBar'
|
import { TopBar } from '@/components/layout/TopBar'
|
||||||
import { UpdateBanner } from '@/components/version/UpdateBanner'
|
import { UpdateBanner } from '@/components/version/UpdateBanner'
|
||||||
|
import { AppErrorBoundary } from '@/components/layout/AppErrorBoundary'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Root layout (Stage 3.1 + 3.x mobile responsive):
|
* Root layout (Stage 3.1 + 3.x mobile responsive):
|
||||||
@@ -24,6 +25,9 @@ import { UpdateBanner } from '@/components/version/UpdateBanner'
|
|||||||
*/
|
*/
|
||||||
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
|
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
|
||||||
component: RootLayout,
|
component: RootLayout,
|
||||||
|
// Catch unhandled errors inside any route — show friendly UI instead of
|
||||||
|
// TanStack default "Something went wrong! Hide Error" + raw stack trace.
|
||||||
|
errorComponent: AppErrorBoundary,
|
||||||
})
|
})
|
||||||
|
|
||||||
function RootLayout() {
|
function RootLayout() {
|
||||||
|
|||||||
@@ -598,6 +598,25 @@ function DictionaryDetail() {
|
|||||||
return false
|
return false
|
||||||
}, [detailQuery.data?.schemaJson])
|
}, [detailQuery.data?.schemaJson])
|
||||||
|
|
||||||
|
// Full-page loader пока ОБА query (detail + records) не готовы. Иначе
|
||||||
|
// юзер видит partial render — узкая колонка без extra columns, info panel
|
||||||
|
// ещё не появилась, бар нагружается частями. Per user: "вот такого не
|
||||||
|
// должно быть, лучше лоадер".
|
||||||
|
if (detailQuery.isLoading || (!detailQuery.data && !detailQuery.error)) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-[60vh] flex items-center justify-center">
|
||||||
|
<LoadingBlock size="lg" label={t('loading')} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (detailQuery.error) {
|
||||||
|
return (
|
||||||
|
<Alert variant="error" title={t('error.failed')}>
|
||||||
|
{String(detailQuery.error)}
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{scope && (
|
{scope && (
|
||||||
|
|||||||
@@ -185,7 +185,13 @@ function DictionariesPage() {
|
|||||||
</button>
|
</button>
|
||||||
) : null
|
) : null
|
||||||
|
|
||||||
if (isLoading) return <LoadingBlock size="md" label={t('loading')} />
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-[60vh] flex items-center justify-center">
|
||||||
|
<LoadingBlock size="lg" label={t('loading')} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Alert variant="error" title={t('error.failed')}>
|
<Alert variant="error" title={t('error.failed')}>
|
||||||
|
|||||||
@@ -374,6 +374,44 @@ body {
|
|||||||
color: var(--color-mute);
|
color: var(--color-mute);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === GlobeLoader keyframes — orbital spinner (src/ui/components/globe-loader.tsx).
|
||||||
|
* Класс .globe-loader на wrapper'е активирует вложенные .globe-* animations
|
||||||
|
* через стандартные CSS animation rules. Transform-only — GPU-композитятся. */
|
||||||
|
@keyframes ord-globe-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
@keyframes ord-globe-pulse {
|
||||||
|
0%, 100% { opacity: 0.18; }
|
||||||
|
50% { opacity: 0.42; }
|
||||||
|
}
|
||||||
|
/* transform-box default = view-box для SVG elements — transform-origin
|
||||||
|
* считается в SVG user coordinates (viewBox 0 0 200 200). 100px 100px =
|
||||||
|
* центр глобуса. Прошлая версия с transform-box: fill-box ломала орбиты
|
||||||
|
* (origin становился offset bounding-box'а каждой группы — арки летали
|
||||||
|
* вокруг неправильной точки). Прототип HTML использует default — copy. */
|
||||||
|
.globe-loader .globe-whirl {
|
||||||
|
transform-origin: 100px 100px;
|
||||||
|
animation: ord-globe-spin 3.6s linear infinite;
|
||||||
|
}
|
||||||
|
.globe-loader .globe-whirl-rev {
|
||||||
|
transform-origin: 100px 100px;
|
||||||
|
animation: ord-globe-spin 5.4s linear infinite reverse;
|
||||||
|
}
|
||||||
|
.globe-loader .globe-pulse {
|
||||||
|
transform-origin: 100px 100px;
|
||||||
|
animation: ord-globe-pulse 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
/* Sphere/land внутри globe-loader rotates через d3 projection (rAF
|
||||||
|
* пересчитывает path d-attr), не через CSS transform — иначе land
|
||||||
|
* distort'илась бы при rotation (no projection math). */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.globe-loader .globe-whirl,
|
||||||
|
.globe-loader .globe-whirl-rev,
|
||||||
|
.globe-loader .globe-pulse {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* === Legacy fallback: старые компоненты используют nstart token names
|
/* === Legacy fallback: старые компоненты используют nstart token names
|
||||||
* (--color-carbon, --color-ultramarain etc). Маппим их на новые pending
|
* (--color-carbon, --color-ultramarain etc). Маппим их на новые pending
|
||||||
* полная миграция. === */
|
* полная миграция. === */
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import { useEffect, useId as useReactId, useRef, useState } from 'react'
|
||||||
|
import { geoOrthographic, geoPath, geoGraticule10 } from 'd3-geo'
|
||||||
|
import type { Feature, FeatureCollection, Geometry } from 'geojson'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GlobeLoader — orbital globe spinner per /Users/zimin/Downloads/Globe loader
|
||||||
|
* prototype. Real Natural Earth land outline через d3-geo orthographic
|
||||||
|
* projection, rotating longitude continuously. Outer whirl arcs + pulsing
|
||||||
|
* ring CSS-only.
|
||||||
|
*
|
||||||
|
* <p>Lazy-loads world-atlas (~55kb) — пока геометрия не доехала, видны
|
||||||
|
* только sphere + graticule + orbital arcs (still looks like a loader).
|
||||||
|
*
|
||||||
|
* <p>Использование:
|
||||||
|
* <GlobeLoader /> // 64px default
|
||||||
|
* <GlobeLoader size={120} /> // larger
|
||||||
|
* <GlobeLoader className="text-mute" /> // tint via currentColor
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type GlobeLoaderProps = {
|
||||||
|
/** Pixel size of the spinner square (default 64). */
|
||||||
|
size?: number
|
||||||
|
/** Optional label rendered below. */
|
||||||
|
label?: React.ReactNode
|
||||||
|
/** Extra wrapper class. */
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache загруженной land geometry между mount'ами loader'а (типично загрузка
|
||||||
|
// списка → переход в editor → ещё loader). Без cache каждый GlobeLoader
|
||||||
|
// триггерил бы import заново — Vite кеширует chunk, но parse cost есть.
|
||||||
|
let LAND_CACHE: Feature<Geometry> | null = null
|
||||||
|
let LAND_PROMISE: Promise<Feature<Geometry>> | null = null
|
||||||
|
|
||||||
|
async function loadLand(): Promise<Feature<Geometry>> {
|
||||||
|
if (LAND_CACHE) return LAND_CACHE
|
||||||
|
if (LAND_PROMISE) return LAND_PROMISE
|
||||||
|
LAND_PROMISE = (async () => {
|
||||||
|
const [{ feature }, world] = await Promise.all([
|
||||||
|
import('topojson-client'),
|
||||||
|
import('world-atlas/land-110m.json'),
|
||||||
|
])
|
||||||
|
const data = (world as { default: unknown }).default ?? world
|
||||||
|
// topojson-client expects Topology с objects.land mesh.
|
||||||
|
const f = feature(
|
||||||
|
data as Parameters<typeof feature>[0],
|
||||||
|
(data as { objects: { land: Parameters<typeof feature>[1] } }).objects.land,
|
||||||
|
) as Feature<Geometry> | FeatureCollection<Geometry>
|
||||||
|
const result = 'features' in f ? (f.features[0] as Feature<Geometry>) : f
|
||||||
|
LAND_CACHE = result
|
||||||
|
return result
|
||||||
|
})()
|
||||||
|
return LAND_PROMISE
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlobeLoader({ size = 64, label, className }: GlobeLoaderProps) {
|
||||||
|
const id = useReactId().replace(/:/g, '')
|
||||||
|
const tailA = `globe-tail-a-${id}`
|
||||||
|
const tailB = `globe-tail-b-${id}`
|
||||||
|
|
||||||
|
const sphereRef = useRef<SVGPathElement>(null)
|
||||||
|
const gratRef = useRef<SVGPathElement>(null)
|
||||||
|
const landRef = useRef<SVGPathElement>(null)
|
||||||
|
const [landReady, setLandReady] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Setup projection: orthographic, R=70 (как в прототипе), tilt -15° (легкий
|
||||||
|
// northern lean). viewBox 200x200 — fixed; scale projection относительно него.
|
||||||
|
const projection = geoOrthographic()
|
||||||
|
.scale(70)
|
||||||
|
.translate([100, 100])
|
||||||
|
.clipAngle(90)
|
||||||
|
.rotate([0, -15, 0])
|
||||||
|
|
||||||
|
const path = geoPath(projection)
|
||||||
|
const graticule = geoGraticule10()
|
||||||
|
|
||||||
|
// Initial render (без land — оно lazy).
|
||||||
|
if (sphereRef.current) sphereRef.current.setAttribute('d', path({ type: 'Sphere' }) ?? '')
|
||||||
|
if (gratRef.current) gratRef.current.setAttribute('d', path(graticule) ?? '')
|
||||||
|
|
||||||
|
let landFeature: Feature<Geometry> | null = LAND_CACHE
|
||||||
|
let cancelled = false
|
||||||
|
if (!landFeature) {
|
||||||
|
void loadLand().then((f) => {
|
||||||
|
if (cancelled) return
|
||||||
|
landFeature = f
|
||||||
|
if (landRef.current) landRef.current.setAttribute('d', path(f) ?? '')
|
||||||
|
setLandReady(true)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
if (landRef.current) landRef.current.setAttribute('d', path(landFeature) ?? '')
|
||||||
|
setLandReady(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate rotation — 8s per revolution per prototype.
|
||||||
|
const start = performance.now()
|
||||||
|
const period = 8000
|
||||||
|
let raf = 0
|
||||||
|
|
||||||
|
const frame = (now: number) => {
|
||||||
|
if (cancelled) return
|
||||||
|
const t = ((now - start) % period) / period
|
||||||
|
const lambda = t * 360
|
||||||
|
projection.rotate([lambda, -15, 0])
|
||||||
|
if (gratRef.current) gratRef.current.setAttribute('d', path(graticule) ?? '')
|
||||||
|
if (landFeature && landRef.current) {
|
||||||
|
landRef.current.setAttribute('d', path(landFeature) ?? '')
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(frame)
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(frame)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
cancelAnimationFrame(raf)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-busy="true"
|
||||||
|
aria-label="loading"
|
||||||
|
className={cn('inline-flex flex-col items-center justify-center gap-2 text-ink', className)}
|
||||||
|
>
|
||||||
|
<div className="globe-loader relative" style={{ width: size, height: size }}>
|
||||||
|
{/* Outer overlay svg: whirl arcs + pulse ring (CSS-animated). */}
|
||||||
|
<svg viewBox="0 0 200 200" className="absolute inset-0 size-full overflow-visible">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={tailA} gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="200" y2="0">
|
||||||
|
<stop offset="0%" stopColor="currentColor" stopOpacity="0" />
|
||||||
|
<stop offset="60%" stopColor="currentColor" stopOpacity="0.18" />
|
||||||
|
<stop offset="100%" stopColor="currentColor" stopOpacity="0.95" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id={tailB} gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="200" y2="0">
|
||||||
|
<stop offset="0%" stopColor="currentColor" stopOpacity="0" />
|
||||||
|
<stop offset="70%" stopColor="currentColor" stopOpacity="0.12" />
|
||||||
|
<stop offset="100%" stopColor="currentColor" stopOpacity="0.55" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Pulsing outermost dashed ring */}
|
||||||
|
<g className="globe-pulse">
|
||||||
|
<circle
|
||||||
|
cx="100"
|
||||||
|
cy="100"
|
||||||
|
r="96"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="0.6"
|
||||||
|
strokeDasharray="1 4"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Outer whirl: 270° tail arc + leading dot + ghost arc */}
|
||||||
|
<g className="globe-whirl">
|
||||||
|
<path
|
||||||
|
d="M 100 4 A 96 96 0 1 1 4 100"
|
||||||
|
fill="none"
|
||||||
|
stroke={`url(#${tailA})`}
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<circle cx="100" cy="4" r="2.4" fill="currentColor" />
|
||||||
|
<path
|
||||||
|
d="M 100 12 A 88 88 0 0 1 188 100"
|
||||||
|
fill="none"
|
||||||
|
stroke={`url(#${tailB})`}
|
||||||
|
strokeWidth="1.2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
opacity="0.7"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Inner whirl, counter-rotating */}
|
||||||
|
<g className="globe-whirl-rev">
|
||||||
|
<path
|
||||||
|
d="M 100 18 A 82 82 0 1 1 18 100"
|
||||||
|
fill="none"
|
||||||
|
stroke={`url(#${tailA})`}
|
||||||
|
strokeWidth="1.1"
|
||||||
|
strokeLinecap="round"
|
||||||
|
opacity="0.55"
|
||||||
|
/>
|
||||||
|
<circle cx="100" cy="18" r="1.6" fill="currentColor" opacity="0.85" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Globe svg: real orthographic projection с rotating land. */}
|
||||||
|
<svg viewBox="0 0 200 200" className="absolute inset-0 size-full overflow-visible">
|
||||||
|
{/* Sphere — disk silhouette (fill = bg чтобы land не светилось через
|
||||||
|
* прозрачное место). */}
|
||||||
|
<path
|
||||||
|
ref={sphereRef}
|
||||||
|
fill="var(--color-bg, #f4f1ec)"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.25"
|
||||||
|
/>
|
||||||
|
{/* Graticule — subtle lat/lon grid. */}
|
||||||
|
<path
|
||||||
|
ref={gratRef}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="0.4"
|
||||||
|
strokeOpacity="0.18"
|
||||||
|
/>
|
||||||
|
{/* Land — Natural Earth outline, rotated continuously. fill-opacity
|
||||||
|
* 0.88 чтобы slightly soften. */}
|
||||||
|
<path
|
||||||
|
ref={landRef}
|
||||||
|
fill="currentColor"
|
||||||
|
fillOpacity={landReady ? 0.88 : 0}
|
||||||
|
stroke="var(--color-bg, #f4f1ec)"
|
||||||
|
strokeWidth="0.35"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
style={{ transition: 'fill-opacity 0.3s ease' }}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{label && <span className="text-body text-mute">{label}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
import { Loader2 } from 'lucide-react'
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { GlobeLoader } from './globe-loader'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LoadingBlock — central spinner с подписью. Заменяет {@code @nstart/ui}
|
* LoadingBlock — central spinner с подписью. Используется везде где
|
||||||
* LoadingBlock. Для inline placeholder используйте Skeleton.
|
* загружаются данные / окна. С 11.05.2026 заменили Loader2 spinner на
|
||||||
|
* GlobeLoader (orbital globe loader per /Users/zimin/Downloads/Globe loader).
|
||||||
*
|
*
|
||||||
* Size variants:
|
* Size variants:
|
||||||
* - sm: small inline spinner (py-4)
|
* - sm: small inline (py-4, 32px globe)
|
||||||
* - md: default centered (py-12)
|
* - md: default centered (py-12, 64px globe)
|
||||||
* - lg: prominent (py-20)
|
* - lg: prominent full-page (py-20, 96px globe)
|
||||||
*/
|
*/
|
||||||
export type LoadingBlockProps = React.HTMLAttributes<HTMLDivElement> & {
|
export type LoadingBlockProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||||
label?: React.ReactNode
|
label?: React.ReactNode
|
||||||
@@ -16,9 +17,9 @@ export type LoadingBlockProps = React.HTMLAttributes<HTMLDivElement> & {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SIZES = {
|
const SIZES = {
|
||||||
sm: { wrapper: 'py-4', icon: 'size-3' },
|
sm: { wrapper: 'py-4', globe: 32 },
|
||||||
md: { wrapper: 'py-12', icon: 'size-5' },
|
md: { wrapper: 'py-12', globe: 64 },
|
||||||
lg: { wrapper: 'py-20', icon: 'size-8' },
|
lg: { wrapper: 'py-20', globe: 96 },
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export function LoadingBlock({ className, label, size = 'md', ...props }: LoadingBlockProps) {
|
export function LoadingBlock({ className, label, size = 'md', ...props }: LoadingBlockProps) {
|
||||||
@@ -28,14 +29,14 @@ export function LoadingBlock({ className, label, size = 'md', ...props }: Loadin
|
|||||||
role="status"
|
role="status"
|
||||||
aria-busy="true"
|
aria-busy="true"
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-col items-center justify-center gap-2 text-mute',
|
'flex flex-col items-center justify-center gap-3 text-ink',
|
||||||
s.wrapper,
|
s.wrapper,
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<Loader2 className={cn('animate-spin text-accent', s.icon)} />
|
<GlobeLoader size={s.globe} />
|
||||||
{label && <span className="text-body">{label}</span>}
|
{label && <span className="text-body text-mute">{label}</span>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export { Panel, type PanelProps } from './components/panel'
|
|||||||
export { PageHeader, type PageHeaderProps } from './components/page-header'
|
export { PageHeader, type PageHeaderProps } from './components/page-header'
|
||||||
export { EmptyState, type EmptyStateProps } from './components/empty-state'
|
export { EmptyState, type EmptyStateProps } from './components/empty-state'
|
||||||
export { LoadingBlock, type LoadingBlockProps } from './components/loading-block'
|
export { LoadingBlock, type LoadingBlockProps } from './components/loading-block'
|
||||||
|
export { GlobeLoader, type GlobeLoaderProps } from './components/globe-loader'
|
||||||
|
|
||||||
// === Tables ===
|
// === Tables ===
|
||||||
export {
|
export {
|
||||||
|
|||||||
Reference in New Issue
Block a user