refactor state storge, UI editor
This commit is contained in:
312
EDITOR_LOCAL_STORAGE.md
Normal file
312
EDITOR_LOCAL_STORAGE.md
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
# Editor (/editor) - Local Store & Snapshot Conversion
|
||||||
|
|
||||||
|
Tài liệu này mô tả chi tiết **các nơi lưu trữ state (store) ở phía FrontEndUser** trong `/editor/[id]`, ý nghĩa từng biến state, state nào là “single source of truth”, state nào chỉ là cache/UI, và cách chuyển đổi qua lại giữa:
|
||||||
|
|
||||||
|
1. **Local session state** (React state trong phiên làm việc)
|
||||||
|
2. **Commit snapshot** (`commits.snapshot_json`)
|
||||||
|
3. **Reload trang** (mất state local, load lại từ commit snapshot)
|
||||||
|
|
||||||
|
Mục tiêu: dễ debug, nhất quán dữ liệu, tránh sai semantics `"reference"`/`"binding"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0) 5 Dataset Quan Trọng Nhất (GEO/ENT/WIKI/ENT_WIKI/GEO_ENT)
|
||||||
|
|
||||||
|
Trong `/editor`, 5 nhóm dữ liệu quan trọng nhất tương ứng trực tiếp với snapshot:
|
||||||
|
|
||||||
|
1. **GEO**: `snapshot_json.geometries[]` + `snapshot_json.editor_feature_collection`
|
||||||
|
2. **ENT**: `snapshot_json.entities[]`
|
||||||
|
3. **WIKI**: `snapshot_json.wikis[]`
|
||||||
|
4. **ENT_WIKI** (entity ↔ wiki): `snapshot_json.entity_wiki[]`
|
||||||
|
5. **GEO_ENT** (geometry ↔ entity): `snapshot_json.geometry_entity[]`
|
||||||
|
|
||||||
|
Điểm quan trọng về “store”:
|
||||||
|
|
||||||
|
- **ENT/WIKI/ENT_WIKI** có store snapshot riêng trong React session:
|
||||||
|
- `snapshotEntities` -> `entities[]`
|
||||||
|
- `snapshotWikis` -> `wikis[]`
|
||||||
|
- `snapshotEntityWikiLinks` -> `entity_wiki[]`
|
||||||
|
|
||||||
|
- **GEO/GEO_ENT không có store snapshot riêng theo kiểu `snapshotGeometries` / `snapshotGeometryEntity`**.
|
||||||
|
- Trong session, GEO sống ở **`editor.draft`** (GeoJSON FeatureCollection).
|
||||||
|
- Khi commit, FE **build ra**:
|
||||||
|
- `geometries[]` từ `editor.draft + editor.changes + baselineSnapshot.geometries`
|
||||||
|
- `geometry_entity[]` từ `editor.draft.features[].properties.entity_ids`
|
||||||
|
|
||||||
|
Vì vậy, nếu bạn “tìm store của geo trong React state” thì bạn sẽ thấy nó nằm ở `useEditorState()` chứ không nằm trong `useEditorSessionState()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) Nguyên tắc chung
|
||||||
|
|
||||||
|
### 1.1 Single source of truth theo lớp
|
||||||
|
|
||||||
|
- **Geometry (map/editor):** `useEditorState(initialData)` là state trung tâm cho `draft/changes/undo`.
|
||||||
|
- **Snapshot stores (phần sẽ đi vào commit snapshot):**
|
||||||
|
- `snapshotEntities` -> `snapshot_json.entities`
|
||||||
|
- `snapshotWikis` -> `snapshot_json.wikis`
|
||||||
|
- `snapshotEntityWikiLinks` -> `snapshot_json.entity_wiki`
|
||||||
|
- **Catalog/cache để tìm kiếm & hiển thị:**
|
||||||
|
- `entityCatalog` là danh sách entity “global” trong RAM (fetch + search merge). Không phải snapshot.
|
||||||
|
|
||||||
|
### 1.2 “reference” vs “binding”
|
||||||
|
|
||||||
|
- `"reference"` (entities/wikis/geometries.operation) nghĩa là **không sửa record** trong commit đó.
|
||||||
|
- `"binding"` (chỉ áp dụng cho `entity_wiki.operation`) nghĩa là **link entity ↔ wiki đang tồn tại** trong snapshot.
|
||||||
|
- `"delete"` nghĩa là xóa record (entities/wikis/geometries) hoặc unlink (entity_wiki).
|
||||||
|
|
||||||
|
Khi **mở 1 phiên editor mới từ commit**, mọi operation local đều bị “reset về baseline”:
|
||||||
|
|
||||||
|
- `entities[].operation` và `wikis[].operation` trong session -> `"reference"`
|
||||||
|
- `entity_wiki[].operation` trong session -> `"binding"` (nếu link còn active)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Local state: danh sách đầy đủ và ý nghĩa
|
||||||
|
|
||||||
|
Các state này được tạo từ `useEditorSessionState()` và `useEditorState()` trong:
|
||||||
|
|
||||||
|
- `FrontEndUser/src/app/editor/[id]/page.tsx`
|
||||||
|
- `FrontEndUser/src/uhm/lib/useEditorSessionState.ts`
|
||||||
|
- `FrontEndUser/src/uhm/lib/useEditorState.ts`
|
||||||
|
|
||||||
|
### 2.1 Geometry editor state (core)
|
||||||
|
|
||||||
|
Nguồn: `const editor = useEditorState(initialData)`
|
||||||
|
|
||||||
|
- `initialData: FeatureCollection`
|
||||||
|
- Là **baseline** của session hiện tại để render Map ban đầu.
|
||||||
|
- Được set khi:
|
||||||
|
- mở project (load snapshot head),
|
||||||
|
- restore FE-only từ 1 commit,
|
||||||
|
- hoặc import/replace dữ liệu session.
|
||||||
|
|
||||||
|
- `editor.draft: FeatureCollection`
|
||||||
|
- **Single source of truth** cho geometry đang hiển thị + chỉnh sửa.
|
||||||
|
- Map render trực tiếp từ `draft` (hoặc bản “visibleDraft” đã filter theo timeline/binding).
|
||||||
|
- Đây chính là **store runtime của GEO** trong session.
|
||||||
|
|
||||||
|
- `editor.changes: Map<id, Change>`
|
||||||
|
- Diff giữa `draft` và baseline map nội bộ (initialMapRef).
|
||||||
|
- Dùng để tính `pendingSaveCount` và để build snapshot geometries/update/delete.
|
||||||
|
|
||||||
|
- `editor.undoStack`
|
||||||
|
- Danh sách thao tác gần nhất (create/update/properties/delete).
|
||||||
|
|
||||||
|
- `editor.changeCount`
|
||||||
|
- Số lượng changes (để chặn commit khi không đổi gì).
|
||||||
|
|
||||||
|
- `editor.hasPersistedFeature(id)`
|
||||||
|
- `true` nếu feature đã tồn tại trong baseline map nội bộ.
|
||||||
|
- Dùng cho timeline filter: feature mới tạo trong session vẫn luôn visible.
|
||||||
|
|
||||||
|
### 2.2 Snapshot stores (persisted on commit)
|
||||||
|
|
||||||
|
Các state này là “source of truth” cho những phần non-geometry trong commit snapshot.
|
||||||
|
|
||||||
|
#### a) `snapshotEntities: EntitySnapshot[]`
|
||||||
|
|
||||||
|
- Dùng để build `snapshot_json.entities`.
|
||||||
|
- Bao gồm:
|
||||||
|
- entity “pin” vào project (`source:"ref"`, `operation:"reference"`),
|
||||||
|
- entity tạo mới local (`source:"inline"`, `operation:"create"`),
|
||||||
|
- entity bị xóa (nếu có) (`operation:"delete"`).
|
||||||
|
|
||||||
|
Lưu ý quan trọng:
|
||||||
|
|
||||||
|
- `snapshotEntities` là nơi “giữ entity” **qua các commit**, kể cả entity tạo mới chưa bind geometry.
|
||||||
|
- `buildEditorSnapshot()` có logic carry-forward inline entity từ `previousSnapshot` để tránh mất entity sau commit/reload.
|
||||||
|
|
||||||
|
#### b) `snapshotWikis: WikiSnapshot[]`
|
||||||
|
|
||||||
|
- Dùng để build `snapshot_json.wikis`.
|
||||||
|
- Wiki hiện lưu `doc` là **string (HTML)** (Quill) hoặc `null` với ref wiki.
|
||||||
|
- Tiptap JSON cũ: được normalize sang HTML để hiển thị.
|
||||||
|
|
||||||
|
#### c) `snapshotEntityWikiLinks: EntityWikiLinkSnapshot[]`
|
||||||
|
|
||||||
|
- Dùng để build `snapshot_json.entity_wiki`.
|
||||||
|
- `operation`:
|
||||||
|
- `"binding"`: link đang tồn tại
|
||||||
|
- `"delete"`: unlink trong snapshot
|
||||||
|
- (compat) `"reference"` từ snapshot cũ được normalize thành `"binding"` khi load.
|
||||||
|
|
||||||
|
### 2.3 Catalog/cache state (không persist)
|
||||||
|
|
||||||
|
#### `entityCatalog: Entity[]`
|
||||||
|
|
||||||
|
Đây là **RAM cache** để:
|
||||||
|
|
||||||
|
- hiển thị tên/description/status của entity,
|
||||||
|
- merge kết quả fetch + search,
|
||||||
|
- giảm tình trạng UI “cùng 1 entity nhưng 2 object khác nhau”.
|
||||||
|
|
||||||
|
Không ghi thẳng vào snapshot. Snapshot vẫn lấy từ `snapshotEntities`.
|
||||||
|
|
||||||
|
Trong page, danh sách `entities` dùng cho UI được merge:
|
||||||
|
|
||||||
|
`entities = mergeEntitySearchResults(entityCatalog, snapshotEntitiesAsEntities)`
|
||||||
|
|
||||||
|
Nghĩa là: snapshot entities (local) luôn được ưu tiên hiển thị trong UI.
|
||||||
|
|
||||||
|
### 2.4 UI-only state (không persist)
|
||||||
|
|
||||||
|
Các state sau chỉ phục vụ UX, mất khi reload:
|
||||||
|
|
||||||
|
- `mode` (idle/select/add-*)
|
||||||
|
- `selectedFeatureId`
|
||||||
|
- `selectedGeometryEntityIds` (list bind tạm thời cho UI, map patch sẽ sync vào feature properties)
|
||||||
|
- `geometryMetaForm`
|
||||||
|
- `entityForm` (tạo entity mới)
|
||||||
|
- `entityFormStatus` (toast/status 3s)
|
||||||
|
- `searchKind`, `searchQuery`
|
||||||
|
- `entitySearchResults`, `wikiSearchResults`, `geoSearchResults`
|
||||||
|
- `timelineDraftYear`, `timelineFilterEnabled`
|
||||||
|
- panel widths (`leftPanelWidth`, `rightPanelWidth`)
|
||||||
|
|
||||||
|
### 2.5 LocalStorage (trên browser)
|
||||||
|
|
||||||
|
Hiện tại chỉ có **1 thứ** persist sang LocalStorage:
|
||||||
|
|
||||||
|
- `backgroundVisibility` (ẩn/hiện layer nền)
|
||||||
|
|
||||||
|
Các snapshot stores (`snapshotEntities`, `snapshotWikis`, `snapshotEntityWikiLinks`, `draft`) **không** lưu LocalStorage; chúng được persist qua commit snapshot (backend).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) Chuyển đổi giữa local session ↔ snapshot
|
||||||
|
|
||||||
|
### 3.1 Load snapshot -> mở session
|
||||||
|
|
||||||
|
Luồng: `openSectionEditor()` -> `normalizeEditorSnapshot()` -> `toEditorSessionSnapshot()`
|
||||||
|
|
||||||
|
Khi mở session mới:
|
||||||
|
|
||||||
|
1. `baselineSnapshot = toEditorSessionSnapshot(snapshot)`
|
||||||
|
2. `initialData = baselineSnapshot.editor_feature_collection || EMPTY_FEATURE_COLLECTION`
|
||||||
|
3. `snapshotEntities = baselineSnapshot.entities || []`
|
||||||
|
4. `snapshotWikis = baselineSnapshot.wikis || []`
|
||||||
|
5. `snapshotEntityWikiLinks = baselineSnapshot.entity_wiki || []`
|
||||||
|
|
||||||
|
Riêng về GEO/GEO_ENT khi load:
|
||||||
|
|
||||||
|
- `baselineSnapshot.editor_feature_collection` là dữ liệu map gốc đưa vào `initialData`.
|
||||||
|
- `normalizeEditorSnapshot()` sẽ **rehydrate** `feature.properties.entity_ids/entity_id` từ `snapshot.geometry_entity[]` (hoặc legacy `link_scopes`) để UI bind entity hoạt động.
|
||||||
|
- Lưu ý: đây là rehydrate phục vụ editor UX, **không phải** dữ liệu persist chính thức trên `feature.properties` trong snapshot.
|
||||||
|
|
||||||
|
Điểm mấu chốt: **toEditorSessionSnapshot() reset operation** để snapshot trở thành “baseline state”:
|
||||||
|
|
||||||
|
- entities/wikis -> `"reference"`
|
||||||
|
- entity_wiki active -> `"binding"`
|
||||||
|
|
||||||
|
### 3.2 Commit session -> snapshot_json
|
||||||
|
|
||||||
|
Luồng: `commitSection()` -> `buildEditorSnapshot({ draft, changes, snapshotEntities, snapshotWikis, snapshotEntityWikiLinks, previousSnapshot: baselineSnapshot })`
|
||||||
|
|
||||||
|
`buildEditorSnapshot()` sẽ tạo:
|
||||||
|
|
||||||
|
- `editor_feature_collection` (draft đã strip các field denormalized)
|
||||||
|
- `geometries[]` (create/update/delete dựa trên changes + previousSnapshot)
|
||||||
|
- `geometry_entity[]` (join table từ feature.properties.entity_ids)
|
||||||
|
- `entities[]` (từ snapshotEntities + carry-forward inline + ensure entities referenced by joins)
|
||||||
|
- `wikis[]` (từ snapshotWikis, tương tự)
|
||||||
|
- `entity_wiki[]` (từ snapshotEntityWikiLinks, đã dedupe/sort)
|
||||||
|
|
||||||
|
Sau khi commit thành công:
|
||||||
|
|
||||||
|
- `baselineSnapshot` cập nhật = `toEditorSessionSnapshot(snapshot)` của commit mới
|
||||||
|
- snapshot stores cập nhật theo baseline mới (operation reset về `"reference"/"binding"`)
|
||||||
|
|
||||||
|
### 3.3 Reload trang -> mất local state
|
||||||
|
|
||||||
|
Khi reload:
|
||||||
|
|
||||||
|
- Toàn bộ React state reset
|
||||||
|
- App sẽ load lại snapshot từ backend (head commit)
|
||||||
|
- Các thứ bạn “tạo/sửa” chỉ còn lại nếu đã nằm trong commit snapshot
|
||||||
|
|
||||||
|
Vì vậy:
|
||||||
|
|
||||||
|
- Entity/Wiki/Link/Geometry muốn “không mất” phải đi qua **Commit**.
|
||||||
|
- Các state UI (selected geo, search results, form đang nhập) sẽ mất.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) GEO Search (`/geometries/entity`) và tác động lên local store
|
||||||
|
|
||||||
|
Search GEO gọi:
|
||||||
|
|
||||||
|
`GET /geometries/entity?name=<keyword>&limit=<n>`
|
||||||
|
|
||||||
|
Khi bấm **Import** một geometry từ kết quả search:
|
||||||
|
|
||||||
|
1. Tắt `timelineFilterEnabled` để geometry luôn nhìn thấy (không bị filter theo năm).
|
||||||
|
2. Add entity tương ứng vào:
|
||||||
|
- `snapshotEntities` (source:"ref", operation:"reference")
|
||||||
|
- `entityCatalog` (để UI có name/description)
|
||||||
|
3. Nếu geometry chưa có trong `editor.draft`:
|
||||||
|
- tạo `Feature` mới với `id = geometry.id`
|
||||||
|
- set `properties.type` từ `geo_type` (map qua `geoTypeCodeToTypeKey`)
|
||||||
|
- set `time_start/time_end/binding`
|
||||||
|
- set denormalized `entity_id/entity_ids/entity_name/entity_names` để UI/joins hoạt động
|
||||||
|
4. `editor.createFeature(feature)` và auto select feature đó.
|
||||||
|
|
||||||
|
Lưu ý: Import geo tạo ra “create change” trong editor session, nên sẽ đi vào commit snapshot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.1 Nhìn nhanh “5 dataset nằm ở đâu” trong session
|
||||||
|
|
||||||
|
- GEO:
|
||||||
|
- Runtime store: `editor.draft.features[]`
|
||||||
|
- Persisted on commit: `snapshot_json.geometries[]` (build khi commit)
|
||||||
|
|
||||||
|
- ENT:
|
||||||
|
- Runtime store (snapshot): `snapshotEntities`
|
||||||
|
- Persisted on commit: `snapshot_json.entities[]`
|
||||||
|
|
||||||
|
- WIKI:
|
||||||
|
- Runtime store (snapshot): `snapshotWikis`
|
||||||
|
- Persisted on commit: `snapshot_json.wikis[]`
|
||||||
|
|
||||||
|
- ENT_WIKI:
|
||||||
|
- Runtime store (snapshot): `snapshotEntityWikiLinks`
|
||||||
|
- Persisted on commit: `snapshot_json.entity_wiki[]`
|
||||||
|
|
||||||
|
- GEO_ENT:
|
||||||
|
- Runtime store: denormalized tạm thời trên `editor.draft.features[].properties.entity_ids` (để UI chạy)
|
||||||
|
- Persisted on commit: `snapshot_json.geometry_entity[]` (build khi commit)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) Checklist khi debug “mất dữ liệu”
|
||||||
|
|
||||||
|
1. Dữ liệu có nằm trong `snapshotEntities/snapshotWikis/snapshotEntityWikiLinks/editor.draft` không?
|
||||||
|
2. Có bấm **Commit** chưa?
|
||||||
|
3. `pendingSaveCount` có > 0 không (Commit button có enable không)?
|
||||||
|
4. Khi reload, snapshot head commit load lên có chứa các rows đó không?
|
||||||
|
5. Nếu entity tạo mới bị mất:
|
||||||
|
- kiểm tra commit snapshot có `entities[].source:"inline"` không
|
||||||
|
- nếu có mà reload vẫn mất, kiểm tra `normalizeEditorSnapshot()` có parse đúng không
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) File/entrypoints liên quan
|
||||||
|
|
||||||
|
- Session stores:
|
||||||
|
- `FrontEndUser/src/uhm/lib/useEditorSessionState.ts`
|
||||||
|
- `FrontEndUser/src/uhm/lib/editor/session/useEntitySessionState.ts`
|
||||||
|
- `FrontEndUser/src/uhm/lib/editor/session/useWikiSessionState.ts`
|
||||||
|
- `FrontEndUser/src/uhm/lib/editor/session/useSectionSessionState.ts`
|
||||||
|
|
||||||
|
- Geometry editor core:
|
||||||
|
- `FrontEndUser/src/uhm/lib/useEditorState.ts`
|
||||||
|
|
||||||
|
- Snapshot normalization + build snapshot:
|
||||||
|
- `FrontEndUser/src/uhm/lib/editor/snapshot/editorSnapshot.ts`
|
||||||
|
|
||||||
|
- Open/commit/restore commands:
|
||||||
|
- `FrontEndUser/src/uhm/lib/editor/section/useSectionCommands.ts`
|
||||||
|
|
||||||
|
- Page wiring / UI state:
|
||||||
|
- `FrontEndUser/src/app/editor/[id]/page.tsx`
|
||||||
@@ -88,7 +88,6 @@ export type EntitySnapshot = {
|
|||||||
name?: string;
|
name?: string;
|
||||||
slug?: string | null;
|
slug?: string | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
type_id?: string | null;
|
|
||||||
status?: number | null;
|
status?: number | null;
|
||||||
base_updated_at?: string;
|
base_updated_at?: string;
|
||||||
base_hash?: string;
|
base_hash?: string;
|
||||||
@@ -178,7 +177,7 @@ FE hiện tại luôn ghi `source` cho `entities[]`, `geometries[]`, `wikis[]`.
|
|||||||
|
|
||||||
`geometry_entity[]` không có `operation` (join table state).
|
`geometry_entity[]` không có `operation` (join table state).
|
||||||
|
|
||||||
`entity_wiki[]` dùng `operation:"reference"|"delete"` để biểu diễn link/unlink **trong snapshot** (không phải delete trong DB).
|
`entity_wiki[]` dùng `operation:"binding"|"delete"` để biểu diễn link/unlink **trong snapshot** (không phải delete trong DB).
|
||||||
|
|
||||||
## 3) Ý Nghĩa Từng Phần
|
## 3) Ý Nghĩa Từng Phần
|
||||||
|
|
||||||
@@ -254,8 +253,8 @@ Join table many-to-many giữa geometry và entity. Mỗi cặp geometry↔entit
|
|||||||
|
|
||||||
Danh sách wiki của project tại thời điểm commit:
|
Danh sách wiki của project tại thời điểm commit:
|
||||||
|
|
||||||
- Wiki tạo mới: `source:"inline"`, `operation:"create"`, `doc` là tiptap JSON.
|
- Wiki tạo mới: `source:"inline"`, `operation:"create"`, `doc` là HTML string (Quill).
|
||||||
- Wiki sửa: `source:"inline"`, `operation:"update"`, `doc` là tiptap JSON.
|
- Wiki sửa: `source:"inline"`, `operation:"update"`, `doc` là HTML string (Quill).
|
||||||
- Wiki không đổi: thường không có `operation`.
|
- Wiki không đổi: thường không có `operation`.
|
||||||
- Wiki add từ search (wiki đã có trong DB): `source:"ref"`, `operation:"reference"`, `doc` có thể là `null`.
|
- Wiki add từ search (wiki đã có trong DB): `source:"ref"`, `operation:"reference"`, `doc` có thể là `null`.
|
||||||
|
|
||||||
@@ -265,13 +264,17 @@ Danh sách wiki của project tại thời điểm commit:
|
|||||||
export type EntityWikiLinkSnapshot = {
|
export type EntityWikiLinkSnapshot = {
|
||||||
entity_id: string;
|
entity_id: string;
|
||||||
wiki_id: string;
|
wiki_id: string;
|
||||||
operation?: "reference" | "delete";
|
// New semantics:
|
||||||
|
// - binding: link active
|
||||||
|
// - delete: link removed in this snapshot
|
||||||
|
// Backwards-compat: older snapshots may use "reference" meaning link active.
|
||||||
|
operation?: "binding" | "delete" | "reference";
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
Toggle link trong UI:
|
Toggle link trong UI:
|
||||||
|
|
||||||
- Tick checkbox: `{ operation: "reference" }`
|
- Toggle ON (bind): `{ operation: "binding" }` (or legacy `"reference"`)
|
||||||
- Untick checkbox: `{ operation: "delete" }`
|
- Untick checkbox: `{ operation: "delete" }`
|
||||||
|
|
||||||
## 4) Ví Dụ JSON (rút gọn)
|
## 4) Ví Dụ JSON (rút gọn)
|
||||||
@@ -296,7 +299,7 @@ Toggle link trong UI:
|
|||||||
},
|
},
|
||||||
"entities": [
|
"entities": [
|
||||||
{ "id": "e_2", "source": "ref", "name": "Pinned Entity" },
|
{ "id": "e_2", "source": "ref", "name": "Pinned Entity" },
|
||||||
{ "id": "e_1", "source": "ref", "operation": "reference", "name": "Ha Noi", "type_id": "city", "status": 1 }
|
{ "id": "e_1", "source": "ref", "operation": "reference", "name": "Ha Noi", "status": 1 }
|
||||||
],
|
],
|
||||||
"geometries": [
|
"geometries": [
|
||||||
{
|
{
|
||||||
@@ -320,7 +323,7 @@ Toggle link trong UI:
|
|||||||
"source": "inline",
|
"source": "inline",
|
||||||
"operation": "create",
|
"operation": "create",
|
||||||
"title": "Overview",
|
"title": "Overview",
|
||||||
"doc": { "type": "doc", "content": [{ "type": "paragraph" }] }
|
"doc": "<p>Overview</p>"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "019d...wiki_from_db",
|
"id": "019d...wiki_from_db",
|
||||||
@@ -331,7 +334,7 @@ Toggle link trong UI:
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"entity_wiki": [
|
"entity_wiki": [
|
||||||
{ "entity_id": "e_1", "wiki_id": "w_inline_1", "operation": "reference" }
|
{ "entity_id": "e_1", "wiki_id": "w_inline_1", "operation": "binding" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -342,9 +345,9 @@ BE nên normalize trước khi convert snapshot → DB:
|
|||||||
|
|
||||||
- Ignore toàn bộ field entity denormalize trên `feature.properties` (nếu có): `entity_id/entity_ids/entity_name/entity_names/entity_type_id`. Quan hệ geometry↔entity lấy từ `geometry_entity[]`.
|
- Ignore toàn bộ field entity denormalize trên `feature.properties` (nếu có): `entity_id/entity_ids/entity_name/entity_names/entity_type_id`. Quan hệ geometry↔entity lấy từ `geometry_entity[]`.
|
||||||
- `entity_wiki[].operation`:
|
- `entity_wiki[].operation`:
|
||||||
- `"reference"`: link active
|
- `"binding"` (or legacy `"reference"`): link active
|
||||||
- `"delete"`: link removed trong snapshot
|
- `"delete"`: link removed trong snapshot
|
||||||
- missing: treat as `"reference"` (compat)
|
- missing: treat as `"binding"` (compat)
|
||||||
|
|
||||||
## 6) Legacy Compatibility (nếu gặp snapshot cũ)
|
## 6) Legacy Compatibility (nếu gặp snapshot cũ)
|
||||||
|
|
||||||
@@ -352,4 +355,4 @@ FE đã từng gửi các field legacy; BE có thể gặp nếu đang xử lý
|
|||||||
|
|
||||||
- `entity_wikis` (plural) thay vì `entity_wiki` (singular): treat như nhau.
|
- `entity_wikis` (plural) thay vì `entity_wiki` (singular): treat như nhau.
|
||||||
- `ref:{id}` trong `entities/geometries/wikis`: ignore (id canonical).
|
- `ref:{id}` trong `entities/geometries/wikis`: ignore (id canonical).
|
||||||
- `is_deleted` trong join table entity↔wiki: map sang `operation:"delete"` khi `is_deleted==1`, ngược lại `"reference"`.
|
- `is_deleted` trong join table entity↔wiki: map sang `operation:"delete"` khi `is_deleted==1`, ngược lại `"binding"` (or legacy `"reference"`).
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
// Turbopack uses its "root" directory for module resolution. In a repo that
|
||||||
|
// contains multiple projects (without a package.json at the repo root),
|
||||||
|
// Turbopack can accidentally pick the repo root and then fail to resolve
|
||||||
|
// dependencies like `tailwindcss`. Force Turbopack root to this app directory.
|
||||||
|
const turbopackRoot = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
/* config options here */
|
||||||
@@ -28,6 +36,7 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
turbopack: {
|
turbopack: {
|
||||||
|
root: turbopackRoot,
|
||||||
rules: {
|
rules: {
|
||||||
'*.svg': {
|
'*.svg': {
|
||||||
loaders: ['@svgr/webpack'],
|
loaders: ['@svgr/webpack'],
|
||||||
|
|||||||
293
package-lock.json
generated
293
package-lock.json
generated
@@ -30,7 +30,6 @@
|
|||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-apexcharts": "^1.8.0",
|
"react-apexcharts": "^1.8.0",
|
||||||
"react-d3-tree": "^3.6.6",
|
|
||||||
"react-dnd": "^16.0.1",
|
"react-dnd": "^16.0.1",
|
||||||
"react-dnd-html5-backend": "^16.0.1",
|
"react-dnd-html5-backend": "^16.0.1",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
@@ -1772,28 +1771,11 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@bkrem/react-transition-group": {
|
|
||||||
"version": "1.3.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@bkrem/react-transition-group/-/react-transition-group-1.3.5.tgz",
|
|
||||||
"integrity": "sha512-lbBYhC42sxAeFEopxzd9oWdkkV0zirO5E9WyeOBxOrpXsf7m30Aj8vnbayZxFOwD9pvUQ2Pheb1gO79s0Qap3Q==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"dependencies": {
|
|
||||||
"chain-function": "^1.0.0",
|
|
||||||
"dom-helpers": "^3.3.1",
|
|
||||||
"loose-envify": "^1.3.1",
|
|
||||||
"prop-types": "^15.5.6",
|
|
||||||
"react-lifecycles-compat": "^3.0.4",
|
|
||||||
"warning": "^3.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
|
||||||
"react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
|
||||||
"integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
|
"integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1815,6 +1797,7 @@
|
|||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
|
||||||
"integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
|
"integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2697,6 +2680,7 @@
|
|||||||
"version": "0.2.12",
|
"version": "0.2.12",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||||
"integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==",
|
"integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3577,6 +3561,64 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||||
|
"version": "1.8.1",
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.1.0",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.8.1",
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/core": "^1.7.1",
|
||||||
|
"@emnapi/runtime": "^1.7.1",
|
||||||
|
"@tybys/wasm-util": "^0.10.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||||
|
"version": "0.10.1",
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "0BSD",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.2.2",
|
"version": "4.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz",
|
||||||
@@ -4030,18 +4072,13 @@
|
|||||||
"version": "0.10.1",
|
"version": "0.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||||
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
|
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/d3-hierarchy": {
|
|
||||||
"version": "1.1.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-1.1.11.tgz",
|
|
||||||
"integrity": "sha512-lnQiU7jV+Gyk9oQYk0GGYccuexmQPTp08E0+4BidgFdiJivjEvf+esPSdZqCZ2C7UwTWejWpqetVaU8A+eX3FA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -5301,12 +5338,6 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
"node_modules/chain-function": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/chain-function/-/chain-function-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-SxltgMwL9uCko5/ZCLiyG2B7R9fY4pDZUw7hJ4MhirdjBLosoDqkWABi3XMucddHdLiFJMb7PD2MZifZriuMTg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/chalk": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
@@ -5336,15 +5367,6 @@
|
|||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/clone": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -5550,133 +5572,6 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/d3-color": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-dispatch": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-drag": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-dispatch": "1 - 3",
|
|
||||||
"d3-selection": "3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-ease": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-hierarchy": {
|
|
||||||
"version": "1.1.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz",
|
|
||||||
"integrity": "sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==",
|
|
||||||
"license": "BSD-3-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/d3-interpolate": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-color": "1 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-path": {
|
|
||||||
"version": "1.0.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz",
|
|
||||||
"integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==",
|
|
||||||
"license": "BSD-3-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/d3-selection": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
|
||||||
"license": "ISC",
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-shape": {
|
|
||||||
"version": "1.3.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz",
|
|
||||||
"integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-path": "1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-timer": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-transition": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-color": "1 - 3",
|
|
||||||
"d3-dispatch": "1 - 3",
|
|
||||||
"d3-ease": "1 - 3",
|
|
||||||
"d3-interpolate": "1 - 3",
|
|
||||||
"d3-timer": "1 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"d3-selection": "2 - 3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-zoom": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-dispatch": "1 - 3",
|
|
||||||
"d3-drag": "2 - 3",
|
|
||||||
"d3-interpolate": "1 - 3",
|
|
||||||
"d3-selection": "2 - 3",
|
|
||||||
"d3-transition": "2 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/damerau-levenshtein": {
|
"node_modules/damerau-levenshtein": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||||
@@ -5818,15 +5713,6 @@
|
|||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dequal": {
|
|
||||||
"version": "2.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
|
||||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -5869,15 +5755,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dom-helpers": {
|
|
||||||
"version": "3.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz",
|
|
||||||
"integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.1.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/dom-serializer": {
|
"node_modules/dom-serializer": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||||
@@ -9241,37 +9118,6 @@
|
|||||||
"react": ">=16.8.0"
|
"react": ">=16.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-d3-tree": {
|
|
||||||
"version": "3.6.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-d3-tree/-/react-d3-tree-3.6.6.tgz",
|
|
||||||
"integrity": "sha512-E9ByUdeqvlxLlF9BSL7KWQH3ikYHtHO+g1rAPcVgj6mu92tjRUCan2AWxoD4eTSzzAATf8BZtf+CXGSoSd6ioQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@bkrem/react-transition-group": "^1.3.5",
|
|
||||||
"@types/d3-hierarchy": "^1.1.8",
|
|
||||||
"clone": "^2.1.1",
|
|
||||||
"d3-hierarchy": "^1.1.9",
|
|
||||||
"d3-selection": "^3.0.0",
|
|
||||||
"d3-shape": "^1.3.7",
|
|
||||||
"d3-zoom": "^3.0.0",
|
|
||||||
"dequal": "^2.0.2",
|
|
||||||
"uuid": "^8.3.1"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "16.x || 17.x || 18.x || 19.x",
|
|
||||||
"react-dom": "16.x || 17.x || 18.x || 19.x"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-d3-tree/node_modules/uuid": {
|
|
||||||
"version": "8.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
|
||||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
|
||||||
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"uuid": "dist/bin/uuid"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-dnd": {
|
"node_modules/react-dnd": {
|
||||||
"version": "16.0.1",
|
"version": "16.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz",
|
||||||
@@ -9347,12 +9193,6 @@
|
|||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/react-lifecycles-compat": {
|
|
||||||
"version": "3.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
|
|
||||||
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/react-quill-new": {
|
"node_modules/react-quill-new": {
|
||||||
"version": "3.8.3",
|
"version": "3.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-quill-new/-/react-quill-new-3.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-quill-new/-/react-quill-new-3.8.3.tgz",
|
||||||
@@ -10668,15 +10508,6 @@
|
|||||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/warning": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/warning/-/warning-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-jMBt6pUrKn5I+OGgtQ4YZLdhIeJmObddh6CsibPxyQ5yPZm1XExSyzC1LCNX7BzhxWgiHmizBWJTHJIjMjTQYQ==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"dependencies": {
|
|
||||||
"loose-envify": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "node ./scripts/dev.mjs",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint ."
|
"lint": "eslint ."
|
||||||
@@ -31,7 +31,6 @@
|
|||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-apexcharts": "^1.8.0",
|
"react-apexcharts": "^1.8.0",
|
||||||
"react-d3-tree": "^3.6.6",
|
|
||||||
"react-dnd": "^16.0.1",
|
"react-dnd": "^16.0.1",
|
||||||
"react-dnd-html5-backend": "^16.0.1",
|
"react-dnd-html5-backend": "^16.0.1",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
|||||||
26
scripts/dev.mjs
Normal file
26
scripts/dev.mjs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
// WebStorm sometimes runs npm scripts with a different working directory (repo root),
|
||||||
|
// which breaks module resolution for PostCSS/Tailwind. Force cwd to this package.
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const pkgRoot = path.resolve(here, "..");
|
||||||
|
|
||||||
|
// Ensure this process (and any child tools that read process.cwd()) runs from package root,
|
||||||
|
// even if the caller started in a different working directory (e.g. IDE run configs).
|
||||||
|
process.chdir(pkgRoot);
|
||||||
|
|
||||||
|
const nextBin = path.join(pkgRoot, "node_modules", "next", "dist", "bin", "next");
|
||||||
|
|
||||||
|
// Forward any args passed after `--` from npm, e.g. `npm run dev -- --port 3005`.
|
||||||
|
const extraArgs = process.argv.slice(2);
|
||||||
|
const child = spawn(process.execPath, [nextBin, "dev", ...extraArgs], {
|
||||||
|
cwd: pkgRoot,
|
||||||
|
stdio: "inherit",
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("exit", (code) => {
|
||||||
|
process.exit(code ?? 0);
|
||||||
|
});
|
||||||
@@ -39,18 +39,26 @@ export function useFeatureCommands(options: Options) {
|
|||||||
setEntityFormStatus,
|
setEntityFormStatus,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const applyGeometryMetadata = useCallback(async () => {
|
const applyGeometryMetadata = useCallback(async (): Promise<{ ok: boolean; error?: string }> => {
|
||||||
if (!selectedFeature) {
|
if (!selectedFeature) {
|
||||||
setEntityFormStatus("Hãy chọn một geometry trước.");
|
const msg = "Hãy chọn một geometry trước.";
|
||||||
return;
|
setEntityFormStatus(msg);
|
||||||
|
return { ok: false, error: msg };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!geometryMetaForm.time_start.trim() || !geometryMetaForm.time_end.trim()) {
|
||||||
|
const msg = "time_start và time_end là bắt buộc.";
|
||||||
|
setEntityFormStatus(msg);
|
||||||
|
return { ok: false, error: msg };
|
||||||
}
|
}
|
||||||
|
|
||||||
let metadata;
|
let metadata;
|
||||||
try {
|
try {
|
||||||
metadata = buildGeometryMetadataPatch(geometryMetaForm);
|
metadata = buildGeometryMetadataPatch(geometryMetaForm);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ.");
|
const msg = err instanceof Error ? err.message : "Thời gian không hợp lệ.";
|
||||||
return;
|
setEntityFormStatus(msg);
|
||||||
|
return { ok: false, error: msg };
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsEntitySubmitting(true);
|
setIsEntitySubmitting(true);
|
||||||
@@ -59,6 +67,7 @@ export function useFeatureCommands(options: Options) {
|
|||||||
editor.patchFeatureProperties(selectedFeature.properties.id, metadata.patch);
|
editor.patchFeatureProperties(selectedFeature.properties.id, metadata.patch);
|
||||||
setGeometryMetaForm(metadata.formState);
|
setGeometryMetaForm(metadata.formState);
|
||||||
setEntityFormStatus("Đã cập nhật thuộc tính GEO. Commit khi sẵn sàng.");
|
setEntityFormStatus("Đã cập nhật thuộc tính GEO. Commit khi sẵn sàng.");
|
||||||
|
return { ok: true };
|
||||||
} finally {
|
} finally {
|
||||||
setIsEntitySubmitting(false);
|
setIsEntitySubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -111,4 +120,3 @@ export function useFeatureCommands(options: Options) {
|
|||||||
applyEntitiesToSelectedGeometry,
|
applyEntitiesToSelectedGeometry,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
185
src/app/page.tsx
185
src/app/page.tsx
@@ -1,9 +1,186 @@
|
|||||||
import AdminLayout from "./user/layout";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import Map from "@/uhm/components/Map";
|
||||||
|
import BackgroundLayersPanel from "@/uhm/components/BackgroundLayersPanel";
|
||||||
|
import TimelineBar from "@/uhm/components/TimelineBar";
|
||||||
|
import { fetchGeometriesByBBox } from "@/uhm/api/geometries";
|
||||||
|
import { ApiError } from "@/uhm/api/http";
|
||||||
|
import { API_BASE_URL } from "@/uhm/api/config";
|
||||||
|
import {
|
||||||
|
BackgroundLayerId,
|
||||||
|
BackgroundLayerVisibility,
|
||||||
|
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
||||||
|
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
||||||
|
} from "@/uhm/lib/backgroundLayers";
|
||||||
|
import {
|
||||||
|
loadBackgroundLayerVisibilityFromStorage,
|
||||||
|
persistBackgroundLayerVisibility,
|
||||||
|
} from "@/uhm/lib/editor/background/backgroundVisibilityStorage";
|
||||||
|
import { EMPTY_FEATURE_COLLECTION, WORLD_BBOX } from "@/uhm/lib/geo/constants";
|
||||||
|
import { clampYearToFixedRange, TIMELINE_DEBOUNCE_MS } from "@/uhm/lib/timeline";
|
||||||
|
import type { Feature, FeatureCollection } from "@/uhm/types/geo";
|
||||||
|
|
||||||
|
const CURRENT_YEAR = new Date().getUTCFullYear();
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
|
const [data, setData] = useState<FeatureCollection>(EMPTY_FEATURE_COLLECTION);
|
||||||
|
const [selectedFeatureId, setSelectedFeatureId] = useState<string | number | null>(null);
|
||||||
|
const [timelineYear, setTimelineYear] = useState<number>(() => clampYearToFixedRange(CURRENT_YEAR));
|
||||||
|
const [timelineDraftYear, setTimelineDraftYear] = useState<number>(() => clampYearToFixedRange(CURRENT_YEAR));
|
||||||
|
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
|
||||||
|
const [timelineStatus, setTimelineStatus] = useState<string | null>(null);
|
||||||
|
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
|
||||||
|
() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })
|
||||||
|
);
|
||||||
|
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
|
||||||
|
const timelineFetchRequestRef = useRef(0);
|
||||||
|
const [lastLoadedAt, setLastLoadedAt] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const selectedFeature: Feature | null = useMemo(() => {
|
||||||
|
if (selectedFeatureId === null) return null;
|
||||||
return (
|
return (
|
||||||
<AdminLayout>
|
data.features.find((feature) => String(feature.properties.id) === String(selectedFeatureId)) || null
|
||||||
<div className=''>Page</div>
|
);
|
||||||
</AdminLayout>
|
}, [data.features, selectedFeatureId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedFeatureId === null) return;
|
||||||
|
const stillExists = data.features.some((feature) => String(feature.properties.id) === String(selectedFeatureId));
|
||||||
|
if (!stillExists) setSelectedFeatureId(null);
|
||||||
|
}, [data.features, selectedFeatureId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
if (timelineDraftYear !== timelineYear) setTimelineYear(timelineDraftYear);
|
||||||
|
}, TIMELINE_DEBOUNCE_MS);
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}, [timelineDraftYear, timelineYear]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage());
|
||||||
|
setIsBackgroundVisibilityReady(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let disposed = false;
|
||||||
|
const requestId = ++timelineFetchRequestRef.current;
|
||||||
|
|
||||||
|
async function loadByTimeline() {
|
||||||
|
setIsTimelineLoading(true);
|
||||||
|
setTimelineStatus(null);
|
||||||
|
try {
|
||||||
|
const next = await fetchGeometriesByBBox({ ...WORLD_BBOX, time: timelineYear });
|
||||||
|
if (disposed || requestId !== timelineFetchRequestRef.current) return;
|
||||||
|
setData(next);
|
||||||
|
setLastLoadedAt(new Date().toISOString());
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
console.error("Load timeline data failed", err.body);
|
||||||
|
} else {
|
||||||
|
console.error("Load timeline data failed", err);
|
||||||
|
}
|
||||||
|
if (!disposed && requestId === timelineFetchRequestRef.current) {
|
||||||
|
setTimelineStatus("Không tải được geometry tại mốc thời gian đã chọn.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!disposed && requestId === timelineFetchRequestRef.current) {
|
||||||
|
setIsTimelineLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadByTimeline();
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
};
|
||||||
|
}, [timelineYear]);
|
||||||
|
|
||||||
|
const updateBackgroundVisibility = (updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility) => {
|
||||||
|
setBackgroundVisibility((prev) => {
|
||||||
|
const next = updater(prev);
|
||||||
|
persistBackgroundLayerVisibility(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleBackgroundLayer = (id: BackgroundLayerId) => {
|
||||||
|
updateBackgroundVisibility((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShowAllBackgroundLayers = () => {
|
||||||
|
updateBackgroundVisibility(() => ({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHideAllBackgroundLayers = () => {
|
||||||
|
updateBackgroundVisibility(() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimelineYearChange = (nextYear: number) => {
|
||||||
|
setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear)));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", minHeight: "100vh" }}>
|
||||||
|
<div style={{ flex: 1, position: "relative", minHeight: "100vh" }}>
|
||||||
|
{isBackgroundVisibilityReady ? (
|
||||||
|
<Map
|
||||||
|
mode="select"
|
||||||
|
draft={data}
|
||||||
|
selectedFeatureId={selectedFeatureId}
|
||||||
|
onSelectFeatureId={setSelectedFeatureId}
|
||||||
|
backgroundVisibility={backgroundVisibility}
|
||||||
|
allowGeometryEditing={false}
|
||||||
|
respectBindingFilter={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TimelineBar
|
||||||
|
year={timelineDraftYear}
|
||||||
|
onYearChange={handleTimelineYearChange}
|
||||||
|
isLoading={isTimelineLoading}
|
||||||
|
disabled={false}
|
||||||
|
statusText={timelineStatus}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BackgroundLayersPanel
|
||||||
|
visibility={backgroundVisibility}
|
||||||
|
onToggleLayer={handleToggleBackgroundLayer}
|
||||||
|
onShowAll={handleShowAllBackgroundLayers}
|
||||||
|
onHideAll={handleHideAllBackgroundLayers}
|
||||||
|
topContent={
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "10px",
|
||||||
|
background: "#0b1220",
|
||||||
|
borderRadius: "8px",
|
||||||
|
border: "1px solid #1f2937",
|
||||||
|
display: "grid",
|
||||||
|
gap: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: "14px", color: "#f8fafc" }}>Viewer</div>
|
||||||
|
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
|
||||||
|
API: {API_BASE_URL}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
|
||||||
|
Year: {timelineYear} | Features: {data.features.length}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
|
||||||
|
{isTimelineLoading ? "Loading geometries..." : lastLoadedAt ? `Loaded: ${lastLoadedAt}` : "Not loaded yet"}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#cbd5e1", fontSize: "13px", overflowWrap: "anywhere" }}>
|
||||||
|
{selectedFeature ? `ID: ${String(selectedFeature.properties.id)}` : "Chưa chọn geometry"}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
|
||||||
|
{selectedFeature?.properties?.type ? `Type: ${String(selectedFeature.properties.type)}` : "Type: -"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,330 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
|
||||||
import ComponentCard from "@/components/common/ComponentCard";
|
|
||||||
import Badge from "@/components/ui/badge/Badge";
|
|
||||||
import Button from "@/components/ui/button/Button";
|
|
||||||
|
|
||||||
import Map from "@/uhm/components/Map";
|
|
||||||
import { DEFAULT_BACKGROUND_LAYER_VISIBILITY } from "@/uhm/lib/backgroundLayers";
|
|
||||||
import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/geo/constants";
|
|
||||||
import { fetchSectionCommits } from "@/uhm/api/sections";
|
|
||||||
import { normalizeEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
|
||||||
import type { EditorSnapshot, SectionCommit } from "@/uhm/types/sections";
|
|
||||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
|
||||||
|
|
||||||
import type { Submission } from "@/interface/submission";
|
|
||||||
import { apiGetSubmissionById } from "@/service/submissionService";
|
|
||||||
import type { Project } from "@/interface/project";
|
|
||||||
import { apiGetProjectDetail } from "@/service/projectService";
|
|
||||||
|
|
||||||
function formatTime(value?: string | null) {
|
|
||||||
if (!value) return "-";
|
|
||||||
const d = new Date(value);
|
|
||||||
if (Number.isNaN(d.getTime())) return String(value);
|
|
||||||
return d.toLocaleString("vi-VN");
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SubmissionDetailPage() {
|
|
||||||
const params = useParams();
|
|
||||||
const id = String(params.id || "");
|
|
||||||
const [row, setRow] = useState<Submission | null>(null);
|
|
||||||
const [project, setProject] = useState<Project | null>(null);
|
|
||||||
const [commits, setCommits] = useState<SectionCommit[]>([]);
|
|
||||||
const [snapshot, setSnapshot] = useState<EditorSnapshot | null>(null);
|
|
||||||
const [snapshotEntities, setSnapshotEntities] = useState<EntitySnapshot[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [isLoadingExtras, setIsLoadingExtras] = useState(false);
|
|
||||||
|
|
||||||
const headCommitSnapshotJson = useMemo(() => {
|
|
||||||
const headId = project?.latest_commit_id || null;
|
|
||||||
if (!headId) return null;
|
|
||||||
const head = commits.find((c) => c.id === headId) || null;
|
|
||||||
return (head as any)?.snapshot_json ?? null;
|
|
||||||
}, [commits, project?.latest_commit_id]);
|
|
||||||
|
|
||||||
const draft = useMemo(
|
|
||||||
() => snapshot?.editor_feature_collection || EMPTY_FEATURE_COLLECTION,
|
|
||||||
[snapshot]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let disposed = false;
|
|
||||||
async function load() {
|
|
||||||
if (!id) return;
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
const res = await apiGetSubmissionById(id);
|
|
||||||
if (!disposed) setRow(res?.data || null);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
toast.error("Khong the tai submission.");
|
|
||||||
} finally {
|
|
||||||
if (!disposed) setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
load();
|
|
||||||
return () => {
|
|
||||||
disposed = true;
|
|
||||||
};
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let disposed = false;
|
|
||||||
async function loadExtras() {
|
|
||||||
if (!row?.project_id) return;
|
|
||||||
try {
|
|
||||||
setIsLoadingExtras(true);
|
|
||||||
|
|
||||||
const [projectRes, commitRows] = await Promise.all([
|
|
||||||
apiGetProjectDetail(row.project_id),
|
|
||||||
fetchSectionCommits(row.project_id),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (disposed) return;
|
|
||||||
setProject(projectRes?.data || null);
|
|
||||||
setCommits(commitRows || []);
|
|
||||||
|
|
||||||
const commit = (commitRows || []).find((c) => c.id === row.commit_id) || null;
|
|
||||||
const snap = normalizeEditorSnapshot(commit?.snapshot_json || null);
|
|
||||||
setSnapshot(snap);
|
|
||||||
setSnapshotEntities((snap?.entities || []) as EntitySnapshot[]);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
toast.error("Khong the tai thong tin project/commit.");
|
|
||||||
} finally {
|
|
||||||
if (!disposed) setIsLoadingExtras(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loadExtras();
|
|
||||||
return () => {
|
|
||||||
disposed = true;
|
|
||||||
};
|
|
||||||
}, [row?.commit_id, row?.project_id]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-6xl mx-auto pb-10">
|
|
||||||
<PageBreadcrumb
|
|
||||||
pageTitle="Chi tiet submission"
|
|
||||||
paths={[{ name: "Kiem duyet submissions", href: "/user/submissions" }]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<ComponentCard title="Thong tin">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="p-6 text-sm text-gray-500 dark:text-gray-400">Dang tai...</div>
|
|
||||||
) : row ? (
|
|
||||||
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">ID</div>
|
|
||||||
<div className="font-mono break-all">{row.id}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">Status</div>
|
|
||||||
<div className="mt-1">
|
|
||||||
<Badge size="sm" variant="light" color="light">
|
|
||||||
{row.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">Project</div>
|
|
||||||
<div className="font-medium break-words">{row.project_title || "-"}</div>
|
|
||||||
<div className="font-mono break-all text-xs text-gray-500 dark:text-gray-400 mt-1">{row.project_id}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">Commit</div>
|
|
||||||
<div className="font-mono break-all">{row.commit_id}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">User</div>
|
|
||||||
<div className="font-mono break-all">{row.user_id}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">Created</div>
|
|
||||||
<div>{formatTime(row.created_at)}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">Reviewed by</div>
|
|
||||||
<div className="font-mono break-all">{row.reviewed_by || "-"}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">Reviewed at</div>
|
|
||||||
<div>{formatTime(row.reviewed_at)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">Review note</div>
|
|
||||||
<div className="mt-1 whitespace-pre-wrap">{row.review_note || "-"}</div>
|
|
||||||
</div>
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">Content</div>
|
|
||||||
<div className="mt-1 whitespace-pre-wrap">{row.content || "-"}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="md:col-span-2 flex justify-end">
|
|
||||||
<Button size="sm" variant="outline" onClick={() => (window.location.href = `/editor/${row.project_id}`)}>
|
|
||||||
Open editor
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="p-6 text-sm text-gray-500 dark:text-gray-400">Khong tim thay submission.</div>
|
|
||||||
)}
|
|
||||||
</ComponentCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{row ? (
|
|
||||||
<div className="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
<ComponentCard title="Map view">
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="rounded-xl overflow-hidden border border-gray-200 dark:border-gray-800">
|
|
||||||
<Map
|
|
||||||
mode="idle"
|
|
||||||
draft={draft}
|
|
||||||
selectedFeatureId={null}
|
|
||||||
onSelectFeatureId={() => {}}
|
|
||||||
backgroundVisibility={DEFAULT_BACKGROUND_LAYER_VISIBILITY}
|
|
||||||
allowGeometryEditing={false}
|
|
||||||
respectBindingFilter={false}
|
|
||||||
height="320px"
|
|
||||||
fitToDraftBounds
|
|
||||||
fitBoundsKey={row.id}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{isLoadingExtras ? (
|
|
||||||
<div className="mt-3 text-xs text-gray-500 dark:text-gray-400">Dang tai snapshot/commits...</div>
|
|
||||||
) : snapshot ? (
|
|
||||||
<div className="mt-3 text-xs text-gray-500 dark:text-gray-400">Da tai snapshot.</div>
|
|
||||||
) : (
|
|
||||||
<div className="mt-3 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
Khong tim thay snapshot cho commit nay.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ComponentCard>
|
|
||||||
|
|
||||||
<ComponentCard title="Entities (snapshot)">
|
|
||||||
<div className="p-4">
|
|
||||||
{snapshotEntities.length === 0 ? (
|
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">Khong co entities trong snapshot.</div>
|
|
||||||
) : (
|
|
||||||
<div className="max-w-full overflow-x-auto">
|
|
||||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-[#0d1117] min-w-[720px]">
|
|
||||||
<div className="grid grid-cols-12 gap-4 px-5 py-3 border-b border-gray-200 dark:border-gray-800 bg-gray-50/50 dark:bg-[#161b22] text-xs font-semibold text-gray-600 dark:text-gray-300">
|
|
||||||
<div className="col-span-2">Op</div>
|
|
||||||
<div className="col-span-6">Name</div>
|
|
||||||
<div className="col-span-4">Entity ID</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
|
|
||||||
{snapshotEntities.map((e) => (
|
|
||||||
<div key={`${e.operation}:${e.id}`} className="grid grid-cols-12 gap-4 px-5 py-3 text-sm">
|
|
||||||
<div className="col-span-2">
|
|
||||||
<Badge size="sm" variant="light" color="dark">
|
|
||||||
{e.operation}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-6 min-w-0 truncate">{e.name || "-"}</div>
|
|
||||||
<div className="col-span-4 font-mono text-xs break-all">{e.id}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ComponentCard>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{row ? (
|
|
||||||
<div className="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
<ComponentCard title="Head commit snapshot_json">
|
|
||||||
<div className="p-4">
|
|
||||||
<pre className="text-xs whitespace-pre-wrap break-words rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-[#0d1117] p-4 overflow-auto max-h-[420px]">
|
|
||||||
{JSON.stringify(headCommitSnapshotJson, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</ComponentCard>
|
|
||||||
|
|
||||||
<ComponentCard title="Project members">
|
|
||||||
<div className="p-4">
|
|
||||||
{!project ? (
|
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">Khong co du lieu project.</div>
|
|
||||||
) : (project.members || []).length === 0 ? (
|
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">Khong co thanh vien.</div>
|
|
||||||
) : (
|
|
||||||
<div className="max-w-full overflow-x-auto">
|
|
||||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-[#0d1117] min-w-[640px]">
|
|
||||||
<div className="grid grid-cols-12 gap-4 px-5 py-3 border-b border-gray-200 dark:border-gray-800 bg-gray-50/50 dark:bg-[#161b22] text-xs font-semibold text-gray-600 dark:text-gray-300">
|
|
||||||
<div className="col-span-5">Member</div>
|
|
||||||
<div className="col-span-3">Role</div>
|
|
||||||
<div className="col-span-4">User ID</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
|
|
||||||
{(project.members || []).map((m) => (
|
|
||||||
<div key={m.user_id} className="grid grid-cols-12 gap-4 px-5 py-3 text-sm">
|
|
||||||
<div className="col-span-5 min-w-0 truncate">{m.display_name || "-"}</div>
|
|
||||||
<div className="col-span-3">
|
|
||||||
<Badge size="sm" variant="light" color="info">
|
|
||||||
{m.role}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-4 font-mono text-xs break-all">{m.user_id}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ComponentCard>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{row ? (
|
|
||||||
<div className="mt-6">
|
|
||||||
<ComponentCard title="Commits">
|
|
||||||
<div className="p-4">
|
|
||||||
{commits.length === 0 ? (
|
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">Khong co commits.</div>
|
|
||||||
) : (
|
|
||||||
<div className="max-w-full overflow-x-auto">
|
|
||||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-[#0d1117] min-w-[900px]">
|
|
||||||
<div className="grid grid-cols-12 gap-4 px-5 py-3 border-b border-gray-200 dark:border-gray-800 bg-gray-50/50 dark:bg-[#161b22] text-xs font-semibold text-gray-600 dark:text-gray-300">
|
|
||||||
<div className="col-span-3">Commit</div>
|
|
||||||
<div className="col-span-5">Title</div>
|
|
||||||
<div className="col-span-2">Created</div>
|
|
||||||
<div className="col-span-2">User</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
|
|
||||||
{commits.map((c) => {
|
|
||||||
const isTarget = c.id === row.commit_id;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={c.id}
|
|
||||||
className={`grid grid-cols-12 gap-4 px-5 py-3 text-sm ${isTarget ? "bg-brand-50/60 dark:bg-brand-500/10" : ""}`}
|
|
||||||
>
|
|
||||||
<div className="col-span-3 font-mono text-xs break-all">
|
|
||||||
{isTarget ? <b>{c.id}</b> : c.id}
|
|
||||||
</div>
|
|
||||||
<div className="col-span-5 min-w-0 truncate">{c.edit_summary || "-"}</div>
|
|
||||||
<div className="col-span-2 text-xs text-gray-600 dark:text-gray-300">{formatTime(c.created_at)}</div>
|
|
||||||
<div className="col-span-2 font-mono text-xs break-all">{c.user_id}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ComponentCard>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,326 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
|
||||||
import ComponentCard from "@/components/common/ComponentCard";
|
|
||||||
import Badge from "@/components/ui/badge/Badge";
|
|
||||||
import Button from "@/components/ui/button/Button";
|
|
||||||
import Label from "@/components/form/Label";
|
|
||||||
import { Modal } from "@/components/ui/modal";
|
|
||||||
import { useModal } from "@/hooks/useModal";
|
|
||||||
|
|
||||||
import type { Submission, SubmissionStatus } from "@/interface/submission";
|
|
||||||
import { apiSearchSubmissions, apiUpdateSubmissionStatus } from "@/service/submissionService";
|
|
||||||
|
|
||||||
type Decision = "APPROVED" | "REJECTED";
|
|
||||||
|
|
||||||
function statusBadge(status: SubmissionStatus) {
|
|
||||||
switch (status) {
|
|
||||||
case "PENDING":
|
|
||||||
return (
|
|
||||||
<Badge size="sm" variant="light" color="warning">
|
|
||||||
PENDING
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
case "APPROVED":
|
|
||||||
return (
|
|
||||||
<Badge size="sm" variant="light" color="success">
|
|
||||||
APPROVED
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
case "REJECTED":
|
|
||||||
return (
|
|
||||||
<Badge size="sm" variant="light" color="error">
|
|
||||||
REJECTED
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return (
|
|
||||||
<Badge size="sm" variant="light" color="light">
|
|
||||||
{String(status || "UNKNOWN")}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(value?: string | null) {
|
|
||||||
if (!value) return "-";
|
|
||||||
const d = new Date(value);
|
|
||||||
if (Number.isNaN(d.getTime())) return String(value);
|
|
||||||
return d.toLocaleString("vi-VN");
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SubmissionsPage() {
|
|
||||||
const [items, setItems] = useState<Submission[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const limit = 20;
|
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [projectId, setProjectId] = useState("");
|
|
||||||
const [status, setStatus] = useState<"ALL" | "PENDING" | "APPROVED" | "REJECTED">("PENDING");
|
|
||||||
|
|
||||||
const { isOpen, openModal, closeModal } = useModal();
|
|
||||||
const [active, setActive] = useState<Submission | null>(null);
|
|
||||||
const [decision, setDecision] = useState<Decision>("APPROVED");
|
|
||||||
const [reviewNote, setReviewNote] = useState("");
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const query = useMemo(() => {
|
|
||||||
const trimmedSearch = search.trim();
|
|
||||||
const trimmedProject = projectId.trim();
|
|
||||||
return {
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
project_id: trimmedProject.length ? trimmedProject : undefined,
|
|
||||||
search: trimmedSearch.length ? trimmedSearch : undefined,
|
|
||||||
statuses: status === "ALL" ? undefined : ([status] as any),
|
|
||||||
sort: "created_at" as const,
|
|
||||||
};
|
|
||||||
}, [limit, page, projectId, search, status]);
|
|
||||||
|
|
||||||
const fetchList = async () => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
const res = await apiSearchSubmissions(query);
|
|
||||||
const payload = res?.data;
|
|
||||||
const rows = payload?.data || [];
|
|
||||||
setItems(rows);
|
|
||||||
setTotalPages(payload?.pagination?.total_pages || 1);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
toast.error("Khong the tai danh sach submissions.");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchList();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [query]);
|
|
||||||
|
|
||||||
const openReview = (row: Submission, nextDecision: Decision) => {
|
|
||||||
setActive(row);
|
|
||||||
setDecision(nextDecision);
|
|
||||||
setReviewNote("");
|
|
||||||
openModal();
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitDecision = async () => {
|
|
||||||
if (!active) return;
|
|
||||||
const note = reviewNote.trim();
|
|
||||||
if (note.length < 10) {
|
|
||||||
toast.error("Review note toi thieu 10 ky tu.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsSubmitting(true);
|
|
||||||
await apiUpdateSubmissionStatus(active.id, { status: decision, review_note: note });
|
|
||||||
toast.success(decision === "APPROVED" ? "Da duyet submission." : "Da tu choi submission.");
|
|
||||||
closeModal();
|
|
||||||
await fetchList();
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(err);
|
|
||||||
toast.error(err?.response?.data?.message || "Cap nhat trang thai that bai.");
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-7xl mx-auto pb-10">
|
|
||||||
<PageBreadcrumb pageTitle="Kiem duyet submissions" />
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<ComponentCard title="Danh sach submissions">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-5">
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<Label>Search</Label>
|
|
||||||
<input
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => {
|
|
||||||
setPage(1);
|
|
||||||
setSearch(e.target.value);
|
|
||||||
}}
|
|
||||||
placeholder="Tim theo keyword (>= 2 ky tu)"
|
|
||||||
className="h-11 w-full rounded-xl border border-gray-200 bg-transparent px-4 py-2.5 text-sm text-gray-800 outline-none focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:text-white/90 dark:focus:border-brand-800"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>Project ID</Label>
|
|
||||||
<input
|
|
||||||
value={projectId}
|
|
||||||
onChange={(e) => {
|
|
||||||
setPage(1);
|
|
||||||
setProjectId(e.target.value);
|
|
||||||
}}
|
|
||||||
placeholder="UUID"
|
|
||||||
className="h-11 w-full rounded-xl border border-gray-200 bg-transparent px-4 py-2.5 text-sm text-gray-800 outline-none focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:text-white/90 dark:focus:border-brand-800"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>Status</Label>
|
|
||||||
<select
|
|
||||||
value={status}
|
|
||||||
onChange={(e) => {
|
|
||||||
setPage(1);
|
|
||||||
setStatus(e.target.value as any);
|
|
||||||
}}
|
|
||||||
className="h-11 w-full rounded-xl border border-gray-200 bg-transparent px-4 py-2.5 text-sm text-gray-800 outline-none focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:text-white/90 dark:focus:border-brand-800"
|
|
||||||
>
|
|
||||||
<option value="PENDING">PENDING</option>
|
|
||||||
<option value="APPROVED">APPROVED</option>
|
|
||||||
<option value="REJECTED">REJECTED</option>
|
|
||||||
<option value="ALL">ALL</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative min-h-[260px]">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/50 dark:bg-gray-900/50 rounded-xl">
|
|
||||||
<div className="w-10 h-10 border-4 border-t-brand-500 rounded-full animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="max-w-full overflow-x-auto">
|
|
||||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-[#0d1117] min-w-[900px]">
|
|
||||||
<div className="grid grid-cols-12 gap-4 px-5 py-3 border-b border-gray-200 dark:border-gray-800 bg-gray-50/50 dark:bg-[#161b22] text-xs font-semibold text-gray-600 dark:text-gray-300">
|
|
||||||
<div className="col-span-4">Project</div>
|
|
||||||
<div className="col-span-2">Submitter</div>
|
|
||||||
<div className="col-span-1">Status</div>
|
|
||||||
<div className="col-span-2">Created</div>
|
|
||||||
<div className="col-span-3 text-right">Actions</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
|
|
||||||
{items.length === 0 ? (
|
|
||||||
<div className="p-6 text-sm text-gray-500 dark:text-gray-400">Khong co submissions.</div>
|
|
||||||
) : null}
|
|
||||||
{items.map((row) => (
|
|
||||||
<div
|
|
||||||
key={row.id}
|
|
||||||
className="grid grid-cols-12 gap-4 px-5 py-4 text-sm hover:bg-gray-50 dark:hover:bg-[#161b22] cursor-pointer"
|
|
||||||
onClick={(e) => {
|
|
||||||
const target = e.target as HTMLElement | null;
|
|
||||||
if (target && target.closest("button")) return;
|
|
||||||
window.location.href = `/user/submissions/${row.id}`;
|
|
||||||
}}
|
|
||||||
role="link"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") window.location.href = `/user/submissions/${row.id}`;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="col-span-4 min-w-0">
|
|
||||||
<div className="font-medium text-gray-800 dark:text-gray-200 truncate">
|
|
||||||
{row.project_title || row.project_id}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
|
||||||
Submission:{" "}
|
|
||||||
<Link className="hover:underline" href={`/user/submissions/${row.id}`}>
|
|
||||||
{row.id}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
|
||||||
Commit: {row.commit_id}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2 min-w-0 text-xs text-gray-600 dark:text-gray-300 truncate">
|
|
||||||
{row.user?.display_name || row.user?.email || row.user_id}
|
|
||||||
</div>
|
|
||||||
<div className="col-span-1">{statusBadge(row.status)}</div>
|
|
||||||
<div className="col-span-2 text-xs text-gray-600 dark:text-gray-300">{formatTime(row.created_at)}</div>
|
|
||||||
<div className="col-span-3 flex justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => (window.location.href = `/editor/${row.project_id}`)}
|
|
||||||
>
|
|
||||||
Open editor
|
|
||||||
</Button>
|
|
||||||
{row.status === "PENDING" ? (
|
|
||||||
<>
|
|
||||||
<Button size="sm" className="bg-brand-500 hover:bg-brand-600 text-white" onClick={() => openReview(row, "APPROVED")}>
|
|
||||||
Duyet
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" onClick={() => openReview(row, "REJECTED")}>
|
|
||||||
Tu choi
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Button size="sm" variant="outline" onClick={() => openReview(row, row.status === "APPROVED" ? "REJECTED" : "APPROVED")}>
|
|
||||||
Doi trang thai
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between mt-4">
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
Page {page} / {totalPages}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button size="sm" variant="outline" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>
|
|
||||||
Prev
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={page >= totalPages}>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ComponentCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Modal isOpen={isOpen} onClose={closeModal} className="max-w-[620px] m-4">
|
|
||||||
<div className="p-6 bg-white rounded-3xl dark:bg-gray-900">
|
|
||||||
<h3 className="mb-2 text-xl font-bold text-gray-800 dark:text-white/90">
|
|
||||||
{decision === "APPROVED" ? "Duyet submission" : "Tu choi submission"}
|
|
||||||
</h3>
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-4 break-all">
|
|
||||||
{active?.id}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<div>
|
|
||||||
<Label>Review note (>= 10 ky tu)</Label>
|
|
||||||
<textarea
|
|
||||||
rows={4}
|
|
||||||
value={reviewNote}
|
|
||||||
onChange={(e) => setReviewNote(e.target.value)}
|
|
||||||
className="w-full rounded-xl border border-gray-200 bg-transparent px-4 py-3 text-sm text-gray-800 outline-none focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:text-white/90 dark:focus:border-brand-800 custom-scrollbar"
|
|
||||||
placeholder={decision === "APPROVED" ? "Ly do duyet..." : "Ly do tu choi..."}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-3 mt-2">
|
|
||||||
<Button size="sm" variant="outline" type="button" onClick={closeModal} disabled={isSubmitting}>
|
|
||||||
Huy
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
type="button"
|
|
||||||
onClick={submitDecision}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className={decision === "APPROVED" ? "bg-brand-500 hover:bg-brand-600 text-white" : "bg-red-600 hover:bg-red-700 text-white"}
|
|
||||||
>
|
|
||||||
{isSubmitting ? "Dang xu ly..." : decision === "APPROVED" ? "Duyet" : "Tu choi"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -112,8 +112,12 @@ export default function SignInForm() {
|
|||||||
<div className="grid grid-cols-1 gap-3 sm:gap-5">
|
<div className="grid grid-cols-1 gap-3 sm:gap-5">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const redirectUrl = HOME_URL;
|
// Redirect back to the same origin (avoid hard-coded port/env mismatches).
|
||||||
window.location.href = `${API.Auth.GOOGLE_LOGIN}?redirect=${redirectUrl}`;
|
const redirectUrl =
|
||||||
|
typeof window !== "undefined" ? window.location.origin : HOME_URL;
|
||||||
|
window.location.href = `${API.Auth.GOOGLE_LOGIN}?redirect=${encodeURIComponent(
|
||||||
|
redirectUrl
|
||||||
|
)}`;
|
||||||
}}
|
}}
|
||||||
className="inline-flex items-center justify-center gap-3 py-3 text-sm font-normal text-gray-700 transition-colors bg-gray-100 rounded-lg px-7 hover:bg-gray-200 hover:text-gray-800 dark:bg-white/5 dark:text-white/90 dark:hover:bg-white/10"
|
className="inline-flex items-center justify-center gap-3 py-3 text-sm font-normal text-gray-700 transition-colors bg-gray-100 rounded-lg px-7 hover:bg-gray-200 hover:text-gray-800 dark:bg-white/5 dark:text-white/90 dark:hover:bg-white/10"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -163,8 +163,12 @@ export default function SignUpForm() {
|
|||||||
<div className="grid grid-cols-1 gap-3 sm:gap-5">
|
<div className="grid grid-cols-1 gap-3 sm:gap-5">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const redirectUrl = HOME_URL;
|
// Redirect back to the same origin (avoid hard-coded port/env mismatches).
|
||||||
window.location.href = `${API.Auth.GOOGLE_LOGIN}?redirect=${redirectUrl}`;
|
const redirectUrl =
|
||||||
|
typeof window !== "undefined" ? window.location.origin : HOME_URL;
|
||||||
|
window.location.href = `${API.Auth.GOOGLE_LOGIN}?redirect=${encodeURIComponent(
|
||||||
|
redirectUrl
|
||||||
|
)}`;
|
||||||
}}
|
}}
|
||||||
className="inline-flex items-center justify-center gap-3 py-3 text-sm font-normal text-gray-700 transition-colors bg-gray-100 rounded-lg px-7 hover:bg-gray-200 hover:text-gray-800 dark:bg-white/5 dark:text-white/90 dark:hover:bg-white/10"
|
className="inline-flex items-center justify-center gap-3 py-3 text-sm font-normal text-gray-700 transition-colors bg-gray-100 rounded-lg px-7 hover:bg-gray-200 hover:text-gray-800 dark:bg-white/5 dark:text-white/90 dark:hover:bg-white/10"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ export interface Project {
|
|||||||
avatar_url: string;
|
avatar_url: string;
|
||||||
};
|
};
|
||||||
commits?: any[];
|
commits?: any[];
|
||||||
|
// Legacy (old BE): submission_ids
|
||||||
submission_ids?: any[];
|
submission_ids?: any[];
|
||||||
|
// New BE: lightweight submissions list on project response
|
||||||
|
submissions?: Array<{ id: string; status: string }>;
|
||||||
members?: ProjectMember[];
|
members?: ProjectMember[];
|
||||||
}
|
}
|
||||||
export interface ProjectsResponse<T = Project> {
|
export interface ProjectsResponse<T = Project> {
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
export type SubmissionStatus = "PENDING" | "APPROVED" | "REJECTED" | string;
|
|
||||||
|
|
||||||
export type Submission = {
|
|
||||||
id: string;
|
|
||||||
project_id: string;
|
|
||||||
commit_id: string;
|
|
||||||
user_id: string;
|
|
||||||
created_at?: string | null;
|
|
||||||
status: SubmissionStatus;
|
|
||||||
reviewed_by?: string | null;
|
|
||||||
reviewed_at?: string | null;
|
|
||||||
review_note?: string | null;
|
|
||||||
content?: string | null;
|
|
||||||
project_title?: string | null;
|
|
||||||
project_description?: string | null;
|
|
||||||
user?: any;
|
|
||||||
reviewer?: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
// BackEndGo's /submissions search returns:
|
|
||||||
// CommonResponse { data: PaginatedResponse { data: Submission[], pagination: ... } }
|
|
||||||
export type NestedPaginatedResponse<T> = {
|
|
||||||
status: boolean;
|
|
||||||
message?: string;
|
|
||||||
data: T[];
|
|
||||||
pagination?: {
|
|
||||||
current_page: number;
|
|
||||||
page_size: number;
|
|
||||||
total_records: number;
|
|
||||||
total_pages: number;
|
|
||||||
};
|
|
||||||
errors?: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SearchSubmissionsParams = {
|
|
||||||
page?: number;
|
|
||||||
limit?: number;
|
|
||||||
project_id?: string;
|
|
||||||
sort?: "id" | "created_at" | "reviewed_at" | "status";
|
|
||||||
search?: string;
|
|
||||||
statuses?: Array<"PENDING" | "APPROVED" | "REJECTED">;
|
|
||||||
reviewed_by?: string;
|
|
||||||
created_from?: string;
|
|
||||||
created_to?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UpdateSubmissionStatusPayload = {
|
|
||||||
status: "APPROVED" | "REJECTED";
|
|
||||||
review_note: string;
|
|
||||||
};
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import api from "@/config/config";
|
|
||||||
import { API } from "../../api";
|
|
||||||
import type { CommonResponse } from "@/interface/common";
|
|
||||||
import type {
|
|
||||||
NestedPaginatedResponse,
|
|
||||||
SearchSubmissionsParams,
|
|
||||||
Submission,
|
|
||||||
UpdateSubmissionStatusPayload,
|
|
||||||
} from "@/interface/submission";
|
|
||||||
|
|
||||||
export async function apiSearchSubmissions(
|
|
||||||
params: SearchSubmissionsParams
|
|
||||||
): Promise<CommonResponse<NestedPaginatedResponse<Submission>>> {
|
|
||||||
const response = await api.get(API.Submission.SEARCH, { params });
|
|
||||||
return response?.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function apiGetSubmissionById(
|
|
||||||
id: string
|
|
||||||
): Promise<CommonResponse<Submission>> {
|
|
||||||
const response = await api.get(API.Submission.GET_BY_ID(id));
|
|
||||||
return response?.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function apiUpdateSubmissionStatus(
|
|
||||||
id: string,
|
|
||||||
payload: UpdateSubmissionStatusPayload
|
|
||||||
): Promise<CommonResponse<Submission>> {
|
|
||||||
const response = await api.patch(API.Submission.UPDATE_STATUS(id), payload);
|
|
||||||
return response?.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function apiDeleteSubmission(id: string): Promise<CommonResponse> {
|
|
||||||
const response = await api.delete(API.Submission.DELETE(id));
|
|
||||||
return response?.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -2,9 +2,31 @@ import { API_ENDPOINTS } from "@/uhm/api/config";
|
|||||||
import { requestJson } from "@/uhm/api/http";
|
import { requestJson } from "@/uhm/api/http";
|
||||||
import type { GeometriesBBoxQuery } from "@/uhm/types/api";
|
import type { GeometriesBBoxQuery } from "@/uhm/types/api";
|
||||||
import type { Feature, FeatureCollection, FeatureProperties, Geometry } from "@/uhm/types/geo";
|
import type { Feature, FeatureCollection, FeatureProperties, Geometry } from "@/uhm/types/geo";
|
||||||
|
import { geoTypeCodeToTypeKey } from "@/uhm/lib/geoTypeMap";
|
||||||
|
|
||||||
export type { GeometriesBBoxQuery } from "@/uhm/types/api";
|
export type { GeometriesBBoxQuery } from "@/uhm/types/api";
|
||||||
|
|
||||||
|
export type EntityGeometrySearchGeo = {
|
||||||
|
id: string;
|
||||||
|
geo_type: number;
|
||||||
|
draw_geometry: unknown;
|
||||||
|
binding?: unknown;
|
||||||
|
time_start?: number | null;
|
||||||
|
time_end?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EntityGeometriesSearchItem = {
|
||||||
|
entity_id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
geometries: EntityGeometrySearchGeo[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchGeometriesByEntityNameResponse = {
|
||||||
|
items: EntityGeometriesSearchItem[];
|
||||||
|
next_cursor?: string;
|
||||||
|
};
|
||||||
|
|
||||||
function buildBBoxQueryString(params: GeometriesBBoxQuery): string {
|
function buildBBoxQueryString(params: GeometriesBBoxQuery): string {
|
||||||
const query = new URLSearchParams({
|
const query = new URLSearchParams({
|
||||||
// API mới dùng snake_case
|
// API mới dùng snake_case
|
||||||
@@ -32,9 +54,25 @@ export async function fetchGeometriesByBBox(params: GeometriesBBoxQuery): Promis
|
|||||||
return geometriesToFeatureCollection(rows);
|
return geometriesToFeatureCollection(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function searchGeometriesByEntityName(
|
||||||
|
name: string,
|
||||||
|
options?: { cursor?: string; limit?: number }
|
||||||
|
): Promise<SearchGeometriesByEntityNameResponse> {
|
||||||
|
const keyword = name.trim();
|
||||||
|
if (!keyword.length) return { items: [] };
|
||||||
|
|
||||||
|
const params = new URLSearchParams({ name: keyword });
|
||||||
|
if (options?.cursor) params.set("cursor", options.cursor);
|
||||||
|
if (options?.limit && Number.isFinite(options.limit)) {
|
||||||
|
params.set("limit", String(Math.trunc(options.limit)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestJson<SearchGeometriesByEntityNameResponse>(`${API_ENDPOINTS.geometries}/entity?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
type GeometryRow = {
|
type GeometryRow = {
|
||||||
id: string;
|
id: string;
|
||||||
geo_type: string;
|
geo_type: number;
|
||||||
draw_geometry: unknown;
|
draw_geometry: unknown;
|
||||||
binding?: unknown;
|
binding?: unknown;
|
||||||
time_start?: number;
|
time_start?: number;
|
||||||
@@ -55,10 +93,11 @@ function geometriesToFeatureCollection(rows: GeometryRow[]): FeatureCollection {
|
|||||||
if (!geometry) continue;
|
if (!geometry) continue;
|
||||||
|
|
||||||
const binding = normalizeBinding(row.binding);
|
const binding = normalizeBinding(row.binding);
|
||||||
|
const typeKey = geoTypeCodeToTypeKey(row.geo_type) || null;
|
||||||
|
|
||||||
const properties: FeatureProperties = {
|
const properties: FeatureProperties = {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
type: row.geo_type || null,
|
type: typeKey,
|
||||||
time_start: row.time_start ?? null,
|
time_start: row.time_start ?? null,
|
||||||
time_end: row.time_end ?? null,
|
time_end: row.time_end ?? null,
|
||||||
binding: binding.length ? binding : undefined,
|
binding: binding.length ? binding : undefined,
|
||||||
@@ -76,10 +115,10 @@ function geometriesToFeatureCollection(rows: GeometryRow[]): FeatureCollection {
|
|||||||
|
|
||||||
function normalizeGeometry(value: unknown): Geometry | null {
|
function normalizeGeometry(value: unknown): Geometry | null {
|
||||||
if (!value || typeof value !== "object") return null;
|
if (!value || typeof value !== "object") return null;
|
||||||
const g = value as any;
|
const g = value as Record<string, unknown>;
|
||||||
if (typeof g.type !== "string") return null;
|
if (typeof g.type !== "string") return null;
|
||||||
if (!("coordinates" in g)) return null;
|
if (!("coordinates" in g)) return null;
|
||||||
return g as Geometry;
|
return value as Geometry;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeBinding(value: unknown): string[] {
|
function normalizeBinding(value: unknown): string[] {
|
||||||
|
|||||||
@@ -45,7 +45,19 @@ async function requestJsonInternal<T>(
|
|||||||
options?: RequestJsonOptions
|
options?: RequestJsonOptions
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const nextInit = withAuthHeaders(init, options);
|
const nextInit = withAuthHeaders(init, options);
|
||||||
const res = await fetch(input, nextInit);
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(input, nextInit);
|
||||||
|
} catch (err) {
|
||||||
|
// Browser "TypeError: Failed to fetch" typically means:
|
||||||
|
// - CORS blocked (common when using 127.0.0.1 instead of localhost in dev),
|
||||||
|
// - DNS/TLS/network error,
|
||||||
|
// - request blocked by the browser.
|
||||||
|
const origin = typeof window !== "undefined" ? window.location.origin : "<server>";
|
||||||
|
const url = typeof input === "string" ? input : String(input);
|
||||||
|
const details = { origin, url, apiBase: API_ENDPOINTS.projects.split("/projects")[0] };
|
||||||
|
throw new ApiError("Network error (failed to fetch)", 0, stringifyPayload(details));
|
||||||
|
}
|
||||||
|
|
||||||
// One-shot refresh + retry for protected endpoints.
|
// One-shot refresh + retry for protected endpoints.
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { API_BASE_URL, API_ENDPOINTS } from "@/uhm/api/config";
|
import { API_BASE_URL, API_ENDPOINTS } from "@/uhm/api/config";
|
||||||
import { jsonRequestInit, requestJson } from "@/uhm/api/http";
|
import { ApiError, jsonRequestInit, requestJson } from "@/uhm/api/http";
|
||||||
import type {
|
import type {
|
||||||
CreateCommitInput,
|
CreateCommitInput,
|
||||||
CreateSectionInput,
|
CreateSectionInput,
|
||||||
@@ -39,6 +39,18 @@ export async function openSectionEditor(sectionId: string): Promise<EditorLoadRe
|
|||||||
// 1) Project details
|
// 1) Project details
|
||||||
// 2) Project commits (to get snapshot_json of latest commit)
|
// 2) Project commits (to get snapshot_json of latest commit)
|
||||||
const project = await requestJson<Section>(`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}`);
|
const project = await requestJson<Section>(`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}`);
|
||||||
|
|
||||||
|
const pending = (project.submissions || []).find((s) => s?.status === "PENDING") || null;
|
||||||
|
if (pending) {
|
||||||
|
// BE rule: pending submission blocks further editing/submitting until deleted/reviewed.
|
||||||
|
// We surface a typed error so UI can offer "delete to unlock".
|
||||||
|
throw new ApiError(
|
||||||
|
"Project has a pending submission",
|
||||||
|
409,
|
||||||
|
JSON.stringify({ pending_submission_id: pending.id })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const commits = await fetchSectionCommits(sectionId);
|
const commits = await fetchSectionCommits(sectionId);
|
||||||
|
|
||||||
const headCommitId = project.latest_commit_id ?? null;
|
const headCommitId = project.latest_commit_id ?? null;
|
||||||
@@ -130,56 +142,10 @@ export async function submitSection(sectionId: string): Promise<SectionSubmissio
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// API mới không có list submissions theo project kèm snapshot.
|
export async function deleteSubmission(submissionId: string): Promise<unknown> {
|
||||||
// FE dùng /submissions (admin/mod) hoặc fetch từng submission id.
|
return requestJson(
|
||||||
export async function fetchSectionSubmissions(_sectionId: string): Promise<SectionSubmission[]> {
|
`${API_ENDPOINTS.submissions}/${encodeURIComponent(submissionId)}`,
|
||||||
return [];
|
{ method: "DELETE" }
|
||||||
}
|
|
||||||
|
|
||||||
export async function searchSubmissions(query?: {
|
|
||||||
page?: number;
|
|
||||||
limit?: number;
|
|
||||||
statuses?: string[];
|
|
||||||
project_id?: string;
|
|
||||||
search?: string;
|
|
||||||
}): Promise<SectionSubmission[]> {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (query?.page) params.set("page", String(Math.trunc(query.page)));
|
|
||||||
if (query?.limit) params.set("limit", String(Math.trunc(query.limit)));
|
|
||||||
if (query?.project_id) params.set("project_id", query.project_id);
|
|
||||||
if (query?.search) params.set("search", query.search);
|
|
||||||
if (query?.statuses?.length) {
|
|
||||||
for (const s of query.statuses) params.append("statuses", s);
|
|
||||||
}
|
|
||||||
|
|
||||||
const suffix = params.toString();
|
|
||||||
const url = suffix ? `${API_ENDPOINTS.submissions}?${suffix}` : API_ENDPOINTS.submissions;
|
|
||||||
return requestJson<SectionSubmission[]>(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function approveSubmission(
|
|
||||||
submissionId: string,
|
|
||||||
input: { review_note: string }
|
|
||||||
): Promise<SectionSubmission> {
|
|
||||||
return requestJson<SectionSubmission>(
|
|
||||||
`${API_ENDPOINTS.submissions}/${encodeURIComponent(submissionId)}/status`,
|
|
||||||
jsonRequestInit("PATCH", {
|
|
||||||
status: "APPROVED",
|
|
||||||
review_note: input.review_note,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function rejectSubmission(
|
|
||||||
submissionId: string,
|
|
||||||
input: { review_note: string }
|
|
||||||
): Promise<SectionSubmission> {
|
|
||||||
return requestJson<SectionSubmission>(
|
|
||||||
`${API_ENDPOINTS.submissions}/${encodeURIComponent(submissionId)}/status`,
|
|
||||||
jsonRequestInit("PATCH", {
|
|
||||||
status: "REJECTED",
|
|
||||||
review_note: input.review_note,
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// FrontEndAdmin is the primary FE and follows BackEndGo cookie-based auth.
|
// FrontEndUser is the primary FE and follows BackEndGo cookie-based auth.
|
||||||
// Users sign in via the app's /signin page; the editor reuses those httpOnly cookies.
|
// Users sign in via the app's /signin page; the editor reuses those httpOnly cookies.
|
||||||
// This component remains as a no-op placeholder for any legacy imports.
|
// This component remains as a no-op placeholder for any legacy imports.
|
||||||
export default function AuthPanel() {
|
export default function AuthPanel() {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type Props = {
|
|||||||
onShowAll: () => void;
|
onShowAll: () => void;
|
||||||
onHideAll: () => void;
|
onHideAll: () => void;
|
||||||
topContent?: ReactNode;
|
topContent?: ReactNode;
|
||||||
|
width?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function BackgroundLayersPanel({
|
export default function BackgroundLayersPanel({
|
||||||
@@ -21,11 +22,12 @@ export default function BackgroundLayersPanel({
|
|||||||
onShowAll,
|
onShowAll,
|
||||||
onHideAll,
|
onHideAll,
|
||||||
topContent,
|
topContent,
|
||||||
|
width = 240,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
style={{
|
style={{
|
||||||
width: "240px",
|
width,
|
||||||
background: "#111827",
|
background: "#111827",
|
||||||
color: "#e5e7eb",
|
color: "#e5e7eb",
|
||||||
borderLeft: "1px solid #1f2937",
|
borderLeft: "1px solid #1f2937",
|
||||||
@@ -38,57 +40,34 @@ export default function BackgroundLayersPanel({
|
|||||||
|
|
||||||
<h3 style={{ margin: 0, marginBottom: "10px" }}>Map Layers</h3>
|
<h3 style={{ margin: 0, marginBottom: "10px" }}>Map Layers</h3>
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: "8px", marginBottom: "12px" }}>
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "10px 12px" }}>
|
||||||
|
{BACKGROUND_LAYER_OPTIONS.map((layer) => {
|
||||||
|
const on = Boolean(visibility[layer.id]);
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onShowAll}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "6px",
|
|
||||||
padding: "6px 8px",
|
|
||||||
cursor: "pointer",
|
|
||||||
background: "#374151",
|
|
||||||
color: "#f9fafb",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Bật hết
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onHideAll}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "6px",
|
|
||||||
padding: "6px 8px",
|
|
||||||
cursor: "pointer",
|
|
||||||
background: "#1f2937",
|
|
||||||
color: "#f9fafb",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Tắt hết
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: "grid", gap: "8px" }}>
|
|
||||||
{BACKGROUND_LAYER_OPTIONS.map((layer) => (
|
|
||||||
<label
|
|
||||||
key={layer.id}
|
key={layer.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggleLayer(layer.id)}
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
border: "none",
|
||||||
alignItems: "center",
|
background: "transparent",
|
||||||
gap: "8px",
|
padding: 0,
|
||||||
fontSize: "14px",
|
margin: 0,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
|
color: on ? "#22c55e" : "#e5e7eb",
|
||||||
|
textDecorationLine: on ? "none" : "line-through",
|
||||||
|
textDecorationThickness: on ? undefined : "2px",
|
||||||
|
textDecorationColor: on ? undefined : "rgba(148, 163, 184, 0.7)",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 750,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
}}
|
}}
|
||||||
|
title={on ? "On" : "Off"}
|
||||||
>
|
>
|
||||||
<input
|
{layer.label}
|
||||||
type="checkbox"
|
</button>
|
||||||
checked={visibility[layer.id]}
|
);
|
||||||
onChange={() => onToggleLayer(layer.id)}
|
})}
|
||||||
/>
|
|
||||||
<span>{layer.label}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,349 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useMemo, type ComponentProps } from "react";
|
|
||||||
import Tree from "react-d3-tree";
|
|
||||||
|
|
||||||
export type CommitTreeItem = {
|
|
||||||
id: string;
|
|
||||||
parent_commit_id: string | null;
|
|
||||||
restored_from_commit_id: string | null;
|
|
||||||
commit_no: number;
|
|
||||||
kind: string;
|
|
||||||
created_by: string;
|
|
||||||
created_at: string;
|
|
||||||
title: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CommitTreeNode = {
|
|
||||||
commit: CommitTreeItem;
|
|
||||||
children: CommitTreeNode[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type CommitTreeDatum = {
|
|
||||||
name: string;
|
|
||||||
commit: CommitTreeItem;
|
|
||||||
isHead: boolean;
|
|
||||||
detail: string;
|
|
||||||
restoredFromLabel: string | null;
|
|
||||||
children?: CommitTreeDatum[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
open: boolean;
|
|
||||||
commits: CommitTreeItem[];
|
|
||||||
headCommitId: string | null;
|
|
||||||
onClose: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TreeRenderNode = NonNullable<ComponentProps<typeof Tree>["renderCustomNodeElement"]>;
|
|
||||||
|
|
||||||
export default function CommitTreePopup({
|
|
||||||
open,
|
|
||||||
commits,
|
|
||||||
headCommitId,
|
|
||||||
onClose,
|
|
||||||
}: Props) {
|
|
||||||
const { roots, commitById } = useMemo(() => buildCommitTree(commits), [commits]);
|
|
||||||
const treeData = useMemo(
|
|
||||||
() => roots.map((node) => toTreeDatum(node, commitById, headCommitId)),
|
|
||||||
[roots, commitById, headCommitId]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [open, onClose]);
|
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="presentation"
|
|
||||||
onClick={onClose}
|
|
||||||
style={{
|
|
||||||
position: "fixed",
|
|
||||||
inset: 0,
|
|
||||||
zIndex: 1000,
|
|
||||||
background: "rgba(2, 6, 23, 0.72)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
padding: "24px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<section
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label="Commit tree"
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
style={{
|
|
||||||
width: "min(1120px, calc(100vw - 48px))",
|
|
||||||
maxHeight: "min(720px, calc(100vh - 48px))",
|
|
||||||
overflow: "hidden",
|
|
||||||
border: "1px solid #334155",
|
|
||||||
borderRadius: "8px",
|
|
||||||
background: "#0f172a",
|
|
||||||
color: "#e2e8f0",
|
|
||||||
boxShadow: "0 24px 80px rgba(0, 0, 0, 0.45)",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<style>
|
|
||||||
{`
|
|
||||||
.commit-tree-link {
|
|
||||||
fill: none;
|
|
||||||
stroke: #ffffff;
|
|
||||||
stroke-width: 4px;
|
|
||||||
stroke-opacity: 1;
|
|
||||||
filter: drop-shadow(0 0 5px rgba(255, 255, 255, 0.75));
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
</style>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
gap: "12px",
|
|
||||||
padding: "14px 16px",
|
|
||||||
borderBottom: "1px solid #1f2937",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: "16px", fontWeight: 700, color: "#f8fafc" }}>
|
|
||||||
Commit tree
|
|
||||||
</div>
|
|
||||||
<div style={{ marginTop: "3px", fontSize: "12px", color: "#94a3b8" }}>
|
|
||||||
{commits.length} commit{commits.length === 1 ? "" : "s"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
style={{
|
|
||||||
padding: "7px 10px",
|
|
||||||
border: "1px solid #475569",
|
|
||||||
borderRadius: "4px",
|
|
||||||
background: "#111827",
|
|
||||||
color: "#f8fafc",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "16px",
|
|
||||||
overflow: "auto",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{treeData.length === 0 ? (
|
|
||||||
<div style={{ color: "#94a3b8", fontSize: "14px" }}>
|
|
||||||
Chưa có commit.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
minWidth: "640px",
|
|
||||||
height: "540px",
|
|
||||||
border: "1px solid #64748b",
|
|
||||||
borderRadius: "6px",
|
|
||||||
background: "#111827",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tree
|
|
||||||
data={treeData}
|
|
||||||
orientation="vertical"
|
|
||||||
translate={{ x: 520, y: 56 }}
|
|
||||||
nodeSize={{ x: 300, y: 165 }}
|
|
||||||
separation={{ siblings: 1.15, nonSiblings: 1.45 }}
|
|
||||||
pathFunc="step"
|
|
||||||
collapsible={false}
|
|
||||||
zoomable
|
|
||||||
draggable
|
|
||||||
scaleExtent={{ min: 0.45, max: 1.4 }}
|
|
||||||
renderCustomNodeElement={renderCommitTreeNode}
|
|
||||||
pathClassFunc={() => "commit-tree-link"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderCommitTreeNode: TreeRenderNode = function renderCommitTreeNode({ nodeDatum }) {
|
|
||||||
const datum = nodeDatum as unknown as CommitTreeDatum;
|
|
||||||
const commit = datum.commit;
|
|
||||||
const isHead = datum.isHead;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<g>
|
|
||||||
<circle
|
|
||||||
r={8}
|
|
||||||
fill={isHead ? "#16a34a" : "#111827"}
|
|
||||||
stroke={isHead ? "#bbf7d0" : "#f8fafc"}
|
|
||||||
strokeWidth={3}
|
|
||||||
/>
|
|
||||||
<foreignObject x={-115} y={18} width={230} height={96}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: "220px",
|
|
||||||
minHeight: "78px",
|
|
||||||
padding: "8px 9px",
|
|
||||||
border: isHead ? "2px solid #86efac" : "2px solid #e2e8f0",
|
|
||||||
borderRadius: "6px",
|
|
||||||
background: isHead ? "#14532d" : "#1f2937",
|
|
||||||
color: "#f8fafc",
|
|
||||||
fontSize: "12px",
|
|
||||||
lineHeight: 1.35,
|
|
||||||
boxSizing: "border-box",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "6px",
|
|
||||||
minWidth: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ color: "#f8fafc", fontWeight: 700 }}>
|
|
||||||
#{commit.commit_no}
|
|
||||||
</span>
|
|
||||||
{isHead ? (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
padding: "1px 5px",
|
|
||||||
border: "1px solid #22c55e",
|
|
||||||
borderRadius: "4px",
|
|
||||||
color: "#bbf7d0",
|
|
||||||
fontSize: "10px",
|
|
||||||
fontWeight: 700,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
HEAD
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
title={formatCommitTitle(commit)}
|
|
||||||
style={{
|
|
||||||
marginTop: "4px",
|
|
||||||
color: "#f8fafc",
|
|
||||||
fontWeight: 700,
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatCommitTitle(commit)}
|
|
||||||
</div>
|
|
||||||
<div style={{ marginTop: "4px", color: "#94a3b8" }}>
|
|
||||||
{datum.detail}
|
|
||||||
</div>
|
|
||||||
{datum.restoredFromLabel ? (
|
|
||||||
<div
|
|
||||||
title={datum.restoredFromLabel}
|
|
||||||
style={{
|
|
||||||
marginTop: "3px",
|
|
||||||
color: "#93c5fd",
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{datum.restoredFromLabel}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</foreignObject>
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildCommitTree(commits: CommitTreeItem[]) {
|
|
||||||
const commitById = new Map<string, CommitTreeItem>();
|
|
||||||
const nodeById = new Map<string, CommitTreeNode>();
|
|
||||||
|
|
||||||
for (const commit of commits) {
|
|
||||||
commitById.set(commit.id, commit);
|
|
||||||
nodeById.set(commit.id, { commit, children: [] });
|
|
||||||
}
|
|
||||||
|
|
||||||
const roots: CommitTreeNode[] = [];
|
|
||||||
for (const node of nodeById.values()) {
|
|
||||||
const parentId = getDisplayParentCommitId(node.commit);
|
|
||||||
const parent = parentId ? nodeById.get(parentId) : null;
|
|
||||||
if (parent) {
|
|
||||||
parent.children.push(node);
|
|
||||||
} else {
|
|
||||||
roots.push(node);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortNodes = (nodes: CommitTreeNode[]) => {
|
|
||||||
nodes.sort((a, b) => a.commit.commit_no - b.commit.commit_no);
|
|
||||||
for (const node of nodes) {
|
|
||||||
sortNodes(node.children);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
sortNodes(roots);
|
|
||||||
|
|
||||||
return { roots, commitById };
|
|
||||||
}
|
|
||||||
|
|
||||||
function toTreeDatum(
|
|
||||||
node: CommitTreeNode,
|
|
||||||
commitById: Map<string, CommitTreeItem>,
|
|
||||||
headCommitId: string | null
|
|
||||||
): CommitTreeDatum {
|
|
||||||
const commit = node.commit;
|
|
||||||
const restoredFromCommit = commit.restored_from_commit_id
|
|
||||||
? commitById.get(commit.restored_from_commit_id) || null
|
|
||||||
: null;
|
|
||||||
const children = node.children.map((child) => toTreeDatum(child, commitById, headCommitId));
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: formatCommitTitle(commit),
|
|
||||||
commit,
|
|
||||||
isHead: headCommitId === commit.id,
|
|
||||||
detail: `${commit.kind} by ${commit.created_by} - ${formatDateTime(commit.created_at)}`,
|
|
||||||
restoredFromLabel: restoredFromCommit
|
|
||||||
? `restored from #${restoredFromCommit.commit_no} ${formatCommitTitle(restoredFromCommit)}`
|
|
||||||
: null,
|
|
||||||
children: children.length ? children : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDisplayParentCommitId(commit: CommitTreeItem): string | null {
|
|
||||||
if (commit.kind === "restore" && commit.restored_from_commit_id) {
|
|
||||||
return commit.restored_from_commit_id;
|
|
||||||
}
|
|
||||||
return commit.parent_commit_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCommitTitle(commit: CommitTreeItem): string {
|
|
||||||
return commit.title?.trim() || `Commit #${commit.commit_no}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(value: string): string {
|
|
||||||
const date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) return value;
|
|
||||||
return date.toLocaleString();
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { UndoAction } from "@/uhm/lib/useEditorState";
|
import type { ReactNode } from "react";
|
||||||
|
import type { UndoAction } from "@/uhm/lib/useEditorState";
|
||||||
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
|
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -34,7 +35,6 @@ type Props = {
|
|||||||
createdEntities: Array<{
|
createdEntities: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type_id?: string | null;
|
|
||||||
}>;
|
}>;
|
||||||
createdGeometries: Array<{
|
createdGeometries: Array<{
|
||||||
id: string | number;
|
id: string | number;
|
||||||
@@ -42,6 +42,7 @@ type Props = {
|
|||||||
semanticType?: string | null;
|
semanticType?: string | null;
|
||||||
entityNames: string[];
|
entityNames: string[];
|
||||||
}>;
|
}>;
|
||||||
|
width?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Editor({
|
export default function Editor({
|
||||||
@@ -69,19 +70,16 @@ export default function Editor({
|
|||||||
undoStack,
|
undoStack,
|
||||||
createdEntities,
|
createdEntities,
|
||||||
createdGeometries,
|
createdGeometries,
|
||||||
|
width = 280,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const formatCommitTitle = (commit: Props["commits"][number]) =>
|
|
||||||
commit.edit_summary?.trim() || `Commit ${commit.id.slice(0, 8)}`;
|
|
||||||
|
|
||||||
const toggleMode = (newMode: EditorMode) => {
|
const toggleMode = (newMode: EditorMode) => {
|
||||||
if (mode === newMode) {
|
if (mode === newMode) {
|
||||||
setMode("idle"); // bấm lại → tắt
|
setMode("idle");
|
||||||
} else {
|
} else {
|
||||||
setMode(newMode); // chuyển mode
|
setMode(newMode);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Lấy tối đa 8 tác vụ mới nhất, bỏ trùng nhãn (cùng loại/cùng id)
|
|
||||||
const recentUndoLabels = (() => {
|
const recentUndoLabels = (() => {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const labels: string[] = [];
|
const labels: string[] = [];
|
||||||
@@ -94,177 +92,144 @@ export default function Editor({
|
|||||||
return labels.reverse();
|
return labels.reverse();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const getButtonStyle = (btnMode: EditorMode) => ({
|
const formatCommitTitle = (commit: Props["commits"][number]) =>
|
||||||
|
commit.edit_summary?.trim() || `Commit ${commit.id.slice(0, 8)}`;
|
||||||
|
|
||||||
|
const modeButtonStyle = (btnMode: EditorMode) =>
|
||||||
|
({
|
||||||
|
padding: "8px 10px",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "1px solid #334155",
|
||||||
|
background: mode === btnMode ? "#16a34a" : "#111827",
|
||||||
|
color: "white",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: 12,
|
||||||
|
minHeight: 34,
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}) as const;
|
||||||
|
|
||||||
|
const primaryButtonStyle =
|
||||||
|
({
|
||||||
width: "100%",
|
width: "100%",
|
||||||
padding: "8px",
|
padding: "8px 10px",
|
||||||
marginBottom: "6px",
|
borderRadius: 6,
|
||||||
border: "none",
|
border: "none",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
background: mode === btnMode ? "#4caf50" : "#222",
|
fontWeight: 850,
|
||||||
color: "white",
|
fontSize: 12,
|
||||||
borderRadius: "4px",
|
}) as const;
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: "220px",
|
width,
|
||||||
height: "100vh",
|
height: "100vh",
|
||||||
overflowY: "auto",
|
overflowY: "auto",
|
||||||
background: "#111",
|
background: "#0b1220",
|
||||||
color: "white",
|
color: "white",
|
||||||
padding: "12px",
|
padding: "12px 12px 20px",
|
||||||
borderRight: "1px solid #333",
|
borderRight: "1px solid #1f2937",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h3 style={{ marginBottom: "10px" }}>Editor</h3>
|
<div style={{ position: "sticky", top: 0, zIndex: 5, background: "#0b1220", paddingBottom: 10 }}>
|
||||||
|
<div style={{ fontWeight: 950, fontSize: 14, marginBottom: 10 }}>Editor</div>
|
||||||
|
|
||||||
<div
|
<Panel title="Project" defaultOpen>
|
||||||
style={{
|
<div style={{ fontSize: 12, color: "#cbd5e1", lineHeight: 1.4 }}>
|
||||||
marginBottom: "12px",
|
<div style={{ color: "white", fontWeight: 850, overflowWrap: "anywhere" }}>{sectionTitle}</div>
|
||||||
padding: "10px",
|
<div style={{ marginTop: 6 }}>
|
||||||
background: "#0b1220",
|
Status: <span style={{ color: "#e2e8f0" }}>{sectionStatus}</span>
|
||||||
borderRadius: "6px",
|
|
||||||
border: "1px solid #1f2937",
|
|
||||||
fontSize: "12px",
|
|
||||||
color: "#cbd5e1",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ color: "white", fontWeight: 600 }}>{sectionTitle}</div>
|
|
||||||
<div style={{ marginTop: "4px" }}>Status: {sectionStatus}</div>
|
|
||||||
<div>Commits: {commitCount}</div>
|
|
||||||
<div>{latestCommitLabel || "Chưa có commit"}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ marginTop: 6 }}>
|
||||||
<div
|
Commits: <span style={{ color: "#e2e8f0" }}>{commitCount}</span>
|
||||||
style={{
|
|
||||||
marginBottom: "12px",
|
|
||||||
padding: "10px",
|
|
||||||
background: "#0b1220",
|
|
||||||
borderRadius: "6px",
|
|
||||||
border: "1px solid #1f2937",
|
|
||||||
fontSize: "12px",
|
|
||||||
color: "#cbd5e1",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ marginBottom: "8px", fontWeight: 600, color: "white" }}>Project</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ marginTop: 6 }}>
|
||||||
|
{latestCommitLabel ? (
|
||||||
|
<span style={{ color: "#e2e8f0" }}>{latestCommitLabel}</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: "#94a3b8" }}>Chưa có head commit</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<button
|
<Panel title="Tools" defaultOpen>
|
||||||
style={getButtonStyle("draw")}
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||||
onClick={() => toggleMode("draw")}
|
<button style={modeButtonStyle("select")} onClick={() => toggleMode("select")} title="Select">
|
||||||
>
|
|
||||||
Draw
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
style={getButtonStyle("select")}
|
|
||||||
onClick={() => toggleMode("select")}
|
|
||||||
>
|
|
||||||
Select
|
Select
|
||||||
</button>
|
</button>
|
||||||
|
<button style={modeButtonStyle("draw")} onClick={() => toggleMode("draw")} title="Draw polygon">
|
||||||
|
Draw
|
||||||
|
</button>
|
||||||
|
<button style={modeButtonStyle("add-point")} onClick={() => setMode("add-point")} title="Add point">
|
||||||
|
Point
|
||||||
|
</button>
|
||||||
|
<button style={modeButtonStyle("add-line")} onClick={() => setMode("add-line")} title="Add line">
|
||||||
|
Line
|
||||||
|
</button>
|
||||||
|
<button style={modeButtonStyle("add-path")} onClick={() => setMode("add-path")} title="Add path">
|
||||||
|
Path
|
||||||
|
</button>
|
||||||
|
<button style={modeButtonStyle("add-circle")} onClick={() => setMode("add-circle")} title="Add circle">
|
||||||
|
Circle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 10, fontSize: 12, color: "#94a3b8" }}>
|
||||||
|
Mode: <span style={{ color: "white", fontWeight: 850 }}>{mode}</span>
|
||||||
|
</div>
|
||||||
|
<ModeHint mode={mode} />
|
||||||
|
|
||||||
|
<div style={{ marginTop: 10, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||||
<button
|
<button
|
||||||
style={getButtonStyle("idle")}
|
style={{
|
||||||
|
...modeButtonStyle("idle"),
|
||||||
|
background: "#111827",
|
||||||
|
}}
|
||||||
onClick={() => setMode("idle")}
|
onClick={() => setMode("idle")}
|
||||||
|
title="Tắt tool hiện tại"
|
||||||
>
|
>
|
||||||
Idle
|
Idle
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
style={getButtonStyle("add-point")}
|
style={{
|
||||||
onClick={() => setMode("add-point")}
|
...modeButtonStyle("idle"),
|
||||||
|
background: "#334155",
|
||||||
|
}}
|
||||||
|
onClick={onUndo}
|
||||||
|
title="Undo thao tác gần nhất"
|
||||||
>
|
>
|
||||||
Add point
|
Undo
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
|
||||||
style={getButtonStyle("add-line")}
|
|
||||||
onClick={() => setMode("add-line")}
|
|
||||||
>
|
|
||||||
Add line
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
style={getButtonStyle("add-path")}
|
|
||||||
onClick={() => setMode("add-path")}
|
|
||||||
>
|
|
||||||
Add path
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
style={getButtonStyle("add-circle")}
|
|
||||||
onClick={() => setMode("add-circle")}
|
|
||||||
>
|
|
||||||
Add circle
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div style={{ marginTop: "12px", fontSize: "14px" }}>
|
|
||||||
Mode: <b>{mode}</b>
|
|
||||||
</div>
|
</div>
|
||||||
{mode === "add-line" ? (
|
</Panel>
|
||||||
<div style={{ marginTop: "6px", fontSize: "12px", color: "#93c5fd" }}>
|
|
||||||
Click để thêm điểm, Enter để hoàn tất, Esc để hủy.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{mode === "add-path" ? (
|
|
||||||
<div style={{ marginTop: "6px", fontSize: "12px", color: "#93c5fd" }}>
|
|
||||||
Click để thêm điểm, Enter để hoàn tất, Esc để hủy.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{mode === "add-circle" ? (
|
|
||||||
<div style={{ marginTop: "6px", fontSize: "12px", color: "#93c5fd" }}>
|
|
||||||
Giữ chuột trái kéo để mở bán kính, thả chuột để hoàn tất.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{entityStatus ? (
|
{entityStatus ? (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginTop: "12px",
|
marginTop: 10,
|
||||||
padding: "10px",
|
padding: "10px",
|
||||||
background: "#0b1220",
|
background: "#111827",
|
||||||
borderRadius: "6px",
|
borderRadius: 8,
|
||||||
border: "1px solid #1f2937",
|
border: "1px solid #7f1d1d",
|
||||||
color: "#fca5a5",
|
color: "#fecaca",
|
||||||
fontSize: "12px",
|
fontSize: 12,
|
||||||
|
overflowWrap: "anywhere",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{entityStatus}
|
{entityStatus}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div style={{ marginTop: "12px" }}>
|
|
||||||
<button
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
padding: "8px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
border: "none",
|
|
||||||
cursor: "pointer",
|
|
||||||
background: "#334155",
|
|
||||||
color: "white",
|
|
||||||
}}
|
|
||||||
onClick={onUndo}
|
|
||||||
>
|
|
||||||
Undo
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Panel title="Commit" defaultOpen>
|
||||||
<input
|
<input
|
||||||
value={commitTitle}
|
value={commitTitle}
|
||||||
onChange={(event) => onCommitTitleChange(event.target.value)}
|
onChange={(event) => onCommitTitleChange(event.target.value)}
|
||||||
placeholder="Commit title"
|
placeholder="Commit title"
|
||||||
disabled={isSaving || isSubmitting}
|
disabled={isSaving || isSubmitting}
|
||||||
style={{
|
style={textInputStyle}
|
||||||
width: "100%",
|
|
||||||
marginTop: "8px",
|
|
||||||
padding: "7px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
border: "1px solid #334155",
|
|
||||||
background: "#111827",
|
|
||||||
color: "white",
|
|
||||||
boxSizing: "border-box",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
value={commitNote}
|
value={commitNote}
|
||||||
@@ -272,47 +237,28 @@ export default function Editor({
|
|||||||
placeholder="Commit note"
|
placeholder="Commit note"
|
||||||
disabled={isSaving || isSubmitting}
|
disabled={isSaving || isSubmitting}
|
||||||
rows={3}
|
rows={3}
|
||||||
style={{
|
style={textAreaStyle}
|
||||||
width: "100%",
|
|
||||||
marginTop: "8px",
|
|
||||||
padding: "7px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
border: "1px solid #334155",
|
|
||||||
background: "#111827",
|
|
||||||
color: "white",
|
|
||||||
boxSizing: "border-box",
|
|
||||||
resize: "vertical",
|
|
||||||
fontFamily: "inherit",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 68px", gap: "8px", marginTop: "8px" }}>
|
|
||||||
<button
|
<button
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
...primaryButtonStyle,
|
||||||
padding: "8px",
|
marginTop: 8,
|
||||||
borderRadius: "4px",
|
background: isSaving || isSubmitting || changesCount <= 0 ? "#475569" : "#0f766e",
|
||||||
border: "none",
|
cursor: isSaving || isSubmitting || changesCount <= 0 ? "not-allowed" : "pointer",
|
||||||
cursor: isSaving || isSubmitting ? "not-allowed" : "pointer",
|
opacity: changesCount <= 0 ? 0.75 : 1,
|
||||||
background: isSaving || isSubmitting ? "#555" : "#0f766e",
|
|
||||||
color: "white",
|
|
||||||
}}
|
}}
|
||||||
onClick={onCommit}
|
onClick={onCommit}
|
||||||
disabled={isSaving || isSubmitting}
|
disabled={isSaving || isSubmitting || changesCount <= 0}
|
||||||
|
title={changesCount <= 0 ? "Khong co thay doi de commit" : undefined}
|
||||||
>
|
>
|
||||||
Commit ({changesCount})
|
Commit ({changesCount})
|
||||||
</button>
|
</button>
|
||||||
<div />
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
...primaryButtonStyle,
|
||||||
marginTop: "8px",
|
marginTop: 8,
|
||||||
padding: "8px",
|
background: isSubmitting || !hasHeadCommit ? "#475569" : "#16a34a",
|
||||||
borderRadius: "4px",
|
|
||||||
border: "none",
|
|
||||||
cursor: isSubmitting || !hasHeadCommit ? "not-allowed" : "pointer",
|
cursor: isSubmitting || !hasHeadCommit ? "not-allowed" : "pointer",
|
||||||
background: isSubmitting || !hasHeadCommit ? "#555" : "#16a34a",
|
|
||||||
color: "white",
|
|
||||||
opacity: !hasHeadCommit ? 0.6 : 1,
|
opacity: !hasHeadCommit ? 0.6 : 1,
|
||||||
}}
|
}}
|
||||||
onClick={onSubmit}
|
onClick={onSubmit}
|
||||||
@@ -320,161 +266,244 @@ export default function Editor({
|
|||||||
>
|
>
|
||||||
Submit
|
Submit
|
||||||
</button>
|
</button>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<div
|
<Panel title="Commit History" badge={String(commits.length)} defaultOpen={false}>
|
||||||
style={{
|
|
||||||
marginTop: "16px",
|
|
||||||
padding: "10px",
|
|
||||||
background: "#0b1220",
|
|
||||||
borderRadius: "6px",
|
|
||||||
border: "1px solid #1f2937",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ marginBottom: "8px", fontWeight: 600, fontSize: "14px" }}>
|
|
||||||
Commit history
|
|
||||||
</div>
|
|
||||||
{commits.length === 0 ? (
|
{commits.length === 0 ? (
|
||||||
<div style={{ color: "#64748b", fontSize: "12px" }}>
|
<div style={{ color: "#64748b", fontSize: 12 }}>Chưa có commit</div>
|
||||||
Chưa có commit
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: "12px" }}>
|
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: 12 }}>
|
||||||
{commits.slice(0, 8).map((commit) => (
|
{commits.slice(0, 8).map((commit) => {
|
||||||
|
const isHead = Boolean(headCommitId && commit.id === headCommitId);
|
||||||
|
return (
|
||||||
<li
|
<li
|
||||||
key={commit.id}
|
key={commit.id}
|
||||||
style={{
|
style={{
|
||||||
padding: "6px 0",
|
padding: "8px 0",
|
||||||
borderBottom: "1px solid #1f2937",
|
borderBottom: "1px solid #1f2937",
|
||||||
color: "#e2e8f0",
|
color: "#e2e8f0",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<div style={{flex:1}}>
|
||||||
<div
|
<div
|
||||||
title={formatCommitTitle(commit)}
|
title={formatCommitTitle(commit)}
|
||||||
style={{
|
style={{
|
||||||
fontWeight: 600,
|
fontWeight: 750,
|
||||||
color: "#f8fafc",
|
color: "#f8fafc",
|
||||||
overflowWrap: "anywhere",
|
overflowWrap: "anywhere",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{formatCommitTitle(commit)}
|
{formatCommitTitle(commit)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: "2px", color: "#94a3b8" }}>
|
<div style={{ marginTop: 3, color: "#94a3b8" }}>
|
||||||
{commit.created_at ? new Date(commit.created_at).toLocaleString() : ""}
|
{commit.created_at ? new Date(commit.created_at).toLocaleString() : ""}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
style={{
|
style={{
|
||||||
marginTop: "4px",
|
marginTop: 6,
|
||||||
padding: "4px 6px",
|
padding: "6px 8px",
|
||||||
borderRadius: "4px",
|
borderRadius: 6,
|
||||||
border: "none",
|
border: "1px solid #334155",
|
||||||
background: "#334155",
|
background: isHead ? "#0b1220" : "#334155",
|
||||||
color: "white",
|
color: "white",
|
||||||
cursor: isSaving || isSubmitting ? "not-allowed" : "pointer",
|
cursor: isSaving || isSubmitting || isHead ? "not-allowed" : "pointer",
|
||||||
|
opacity: isHead ? 0.65 : 1,
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: 12,
|
||||||
}}
|
}}
|
||||||
onClick={() => onRestoreCommit(commit.id)}
|
onClick={() => onRestoreCommit(commit.id)}
|
||||||
disabled={isSaving || isSubmitting}
|
disabled={isSaving || isSubmitting || isHead}
|
||||||
|
title={isHead ? "Đang là head commit" : "Restore snapshot từ commit này (FE-only)"}
|
||||||
>
|
>
|
||||||
Restore
|
Restore
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Panel>
|
||||||
|
|
||||||
<div
|
<Panel title="Undo List" badge={String(recentUndoLabels.length)} defaultOpen={false}>
|
||||||
style={{
|
|
||||||
marginTop: "16px",
|
|
||||||
padding: "10px",
|
|
||||||
background: "#0b1220",
|
|
||||||
borderRadius: "6px",
|
|
||||||
border: "1px solid #1f2937",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ marginBottom: "6px", fontWeight: 600, fontSize: "14px" }}>
|
|
||||||
Tác vụ có thể undo ({recentUndoLabels.length})
|
|
||||||
</div>
|
|
||||||
{recentUndoLabels.length === 0 ? (
|
{recentUndoLabels.length === 0 ? (
|
||||||
<div style={{ color: "#94a3b8", fontSize: "13px" }}>Chưa có thao tác</div>
|
<div style={{ color: "#94a3b8", fontSize: 13 }}>Chưa có thao tác</div>
|
||||||
) : (
|
) : (
|
||||||
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: "13px", color: "#e2e8f0" }}>
|
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: 13, color: "#e2e8f0" }}>
|
||||||
{recentUndoLabels.map((label, idx) => (
|
{recentUndoLabels.map((label, idx) => (
|
||||||
<li key={`${label}-${idx}`} style={{ padding: "4px 0", borderBottom: "1px solid #1f2937" }}>
|
<li key={`${label}-${idx}`} style={{ padding: "6px 0", borderBottom: "1px solid #1f2937" }}>
|
||||||
{label}
|
{label}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Panel>
|
||||||
|
|
||||||
<div
|
<Panel title="This Session" defaultOpen={false}>
|
||||||
style={{
|
<div style={{ fontSize: 13, color: "#cbd5e1", marginBottom: 6 }}>
|
||||||
marginTop: "16px",
|
|
||||||
padding: "10px",
|
|
||||||
background: "#0b1220",
|
|
||||||
borderRadius: "6px",
|
|
||||||
border: "1px solid #1f2937",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ marginBottom: "8px", fontWeight: 600, fontSize: "14px" }}>
|
|
||||||
Mới tạo trong phiên
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ fontSize: "13px", color: "#cbd5e1", marginBottom: "6px" }}>
|
|
||||||
Entities ({createdEntities.length})
|
Entities ({createdEntities.length})
|
||||||
</div>
|
</div>
|
||||||
{createdEntities.length === 0 ? (
|
{createdEntities.length === 0 ? (
|
||||||
<div style={{ color: "#64748b", fontSize: "12px", marginBottom: "10px" }}>
|
<div style={{ color: "#64748b", fontSize: 12, marginBottom: 10 }}>Chưa tạo entity mới</div>
|
||||||
Chưa tạo entity mới
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: "12px", marginBottom: "10px" }}>
|
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: 12, marginBottom: 10 }}>
|
||||||
{createdEntities.map((entity) => (
|
{createdEntities.map((entity) => (
|
||||||
<li
|
<li
|
||||||
key={entity.id}
|
key={entity.id}
|
||||||
style={{
|
style={{ padding: "6px 0", borderBottom: "1px solid #1f2937", color: "#e2e8f0" }}
|
||||||
padding: "4px 0",
|
|
||||||
borderBottom: "1px solid #1f2937",
|
|
||||||
color: "#e2e8f0",
|
|
||||||
}}
|
|
||||||
title={entity.id}
|
title={entity.id}
|
||||||
>
|
>
|
||||||
{entity.name} ({entity.type_id || "country"})
|
{entity.name}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ fontSize: "13px", color: "#cbd5e1", marginBottom: "6px" }}>
|
<div style={{ fontSize: 13, color: "#cbd5e1", marginBottom: 6 }}>
|
||||||
Geometries mới chưa commit ({createdGeometries.length})
|
Geometries mới chưa commit ({createdGeometries.length})
|
||||||
</div>
|
</div>
|
||||||
{createdGeometries.length === 0 ? (
|
{createdGeometries.length === 0 ? (
|
||||||
<div style={{ color: "#64748b", fontSize: "12px" }}>
|
<div style={{ color: "#64748b", fontSize: 12 }}>Chưa có geometry mới chờ commit</div>
|
||||||
Chưa có geometry mới chờ commit
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: "12px" }}>
|
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: 12 }}>
|
||||||
{createdGeometries.map((geometry) => (
|
{createdGeometries.map((geometry) => (
|
||||||
<li
|
<li
|
||||||
key={String(geometry.id)}
|
key={String(geometry.id)}
|
||||||
style={{
|
style={{ padding: "6px 0", borderBottom: "1px solid #1f2937", color: "#e2e8f0" }}
|
||||||
padding: "4px 0",
|
|
||||||
borderBottom: "1px solid #1f2937",
|
|
||||||
color: "#e2e8f0",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
#{geometry.id} [{geometry.geometryType}] {geometry.semanticType ? `- ${geometry.semanticType}` : ""}
|
#{geometry.id} [{geometry.geometryType}]{" "}
|
||||||
|
{geometry.semanticType ? `- ${geometry.semanticType}` : ""}
|
||||||
{geometry.entityNames.length ? ` | ${geometry.entityNames.join(", ")}` : ""}
|
{geometry.entityNames.length ? ` | ${geometry.entityNames.join(", ")}` : ""}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Panel>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const textInputStyle = {
|
||||||
|
width: "100%",
|
||||||
|
marginTop: 0,
|
||||||
|
padding: "8px 10px",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "1px solid #334155",
|
||||||
|
background: "#0b1220",
|
||||||
|
color: "white",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
fontSize: 13,
|
||||||
|
outline: "none",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const textAreaStyle = {
|
||||||
|
...textInputStyle,
|
||||||
|
marginTop: 8,
|
||||||
|
resize: "vertical",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function Panel({
|
||||||
|
title,
|
||||||
|
badge,
|
||||||
|
defaultOpen,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
badge?: string | null;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<details
|
||||||
|
open={Boolean(defaultOpen)}
|
||||||
|
style={{
|
||||||
|
marginTop: 10,
|
||||||
|
padding: 10,
|
||||||
|
background: "#111827",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid #1f2937",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<summary
|
||||||
|
style={{
|
||||||
|
cursor: "pointer",
|
||||||
|
listStyle: "none",
|
||||||
|
fontWeight: 900,
|
||||||
|
fontSize: 13,
|
||||||
|
color: "white",
|
||||||
|
userSelect: "none",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{title}</span>
|
||||||
|
{badge ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: 999,
|
||||||
|
border: "1px solid #334155",
|
||||||
|
background: "#0b1220",
|
||||||
|
color: "#cbd5e1",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 850,
|
||||||
|
flex: "0 0 auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</summary>
|
||||||
|
<div style={{ marginTop: 10 }}>{children}</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModeHint({ mode }: { mode: EditorMode }) {
|
||||||
|
if (mode === "add-line" || mode === "add-path") {
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
|
||||||
|
Click để thêm điểm, Enter để hoàn tất, Esc để hủy.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (mode === "add-circle") {
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
|
||||||
|
Giữ chuột trái kéo để mở bán kính, thả chuột để hoàn tất.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (mode === "add-point") {
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
|
||||||
|
Chọn 1 điểm trên bản đồ để đặt địa điểm.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (mode === "select") {
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
|
||||||
|
Chọn 1 hình, đường, điểm trên bản đồ để xem chi tiết.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (mode === "draw") {
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
|
||||||
|
Chọn các điểm trên bản đồ để vẽ hình, ENTER để kết thúc, ESC để hủy.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function formatUndoLabel(action: UndoAction) {
|
function formatUndoLabel(action: UndoAction) {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "create":
|
case "create":
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ function wikiTitle(w: WikiSnapshot): string {
|
|||||||
|
|
||||||
export default function EntityWikiBindingsPanel({ entities, wikis, links, setLinks }: Props) {
|
export default function EntityWikiBindingsPanel({ entities, wikis, links, setLinks }: Props) {
|
||||||
const [activeEntityId, setActiveEntityId] = useState<string>("");
|
const [activeEntityId, setActiveEntityId] = useState<string>("");
|
||||||
|
const [activeWikiId, setActiveWikiId] = useState<string>("");
|
||||||
|
|
||||||
const wikiChoices: WikiChoice[] = useMemo(
|
const wikiChoices: WikiChoice[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -60,19 +61,22 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
|
|||||||
const currentlyOn = existing.operation !== "delete";
|
const currentlyOn = existing.operation !== "delete";
|
||||||
next[idx] = {
|
next[idx] = {
|
||||||
...existing,
|
...existing,
|
||||||
operation: currentlyOn ? "delete" : "reference",
|
operation: currentlyOn ? "delete" : "binding",
|
||||||
};
|
};
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
next.push({
|
next.push({
|
||||||
entity_id: activeEntityId,
|
entity_id: activeEntityId,
|
||||||
wiki_id: id,
|
wiki_id: id,
|
||||||
operation: "reference",
|
operation: "binding",
|
||||||
});
|
});
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const activeWikiLinked = activeEntityId && activeWikiId ? activeLinks.has(activeWikiId) : false;
|
||||||
|
const activeWikiChoice = activeWikiId ? wikiChoices.find((w) => w.id === activeWikiId) || null : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -115,48 +119,132 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: "12px", color: "#94a3b8", marginBottom: "6px" }}>Wikis</div>
|
<div style={{ fontSize: "12px", color: "#94a3b8", marginBottom: "6px" }}>Wikis</div>
|
||||||
{!wikiChoices.length ? (
|
<div style={{ display: "grid", gap: "8px" }}>
|
||||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>No wiki in project yet.</div>
|
<select
|
||||||
) : !activeEntityId ? (
|
value={activeWikiId}
|
||||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>Pick an entity to bind wikis.</div>
|
onChange={(e) => setActiveWikiId(e.target.value)}
|
||||||
) : (
|
disabled={wikiChoices.length === 0}
|
||||||
<div style={{ display: "grid", gap: "6px" }}>
|
style={{
|
||||||
{wikiChoices.slice(0, 12).map((w) => {
|
width: "100%",
|
||||||
const checked = activeLinks.has(w.id);
|
border: "1px solid #1f2937",
|
||||||
const isRefWiki = wikis.find((x) => x.id === w.id)?.source === "ref";
|
background: "#0b1220",
|
||||||
return (
|
color: "#e5e7eb",
|
||||||
<label
|
borderRadius: "6px",
|
||||||
key={w.id}
|
padding: "8px 10px",
|
||||||
|
fontSize: "12px",
|
||||||
|
outline: "none",
|
||||||
|
opacity: wikiChoices.length === 0 ? 0.7 : 1,
|
||||||
|
cursor: wikiChoices.length === 0 ? "not-allowed" : "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{wikiChoices.length === 0 ? "No wikis available" : "Select wiki…"}
|
||||||
|
</option>
|
||||||
|
{wikiChoices.map((w) => (
|
||||||
|
<option key={w.id} value={w.id}>
|
||||||
|
{w.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{wikiChoices.length === 0 ? (
|
||||||
|
<div style={{ fontSize: "12px", color: "#94a3b8" }}>No wiki in project yet.</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!activeEntityId || !activeWikiId}
|
||||||
|
onClick={() => toggle(activeWikiId)}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "6px",
|
||||||
|
padding: "8px 10px",
|
||||||
|
cursor: !activeEntityId || !activeWikiId ? "not-allowed" : "pointer",
|
||||||
|
background: activeWikiLinked ? "#334155" : "#16a34a",
|
||||||
|
color: "white",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: 12,
|
||||||
|
opacity: !activeEntityId || !activeWikiId ? 0.65 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeWikiLinked ? "Unlink wiki" : "Link wiki"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{activeWikiChoice ? (
|
||||||
|
<div style={{ fontSize: 12, color: "#94a3b8", overflowWrap: "anywhere" }}>
|
||||||
|
{activeWikiChoice.id}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!activeEntityId ? (
|
||||||
|
<div style={{ fontSize: 12, color: "#94a3b8" }}>Pick an entity to see/link wikis.</div>
|
||||||
|
) : activeLinks.size ? (
|
||||||
|
<div style={{ display: "grid", gap: "6px" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "#94a3b8" }}>Linked wikis ({activeLinks.size})</div>
|
||||||
|
{Array.from(activeLinks).slice(0, 8).map((id) => {
|
||||||
|
const w = wikiChoices.find((x) => x.id === id) || null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={id}
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "8px",
|
|
||||||
padding: "8px",
|
padding: "8px",
|
||||||
borderRadius: "6px",
|
borderRadius: "6px",
|
||||||
border: "1px solid #1f2937",
|
border: "1px solid #1f2937",
|
||||||
cursor: "pointer",
|
background: "#111827",
|
||||||
background: checked ? "#111827" : "transparent",
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 8,
|
||||||
}}
|
}}
|
||||||
title={w.id}
|
title={id}
|
||||||
>
|
>
|
||||||
<input type="checkbox" checked={checked} onChange={() => toggle(w.id)} />
|
<div style={{ minWidth: 0 }}>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div
|
||||||
<div style={{ color: "#e5e7eb", fontSize: "12px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
style={{
|
||||||
{w.title}
|
color: "#e5e7eb",
|
||||||
{isRefWiki ? " (ref)" : ""}
|
fontSize: 12,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{w?.title || "Untitled wiki"}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: "#94a3b8", fontSize: "11px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
<div style={{ color: "#94a3b8", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||||
{w.id}
|
{id}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggle(id)}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
background: "#0b1220",
|
||||||
|
color: "#fecaca",
|
||||||
|
cursor: "pointer",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "6px 8px",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 800,
|
||||||
|
flex: "0 0 auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Unlink
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{wikiChoices.length > 12 ? (
|
{activeLinks.size > 8 ? (
|
||||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>+{wikiChoices.length - 12} more…</div>
|
<div style={{ fontSize: 12, color: "#94a3b8" }}>+{activeLinks.size - 8} more…</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 12, color: "#94a3b8" }}>No wiki linked yet.</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ export default function Map({
|
|||||||
const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | null>(null);
|
const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | null>(null);
|
||||||
// Đánh dấu đã fitBounds cho fitBoundsKey hiện tại (tránh fit lặp).
|
// Đánh dấu đã fitBounds cho fitBoundsKey hiện tại (tránh fit lặp).
|
||||||
const fitBoundsAppliedRef = useRef(false);
|
const fitBoundsAppliedRef = useRef(false);
|
||||||
|
// Auto center theo vị trí người dùng chỉ nên chạy 1 lần / mount.
|
||||||
|
const geolocationCenteredRef = useRef(false);
|
||||||
// Danh sách cleanup fns để dọn listeners/engines khi unmount map.
|
// Danh sách cleanup fns để dọn listeners/engines khi unmount map.
|
||||||
const mapCleanupFnsRef = useRef<Array<() => void>>([]);
|
const mapCleanupFnsRef = useRef<Array<() => void>>([]);
|
||||||
// Các engine bindings theo mode để gọi cancel/cleanup khi đổi mode.
|
// Các engine bindings theo mode để gọi cancel/cleanup khi đổi mode.
|
||||||
@@ -257,6 +259,34 @@ export default function Map({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const tryCenterToUserLocation = useCallback(() => {
|
||||||
|
if (geolocationCenteredRef.current) return;
|
||||||
|
// Nếu đang "fit to draft bounds" thì không nên override center.
|
||||||
|
if (fitToDraftBoundsRef.current) return;
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
if (!("geolocation" in navigator)) return;
|
||||||
|
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
geolocationCenteredRef.current = true;
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => {
|
||||||
|
if (mapRef.current !== map) return;
|
||||||
|
const { longitude, latitude } = pos.coords;
|
||||||
|
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return;
|
||||||
|
|
||||||
|
const currentZoom = map.getZoom();
|
||||||
|
const nextZoom = Number.isFinite(currentZoom) ? Math.max(currentZoom, 5) : 5;
|
||||||
|
map.easeTo({ center: [longitude, latitude], zoom: nextZoom, duration: 900 });
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// Người dùng từ chối / lỗi định vị: im lặng.
|
||||||
|
},
|
||||||
|
{ enableHighAccuracy: false, timeout: 4000, maximumAge: 60_000 }
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
@@ -841,7 +871,7 @@ export default function Map({
|
|||||||
type: "Feature",
|
type: "Feature",
|
||||||
properties: {
|
properties: {
|
||||||
id,
|
id,
|
||||||
type: null,
|
type: "country",
|
||||||
geometry_preset: "polygon",
|
geometry_preset: "polygon",
|
||||||
entity_id: null,
|
entity_id: null,
|
||||||
entity_ids: [],
|
entity_ids: [],
|
||||||
@@ -880,7 +910,7 @@ export default function Map({
|
|||||||
type: "Feature",
|
type: "Feature",
|
||||||
properties: {
|
properties: {
|
||||||
id,
|
id,
|
||||||
type: null,
|
type: "city",
|
||||||
geometry_preset: "point",
|
geometry_preset: "point",
|
||||||
entity_id: null,
|
entity_id: null,
|
||||||
entity_ids: [],
|
entity_ids: [],
|
||||||
@@ -946,7 +976,7 @@ export default function Map({
|
|||||||
type: "Feature",
|
type: "Feature",
|
||||||
properties: {
|
properties: {
|
||||||
id,
|
id,
|
||||||
type: null,
|
type: "war",
|
||||||
geometry_preset: "circle-area",
|
geometry_preset: "circle-area",
|
||||||
entity_id: null,
|
entity_id: null,
|
||||||
entity_ids: [],
|
entity_ids: [],
|
||||||
@@ -979,6 +1009,8 @@ export default function Map({
|
|||||||
|
|
||||||
// after everything mounted, push current draft to sources
|
// after everything mounted, push current draft to sources
|
||||||
applyDraftToMap(draftRef.current);
|
applyDraftToMap(draftRef.current);
|
||||||
|
// Khi vao web, thu auto center theo vi tri user (neu co quyen).
|
||||||
|
tryCenterToUserLocation();
|
||||||
|
|
||||||
if (allowGeometryEditing) {
|
if (allowGeometryEditing) {
|
||||||
editingEngineRef.current?.bindEditEvents(map);
|
editingEngineRef.current?.bindEditEvents(map);
|
||||||
@@ -1000,7 +1032,7 @@ export default function Map({
|
|||||||
}
|
}
|
||||||
map.remove();
|
map.remove();
|
||||||
};
|
};
|
||||||
}, [allowGeometryEditing, applyDraftToMap]);
|
}, [allowGeometryEditing, applyDraftToMap, tryCenterToUserLocation]);
|
||||||
|
|
||||||
const handleZoomByStep = (delta: number) => {
|
const handleZoomByStep = (delta: number) => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
|
|||||||
@@ -1,69 +1,38 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useState, type CSSProperties } from "react";
|
||||||
import type { Entity } from "@/uhm/types/entities";
|
|
||||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||||
import { searchEntitiesByName } from "@/uhm/api/entities";
|
import type { EntityFormState } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
entityRefs: EntitySnapshot[];
|
entityRefs: EntitySnapshot[];
|
||||||
setEntityRefs: React.Dispatch<React.SetStateAction<EntitySnapshot[]>>;
|
entityForm: EntityFormState;
|
||||||
|
onEntityFormChange: (key: keyof EntityFormState, value: string) => void;
|
||||||
|
isEntitySubmitting: boolean;
|
||||||
|
onCreateEntityOnly: () => void;
|
||||||
|
entityFormStatus: string | null;
|
||||||
|
selectedGeometryEntityIds?: string[];
|
||||||
|
hasSelectedGeometry?: boolean;
|
||||||
|
onToggleBindEntityForSelectedGeometry?: (entityId: string, nextChecked: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ProjectEntityRefsPanel({ entityRefs, setEntityRefs }: Props) {
|
export default function ProjectEntityRefsPanel({
|
||||||
const [query, setQuery] = useState("");
|
entityRefs,
|
||||||
const [results, setResults] = useState<Entity[]>([]);
|
entityForm,
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
onEntityFormChange,
|
||||||
const searchRequestRef = useState(() => ({ id: 0 }))[0];
|
isEntitySubmitting,
|
||||||
|
onCreateEntityOnly,
|
||||||
|
entityFormStatus,
|
||||||
|
selectedGeometryEntityIds,
|
||||||
|
hasSelectedGeometry,
|
||||||
|
onToggleBindEntityForSelectedGeometry,
|
||||||
|
}: Props) {
|
||||||
|
const canBindToggle =
|
||||||
|
Boolean(hasSelectedGeometry) &&
|
||||||
|
Array.isArray(selectedGeometryEntityIds) &&
|
||||||
|
typeof onToggleBindEntityForSelectedGeometry === "function";
|
||||||
|
|
||||||
const existingIds = useMemo(() => new Set(entityRefs.map((e) => String(e.id))), [entityRefs]);
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const keyword = query.trim();
|
|
||||||
if (!keyword.length) {
|
|
||||||
setResults([]);
|
|
||||||
setIsSearching(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let disposed = false;
|
|
||||||
const requestId = ++searchRequestRef.id;
|
|
||||||
const t = window.setTimeout(async () => {
|
|
||||||
setIsSearching(true);
|
|
||||||
try {
|
|
||||||
const rows = await searchEntitiesByName(keyword, { limit: 20 });
|
|
||||||
if (disposed || requestId !== searchRequestRef.id) return;
|
|
||||||
setResults(rows);
|
|
||||||
} catch (err) {
|
|
||||||
if (disposed || requestId !== searchRequestRef.id) return;
|
|
||||||
console.error("Search entities failed", err);
|
|
||||||
setResults([]);
|
|
||||||
} finally {
|
|
||||||
if (disposed || requestId !== searchRequestRef.id) return;
|
|
||||||
setIsSearching(false);
|
|
||||||
}
|
|
||||||
}, 250);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
disposed = true;
|
|
||||||
window.clearTimeout(t);
|
|
||||||
};
|
|
||||||
}, [query, searchRequestRef]);
|
|
||||||
|
|
||||||
const addRef = (e: Entity) => {
|
|
||||||
const id = String(e.id || "").trim();
|
|
||||||
if (!id) return;
|
|
||||||
if (existingIds.has(id)) return;
|
|
||||||
setEntityRefs((prev) => [
|
|
||||||
{
|
|
||||||
id,
|
|
||||||
source: "ref",
|
|
||||||
name: e.name,
|
|
||||||
description: e.description ?? null,
|
|
||||||
},
|
|
||||||
...prev,
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -79,75 +48,6 @@ export default function ProjectEntityRefsPanel({ entityRefs, setEntityRefs }: Pr
|
|||||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{entityRefs.length}</div>
|
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{entityRefs.length}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: "10px" }}>
|
|
||||||
<div style={{ fontSize: "12px", color: "#94a3b8", marginBottom: "6px" }}>Add existing entity</div>
|
|
||||||
<input
|
|
||||||
value={query}
|
|
||||||
onChange={(ev) => setQuery(ev.target.value)}
|
|
||||||
placeholder="Search by name…"
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
border: "1px solid #1f2937",
|
|
||||||
background: "#0b1220",
|
|
||||||
color: "#e5e7eb",
|
|
||||||
borderRadius: "6px",
|
|
||||||
padding: "8px 10px",
|
|
||||||
fontSize: "12px",
|
|
||||||
outline: "none",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{isSearching ? (
|
|
||||||
<div style={{ marginTop: "6px", fontSize: "12px", color: "#94a3b8" }}>Searching…</div>
|
|
||||||
) : null}
|
|
||||||
{!isSearching && query.trim().length > 0 ? (
|
|
||||||
<div style={{ marginTop: "6px", display: "grid", gap: "6px" }}>
|
|
||||||
{results.slice(0, 8).map((r) => (
|
|
||||||
<div
|
|
||||||
key={r.id}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "8px",
|
|
||||||
padding: "8px",
|
|
||||||
borderRadius: "6px",
|
|
||||||
border: "1px solid #1f2937",
|
|
||||||
background: "transparent",
|
|
||||||
opacity: existingIds.has(r.id) ? 0.55 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div style={{ color: "#e5e7eb", fontSize: "12px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
|
||||||
{r.name}
|
|
||||||
</div>
|
|
||||||
<div style={{ color: "#94a3b8", fontSize: "11px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
|
||||||
{r.id}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => addRef(r)}
|
|
||||||
disabled={existingIds.has(r.id)}
|
|
||||||
style={{
|
|
||||||
border: "none",
|
|
||||||
background: "#111827",
|
|
||||||
color: existingIds.has(r.id) ? "#64748b" : "#93c5fd",
|
|
||||||
cursor: existingIds.has(r.id) ? "not-allowed" : "pointer",
|
|
||||||
borderRadius: "6px",
|
|
||||||
padding: "6px 8px",
|
|
||||||
fontSize: "12px",
|
|
||||||
fontWeight: 700,
|
|
||||||
flex: "0 0 auto",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{!results.length ? <div style={{ fontSize: "12px", color: "#94a3b8" }}>No results.</div> : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{entityRefs.length ? (
|
{entityRefs.length ? (
|
||||||
<div style={{ marginTop: "10px", display: "grid", gap: "6px" }}>
|
<div style={{ marginTop: "10px", display: "grid", gap: "6px" }}>
|
||||||
{entityRefs.slice(0, 8).map((e) => (
|
{entityRefs.slice(0, 8).map((e) => (
|
||||||
@@ -158,8 +58,12 @@ export default function ProjectEntityRefsPanel({ entityRefs, setEntityRefs }: Pr
|
|||||||
borderRadius: "6px",
|
borderRadius: "6px",
|
||||||
border: "1px solid #1f2937",
|
border: "1px solid #1f2937",
|
||||||
background: "transparent",
|
background: "transparent",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ fontSize: "12px", color: "#e5e7eb", fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
<div style={{ fontSize: "12px", color: "#e5e7eb", fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||||
{e.name || e.id}
|
{e.name || e.id}
|
||||||
</div>
|
</div>
|
||||||
@@ -167,12 +71,202 @@ export default function ProjectEntityRefsPanel({ entityRefs, setEntityRefs }: Pr
|
|||||||
{e.id}
|
{e.id}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{canBindToggle ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={selectedGeometryEntityIds!.includes(String(e.id)) ? "Unbind from selected geometry" : "Bind to selected geometry"}
|
||||||
|
onClick={() =>
|
||||||
|
onToggleBindEntityForSelectedGeometry!(
|
||||||
|
String(e.id),
|
||||||
|
!selectedGeometryEntityIds!.includes(String(e.id))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "1px solid #334155",
|
||||||
|
background: "#0b1220",
|
||||||
|
cursor: "pointer",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
}}
|
||||||
|
aria-label={
|
||||||
|
selectedGeometryEntityIds!.includes(String(e.id))
|
||||||
|
? `Unbind entity ${String(e.id)} from selected geometry`
|
||||||
|
: `Bind entity ${String(e.id)} to selected geometry`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{selectedGeometryEntityIds!.includes(String(e.id)) ? (
|
||||||
|
<UnlockIcon />
|
||||||
|
) : (
|
||||||
|
<LockIcon />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
{entityRefs.length > 8 ? <div style={{ fontSize: "12px", color: "#94a3b8" }}>+{entityRefs.length - 8} more…</div> : null}
|
{entityRefs.length > 8 ? <div style={{ fontSize: "12px", color: "#94a3b8" }}>+{entityRefs.length - 8} more…</div> : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>No entity ref yet for this project.</div>
|
<div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>No entity ref yet for this project.</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "10px",
|
||||||
|
display: "grid",
|
||||||
|
gap: "8px",
|
||||||
|
border: "1px solid #1e3a8a",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "8px",
|
||||||
|
background: "#0f172a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
||||||
|
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
|
||||||
|
Tạo entity mới
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsCreateOpen((v) => !v)}
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
title={isCreateOpen ? "Dong" : "Mo"}
|
||||||
|
aria-label={isCreateOpen ? "Dong tao entity" : "Mo tao entity"}
|
||||||
|
style={{
|
||||||
|
width: 26,
|
||||||
|
height: 26,
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "1px solid #334155",
|
||||||
|
background: "#0b1220",
|
||||||
|
color: "#e2e8f0",
|
||||||
|
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
|
||||||
|
opacity: isEntitySubmitting ? 0.6 : 1,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isCreateOpen ? <CloseIcon /> : <PlusIcon />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isCreateOpen ? (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
value={entityForm.name}
|
||||||
|
onChange={(event) => onEntityFormChange("name", event.target.value)}
|
||||||
|
placeholder="Tên entity mới"
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={entityInputStyle}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={entityForm.description}
|
||||||
|
onChange={(event) => onEntityFormChange("description", event.target.value)}
|
||||||
|
placeholder="Description"
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={entityInputStyle}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCreateEntityOnly}
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "6px",
|
||||||
|
padding: "7px 8px",
|
||||||
|
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
|
||||||
|
background: "#2563eb",
|
||||||
|
color: "#ffffff",
|
||||||
|
opacity: isEntitySubmitting ? 0.7 : 1,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Tạo entity mới
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{entityFormStatus ? (
|
||||||
|
<div style={{ color: "#93c5fd", fontSize: "12px", marginTop: "8px" }}>
|
||||||
|
{entityFormStatus}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const entityInputStyle: CSSProperties = {
|
||||||
|
width: "100%",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #334155",
|
||||||
|
background: "#111827",
|
||||||
|
color: "#f8fafc",
|
||||||
|
padding: "6px 8px",
|
||||||
|
fontSize: "13px",
|
||||||
|
};
|
||||||
|
|
||||||
|
function LockIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M7 10V8a5 5 0 0 1 10 0v2"
|
||||||
|
stroke="#cbd5e1"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="6"
|
||||||
|
y="10"
|
||||||
|
width="12"
|
||||||
|
height="10"
|
||||||
|
rx="2"
|
||||||
|
stroke="#cbd5e1"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UnlockIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M17 10V8a5 5 0 0 0-9.5-2"
|
||||||
|
stroke="#a7f3d0"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="6"
|
||||||
|
y="10"
|
||||||
|
width="12"
|
||||||
|
height="10"
|
||||||
|
rx="2"
|
||||||
|
stroke="#a7f3d0"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlusIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M12 5v14M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CloseIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M6 6l12 12M18 6L6 18" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { type CSSProperties } from "react";
|
import { type CSSProperties, useMemo, useState } from "react";
|
||||||
import { Entity } from "@/uhm/api/entities";
|
import { Entity } from "@/uhm/api/entities";
|
||||||
import { Feature } from "@/uhm/lib/useEditorState";
|
import { Feature } from "@/uhm/lib/useEditorState";
|
||||||
import {
|
import {
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
findEntityTypeOption,
|
findEntityTypeOption,
|
||||||
groupEntityTypeOptions,
|
groupEntityTypeOptions,
|
||||||
} from "@/uhm/lib/entityTypeOptions";
|
} from "@/uhm/lib/entityTypeOptions";
|
||||||
import type { EntityFormState, GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionTypes";
|
import type { GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
selectedFeature: Feature | null;
|
selectedFeature: Feature | null;
|
||||||
@@ -19,24 +19,12 @@ type Props = {
|
|||||||
entities: Entity[];
|
entities: Entity[];
|
||||||
selectedGeometryEntityIds: string[];
|
selectedGeometryEntityIds: string[];
|
||||||
onEntityIdsChange: (values: string[]) => void;
|
onEntityIdsChange: (values: string[]) => void;
|
||||||
entitySearchQuery: string;
|
|
||||||
onEntitySearchQueryChange: (value: string) => void;
|
|
||||||
entitySearchResults: Entity[];
|
|
||||||
selectedSearchEntityId: string | null;
|
|
||||||
onSelectSearchEntityId: (value: string | null) => void;
|
|
||||||
onAddSelectedSearchEntity: () => void;
|
|
||||||
isEntitySearchLoading: boolean;
|
|
||||||
entityForm: EntityFormState;
|
|
||||||
onEntityFormChange: (key: keyof EntityFormState, value: string) => void;
|
|
||||||
entityTypeOptions: EntityTypeOption[];
|
entityTypeOptions: EntityTypeOption[];
|
||||||
geometryMetaForm: GeometryMetaFormState;
|
geometryMetaForm: GeometryMetaFormState;
|
||||||
onGeometryMetaFormChange: (key: keyof GeometryMetaFormState, value: string) => void;
|
onGeometryMetaFormChange: (key: keyof GeometryMetaFormState, value: string) => void;
|
||||||
isEntitySubmitting: boolean;
|
isEntitySubmitting: boolean;
|
||||||
onCreateEntityOnly: () => void;
|
onApplyGeometryMetadata: () => Promise<{ ok: boolean; error?: string }>;
|
||||||
onApplyGeometryMetadata: () => void;
|
|
||||||
onApplyEntitiesForSelectedGeometry: () => void;
|
|
||||||
changeCount: number;
|
changeCount: number;
|
||||||
entityFormStatus: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SelectedGeometryPanel({
|
export default function SelectedGeometryPanel({
|
||||||
@@ -46,41 +34,60 @@ export default function SelectedGeometryPanel({
|
|||||||
entities,
|
entities,
|
||||||
selectedGeometryEntityIds,
|
selectedGeometryEntityIds,
|
||||||
onEntityIdsChange,
|
onEntityIdsChange,
|
||||||
entitySearchQuery,
|
|
||||||
onEntitySearchQueryChange,
|
|
||||||
entitySearchResults,
|
|
||||||
selectedSearchEntityId,
|
|
||||||
onSelectSearchEntityId,
|
|
||||||
onAddSelectedSearchEntity,
|
|
||||||
isEntitySearchLoading,
|
|
||||||
entityForm,
|
|
||||||
onEntityFormChange,
|
|
||||||
entityTypeOptions,
|
entityTypeOptions,
|
||||||
geometryMetaForm,
|
geometryMetaForm,
|
||||||
onGeometryMetaFormChange,
|
onGeometryMetaFormChange,
|
||||||
isEntitySubmitting,
|
isEntitySubmitting,
|
||||||
onCreateEntityOnly,
|
|
||||||
onApplyGeometryMetadata,
|
onApplyGeometryMetadata,
|
||||||
onApplyEntitiesForSelectedGeometry,
|
|
||||||
changeCount,
|
changeCount,
|
||||||
entityFormStatus,
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const [geoApplyFeedback, setGeoApplyFeedback] = useState<
|
||||||
|
| {
|
||||||
|
kind: "ok" | "error";
|
||||||
|
text: string;
|
||||||
|
signature: string;
|
||||||
|
}
|
||||||
|
| null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
const geoMetaSignature = useMemo(() => {
|
||||||
|
return [
|
||||||
|
geometryMetaForm.type_key,
|
||||||
|
geometryMetaForm.time_start,
|
||||||
|
geometryMetaForm.time_end,
|
||||||
|
geometryMetaForm.binding,
|
||||||
|
].join("|");
|
||||||
|
}, [
|
||||||
|
geometryMetaForm.binding,
|
||||||
|
geometryMetaForm.time_end,
|
||||||
|
geometryMetaForm.time_start,
|
||||||
|
geometryMetaForm.type_key,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleApplyGeoMeta = async () => {
|
||||||
|
setGeoApplyFeedback(null);
|
||||||
|
const result = await onApplyGeometryMetadata();
|
||||||
|
if (result.ok) {
|
||||||
|
setGeoApplyFeedback({ kind: "ok", text: "đã apply thành công", signature: geoMetaSignature });
|
||||||
|
} else if (result.error) {
|
||||||
|
setGeoApplyFeedback({ kind: "error", text: result.error, signature: geoMetaSignature });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const visibleGeoApplyFeedback =
|
||||||
|
geoApplyFeedback && geoApplyFeedback.signature === geoMetaSignature ? geoApplyFeedback : null;
|
||||||
|
|
||||||
|
if (!selectedFeature) return null;
|
||||||
|
|
||||||
const groupedEntityTypeOptions = groupEntityTypeOptions(entityTypeOptions);
|
const groupedEntityTypeOptions = groupEntityTypeOptions(entityTypeOptions);
|
||||||
const featureGeometryPreset = selectedFeature
|
const featureGeometryPreset = resolveFeatureGeometryPreset(selectedFeature);
|
||||||
? resolveFeatureGeometryPreset(selectedFeature)
|
const allowedGroupIds = getAllowedGroupIdsForPreset(featureGeometryPreset);
|
||||||
: null;
|
const groupedGeoTypeOptions = groupedEntityTypeOptions.filter((group) =>
|
||||||
const allowedGroupIds = featureGeometryPreset
|
|
||||||
? getAllowedGroupIdsForPreset(featureGeometryPreset)
|
|
||||||
: [];
|
|
||||||
const visibleGroupedEntityTypeOptions = groupedEntityTypeOptions.filter((group) =>
|
|
||||||
allowedGroupIds.includes(group.id)
|
allowedGroupIds.includes(group.id)
|
||||||
);
|
);
|
||||||
const groupedEntityTypeOptionsForCreate = selectedFeature
|
const selectedTypeOption = findEntityTypeOption(geometryMetaForm.type_key);
|
||||||
? visibleGroupedEntityTypeOptions
|
const hasCurrentVisibleTypeOption = groupedGeoTypeOptions.some((group) =>
|
||||||
: groupedEntityTypeOptions;
|
group.options.some((option) => option.value === geometryMetaForm.type_key)
|
||||||
const selectedTypeOption = findEntityTypeOption(entityForm.type_id);
|
|
||||||
const hasCurrentVisibleTypeOption = groupedEntityTypeOptionsForCreate.some((group) =>
|
|
||||||
group.options.some((option) => option.value === entityForm.type_id)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -96,11 +103,6 @@ export default function SelectedGeometryPanel({
|
|||||||
Entity & Geometry
|
Entity & Geometry
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!selectedFeature ? (
|
|
||||||
<div style={{ color: "#94a3b8", fontSize: "13px" }}>
|
|
||||||
Chưa chọn geometry. Tạo entity mới ở khối bên dưới, hoặc vào mode Select để bind entity cho geometry.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: "grid", gap: "8px", fontSize: "13px" }}>
|
<div style={{ display: "grid", gap: "8px", fontSize: "13px" }}>
|
||||||
<div style={{ color: "#e2e8f0" }}>
|
<div style={{ color: "#e2e8f0" }}>
|
||||||
ID: {String(selectedFeature.properties.id)}
|
ID: {String(selectedFeature.properties.id)}
|
||||||
@@ -179,6 +181,42 @@ export default function SelectedGeometryPanel({
|
|||||||
<div style={{ color: "#94a3b8", fontSize: "11px" }}>
|
<div style={{ color: "#94a3b8", fontSize: "11px" }}>
|
||||||
Các giá trị này thuộc về GEO đang chọn, không phụ thuộc entity.
|
Các giá trị này thuộc về GEO đang chọn, không phụ thuộc entity.
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ color: "#e2e8f0", fontWeight: 600, fontSize: "12px" }}>
|
||||||
|
Loại GEO
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={geometryMetaForm.type_key}
|
||||||
|
onChange={(event) => onGeometryMetaFormChange("type_key", event.target.value)}
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={entityInputStyle}
|
||||||
|
>
|
||||||
|
{!hasCurrentVisibleTypeOption && geometryMetaForm.type_key ? (
|
||||||
|
<option value={geometryMetaForm.type_key}>
|
||||||
|
Custom Type ({geometryMetaForm.type_key})
|
||||||
|
</option>
|
||||||
|
) : null}
|
||||||
|
{groupedGeoTypeOptions.map((group) => (
|
||||||
|
<optgroup
|
||||||
|
key={group.id}
|
||||||
|
label={`${group.label} (${group.geometryLabel})`}
|
||||||
|
>
|
||||||
|
{group.options.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{selectedTypeOption ? (
|
||||||
|
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
|
||||||
|
Đang chọn: <b>{selectedTypeOption.label}</b> ({selectedTypeOption.groupLabel})
|
||||||
|
</div>
|
||||||
|
) : geometryMetaForm.type_key ? (
|
||||||
|
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
|
||||||
|
Đang chọn: <b>{geometryMetaForm.type_key}</b>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<input
|
<input
|
||||||
value={geometryMetaForm.time_start}
|
value={geometryMetaForm.time_start}
|
||||||
onChange={(event) => onGeometryMetaFormChange("time_start", event.target.value)}
|
onChange={(event) => onGeometryMetaFormChange("time_start", event.target.value)}
|
||||||
@@ -195,81 +233,23 @@ export default function SelectedGeometryPanel({
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onApplyGeometryMetadata}
|
onClick={handleApplyGeoMeta}
|
||||||
disabled={isEntitySubmitting}
|
disabled={isEntitySubmitting}
|
||||||
style={primaryGeometryButtonStyle}
|
style={primaryGeometryButtonStyle}
|
||||||
>
|
>
|
||||||
Apply
|
Apply
|
||||||
</button>
|
</button>
|
||||||
</div>
|
{visibleGeoApplyFeedback ? (
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
fontSize: "12px",
|
||||||
gap: "8px",
|
color:
|
||||||
border: "1px solid #1f3b5a",
|
visibleGeoApplyFeedback.kind === "ok" ? "#22c55e" : "#fca5a5",
|
||||||
borderRadius: "8px",
|
|
||||||
padding: "8px",
|
|
||||||
background: "#0f172a",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
|
{visibleGeoApplyFeedback.text}
|
||||||
Bind entity có sẵn
|
|
||||||
</div>
|
|
||||||
<div style={{ color: "#93c5fd", fontSize: "11px" }}>
|
|
||||||
Dùng khi entity đã tồn tại. Tìm kiếm, thêm vào danh sách rồi bấm nút áp dụng.
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
value={entitySearchQuery}
|
|
||||||
onChange={(event) => onEntitySearchQueryChange(event.target.value)}
|
|
||||||
placeholder="Search entity theo name..."
|
|
||||||
disabled={isEntitySubmitting}
|
|
||||||
style={entityInputStyle}
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
value={selectedSearchEntityId || ""}
|
|
||||||
onChange={(event) =>
|
|
||||||
onSelectSearchEntityId(event.target.value ? event.target.value : null)
|
|
||||||
}
|
|
||||||
disabled={isEntitySubmitting || isEntitySearchLoading}
|
|
||||||
style={entityInputStyle}
|
|
||||||
>
|
|
||||||
<option value="">-- Chọn entity từ kết quả search --</option>
|
|
||||||
{entitySearchResults.map((entity) => (
|
|
||||||
<option key={entity.id} value={entity.id}>
|
|
||||||
{entity.name} ({entity.id})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onAddSelectedSearchEntity}
|
|
||||||
disabled={isEntitySubmitting || isEntitySearchLoading}
|
|
||||||
style={secondaryActionButtonStyle}
|
|
||||||
>
|
|
||||||
Thêm entity đã chọn vào danh sách gắn
|
|
||||||
</button>
|
|
||||||
{isEntitySearchLoading ? (
|
|
||||||
<div style={{ color: "#93c5fd", fontSize: "12px" }}>
|
|
||||||
Đang tìm entity...
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
|
||||||
onClick={onApplyEntitiesForSelectedGeometry}
|
|
||||||
disabled={isEntitySubmitting}
|
|
||||||
style={{
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "6px",
|
|
||||||
padding: "7px 8px",
|
|
||||||
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
|
|
||||||
background: "#0f766e",
|
|
||||||
color: "#ffffff",
|
|
||||||
opacity: isEntitySubmitting ? 0.7 : 1,
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Áp dụng danh sách entity
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{changeCount > 0 ? (
|
{changeCount > 0 ? (
|
||||||
@@ -278,106 +258,6 @@ export default function SelectedGeometryPanel({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gap: "8px",
|
|
||||||
border: "1px solid #1e3a8a",
|
|
||||||
borderRadius: "8px",
|
|
||||||
padding: "8px",
|
|
||||||
background: "#0f172a",
|
|
||||||
marginTop: "10px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
|
|
||||||
Tạo entity mới (độc lập)
|
|
||||||
</div>
|
|
||||||
<div style={{ color: "#93c5fd", fontSize: "11px" }}>
|
|
||||||
Chỉ tạo entity, không tự bind vào geometry.
|
|
||||||
</div>
|
|
||||||
{selectedFeature ? (
|
|
||||||
<div style={{ color: "#93c5fd", fontSize: "11px" }}>
|
|
||||||
Type đang bị giới hạn theo geometry: <b>{formatGeometryPresetLabel(featureGeometryPreset)}</b>.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<input
|
|
||||||
value={entityForm.name}
|
|
||||||
onChange={(event) => onEntityFormChange("name", event.target.value)}
|
|
||||||
placeholder="Tên entity mới"
|
|
||||||
disabled={isEntitySubmitting}
|
|
||||||
style={entityInputStyle}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
value={entityForm.slug}
|
|
||||||
onChange={(event) => onEntityFormChange("slug", event.target.value)}
|
|
||||||
placeholder="Slug"
|
|
||||||
disabled={isEntitySubmitting}
|
|
||||||
style={entityInputStyle}
|
|
||||||
/>
|
|
||||||
<div style={{ color: "#e2e8f0", fontWeight: 600, fontSize: "12px" }}>
|
|
||||||
Chọn loại entity
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
value={entityForm.type_id}
|
|
||||||
onChange={(event) => onEntityFormChange("type_id", event.target.value)}
|
|
||||||
disabled={isEntitySubmitting}
|
|
||||||
style={entityInputStyle}
|
|
||||||
>
|
|
||||||
{!selectedFeature && !hasCurrentVisibleTypeOption && entityForm.type_id ? (
|
|
||||||
<option value={entityForm.type_id}>
|
|
||||||
Custom Type ({entityForm.type_id})
|
|
||||||
</option>
|
|
||||||
) : null}
|
|
||||||
{groupedEntityTypeOptionsForCreate.map((group) => (
|
|
||||||
<optgroup
|
|
||||||
key={group.id}
|
|
||||||
label={`${group.label} (${group.geometryLabel})`}
|
|
||||||
>
|
|
||||||
{group.options.map((option) => (
|
|
||||||
<option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</optgroup>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{selectedTypeOption ? (
|
|
||||||
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
|
|
||||||
Type đang chọn: <b>{selectedTypeOption.label}</b> ({selectedTypeOption.groupLabel})
|
|
||||||
</div>
|
|
||||||
) : entityForm.type_id ? (
|
|
||||||
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
|
|
||||||
Type đang chọn: <b>{entityForm.type_id}</b>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={onCreateEntityOnly}
|
|
||||||
disabled={isEntitySubmitting}
|
|
||||||
style={{
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "6px",
|
|
||||||
padding: "7px 8px",
|
|
||||||
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
|
|
||||||
background: "#2563eb",
|
|
||||||
color: "#ffffff",
|
|
||||||
opacity: isEntitySubmitting ? 0.7 : 1,
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Tạo entity mới
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{entityFormStatus ? (
|
|
||||||
<div style={{ color: "#93c5fd", fontSize: "12px", marginTop: "8px" }}>
|
|
||||||
{entityFormStatus}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -402,15 +282,6 @@ const removeButtonStyle: CSSProperties = {
|
|||||||
fontSize: "12px",
|
fontSize: "12px",
|
||||||
};
|
};
|
||||||
|
|
||||||
const secondaryActionButtonStyle: CSSProperties = {
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "6px",
|
|
||||||
padding: "7px 8px",
|
|
||||||
cursor: "pointer",
|
|
||||||
background: "#1d4ed8",
|
|
||||||
color: "#ffffff",
|
|
||||||
};
|
|
||||||
|
|
||||||
const primaryGeometryButtonStyle: CSSProperties = {
|
const primaryGeometryButtonStyle: CSSProperties = {
|
||||||
border: "none",
|
border: "none",
|
||||||
borderRadius: "6px",
|
borderRadius: "6px",
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ type Props = {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
statusText?: string | null;
|
statusText?: string | null;
|
||||||
|
filterEnabled?: boolean;
|
||||||
|
onFilterEnabledChange?: (enabled: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TimelineBar({
|
export default function TimelineBar({
|
||||||
@@ -16,6 +18,8 @@ export default function TimelineBar({
|
|||||||
isLoading,
|
isLoading,
|
||||||
disabled,
|
disabled,
|
||||||
statusText,
|
statusText,
|
||||||
|
filterEnabled,
|
||||||
|
onFilterEnabledChange,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const lower = FIXED_TIMELINE_START_YEAR;
|
const lower = FIXED_TIMELINE_START_YEAR;
|
||||||
const upper = FIXED_TIMELINE_END_YEAR;
|
const upper = FIXED_TIMELINE_END_YEAR;
|
||||||
@@ -24,7 +28,7 @@ export default function TimelineBar({
|
|||||||
|
|
||||||
const helperText = isLoading
|
const helperText = isLoading
|
||||||
? "Đang tải geometry theo mốc thời gian..."
|
? "Đang tải geometry theo mốc thời gian..."
|
||||||
: statusText || "Kéo thanh hoặc nhập số năm để query chính xác.";
|
: statusText || null;
|
||||||
|
|
||||||
const handleYearChange = (nextYear: number) => {
|
const handleYearChange = (nextYear: number) => {
|
||||||
onYearChange(clampYearValue(Math.trunc(nextYear), lower, upper));
|
onYearChange(clampYearValue(Math.trunc(nextYear), lower, upper));
|
||||||
@@ -41,39 +45,69 @@ export default function TimelineBar({
|
|||||||
background: "rgba(15, 23, 42, 0.9)",
|
background: "rgba(15, 23, 42, 0.9)",
|
||||||
border: "1px solid rgba(148, 163, 184, 0.3)",
|
border: "1px solid rgba(148, 163, 184, 0.3)",
|
||||||
borderRadius: "10px",
|
borderRadius: "10px",
|
||||||
padding: "12px 14px",
|
padding: "10px 12px",
|
||||||
color: "#e2e8f0",
|
color: "#e2e8f0",
|
||||||
backdropFilter: "blur(2px)",
|
backdropFilter: "blur(2px)",
|
||||||
}}
|
}}
|
||||||
|
title={helperText || undefined}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
marginBottom: "8px",
|
|
||||||
gap: "8px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: "13px", fontWeight: 600, letterSpacing: "0.02em" }}>
|
|
||||||
Timeline
|
|
||||||
</span>
|
|
||||||
<span style={{ fontSize: "16px", fontWeight: 700, color: "#f8fafc" }}>
|
|
||||||
{formatYear(safeYear)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ fontSize: "12px", color: "#cbd5e1", marginTop: "8px", marginBottom: "6px" }}>
|
|
||||||
Mốc thời gian chi tiết
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "minmax(0, 1fr) 120px",
|
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: "10px",
|
gap: "10px",
|
||||||
|
fontSize: "12px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{typeof filterEnabled === "boolean" && onFilterEnabledChange ? (
|
||||||
|
<label
|
||||||
|
title={filterEnabled ? "Dang bat loc timeline" : "Dang tat loc timeline (hien thi tat ca geometry)"}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
cursor: effectiveDisabled ? "not-allowed" : "pointer",
|
||||||
|
userSelect: "none",
|
||||||
|
opacity: effectiveDisabled ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
width: 36,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: 999,
|
||||||
|
border: "1px solid rgba(148, 163, 184, 0.45)",
|
||||||
|
background: filterEnabled ? "rgba(34, 197, 94, 0.9)" : "rgba(148, 163, 184, 0.25)",
|
||||||
|
position: "relative",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 2,
|
||||||
|
left: filterEnabled ? 18 : 2,
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
borderRadius: 999,
|
||||||
|
background: "#0b1220",
|
||||||
|
border: "1px solid rgba(148, 163, 184, 0.35)",
|
||||||
|
transition: "left 120ms ease",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={filterEnabled}
|
||||||
|
onChange={(e) => onFilterEnabledChange(e.target.checked)}
|
||||||
|
disabled={effectiveDisabled}
|
||||||
|
aria-label="Toggle timeline filter"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<span style={{ color: "#94a3b8", minWidth: 44 }}>{formatYear(lower)}</span>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={lower}
|
min={lower}
|
||||||
@@ -84,12 +118,16 @@ export default function TimelineBar({
|
|||||||
disabled={effectiveDisabled}
|
disabled={effectiveDisabled}
|
||||||
aria-label="Timeline year"
|
aria-label="Timeline year"
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
accentColor: "#22c55e",
|
accentColor: "#22c55e",
|
||||||
cursor: effectiveDisabled ? "not-allowed" : "pointer",
|
cursor: effectiveDisabled ? "not-allowed" : "pointer",
|
||||||
opacity: effectiveDisabled ? 0.6 : 1,
|
opacity: effectiveDisabled ? 0.6 : 1,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<span style={{ color: "#94a3b8", minWidth: 44, textAlign: "right" }}>
|
||||||
|
{formatYear(upper)}
|
||||||
|
</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={lower}
|
min={lower}
|
||||||
@@ -100,7 +138,7 @@ export default function TimelineBar({
|
|||||||
disabled={effectiveDisabled}
|
disabled={effectiveDisabled}
|
||||||
aria-label="Timeline exact year"
|
aria-label="Timeline exact year"
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "128px",
|
||||||
border: "1px solid rgba(148, 163, 184, 0.45)",
|
border: "1px solid rgba(148, 163, 184, 0.45)",
|
||||||
borderRadius: "6px",
|
borderRadius: "6px",
|
||||||
padding: "6px 8px",
|
padding: "6px 8px",
|
||||||
@@ -111,23 +149,6 @@ export default function TimelineBar({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: "8px",
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "1fr auto 1fr",
|
|
||||||
alignItems: "center",
|
|
||||||
columnGap: "10px",
|
|
||||||
fontSize: "12px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ color: "#94a3b8" }}>{formatYear(lower)}</span>
|
|
||||||
<span style={{ color: "#cbd5e1", textAlign: "center", whiteSpace: "nowrap" }}>
|
|
||||||
{helperText}
|
|
||||||
</span>
|
|
||||||
<span style={{ color: "#94a3b8", textAlign: "right" }}>{formatYear(upper)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
86
src/uhm/components/UnifiedSearchBar.tsx
Normal file
86
src/uhm/components/UnifiedSearchBar.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
|
export type UnifiedSearchKind = "entity" | "wiki" | "geo";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
kind: UnifiedSearchKind;
|
||||||
|
onKindChange: (kind: UnifiedSearchKind) => void;
|
||||||
|
query: string;
|
||||||
|
onQueryChange: (query: string) => void;
|
||||||
|
disabledGeo?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function UnifiedSearchBar({ kind, onKindChange, query, onQueryChange, disabledGeo }: Props) {
|
||||||
|
const selectStyle: CSSProperties = {
|
||||||
|
width: 110,
|
||||||
|
border: "1px solid #1f2937",
|
||||||
|
background: "#0b1220",
|
||||||
|
color: "#e5e7eb",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "8px 10px",
|
||||||
|
fontSize: 12,
|
||||||
|
outline: "none",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle: CSSProperties = {
|
||||||
|
width: "100%",
|
||||||
|
border: "1px solid #1f2937",
|
||||||
|
background: "#0b1220",
|
||||||
|
color: "#e5e7eb",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "8px 10px",
|
||||||
|
fontSize: 12,
|
||||||
|
outline: "none",
|
||||||
|
minWidth: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const helperText =
|
||||||
|
kind === "entity"
|
||||||
|
? "Search entity theo name"
|
||||||
|
: kind === "wiki"
|
||||||
|
? "Search wiki theo title"
|
||||||
|
: "Search geo theo entity name";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 10,
|
||||||
|
background: "#0b1220",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid #1f2937",
|
||||||
|
display: "grid",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: 14 }}>Search</div>
|
||||||
|
<div style={{ fontSize: 12, color: "#94a3b8" }}>{helperText}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<select
|
||||||
|
value={kind}
|
||||||
|
onChange={(e) => onKindChange(e.target.value as UnifiedSearchKind)}
|
||||||
|
style={selectStyle}
|
||||||
|
aria-label="Search kind"
|
||||||
|
>
|
||||||
|
<option value="entity">Entity</option>
|
||||||
|
<option value="wiki">Wiki</option>
|
||||||
|
<option value="geo" disabled={Boolean(disabledGeo)}>
|
||||||
|
Geo
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => onQueryChange(e.target.value)}
|
||||||
|
placeholder={kind === "entity" ? "Nhập tên entity…" : kind === "wiki" ? "Nhập title wiki…" : "Nhập tên entity…"}
|
||||||
|
style={inputStyle}
|
||||||
|
aria-label="Search query"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,24 +1,30 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState, type ComponentProps } from "react";
|
||||||
import { EditorContent, useEditor, type JSONContent } from "@tiptap/react";
|
import dynamic from "next/dynamic";
|
||||||
import StarterKit from "@tiptap/starter-kit";
|
import "react-quill-new/dist/quill.snow.css";
|
||||||
import TiptapLink from "@tiptap/extension-link";
|
|
||||||
import { searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
|
|
||||||
|
|
||||||
import { Modal } from "@/components/ui/modal";
|
import { Modal } from "@/components/ui/modal";
|
||||||
import Button from "@/components/ui/button/Button";
|
import Button from "@/components/ui/button/Button";
|
||||||
import Badge from "@/components/ui/badge/Badge";
|
|
||||||
import Label from "@/components/form/Label";
|
import Label from "@/components/form/Label";
|
||||||
|
|
||||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
import { newId } from "@/uhm/lib/id";
|
import { newId } from "@/uhm/lib/id";
|
||||||
|
import type ReactQuill from "react-quill-new";
|
||||||
|
|
||||||
|
type ReactQuillProps = ComponentProps<typeof ReactQuill>;
|
||||||
|
|
||||||
|
const ReactQuillEditor = dynamic<ReactQuillProps>(() => import("react-quill-new"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <div className="h-[480px] w-full animate-pulse bg-gray-100 rounded-lg" />,
|
||||||
|
});
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
wikis: WikiSnapshot[];
|
wikis: WikiSnapshot[];
|
||||||
setWikis: React.Dispatch<React.SetStateAction<WikiSnapshot[]>>;
|
setWikis: React.Dispatch<React.SetStateAction<WikiSnapshot[]>>;
|
||||||
autoOpen?: boolean;
|
autoOpen?: boolean;
|
||||||
|
requestedActiveId?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function clampTitle(title: string) {
|
function clampTitle(title: string) {
|
||||||
@@ -26,35 +32,15 @@ function clampTitle(title: string) {
|
|||||||
return t.length ? t.slice(0, 120) : "Untitled wiki";
|
return t.length ? t.slice(0, 120) : "Untitled wiki";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen }: Props) {
|
export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen, requestedActiveId }: Props) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const activeWiki = useMemo(() => wikis.find((w) => w.id === activeId) || null, [activeId, wikis]);
|
const activeWiki = useMemo(() => wikis.find((w) => w.id === activeId) || null, [activeId, wikis]);
|
||||||
|
|
||||||
const [wikiTitle, setWikiTitle] = useState("");
|
const [wikiTitle, setWikiTitle] = useState("");
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [wikiDocHtml, setWikiDocHtml] = useState("");
|
||||||
const [searchResults, setSearchResults] = useState<Wiki[]>([]);
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [createTitle, setCreateTitle] = useState("");
|
||||||
const searchRequestRef = useState(() => ({ id: 0 }))[0];
|
|
||||||
|
|
||||||
const editor = useEditor({
|
|
||||||
extensions: [
|
|
||||||
StarterKit.configure({
|
|
||||||
heading: { levels: [1, 2, 3] },
|
|
||||||
}),
|
|
||||||
TiptapLink.configure({
|
|
||||||
openOnClick: false,
|
|
||||||
autolink: true,
|
|
||||||
linkOnPaste: true,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
content: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "" }] }] },
|
|
||||||
editorProps: {
|
|
||||||
attributes: {
|
|
||||||
class: "tiptap-editor focus:outline-none min-h-[320px] px-4 py-3",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoOpen) return;
|
if (!autoOpen) return;
|
||||||
@@ -62,17 +48,20 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen
|
|||||||
setOpen(true);
|
setOpen(true);
|
||||||
}, [autoOpen]);
|
}, [autoOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!requestedActiveId) return;
|
||||||
|
if (wikis.some((w) => w.id === requestedActiveId)) {
|
||||||
|
setActiveId(requestedActiveId);
|
||||||
|
}
|
||||||
|
}, [requestedActiveId, wikis]);
|
||||||
|
|
||||||
// keep editor content in sync when switching wiki
|
// keep editor content in sync when switching wiki
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return;
|
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|
||||||
const doc = (activeWiki?.doc || null) as JSONContent | null;
|
|
||||||
editor.commands.setContent(
|
|
||||||
(doc && typeof doc === "object" ? doc : { type: "doc", content: [{ type: "paragraph" }] }) as any
|
|
||||||
);
|
|
||||||
setWikiTitle(activeWiki?.title || "");
|
setWikiTitle(activeWiki?.title || "");
|
||||||
}, [activeWiki?.doc, activeWiki?.title, editor, open]);
|
setWikiDocHtml(normalizeWikiDocForQuill(activeWiki?.doc || null));
|
||||||
|
}, [activeWiki?.doc, activeWiki?.title, open]);
|
||||||
|
|
||||||
const ensureActive = () => {
|
const ensureActive = () => {
|
||||||
if (activeId && wikis.some((w) => w.id === activeId)) return;
|
if (activeId && wikis.some((w) => w.id === activeId)) return;
|
||||||
@@ -84,60 +73,6 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [wikis.length]);
|
}, [wikis.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const keyword = searchQuery.trim();
|
|
||||||
if (!keyword.length) {
|
|
||||||
setSearchResults([]);
|
|
||||||
setIsSearching(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let disposed = false;
|
|
||||||
const requestId = ++searchRequestRef.id;
|
|
||||||
const t = window.setTimeout(async () => {
|
|
||||||
setIsSearching(true);
|
|
||||||
try {
|
|
||||||
const rows = await searchWikisByTitle(keyword, { limit: 12 });
|
|
||||||
if (disposed || requestId !== searchRequestRef.id) return;
|
|
||||||
setSearchResults(rows);
|
|
||||||
} catch (err) {
|
|
||||||
if (disposed || requestId !== searchRequestRef.id) return;
|
|
||||||
console.error("Search wikis failed", err);
|
|
||||||
setSearchResults([]);
|
|
||||||
} finally {
|
|
||||||
if (disposed || requestId !== searchRequestRef.id) return;
|
|
||||||
setIsSearching(false);
|
|
||||||
}
|
|
||||||
}, 250);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
disposed = true;
|
|
||||||
window.clearTimeout(t);
|
|
||||||
};
|
|
||||||
}, [searchQuery, searchRequestRef]);
|
|
||||||
|
|
||||||
const addWikiRef = (wiki: Wiki) => {
|
|
||||||
const id = String(wiki.id || "").trim();
|
|
||||||
if (!id) return;
|
|
||||||
if (wikis.some((w) => w.id === id)) {
|
|
||||||
setActiveId(id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const title = (wiki.title || "").trim() || "Untitled wiki";
|
|
||||||
setWikis((prev) => [
|
|
||||||
{
|
|
||||||
id,
|
|
||||||
source: "ref",
|
|
||||||
operation: "reference",
|
|
||||||
title,
|
|
||||||
doc: null,
|
|
||||||
updated_at: wiki.updated_at,
|
|
||||||
},
|
|
||||||
...prev,
|
|
||||||
]);
|
|
||||||
setActiveId(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEditor = () => {
|
const openEditor = () => {
|
||||||
if (!wikis.length) {
|
if (!wikis.length) {
|
||||||
const id = newId();
|
const id = newId();
|
||||||
@@ -146,7 +81,7 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen
|
|||||||
source: "inline",
|
source: "inline",
|
||||||
operation: "create",
|
operation: "create",
|
||||||
title: "Untitled wiki",
|
title: "Untitled wiki",
|
||||||
doc: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "" }] }] },
|
doc: "",
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
setWikis((prev) => [seed, ...prev]);
|
setWikis((prev) => [seed, ...prev]);
|
||||||
@@ -155,17 +90,18 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen
|
|||||||
setOpen(true);
|
setOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createWiki = () => {
|
const createWikiAndOpen = (title?: string) => {
|
||||||
const id = newId();
|
const id = newId();
|
||||||
const next: WikiSnapshot = {
|
const seedTitle = clampTitle(title || "Untitled wiki");
|
||||||
|
const seed: WikiSnapshot = {
|
||||||
id,
|
id,
|
||||||
source: "inline",
|
source: "inline",
|
||||||
operation: "create",
|
operation: "create",
|
||||||
title: "Untitled wiki",
|
title: seedTitle,
|
||||||
doc: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "" }] }] },
|
doc: "",
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
setWikis((prev) => [next, ...prev]);
|
setWikis((prev) => [seed, ...prev]);
|
||||||
setActiveId(id);
|
setActiveId(id);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
};
|
};
|
||||||
@@ -176,8 +112,8 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen
|
|||||||
};
|
};
|
||||||
|
|
||||||
const saveWiki = () => {
|
const saveWiki = () => {
|
||||||
if (!editor || !activeId) return;
|
if (!activeId) return;
|
||||||
const payload = editor.getJSON();
|
const payload = wikiDocHtml;
|
||||||
const nextTitle = clampTitle(wikiTitle);
|
const nextTitle = clampTitle(wikiTitle);
|
||||||
setWikis((prev) =>
|
setWikis((prev) =>
|
||||||
prev.map((w) =>
|
prev.map((w) =>
|
||||||
@@ -196,19 +132,6 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setLink = () => {
|
|
||||||
if (!editor) return;
|
|
||||||
const prev = editor.getAttributes("link")?.href as string | undefined;
|
|
||||||
const href = window.prompt("Link URL", prev || "https://");
|
|
||||||
if (href == null) return;
|
|
||||||
const next = href.trim();
|
|
||||||
if (!next.length) {
|
|
||||||
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
editor.chain().focus().extendMarkRange("link").setLink({ href: next }).run();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -218,192 +141,13 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen
|
|||||||
border: "1px solid #1f2937",
|
border: "1px solid #1f2937",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<style jsx global>{`
|
|
||||||
.tiptap-editor p {
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
line-height: 1.65;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
.tiptap-editor h1 {
|
|
||||||
margin: 1rem 0 0.5rem;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 800;
|
|
||||||
line-height: 1.25;
|
|
||||||
}
|
|
||||||
.tiptap-editor h2 {
|
|
||||||
margin: 0.9rem 0 0.4rem;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
.tiptap-editor h3 {
|
|
||||||
margin: 0.8rem 0 0.35rem;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.35;
|
|
||||||
}
|
|
||||||
.tiptap-editor ul,
|
|
||||||
.tiptap-editor ol {
|
|
||||||
margin: 0.6rem 0;
|
|
||||||
padding-left: 1.25rem;
|
|
||||||
}
|
|
||||||
.tiptap-editor li {
|
|
||||||
margin: 0.2rem 0;
|
|
||||||
}
|
|
||||||
.tiptap-editor blockquote {
|
|
||||||
margin: 0.75rem 0;
|
|
||||||
padding-left: 0.75rem;
|
|
||||||
border-left: 4px solid rgba(148, 163, 184, 0.55);
|
|
||||||
color: rgba(100, 116, 139, 1);
|
|
||||||
}
|
|
||||||
.dark .tiptap-editor blockquote {
|
|
||||||
border-left-color: rgba(71, 85, 105, 1);
|
|
||||||
color: rgba(148, 163, 184, 1);
|
|
||||||
}
|
|
||||||
.tiptap-editor code {
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
|
||||||
"Liberation Mono", "Courier New", monospace;
|
|
||||||
font-size: 0.85em;
|
|
||||||
padding: 0.1rem 0.25rem;
|
|
||||||
border-radius: 0.35rem;
|
|
||||||
background: rgba(148, 163, 184, 0.15);
|
|
||||||
}
|
|
||||||
.tiptap-editor pre {
|
|
||||||
margin: 0.8rem 0;
|
|
||||||
padding: 0.9rem 1rem;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
border: 1px solid rgba(226, 232, 240, 1);
|
|
||||||
background: rgba(248, 250, 252, 1);
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
.dark .tiptap-editor pre {
|
|
||||||
border-color: rgba(30, 41, 59, 1);
|
|
||||||
background: rgba(13, 17, 23, 1);
|
|
||||||
}
|
|
||||||
.tiptap-editor pre code {
|
|
||||||
background: transparent;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.tiptap-editor a {
|
|
||||||
text-decoration: underline;
|
|
||||||
text-underline-offset: 2px;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
|
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
|
||||||
<div style={{ fontWeight: 700, fontSize: "14px" }}>Wiki</div>
|
<div style={{ fontWeight: 700, fontSize: "14px" }}>Wiki</div>
|
||||||
<Badge size="sm" variant="light" color="info">
|
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{wikis.length}</div>
|
||||||
{wikis.length}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: "8px", marginTop: "10px" }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={openEditor}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "6px",
|
|
||||||
padding: "8px",
|
|
||||||
cursor: "pointer",
|
|
||||||
background: "#2563eb",
|
|
||||||
color: "white",
|
|
||||||
fontWeight: 700,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Open wiki editor
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={createWiki}
|
|
||||||
title="New wiki"
|
|
||||||
style={{
|
|
||||||
width: "42px",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "6px",
|
|
||||||
padding: "8px",
|
|
||||||
cursor: "pointer",
|
|
||||||
background: "#1f2937",
|
|
||||||
color: "white",
|
|
||||||
fontWeight: 900,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginTop: "10px" }}>
|
|
||||||
<div style={{ fontSize: "12px", color: "#94a3b8", marginBottom: "6px" }}>Add existing wiki</div>
|
|
||||||
<input
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
placeholder="Search by title…"
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
border: "1px solid #1f2937",
|
|
||||||
background: "#0b1220",
|
|
||||||
color: "#e5e7eb",
|
|
||||||
borderRadius: "6px",
|
|
||||||
padding: "8px 10px",
|
|
||||||
fontSize: "12px",
|
|
||||||
outline: "none",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{isSearching ? (
|
|
||||||
<div style={{ marginTop: "6px", fontSize: "12px", color: "#94a3b8" }}>Searching…</div>
|
|
||||||
) : null}
|
|
||||||
{!isSearching && searchQuery.trim().length > 0 ? (
|
|
||||||
<div style={{ marginTop: "6px", display: "grid", gap: "6px" }}>
|
|
||||||
{searchResults.slice(0, 8).map((w) => (
|
|
||||||
<div
|
|
||||||
key={w.id}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "8px",
|
|
||||||
padding: "8px",
|
|
||||||
borderRadius: "6px",
|
|
||||||
border: "1px solid #1f2937",
|
|
||||||
background: "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div style={{ color: "#e5e7eb", fontSize: "12px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
|
||||||
{(w.title || "").trim() || "Untitled wiki"}
|
|
||||||
</div>
|
|
||||||
<div style={{ color: "#94a3b8", fontSize: "11px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
|
||||||
{w.id}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => addWikiRef(w)}
|
|
||||||
style={{
|
|
||||||
border: "none",
|
|
||||||
background: "#111827",
|
|
||||||
color: "#93c5fd",
|
|
||||||
cursor: "pointer",
|
|
||||||
borderRadius: "6px",
|
|
||||||
padding: "6px 8px",
|
|
||||||
fontSize: "12px",
|
|
||||||
fontWeight: 700,
|
|
||||||
flex: "0 0 auto",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{!searchResults.length ? (
|
|
||||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>No results.</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{wikis.length ? (
|
{wikis.length ? (
|
||||||
<div style={{ marginTop: "10px", display: "grid", gap: "8px" }}>
|
<div style={{ marginTop: "10px", display: "grid", gap: "6px" }}>
|
||||||
{wikis.slice(0, 8).map((w) => (
|
{wikis.slice(0, 8).map((w) => (
|
||||||
<div
|
<div
|
||||||
key={w.id}
|
key={w.id}
|
||||||
@@ -414,7 +158,7 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen
|
|||||||
padding: "8px",
|
padding: "8px",
|
||||||
borderRadius: "6px",
|
borderRadius: "6px",
|
||||||
border: "1px solid #1f2937",
|
border: "1px solid #1f2937",
|
||||||
background: w.id === activeId ? "#111827" : "transparent",
|
background: "transparent",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -467,6 +211,83 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "10px",
|
||||||
|
display: "grid",
|
||||||
|
gap: "8px",
|
||||||
|
border: "1px solid #1e3a8a",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "8px",
|
||||||
|
background: "#0f172a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
||||||
|
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
|
||||||
|
Tạo wiki mới
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsCreateOpen((v) => !v)}
|
||||||
|
title={isCreateOpen ? "Dong" : "Mo"}
|
||||||
|
aria-label={isCreateOpen ? "Dong tao wiki" : "Mo tao wiki"}
|
||||||
|
style={{
|
||||||
|
width: 26,
|
||||||
|
height: 26,
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "1px solid #334155",
|
||||||
|
background: "#0b1220",
|
||||||
|
color: "#e2e8f0",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isCreateOpen ? <CloseIcon /> : <PlusIcon />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isCreateOpen ? (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
value={createTitle}
|
||||||
|
onChange={(e) => setCreateTitle(e.target.value)}
|
||||||
|
placeholder="Tieu de wiki"
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #334155",
|
||||||
|
background: "#111827",
|
||||||
|
color: "#f8fafc",
|
||||||
|
padding: "6px 8px",
|
||||||
|
fontSize: "13px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
createWikiAndOpen(createTitle);
|
||||||
|
setCreateTitle("");
|
||||||
|
setIsCreateOpen(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "6px",
|
||||||
|
padding: "7px 8px",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: "#2563eb",
|
||||||
|
color: "#ffffff",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Tạo wiki mới
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={open}
|
isOpen={open}
|
||||||
onClose={() => setOpen(false)}
|
onClose={() => setOpen(false)}
|
||||||
@@ -484,7 +305,7 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen
|
|||||||
<Button size="sm" variant="outline" onClick={() => setOpen(false)}>
|
<Button size="sm" variant="outline" onClick={() => setOpen(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" className="bg-brand-500 hover:bg-brand-600 text-white" onClick={saveWiki} disabled={!editor || !activeId}>
|
<Button size="sm" className="bg-brand-500 hover:bg-brand-600 text-white" onClick={saveWiki} disabled={!activeId}>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -510,7 +331,7 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen
|
|||||||
<div className="text-[11px] text-gray-500 dark:text-gray-400 truncate">{w.id}</div>
|
<div className="text-[11px] text-gray-500 dark:text-gray-400 truncate">{w.id}</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<Button size="sm" variant="outline" onClick={createWiki}>
|
<Button size="sm" variant="outline" onClick={openEditor}>
|
||||||
+ New wiki
|
+ New wiki
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -529,41 +350,16 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-[#0d1117] overflow-hidden">
|
||||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleBold().run()} disabled={!editor}>
|
<ReactQuillEditor
|
||||||
B
|
theme="snow"
|
||||||
</Button>
|
value={wikiDocHtml}
|
||||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleItalic().run()} disabled={!editor}>
|
onChange={(content: string) => setWikiDocHtml(content)}
|
||||||
I
|
modules={QUILL_MODULES}
|
||||||
</Button>
|
className="min-h-[320px]"
|
||||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleHeading({ level: 1 }).run()} disabled={!editor}>
|
placeholder="Nhap noi dung wiki..."
|
||||||
H1
|
readOnly={!activeId}
|
||||||
</Button>
|
/>
|
||||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()} disabled={!editor}>
|
|
||||||
H2
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()} disabled={!editor}>
|
|
||||||
H3
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleBulletList().run()} disabled={!editor}>
|
|
||||||
Bullets
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleOrderedList().run()} disabled={!editor}>
|
|
||||||
Numbers
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleBlockquote().run()} disabled={!editor}>
|
|
||||||
Quote
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleCodeBlock().run()} disabled={!editor}>
|
|
||||||
Code
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" onClick={setLink} disabled={!editor}>
|
|
||||||
Link
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-[#0d1117]">
|
|
||||||
{editor ? <EditorContent editor={editor} /> : <div className="p-4 text-sm text-gray-500">Loading editor...</div>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -577,3 +373,80 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PlusIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M12 5v14M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CloseIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M6 6l12 12M18 6L6 18" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUILL_MODULES = {
|
||||||
|
toolbar: [
|
||||||
|
[{ header: [1, 2, 3, false] }],
|
||||||
|
["bold", "italic", "underline", "strike"],
|
||||||
|
[{ list: "ordered" }, { list: "bullet" }],
|
||||||
|
["blockquote", "code-block"],
|
||||||
|
["link", "image"],
|
||||||
|
["clean"],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeWikiDocForQuill(doc: string | null): string {
|
||||||
|
const raw = (doc || "").trim();
|
||||||
|
if (!raw.length) return "";
|
||||||
|
|
||||||
|
// New format (Quill): HTML string.
|
||||||
|
if (raw[0] === "<") return raw;
|
||||||
|
|
||||||
|
// Legacy format (Tiptap): JSON string.
|
||||||
|
if (raw[0] === "{") {
|
||||||
|
try {
|
||||||
|
const json: unknown = JSON.parse(raw);
|
||||||
|
const text = tiptapJsonToPlainText(json).trim();
|
||||||
|
if (!text.length) return "";
|
||||||
|
return `<p>${escapeHtml(text).replace(/\n/g, "<br/>")}</p>`;
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown plaintext: treat as plain text.
|
||||||
|
return `<p>${escapeHtml(raw).replace(/\n/g, "<br/>")}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tiptapJsonToPlainText(node: unknown): string {
|
||||||
|
if (node == null) return "";
|
||||||
|
if (typeof node === "string") return node;
|
||||||
|
if (Array.isArray(node)) return node.map(tiptapJsonToPlainText).join("");
|
||||||
|
|
||||||
|
if (isRecord(node)) {
|
||||||
|
if (node.type === "text" && typeof node.text === "string") return node.text;
|
||||||
|
if (node.type === "hardBreak") return "\n";
|
||||||
|
if ("content" in node) return tiptapJsonToPlainText(node.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(input: string): string {
|
||||||
|
return input
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll("\"", """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,38 +1,8 @@
|
|||||||
import type { Entity } from "@/uhm/types/entities";
|
import type { Entity } from "@/uhm/types/entities";
|
||||||
import type { Feature, FeatureProperties } from "@/uhm/types/geo";
|
import type { Feature, FeatureProperties } from "@/uhm/types/geo";
|
||||||
import type { PendingEntityCreate } from "@/uhm/lib/editor/session/sessionTypes";
|
|
||||||
import { normalizeFeatureEntityIds } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
import { normalizeFeatureEntityIds } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
||||||
import { newId } from "@/uhm/lib/id";
|
import { newId } from "@/uhm/lib/id";
|
||||||
|
|
||||||
export function mergeEntitiesWithPending(
|
|
||||||
persistedEntities: Entity[],
|
|
||||||
pendingCreates: PendingEntityCreate[]
|
|
||||||
): Entity[] {
|
|
||||||
if (!pendingCreates.length) {
|
|
||||||
return persistedEntities;
|
|
||||||
}
|
|
||||||
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const pendingAsEntities: Entity[] = [];
|
|
||||||
for (const pending of pendingCreates) {
|
|
||||||
if (seen.has(pending.id)) continue;
|
|
||||||
seen.add(pending.id);
|
|
||||||
pendingAsEntities.push({
|
|
||||||
id: pending.id,
|
|
||||||
name: pending.name,
|
|
||||||
slug: pending.slug,
|
|
||||||
type_id: pending.type_id,
|
|
||||||
status: pending.status,
|
|
||||||
geometry_count: 0,
|
|
||||||
created_at: undefined,
|
|
||||||
updated_at: undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextPersisted = persistedEntities.filter((entity) => !seen.has(entity.id));
|
|
||||||
return [...pendingAsEntities, ...nextPersisted];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mergeEntitySearchResults(
|
export function mergeEntitySearchResults(
|
||||||
remoteRows: Entity[],
|
remoteRows: Entity[],
|
||||||
localRows: Entity[]
|
localRows: Entity[]
|
||||||
@@ -70,7 +40,7 @@ export function buildClientEntityId(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildFeatureEntityPatch(
|
export function buildFeatureEntityPatch(
|
||||||
feature: Feature,
|
_feature: Feature,
|
||||||
entityIds: string[],
|
entityIds: string[],
|
||||||
entities: Entity[]
|
entities: Entity[]
|
||||||
): Partial<FeatureProperties> {
|
): Partial<FeatureProperties> {
|
||||||
@@ -78,29 +48,14 @@ export function buildFeatureEntityPatch(
|
|||||||
const primaryEntity = primaryEntityId
|
const primaryEntity = primaryEntityId
|
||||||
? entities.find((entity) => entity.id === primaryEntityId) || null
|
? entities.find((entity) => entity.id === primaryEntityId) || null
|
||||||
: null;
|
: null;
|
||||||
const nextGeometryType = resolveGeometryTypeFromEntityIds(entityIds, entities) ||
|
|
||||||
feature.properties.type ||
|
|
||||||
null;
|
|
||||||
const entityNames = entityIds
|
const entityNames = entityIds
|
||||||
.map((id) => entities.find((entity) => entity.id === id)?.name || "")
|
.map((id) => entities.find((entity) => entity.id === id)?.name || "")
|
||||||
.filter((name) => name.length > 0);
|
.filter((name) => name.length > 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: nextGeometryType,
|
|
||||||
entity_id: primaryEntityId,
|
entity_id: primaryEntityId,
|
||||||
entity_ids: entityIds,
|
entity_ids: entityIds,
|
||||||
entity_name: primaryEntity?.name || null,
|
entity_name: primaryEntity?.name || null,
|
||||||
entity_names: entityNames,
|
entity_names: entityNames,
|
||||||
entity_type_id: primaryEntity?.type_id || null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveGeometryTypeFromEntityIds(
|
|
||||||
entityIds: string[],
|
|
||||||
entities: Entity[]
|
|
||||||
): string | null {
|
|
||||||
const primaryEntityId = entityIds[0] || null;
|
|
||||||
if (!primaryEntityId) return null;
|
|
||||||
const primaryEntity = entities.find((entity) => entity.id === primaryEntityId) || null;
|
|
||||||
return primaryEntity?.type_id || null;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type GeometryMetadataPatch = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function buildGeometryMetadataPatch(form: GeometryMetaFormState): GeometryMetadataPatch {
|
export function buildGeometryMetadataPatch(form: GeometryMetaFormState): GeometryMetadataPatch {
|
||||||
|
const typeKey = form.type_key.trim();
|
||||||
const timeStart = parseOptionalYearInput(form.time_start, "time_start");
|
const timeStart = parseOptionalYearInput(form.time_start, "time_start");
|
||||||
const timeEnd = parseOptionalYearInput(form.time_end, "time_end");
|
const timeEnd = parseOptionalYearInput(form.time_end, "time_end");
|
||||||
if (timeStart !== null && timeEnd !== null && timeStart > timeEnd) {
|
if (timeStart !== null && timeEnd !== null && timeStart > timeEnd) {
|
||||||
@@ -20,11 +21,13 @@ export function buildGeometryMetadataPatch(form: GeometryMetaFormState): Geometr
|
|||||||
const bindingIds = parseBindingInput(form.binding);
|
const bindingIds = parseBindingInput(form.binding);
|
||||||
return {
|
return {
|
||||||
patch: {
|
patch: {
|
||||||
|
type: typeKey.length ? typeKey : undefined,
|
||||||
time_start: timeStart,
|
time_start: timeStart,
|
||||||
time_end: timeEnd,
|
time_end: timeEnd,
|
||||||
binding: bindingIds,
|
binding: bindingIds,
|
||||||
},
|
},
|
||||||
formState: {
|
formState: {
|
||||||
|
type_key: typeKey,
|
||||||
time_start: timeStart != null ? String(timeStart) : "",
|
time_start: timeStart != null ? String(timeStart) : "",
|
||||||
time_end: timeEnd != null ? String(timeEnd) : "",
|
time_end: timeEnd != null ? String(timeEnd) : "",
|
||||||
binding: bindingIds.join(", "),
|
binding: bindingIds.join(", "),
|
||||||
@@ -47,4 +50,3 @@ function parseOptionalYearInput(raw: string, fieldName: string): number | null {
|
|||||||
}
|
}
|
||||||
return Math.trunc(parsed);
|
return Math.trunc(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,10 @@ import {
|
|||||||
fetchSectionCommits,
|
fetchSectionCommits,
|
||||||
fetchSections,
|
fetchSections,
|
||||||
openSectionEditor,
|
openSectionEditor,
|
||||||
restoreSectionCommit,
|
|
||||||
submitSection,
|
submitSection,
|
||||||
} from "@/uhm/api/sections";
|
} from "@/uhm/api/sections";
|
||||||
import { buildEditorSnapshot, normalizeEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
import { buildEditorSnapshot, normalizeEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
||||||
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
|
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
|
||||||
import type { CreatedEntitySummary, PendingEntityCreate } from "@/uhm/lib/editor/session/sessionTypes";
|
|
||||||
import type { Feature, FeatureCollection, FeatureId } from "@/uhm/types/geo";
|
import type { Feature, FeatureCollection, FeatureId } from "@/uhm/types/geo";
|
||||||
import type { EditorSnapshot, Section, SectionCommit, SectionState, EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
import type { EditorSnapshot, Section, SectionCommit, SectionState, EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
||||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||||
@@ -34,24 +32,21 @@ type Options = {
|
|||||||
selectedSectionId: string;
|
selectedSectionId: string;
|
||||||
newSectionTitle: string;
|
newSectionTitle: string;
|
||||||
pendingSaveCount: number;
|
pendingSaveCount: number;
|
||||||
pendingEntityCreates: PendingEntityCreate[];
|
snapshotEntities: EntitySnapshot[];
|
||||||
projectEntityRefs: EntitySnapshot[];
|
snapshotWikis: WikiSnapshot[];
|
||||||
wikis: WikiSnapshot[];
|
snapshotEntityWikiLinks: EntityWikiLinkSnapshot[];
|
||||||
entityWikiLinks: EntityWikiLinkSnapshot[];
|
baselineSnapshot: EditorSnapshot | null;
|
||||||
lastSectionSnapshot: EditorSnapshot | null;
|
|
||||||
commitTitle: string;
|
commitTitle: string;
|
||||||
commitNote: string;
|
commitNote: string;
|
||||||
setActiveSection: Dispatch<SetStateAction<Section | null>>;
|
setActiveSection: Dispatch<SetStateAction<Section | null>>;
|
||||||
setSelectedSectionId: Dispatch<SetStateAction<string>>;
|
setSelectedSectionId: Dispatch<SetStateAction<string>>;
|
||||||
setSectionState: Dispatch<SetStateAction<SectionState | null>>;
|
setSectionState: Dispatch<SetStateAction<SectionState | null>>;
|
||||||
setLastSectionSnapshot: Dispatch<SetStateAction<EditorSnapshot | null>>;
|
setBaselineSnapshot: Dispatch<SetStateAction<EditorSnapshot | null>>;
|
||||||
setInitialData: Dispatch<SetStateAction<FeatureCollection>>;
|
setInitialData: Dispatch<SetStateAction<FeatureCollection>>;
|
||||||
setSectionCommits: Dispatch<SetStateAction<SectionCommit[]>>;
|
setSectionCommits: Dispatch<SetStateAction<SectionCommit[]>>;
|
||||||
setPendingEntityCreates: Dispatch<SetStateAction<PendingEntityCreate[]>>;
|
setSnapshotEntities: Dispatch<SetStateAction<EntitySnapshot[]>>;
|
||||||
setProjectEntityRefs: Dispatch<SetStateAction<EntitySnapshot[]>>;
|
setSnapshotWikis: Dispatch<SetStateAction<WikiSnapshot[]>>;
|
||||||
setCreatedEntities: Dispatch<SetStateAction<CreatedEntitySummary[]>>;
|
setSnapshotEntityWikiLinks: Dispatch<SetStateAction<EntityWikiLinkSnapshot[]>>;
|
||||||
setWikis: Dispatch<SetStateAction<WikiSnapshot[]>>;
|
|
||||||
setEntityWikiLinks: Dispatch<SetStateAction<EntityWikiLinkSnapshot[]>>;
|
|
||||||
setSelectedFeatureId: Dispatch<SetStateAction<FeatureId | null>>;
|
setSelectedFeatureId: Dispatch<SetStateAction<FeatureId | null>>;
|
||||||
setEntityFormStatus: Dispatch<SetStateAction<string | null>>;
|
setEntityFormStatus: Dispatch<SetStateAction<string | null>>;
|
||||||
setEntityStatus: Dispatch<SetStateAction<string | null>>;
|
setEntityStatus: Dispatch<SetStateAction<string | null>>;
|
||||||
@@ -68,33 +63,21 @@ export function useSectionCommands(options: Options) {
|
|||||||
const openSectionForEditing = useCallback(async (sectionId: string) => {
|
const openSectionForEditing = useCallback(async (sectionId: string) => {
|
||||||
const editorPayload = await openSectionEditor(sectionId);
|
const editorPayload = await openSectionEditor(sectionId);
|
||||||
const snapshot = normalizeEditorSnapshot(editorPayload.snapshot);
|
const snapshot = normalizeEditorSnapshot(editorPayload.snapshot);
|
||||||
|
// When starting a fresh editor session from a commit snapshot, treat all rows as baseline state:
|
||||||
|
// operations should not carry over as deltas into the next commit.
|
||||||
|
const sessionSnapshot = snapshot ? toEditorSessionSnapshot(snapshot) : null;
|
||||||
const commits = await fetchSectionCommits(sectionId);
|
const commits = await fetchSectionCommits(sectionId);
|
||||||
const nextInitialData = snapshot?.editor_feature_collection || options.emptyFeatureCollection;
|
const nextInitialData = sessionSnapshot?.editor_feature_collection || options.emptyFeatureCollection;
|
||||||
|
|
||||||
options.setActiveSection(editorPayload.section);
|
options.setActiveSection(editorPayload.section);
|
||||||
options.setSelectedSectionId(editorPayload.section.id);
|
options.setSelectedSectionId(editorPayload.section.id);
|
||||||
options.setSectionState(editorPayload.state);
|
options.setSectionState(editorPayload.state);
|
||||||
options.setLastSectionSnapshot(snapshot);
|
options.setBaselineSnapshot(sessionSnapshot);
|
||||||
options.setInitialData(nextInitialData);
|
options.setInitialData(nextInitialData);
|
||||||
options.setSectionCommits(commits);
|
options.setSectionCommits(commits);
|
||||||
options.setPendingEntityCreates([]);
|
options.setSnapshotEntities(sessionSnapshot?.entities || []);
|
||||||
options.setCreatedEntities([]);
|
options.setSnapshotWikis(sessionSnapshot?.wikis || []);
|
||||||
const geoEntityIds = new Set((snapshot?.geometry_entity || []).map((row) => row.entity_id));
|
options.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []);
|
||||||
const linkedByWikiIds = new Set(
|
|
||||||
(snapshot?.entity_wiki || [])
|
|
||||||
.filter((l) => l?.operation !== "delete")
|
|
||||||
.map((l) => l.entity_id)
|
|
||||||
);
|
|
||||||
options.setProjectEntityRefs((snapshot?.entities || []).filter((e) =>
|
|
||||||
e?.source === "ref"
|
|
||||||
&& !geoEntityIds.has(e.id)
|
|
||||||
&& !linkedByWikiIds.has(e.id)
|
|
||||||
&& e.operation !== "create"
|
|
||||||
&& e.operation !== "update"
|
|
||||||
&& e.operation !== "delete"
|
|
||||||
));
|
|
||||||
options.setWikis(snapshot?.wikis || []);
|
|
||||||
options.setEntityWikiLinks(snapshot?.entity_wiki || []);
|
|
||||||
options.setSelectedFeatureId(null);
|
options.setSelectedFeatureId(null);
|
||||||
options.setEntityFormStatus(null);
|
options.setEntityFormStatus(null);
|
||||||
}, [options]);
|
}, [options]);
|
||||||
@@ -104,6 +87,10 @@ export function useSectionCommands(options: Options) {
|
|||||||
options.setEntityStatus("Chưa mở được section editor.");
|
options.setEntityStatus("Chưa mở được section editor.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (options.pendingSaveCount <= 0) {
|
||||||
|
options.setEntityStatus("Không có thay đổi để Commit.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const geometryChanges = options.editor.buildPayload();
|
const geometryChanges = options.editor.buildPayload();
|
||||||
options.setIsSaving(true);
|
options.setIsSaving(true);
|
||||||
@@ -113,26 +100,46 @@ export function useSectionCommands(options: Options) {
|
|||||||
section: options.activeSection,
|
section: options.activeSection,
|
||||||
draft: options.editor.draft,
|
draft: options.editor.draft,
|
||||||
changes: geometryChanges,
|
changes: geometryChanges,
|
||||||
pendingEntities: options.pendingEntityCreates,
|
snapshotEntities: options.snapshotEntities,
|
||||||
projectEntityRefs: options.projectEntityRefs,
|
snapshotWikis: options.snapshotWikis,
|
||||||
wikis: options.wikis,
|
snapshotEntityWikiLinks: options.snapshotEntityWikiLinks,
|
||||||
entityWikiLinks: options.entityWikiLinks,
|
previousSnapshot: options.baselineSnapshot,
|
||||||
previousSnapshot: options.lastSectionSnapshot,
|
|
||||||
hasPersistedFeature: options.editor.hasPersistedFeature,
|
hasPersistedFeature: options.editor.hasPersistedFeature,
|
||||||
});
|
});
|
||||||
|
const editSummary = options.commitTitle.trim()
|
||||||
|
|| options.commitNote.trim()
|
||||||
|
|| `Edit ${new Date().toLocaleString()}`;
|
||||||
|
|
||||||
|
// Guardrail: commit payload can get large and some deployments reject/close connections for big bodies.
|
||||||
|
// When that happens, browsers often surface it as "TypeError: Failed to fetch".
|
||||||
|
try {
|
||||||
|
const payloadText = JSON.stringify({ snapshot_json: snapshot, edit_summary: editSummary });
|
||||||
|
const bytes = typeof Blob !== "undefined" ? new Blob([payloadText]).size : payloadText.length;
|
||||||
|
const limitBytes = 3_500_000; // ~3.5MB (conservative vs common default body limits)
|
||||||
|
if (bytes > limitBytes) {
|
||||||
|
options.setEntityStatus(
|
||||||
|
`Commit payload quá lớn (~${(bytes / (1024 * 1024)).toFixed(2)}MB). ` +
|
||||||
|
`Hãy giảm bớt nội dung snapshot/changes hoặc chạy BE local với body limit lớn hơn.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If stringify fails, let API call throw a more actionable error downstream.
|
||||||
|
}
|
||||||
|
|
||||||
const result = await createSectionCommit(options.activeSection.id, {
|
const result = await createSectionCommit(options.activeSection.id, {
|
||||||
snapshot,
|
snapshot,
|
||||||
edit_summary: options.commitTitle.trim()
|
edit_summary: editSummary,
|
||||||
|| options.commitNote.trim()
|
|
||||||
|| `Edit ${new Date().toLocaleString()}`,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sessionSnapshot = toEditorSessionSnapshot(snapshot);
|
||||||
options.setSectionState(result.state);
|
options.setSectionState(result.state);
|
||||||
options.setLastSectionSnapshot(snapshot);
|
options.setBaselineSnapshot(sessionSnapshot);
|
||||||
|
options.setSnapshotEntities(sessionSnapshot.entities || []);
|
||||||
|
options.setSnapshotWikis(sessionSnapshot.wikis || []);
|
||||||
|
options.setSnapshotEntityWikiLinks(sessionSnapshot.entity_wiki || []);
|
||||||
options.setInitialData(options.editor.draft);
|
options.setInitialData(options.editor.draft);
|
||||||
options.editor.clearChanges();
|
options.editor.clearChanges();
|
||||||
options.setPendingEntityCreates([]);
|
|
||||||
options.setCreatedEntities([]);
|
|
||||||
options.setCommitTitle("");
|
options.setCommitTitle("");
|
||||||
options.setCommitNote("");
|
options.setCommitNote("");
|
||||||
options.setSectionCommits(await fetchSectionCommits(options.activeSection.id));
|
options.setSectionCommits(await fetchSectionCommits(options.activeSection.id));
|
||||||
@@ -250,19 +257,30 @@ export function useSectionCommands(options: Options) {
|
|||||||
options.setIsSaving(true);
|
options.setIsSaving(true);
|
||||||
options.setEntityStatus(null);
|
options.setEntityStatus(null);
|
||||||
try {
|
try {
|
||||||
const result = await restoreSectionCommit(options.activeSection.id, {
|
// FE-only restore: load snapshot from selected commit and apply to editor state.
|
||||||
commit_id: commitId,
|
// Do NOT move project's head commit on backend.
|
||||||
});
|
const commits = await fetchSectionCommits(options.activeSection.id);
|
||||||
const editorPayload = await openSectionEditor(options.activeSection.id);
|
const target = commits.find((c: SectionCommit) => c.id === commitId) || null;
|
||||||
const snapshot = normalizeEditorSnapshot(editorPayload.snapshot);
|
if (!target) {
|
||||||
options.setSectionState(result.state);
|
options.setEntityStatus("Không tìm thấy commit để restore.");
|
||||||
options.setLastSectionSnapshot(snapshot);
|
return;
|
||||||
if (snapshot?.editor_feature_collection) {
|
|
||||||
options.setInitialData(snapshot.editor_feature_collection);
|
|
||||||
}
|
}
|
||||||
options.setWikis(snapshot?.wikis || []);
|
|
||||||
options.setSectionCommits(await fetchSectionCommits(options.activeSection.id));
|
const snapshot = normalizeEditorSnapshot(target.snapshot_json);
|
||||||
options.setEntityFormStatus("Đã restore commit.");
|
const sessionSnapshot = snapshot ? toEditorSessionSnapshot(snapshot) : null;
|
||||||
|
const nextInitialData = sessionSnapshot?.editor_feature_collection || options.emptyFeatureCollection;
|
||||||
|
|
||||||
|
options.setBaselineSnapshot(sessionSnapshot);
|
||||||
|
options.setInitialData(nextInitialData);
|
||||||
|
options.setSnapshotEntities(sessionSnapshot?.entities || []);
|
||||||
|
options.setSnapshotWikis(sessionSnapshot?.wikis || []);
|
||||||
|
options.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []);
|
||||||
|
options.setSelectedFeatureId(null);
|
||||||
|
options.setEntityFormStatus(null);
|
||||||
|
|
||||||
|
// Refresh commits list for UI, but keep sectionState/head as-is.
|
||||||
|
options.setSectionCommits(commits);
|
||||||
|
options.setEntityFormStatus("Đã load snapshot từ commit (không đổi head trên BE).");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
options.setEntityStatus(`Restore thất bại: ${err.body}`);
|
options.setEntityStatus(`Restore thất bại: ${err.body}`);
|
||||||
@@ -283,3 +301,63 @@ export function useSectionCommands(options: Options) {
|
|||||||
restoreCommit,
|
restoreCommit,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toEditorSessionSnapshot(snapshot: EditorSnapshot): EditorSnapshot {
|
||||||
|
return {
|
||||||
|
...snapshot,
|
||||||
|
entities: toEditorSessionEntities(snapshot.entities),
|
||||||
|
wikis: toEditorSessionWikis(snapshot.wikis),
|
||||||
|
entity_wiki: toEditorSessionEntityWikiLinks(snapshot.entity_wiki),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toEditorSessionEntities(input: EditorSnapshot["entities"]): EntitySnapshot[] {
|
||||||
|
const rows = Array.isArray(input) ? input : [];
|
||||||
|
return rows
|
||||||
|
.filter((e) => e && (typeof e.id === "string" || typeof e.id === "number"))
|
||||||
|
.map((e) => {
|
||||||
|
const { operation: _op, ...rest } = e;
|
||||||
|
const id = String(e.id);
|
||||||
|
const source: EntitySnapshot["source"] = e.source === "inline" ? "inline" : "ref";
|
||||||
|
return {
|
||||||
|
...(rest as Omit<EntitySnapshot, "id" | "source" | "operation">),
|
||||||
|
id,
|
||||||
|
source,
|
||||||
|
operation: "reference",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toEditorSessionWikis(input: EditorSnapshot["wikis"]): WikiSnapshot[] {
|
||||||
|
const rows = Array.isArray(input) ? input : [];
|
||||||
|
return rows
|
||||||
|
.filter((w) => w && typeof w.id === "string" && w.id.trim().length > 0)
|
||||||
|
.map((w) => {
|
||||||
|
const { operation: _op, ...rest } = w;
|
||||||
|
const source: WikiSnapshot["source"] = w.source === "inline" ? "inline" : "ref";
|
||||||
|
return {
|
||||||
|
...(rest as Omit<WikiSnapshot, "source" | "operation">),
|
||||||
|
source,
|
||||||
|
operation: "reference",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toEditorSessionEntityWikiLinks(input: EditorSnapshot["entity_wiki"]): EntityWikiLinkSnapshot[] {
|
||||||
|
const rows = Array.isArray(input) ? input : [];
|
||||||
|
const deduped = new globalThis.Map<string, EntityWikiLinkSnapshot>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row || typeof row.entity_id !== "string" || typeof row.wiki_id !== "string") continue;
|
||||||
|
if (row.operation === "delete") continue;
|
||||||
|
const entity_id = row.entity_id.trim();
|
||||||
|
const wiki_id = row.wiki_id.trim();
|
||||||
|
if (!entity_id || !wiki_id) continue;
|
||||||
|
const key = `${entity_id}::${wiki_id}`;
|
||||||
|
deduped.set(key, { entity_id, wiki_id, operation: "binding" });
|
||||||
|
}
|
||||||
|
return Array.from(deduped.values()).sort((a, b) => {
|
||||||
|
const e = a.entity_id.localeCompare(b.entity_id);
|
||||||
|
if (e !== 0) return e;
|
||||||
|
return a.wiki_id.localeCompare(b.wiki_id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ export type TimelineRange = {
|
|||||||
|
|
||||||
export type EntityFormState = {
|
export type EntityFormState = {
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
description: string;
|
||||||
type_id: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GeometryMetaFormState = {
|
export type GeometryMetaFormState = {
|
||||||
|
type_key: string;
|
||||||
time_start: string;
|
time_start: string;
|
||||||
time_end: string;
|
time_end: string;
|
||||||
binding: string;
|
binding: string;
|
||||||
@@ -29,16 +29,13 @@ export type GeometryMetaFormState = {
|
|||||||
export type PendingEntityCreate = {
|
export type PendingEntityCreate = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
slug: string | null;
|
description: string | null;
|
||||||
type_id: string;
|
|
||||||
status: number;
|
status: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreatedEntitySummary = {
|
export type CreatedEntitySummary = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type_id?: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GeometryPreset = EntityGeometryPreset;
|
export type GeometryPreset = EntityGeometryPreset;
|
||||||
|
|
||||||
|
|||||||
@@ -2,23 +2,16 @@ import { useState } from "react";
|
|||||||
import type { Entity } from "@/uhm/types/entities";
|
import type { Entity } from "@/uhm/types/entities";
|
||||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||||
import type { FeatureId } from "@/uhm/types/geo";
|
import type { FeatureId } from "@/uhm/types/geo";
|
||||||
import { DEFAULT_ENTITY_TYPE_ID } from "@/uhm/lib/entityTypeOptions";
|
|
||||||
import type {
|
import type {
|
||||||
CreatedEntitySummary,
|
|
||||||
EntityFormState,
|
EntityFormState,
|
||||||
GeometryMetaFormState,
|
GeometryMetaFormState,
|
||||||
PendingEntityCreate,
|
|
||||||
} from "@/uhm/lib/editor/session/sessionTypes";
|
} from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
|
||||||
export function useEntitySessionState() {
|
export function useEntitySessionState() {
|
||||||
// Entities đã persisted từ backend (dùng cho search/binding).
|
// Entity catalog loaded from backend (global list, used for search/lookup).
|
||||||
const [persistedEntities, setPersistedEntities] = useState<Entity[]>([]);
|
const [entityCatalog, setEntityCatalog] = useState<Entity[]>([]);
|
||||||
// Entities được "pin" vào project dưới dạng reference (không cần chọn geometry).
|
// Snapshot entity store for the current editor session (single source of truth for snapshot.entities).
|
||||||
const [projectEntityRefs, setProjectEntityRefs] = useState<EntitySnapshot[]>([]);
|
const [snapshotEntities, setSnapshotEntities] = useState<EntitySnapshot[]>([]);
|
||||||
// Entities tạo mới trong phiên nhưng chưa commit lên backend.
|
|
||||||
const [pendingEntityCreates, setPendingEntityCreates] = useState<PendingEntityCreate[]>([]);
|
|
||||||
// Tóm tắt entities đã tạo (để hiển thị nhanh ở sidebar).
|
|
||||||
const [createdEntities, setCreatedEntities] = useState<CreatedEntitySummary[]>([]);
|
|
||||||
// Thông báo trạng thái/lỗi liên quan entity/session.
|
// Thông báo trạng thái/lỗi liên quan entity/session.
|
||||||
const [entityStatus, setEntityStatus] = useState<string | null>(null);
|
const [entityStatus, setEntityStatus] = useState<string | null>(null);
|
||||||
// Feature đang được chọn để thao tác bind entities/metadata.
|
// Feature đang được chọn để thao tác bind entities/metadata.
|
||||||
@@ -26,13 +19,13 @@ export function useEntitySessionState() {
|
|||||||
// Form tạo entity mới (độc lập).
|
// Form tạo entity mới (độc lập).
|
||||||
const [entityForm, setEntityForm] = useState<EntityFormState>({
|
const [entityForm, setEntityForm] = useState<EntityFormState>({
|
||||||
name: "",
|
name: "",
|
||||||
slug: "",
|
description: "",
|
||||||
type_id: DEFAULT_ENTITY_TYPE_ID,
|
|
||||||
});
|
});
|
||||||
// Danh sách entity IDs đang chọn để bind vào geometry hiện tại.
|
// Danh sách entity IDs đang chọn để bind vào geometry hiện tại.
|
||||||
const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState<string[]>([]);
|
const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState<string[]>([]);
|
||||||
// Form metadata geometry (time range + binding ids).
|
// Form metadata geometry (time range + binding ids).
|
||||||
const [geometryMetaForm, setGeometryMetaForm] = useState<GeometryMetaFormState>({
|
const [geometryMetaForm, setGeometryMetaForm] = useState<GeometryMetaFormState>({
|
||||||
|
type_key: "",
|
||||||
time_start: "",
|
time_start: "",
|
||||||
time_end: "",
|
time_end: "",
|
||||||
binding: "",
|
binding: "",
|
||||||
@@ -51,14 +44,10 @@ export function useEntitySessionState() {
|
|||||||
const [isEntitySearchLoading, setIsEntitySearchLoading] = useState(false);
|
const [isEntitySearchLoading, setIsEntitySearchLoading] = useState(false);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
persistedEntities,
|
entityCatalog,
|
||||||
setPersistedEntities,
|
setEntityCatalog,
|
||||||
projectEntityRefs,
|
snapshotEntities,
|
||||||
setProjectEntityRefs,
|
setSnapshotEntities,
|
||||||
pendingEntityCreates,
|
|
||||||
setPendingEntityCreates,
|
|
||||||
createdEntities,
|
|
||||||
setCreatedEntities,
|
|
||||||
entityStatus,
|
entityStatus,
|
||||||
setEntityStatus,
|
setEntityStatus,
|
||||||
selectedFeatureId,
|
selectedFeatureId,
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ export function useSectionSessionState(options: Options) {
|
|||||||
const [sectionState, setSectionState] = useState<SectionState | null>(null);
|
const [sectionState, setSectionState] = useState<SectionState | null>(null);
|
||||||
// Danh sách commits của section đang mở.
|
// Danh sách commits của section đang mở.
|
||||||
const [sectionCommits, setSectionCommits] = useState<SectionCommit[]>([]);
|
const [sectionCommits, setSectionCommits] = useState<SectionCommit[]>([]);
|
||||||
// Snapshot gần nhất đã load (để build snapshot diff/metadata).
|
// Baseline snapshot currently loaded for this editor session.
|
||||||
const [lastSectionSnapshot, setLastSectionSnapshot] = useState<EditorSnapshot | null>(null);
|
const [baselineSnapshot, setBaselineSnapshot] = useState<EditorSnapshot | null>(null);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isSaving,
|
isSaving,
|
||||||
@@ -79,7 +79,7 @@ export function useSectionSessionState(options: Options) {
|
|||||||
setSectionState,
|
setSectionState,
|
||||||
sectionCommits,
|
sectionCommits,
|
||||||
setSectionCommits,
|
setSectionCommits,
|
||||||
lastSectionSnapshot,
|
baselineSnapshot,
|
||||||
setLastSectionSnapshot,
|
setBaselineSnapshot,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { WikiSnapshot } from "@/uhm/types/wiki";
|
|||||||
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
||||||
|
|
||||||
export function useWikiSessionState() {
|
export function useWikiSessionState() {
|
||||||
const [wikis, setWikis] = useState<WikiSnapshot[]>([]);
|
const [snapshotWikis, setSnapshotWikis] = useState<WikiSnapshot[]>([]);
|
||||||
const [entityWikiLinks, setEntityWikiLinks] = useState<EntityWikiLinkSnapshot[]>([]);
|
const [snapshotEntityWikiLinks, setSnapshotEntityWikiLinks] = useState<EntityWikiLinkSnapshot[]>([]);
|
||||||
return { wikis, setWikis, entityWikiLinks, setEntityWikiLinks };
|
return { snapshotWikis, setSnapshotWikis, snapshotEntityWikiLinks, setSnapshotEntityWikiLinks };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DEFAULT_ENTITY_TYPE_ID } from "@/uhm/lib/entityTypeOptions";
|
import { DEFAULT_ENTITY_TYPE_ID } from "@/uhm/lib/entityTypeOptions";
|
||||||
|
import { typeKeyToGeoTypeCode } from "@/uhm/lib/geoTypeMap";
|
||||||
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
|
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
|
||||||
import type { PendingEntityCreate } from "@/uhm/lib/editor/session/sessionTypes";
|
|
||||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||||
import type { Feature, FeatureCollection, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
|
import type { Feature, FeatureCollection, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
|
||||||
import type { EditorSnapshot, Section } from "@/uhm/types/sections";
|
import type { EditorSnapshot, Section } from "@/uhm/types/sections";
|
||||||
@@ -42,17 +42,21 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
|||||||
.filter(isRecord)
|
.filter(isRecord)
|
||||||
.map((e) => {
|
.map((e) => {
|
||||||
const id = getStringId(e.id);
|
const id = getStringId(e.id);
|
||||||
const op = typeof e.operation === "string" ? e.operation : undefined;
|
const opRaw = typeof e.operation === "string" ? e.operation : undefined;
|
||||||
|
const operation: EntitySnapshot["operation"] =
|
||||||
|
opRaw === "delete" ? "delete" : "reference";
|
||||||
const existingSource = e.source === "inline" || e.source === "ref" ? e.source : undefined;
|
const existingSource = e.source === "inline" || e.source === "ref" ? e.source : undefined;
|
||||||
const refId = getRefId(e.ref);
|
const refId = getRefId(e.ref);
|
||||||
const source: "inline" | "ref" = existingSource || (refId || op === "reference" ? "ref" : "inline");
|
const source: "inline" | "ref" =
|
||||||
|
existingSource || (refId || opRaw === "reference" ? "ref" : "inline");
|
||||||
const rest: UnknownRecord = { ...e };
|
const rest: UnknownRecord = { ...e };
|
||||||
delete rest.ref;
|
delete rest.ref;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...(rest as unknown as Omit<EntitySnapshot, "id" | "source">),
|
...(rest as unknown as Omit<EntitySnapshot, "id" | "source" | "operation">),
|
||||||
id,
|
id,
|
||||||
source,
|
source,
|
||||||
|
operation,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -63,6 +67,9 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
|||||||
.filter(isRecord)
|
.filter(isRecord)
|
||||||
.map((g) => {
|
.map((g) => {
|
||||||
const id = getStringId(g.id);
|
const id = getStringId(g.id);
|
||||||
|
const opRaw = typeof g.operation === "string" ? g.operation : undefined;
|
||||||
|
const operation: GeometrySnapshot["operation"] =
|
||||||
|
opRaw === "delete" ? "delete" : "reference";
|
||||||
const existingSource = g.source === "inline" || g.source === "ref" ? g.source : undefined;
|
const existingSource = g.source === "inline" || g.source === "ref" ? g.source : undefined;
|
||||||
const refId = getRefId(g.ref);
|
const refId = getRefId(g.ref);
|
||||||
const hasInlineGeometry = "draw_geometry" in g || "geometry" in g;
|
const hasInlineGeometry = "draw_geometry" in g || "geometry" in g;
|
||||||
@@ -71,9 +78,10 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
|||||||
delete rest.ref;
|
delete rest.ref;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...(rest as unknown as Omit<GeometrySnapshot, "id" | "source">),
|
...(rest as unknown as Omit<GeometrySnapshot, "id" | "source" | "operation">),
|
||||||
id,
|
id,
|
||||||
source,
|
source,
|
||||||
|
operation,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -84,17 +92,21 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
|||||||
.filter(isRecord)
|
.filter(isRecord)
|
||||||
.map((w) => {
|
.map((w) => {
|
||||||
const id = typeof w.id === "string" ? w.id : "";
|
const id = typeof w.id === "string" ? w.id : "";
|
||||||
const op = typeof w.operation === "string" ? w.operation : undefined;
|
const opRaw = typeof w.operation === "string" ? w.operation : undefined;
|
||||||
|
const operation: WikiSnapshot["operation"] =
|
||||||
|
opRaw === "delete" ? "delete" : "reference";
|
||||||
const existingSource = w.source === "inline" || w.source === "ref" ? w.source : undefined;
|
const existingSource = w.source === "inline" || w.source === "ref" ? w.source : undefined;
|
||||||
const refId = getRefId(w.ref);
|
const refId = getRefId(w.ref);
|
||||||
const source: "inline" | "ref" = existingSource || (refId || op === "reference" ? "ref" : "inline");
|
const source: "inline" | "ref" =
|
||||||
|
existingSource || (refId || opRaw === "reference" ? "ref" : "inline");
|
||||||
const rest: UnknownRecord = { ...w };
|
const rest: UnknownRecord = { ...w };
|
||||||
delete rest.ref;
|
delete rest.ref;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...(rest as unknown as Omit<WikiSnapshot, "id" | "source">),
|
...(rest as unknown as Omit<WikiSnapshot, "id" | "source" | "operation">),
|
||||||
id,
|
id,
|
||||||
source,
|
source,
|
||||||
|
operation,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -147,8 +159,14 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
|||||||
: typeof r.is_deleted === "boolean"
|
: typeof r.is_deleted === "boolean"
|
||||||
? r.is_deleted
|
? r.is_deleted
|
||||||
: false;
|
: false;
|
||||||
const operation: "reference" | "delete" =
|
const operation: "binding" | "delete" =
|
||||||
opRaw === "delete" ? "delete" : opRaw === "reference" ? "reference" : isDeleted ? "delete" : "reference";
|
opRaw === "delete"
|
||||||
|
? "delete"
|
||||||
|
: opRaw === "binding" || opRaw === "reference"
|
||||||
|
? "binding"
|
||||||
|
: isDeleted
|
||||||
|
? "delete"
|
||||||
|
: "binding";
|
||||||
return { entity_id, wiki_id, operation };
|
return { entity_id, wiki_id, operation };
|
||||||
})
|
})
|
||||||
.filter((r) => r.entity_id.length > 0 && r.wiki_id.length > 0)
|
.filter((r) => r.entity_id.length > 0 && r.wiki_id.length > 0)
|
||||||
@@ -194,10 +212,9 @@ export function buildEditorSnapshot(options: {
|
|||||||
section: Section;
|
section: Section;
|
||||||
draft: FeatureCollection;
|
draft: FeatureCollection;
|
||||||
changes: Change[];
|
changes: Change[];
|
||||||
pendingEntities: PendingEntityCreate[];
|
snapshotEntities: EntitySnapshot[];
|
||||||
projectEntityRefs: EntitySnapshot[];
|
snapshotWikis: WikiSnapshot[];
|
||||||
wikis: WikiSnapshot[];
|
snapshotEntityWikiLinks: EntityWikiLinkSnapshot[];
|
||||||
entityWikiLinks: EntityWikiLinkSnapshot[];
|
|
||||||
previousSnapshot: EditorSnapshot | null;
|
previousSnapshot: EditorSnapshot | null;
|
||||||
hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean;
|
hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean;
|
||||||
}): EditorSnapshot {
|
}): EditorSnapshot {
|
||||||
@@ -225,40 +242,58 @@ export function buildEditorSnapshot(options: {
|
|||||||
if (id && operation) previousGeometryOps.set(id, operation);
|
if (id && operation) previousGeometryOps.set(id, operation);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pendingEntityIds = new Set(options.pendingEntities.map((entity) => entity.id));
|
|
||||||
const entityRows = new globalThis.Map<string, EntitySnapshot>();
|
const entityRows = new globalThis.Map<string, EntitySnapshot>();
|
||||||
for (const entity of options.pendingEntities) {
|
|
||||||
entityRows.set(entity.id, {
|
// Persist inline entity records across commits even when they're not currently bound.
|
||||||
id: entity.id,
|
// Without this, "create entity" can disappear on the next commit unless the entity is referenced
|
||||||
|
// by geometry_entity/entity_wiki or pinned via projectEntityRefs.
|
||||||
|
for (const prev of options.previousSnapshot?.entities || []) {
|
||||||
|
if (!prev) continue;
|
||||||
|
const id = typeof prev.id === "string" || typeof prev.id === "number" ? String(prev.id) : "";
|
||||||
|
if (!id || entityRows.has(id)) continue;
|
||||||
|
if (prev.operation === "delete") continue;
|
||||||
|
if (prev.source !== "inline") continue;
|
||||||
|
// Carry forward as current-state inline entity; operation is a per-commit delta signal.
|
||||||
|
const cloned = JSON.parse(JSON.stringify(prev)) as EntitySnapshot;
|
||||||
|
const { operation: _op, ...rest } = cloned;
|
||||||
|
entityRows.set(id, {
|
||||||
|
...rest,
|
||||||
|
id,
|
||||||
source: "inline",
|
source: "inline",
|
||||||
operation: "create",
|
operation: "reference",
|
||||||
name: entity.name,
|
|
||||||
slug: entity.slug,
|
|
||||||
description: null,
|
|
||||||
type_id: entity.type_id,
|
|
||||||
status: entity.status,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
for (const row of options.snapshotEntities || []) {
|
||||||
for (const ref of options.projectEntityRefs || []) {
|
if (!row) continue;
|
||||||
const id = typeof ref?.id === "string" || typeof ref?.id === "number" ? String(ref.id) : "";
|
const id = typeof row.id === "string" || typeof row.id === "number" ? String(row.id) : "";
|
||||||
if (!id || entityRows.has(id)) continue;
|
if (!id) continue;
|
||||||
const cloned = JSON.parse(JSON.stringify(ref)) as EntitySnapshot;
|
const cloned = JSON.parse(JSON.stringify(row)) as EntitySnapshot;
|
||||||
|
const name =
|
||||||
|
typeof cloned?.name === "string" && cloned.name.trim().length
|
||||||
|
? cloned.name.trim()
|
||||||
|
: id;
|
||||||
|
const source: "inline" | "ref" = cloned.source === "inline" ? "inline" : "ref";
|
||||||
entityRows.set(id, {
|
entityRows.set(id, {
|
||||||
...cloned,
|
...cloned,
|
||||||
id,
|
id,
|
||||||
source: "ref",
|
source,
|
||||||
|
name,
|
||||||
|
operation: cloned.operation ?? "reference",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entities referenced by wiki links should be present as "reference" too.
|
// Entities referenced by wiki links should be present as "reference" too.
|
||||||
for (const link of options.entityWikiLinks || []) {
|
for (const link of options.snapshotEntityWikiLinks || []) {
|
||||||
const id = typeof link?.entity_id === "string" ? link.entity_id : "";
|
const id = typeof link?.entity_id === "string" ? link.entity_id : "";
|
||||||
if (!id || entityRows.has(id)) continue;
|
if (!id || entityRows.has(id)) continue;
|
||||||
entityRows.set(id, {
|
entityRows.set(id, {
|
||||||
id,
|
id,
|
||||||
source: "ref",
|
source: "ref",
|
||||||
operation: "reference",
|
operation: "reference",
|
||||||
|
name: id,
|
||||||
|
slug: null,
|
||||||
|
description: null,
|
||||||
|
status: 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,7 +307,6 @@ export function buildEditorSnapshot(options: {
|
|||||||
name: entityId,
|
name: entityId,
|
||||||
slug: null,
|
slug: null,
|
||||||
description: null,
|
description: null,
|
||||||
type_id: feature.properties.type || DEFAULT_ENTITY_TYPE_ID,
|
|
||||||
status: 1,
|
status: 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -294,11 +328,14 @@ export function buildEditorSnapshot(options: {
|
|||||||
? "update"
|
? "update"
|
||||||
: undefined;
|
: undefined;
|
||||||
const bbox = getFeatureBBox(feature);
|
const bbox = getFeatureBBox(feature);
|
||||||
|
const typeKey = feature.properties.type || getDefaultTypeIdForFeature(feature);
|
||||||
|
const typeCode = typeKeyToGeoTypeCode(typeKey);
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
operation,
|
operation,
|
||||||
source: "inline",
|
source: "inline",
|
||||||
type: feature.properties.type || getDefaultTypeIdForFeature(feature),
|
// BE currently expects geometries[].type as a string. We send the geo_type SMALLINT code as a string.
|
||||||
|
type: String(typeCode ?? 0),
|
||||||
draw_geometry: feature.geometry,
|
draw_geometry: feature.geometry,
|
||||||
binding: normalizeFeatureBindingIds(feature),
|
binding: normalizeFeatureBindingIds(feature),
|
||||||
time_start: feature.properties.time_start ?? null,
|
time_start: feature.properties.time_start ?? null,
|
||||||
@@ -322,11 +359,12 @@ export function buildEditorSnapshot(options: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const geometryEntity: GeometryEntitySnapshot[] = options.draft.features.flatMap((feature) => {
|
const geometryEntityRaw: GeometryEntitySnapshot[] = options.draft.features.flatMap((feature) => {
|
||||||
const geometry_id = String(feature.properties.id);
|
const geometry_id = String(feature.properties.id);
|
||||||
const entityIds = normalizeFeatureEntityIds(feature);
|
const entityIds = normalizeFeatureEntityIds(feature);
|
||||||
return entityIds.map((entity_id) => ({ geometry_id, entity_id }));
|
return entityIds.map((entity_id) => ({ geometry_id, entity_id }));
|
||||||
});
|
});
|
||||||
|
const geometryEntity = dedupeAndSortGeometryEntity(geometryEntityRaw);
|
||||||
|
|
||||||
// Persist snapshot without denormalized entity fields on features (many-to-many lives in geometry_entity[]).
|
// Persist snapshot without denormalized entity fields on features (many-to-many lives in geometry_entity[]).
|
||||||
const draftForSnapshot = JSON.parse(JSON.stringify(options.draft)) as FeatureCollection;
|
const draftForSnapshot = JSON.parse(JSON.stringify(options.draft)) as FeatureCollection;
|
||||||
@@ -350,8 +388,17 @@ export function buildEditorSnapshot(options: {
|
|||||||
// Operation semantics:
|
// Operation semantics:
|
||||||
// - create/update/delete: this commit changes the wiki itself
|
// - create/update/delete: this commit changes the wiki itself
|
||||||
// - reference: this wiki is a ref used for linking (entity<->wiki), not a modification
|
// - reference: this wiki is a ref used for linking (entity<->wiki), not a modification
|
||||||
const wikis: WikiSnapshot[] = (options.wikis || [])
|
const wikis: WikiSnapshot[] = (options.snapshotWikis || [])
|
||||||
.filter((w) => w && typeof w.id === "string" && w.id.trim().length > 0)
|
.filter((w) => {
|
||||||
|
if (!w || typeof w.id !== "string" || w.id.trim().length === 0) return false;
|
||||||
|
if (w.source === "ref") return true;
|
||||||
|
// Keep explicit operations (e.g. delete) even if content is empty.
|
||||||
|
if (w.operation === "create" || w.operation === "update" || w.operation === "delete") return true;
|
||||||
|
// Inline wiki with no content: don't persist it (treat as not written).
|
||||||
|
const title = typeof w.title === "string" ? w.title.trim() : "";
|
||||||
|
const doc = typeof w.doc === "string" ? w.doc.trim() : "";
|
||||||
|
return title.length > 0 || (w.doc !== null && doc.length > 0);
|
||||||
|
})
|
||||||
.map((w) => {
|
.map((w) => {
|
||||||
const prev = previousWikis.get(w.id) || null;
|
const prev = previousWikis.get(w.id) || null;
|
||||||
const cloned = JSON.parse(JSON.stringify(w)) as WikiSnapshot;
|
const cloned = JSON.parse(JSON.stringify(w)) as WikiSnapshot;
|
||||||
@@ -388,28 +435,66 @@ export function buildEditorSnapshot(options: {
|
|||||||
return cloned;
|
return cloned;
|
||||||
});
|
});
|
||||||
|
|
||||||
const entityWikis: EntityWikiLinkSnapshot[] = (options.entityWikiLinks || [])
|
const entityWikisRaw: EntityWikiLinkSnapshot[] = (options.snapshotEntityWikiLinks || [])
|
||||||
.filter((l) => l && typeof l.entity_id === "string" && typeof l.wiki_id === "string")
|
.filter((l) => l && typeof l.entity_id === "string" && typeof l.wiki_id === "string")
|
||||||
.map((l) => ({
|
.map((l) => ({
|
||||||
entity_id: l.entity_id,
|
entity_id: l.entity_id,
|
||||||
wiki_id: l.wiki_id,
|
wiki_id: l.wiki_id,
|
||||||
operation: l.operation === "delete" ? "delete" : "reference",
|
operation: l.operation === "delete" ? "delete" : "binding",
|
||||||
}));
|
}));
|
||||||
|
const entityWikis = dedupeAndSortEntityWiki(entityWikisRaw);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
editor_feature_collection: draftForSnapshot,
|
editor_feature_collection: draftForSnapshot,
|
||||||
entities: Array.from(entityRows.values()).map((entity) => {
|
entities: Array.from(entityRows.values()).sort((a, b) => String(a.id).localeCompare(String(b.id))),
|
||||||
const id = String(entity.id || "");
|
geometries: geometries.slice().sort((a, b) => String(a.id).localeCompare(String(b.id))),
|
||||||
if (pendingEntityIds.has(id)) return entity;
|
|
||||||
return entity;
|
|
||||||
}),
|
|
||||||
geometries,
|
|
||||||
geometry_entity: geometryEntity,
|
geometry_entity: geometryEntity,
|
||||||
wikis,
|
wikis: wikis.slice().sort((a, b) => a.id.localeCompare(b.id)),
|
||||||
entity_wiki: entityWikis,
|
entity_wiki: entityWikis,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dedupeAndSortGeometryEntity(rows: GeometryEntitySnapshot[]): GeometryEntitySnapshot[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const deduped: GeometryEntitySnapshot[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const geometry_id = typeof row.geometry_id === "string" ? row.geometry_id : "";
|
||||||
|
const entity_id = typeof row.entity_id === "string" ? row.entity_id : "";
|
||||||
|
if (!geometry_id || !entity_id) continue;
|
||||||
|
const key = `${geometry_id}::${entity_id}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
deduped.push({ ...row, geometry_id, entity_id });
|
||||||
|
}
|
||||||
|
deduped.sort((a, b) => {
|
||||||
|
const g = a.geometry_id.localeCompare(b.geometry_id);
|
||||||
|
if (g !== 0) return g;
|
||||||
|
return a.entity_id.localeCompare(b.entity_id);
|
||||||
|
});
|
||||||
|
return deduped;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeAndSortEntityWiki(rows: EntityWikiLinkSnapshot[]): EntityWikiLinkSnapshot[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const deduped: EntityWikiLinkSnapshot[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const entity_id = typeof row.entity_id === "string" ? row.entity_id : "";
|
||||||
|
const wiki_id = typeof row.wiki_id === "string" ? row.wiki_id : "";
|
||||||
|
if (!entity_id || !wiki_id) continue;
|
||||||
|
const operation = row.operation === "delete" ? "delete" : "binding";
|
||||||
|
const key = `${entity_id}::${wiki_id}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
deduped.push({ entity_id, wiki_id, operation });
|
||||||
|
}
|
||||||
|
deduped.sort((a, b) => {
|
||||||
|
const e = a.entity_id.localeCompare(b.entity_id);
|
||||||
|
if (e !== 0) return e;
|
||||||
|
return a.wiki_id.localeCompare(b.wiki_id);
|
||||||
|
});
|
||||||
|
return deduped;
|
||||||
|
}
|
||||||
|
|
||||||
export function getDefaultTypeIdForFeature(feature: Feature): string {
|
export function getDefaultTypeIdForFeature(feature: Feature): string {
|
||||||
const preset = feature.properties.geometry_preset;
|
const preset = feature.properties.geometry_preset;
|
||||||
if (preset === "line") return "defense_line";
|
if (preset === "line") return "defense_line";
|
||||||
|
|||||||
33
src/uhm/lib/geoTypeMap.json
Normal file
33
src/uhm/lib/geoTypeMap.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
[
|
||||||
|
{ "type_key": "defense_line", "geo_type_code": 1 },
|
||||||
|
{ "type_key": "attack_route", "geo_type_code": 2 },
|
||||||
|
{ "type_key": "retreat_route", "geo_type_code": 3 },
|
||||||
|
{ "type_key": "invasion_route", "geo_type_code": 4 },
|
||||||
|
{ "type_key": "migration_route", "geo_type_code": 5 },
|
||||||
|
{ "type_key": "refugee_route", "geo_type_code": 6 },
|
||||||
|
{ "type_key": "trade_route", "geo_type_code": 7 },
|
||||||
|
{ "type_key": "shipping_route", "geo_type_code": 8 },
|
||||||
|
|
||||||
|
{ "type_key": "country", "geo_type_code": 9 },
|
||||||
|
{ "type_key": "state", "geo_type_code": 10 },
|
||||||
|
{ "type_key": "empire", "geo_type_code": 11 },
|
||||||
|
{ "type_key": "kingdom", "geo_type_code": 12 },
|
||||||
|
|
||||||
|
{ "type_key": "war", "geo_type_code": 13 },
|
||||||
|
{ "type_key": "battle", "geo_type_code": 14 },
|
||||||
|
{ "type_key": "civilization", "geo_type_code": 15 },
|
||||||
|
{ "type_key": "rebellion_zone", "geo_type_code": 16 },
|
||||||
|
|
||||||
|
{ "type_key": "person_deathplace", "geo_type_code": 17 },
|
||||||
|
{ "type_key": "person_birthplace", "geo_type_code": 18 },
|
||||||
|
{ "type_key": "person_activity", "geo_type_code": 19 },
|
||||||
|
{ "type_key": "temple", "geo_type_code": 20 },
|
||||||
|
{ "type_key": "capital", "geo_type_code": 21 },
|
||||||
|
{ "type_key": "city", "geo_type_code": 22 },
|
||||||
|
{ "type_key": "fortress", "geo_type_code": 23 },
|
||||||
|
{ "type_key": "castle", "geo_type_code": 24 },
|
||||||
|
{ "type_key": "ruin", "geo_type_code": 25 },
|
||||||
|
{ "type_key": "port", "geo_type_code": 26 },
|
||||||
|
{ "type_key": "bridge", "geo_type_code": 27 }
|
||||||
|
]
|
||||||
|
|
||||||
34
src/uhm/lib/geoTypeMap.ts
Normal file
34
src/uhm/lib/geoTypeMap.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import rows from "@/uhm/lib/geoTypeMap.json";
|
||||||
|
|
||||||
|
export type GeoTypeMapRow = {
|
||||||
|
type_key: string;
|
||||||
|
geo_type_code: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAP_ROWS: GeoTypeMapRow[] = rows as GeoTypeMapRow[];
|
||||||
|
|
||||||
|
const CODE_BY_KEY = new Map<string, number>();
|
||||||
|
const KEY_BY_CODE = new Map<number, string>();
|
||||||
|
|
||||||
|
for (const row of MAP_ROWS) {
|
||||||
|
const key = typeof row?.type_key === "string" ? row.type_key.trim().toLowerCase() : "";
|
||||||
|
const code = typeof row?.geo_type_code === "number" ? row.geo_type_code : Number.NaN;
|
||||||
|
if (!key.length) continue;
|
||||||
|
if (!Number.isFinite(code)) continue;
|
||||||
|
CODE_BY_KEY.set(key, Math.trunc(code));
|
||||||
|
KEY_BY_CODE.set(Math.trunc(code), key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function typeKeyToGeoTypeCode(key: string | null | undefined): number | null {
|
||||||
|
if (!key) return null;
|
||||||
|
const normalized = key.trim().toLowerCase();
|
||||||
|
if (!normalized.length) return null;
|
||||||
|
return CODE_BY_KEY.get(normalized) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function geoTypeCodeToTypeKey(code: number | null | undefined): string | null {
|
||||||
|
if (code == null) return null;
|
||||||
|
if (!Number.isFinite(code)) return null;
|
||||||
|
return KEY_BY_CODE.get(Math.trunc(code)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { v7 as uuidv7 } from "uuid";
|
import { v7 as uuidv7 } from "uuid";
|
||||||
|
|
||||||
// Centralized ID generator for all client-created identifiers in FrontEndAdmin.
|
// Centralized ID generator for all client-created identifiers in FrontEndUser.
|
||||||
// UUIDv7 is time-ordered (RFC 9562) and works well for sorting by creation time.
|
// UUIDv7 is time-ordered (RFC 9562) and works well for sorting by creation time.
|
||||||
export function newId(): string {
|
export function newId(): string {
|
||||||
return uuidv7();
|
return uuidv7();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export const DEFAULT_POINT_ICON_ID = "point-icon-default";
|
|||||||
export const POINT_ICON_URL = "/point.png";
|
export const POINT_ICON_URL = "/point.png";
|
||||||
export const PATH_ARROW_ICON_ID = "path-arrow-icon";
|
export const PATH_ARROW_ICON_ID = "path-arrow-icon";
|
||||||
|
|
||||||
export const MAP_MIN_ZOOM = 1;
|
export const MAP_MIN_ZOOM = 2;
|
||||||
export const MAP_MAX_ZOOM = 10;
|
export const MAP_MAX_ZOOM = 10;
|
||||||
|
|
||||||
export const RASTER_BASE_SOURCE_ID = "rasterBase";
|
export const RASTER_BASE_SOURCE_ID = "rasterBase";
|
||||||
|
|||||||
@@ -8,11 +8,9 @@ import { useWikiSessionState } from "@/uhm/lib/editor/session/useWikiSessionStat
|
|||||||
import type { EditorMode, TimelineRange } from "@/uhm/lib/editor/session/sessionTypes";
|
import type { EditorMode, TimelineRange } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
CreatedEntitySummary,
|
|
||||||
EditorMode,
|
EditorMode,
|
||||||
EntityFormState,
|
EntityFormState,
|
||||||
GeometryMetaFormState,
|
GeometryMetaFormState,
|
||||||
PendingEntityCreate,
|
|
||||||
TimelineRange,
|
TimelineRange,
|
||||||
} from "@/uhm/lib/editor/session/sessionTypes";
|
} from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
|
||||||
|
|||||||
@@ -25,12 +25,12 @@ export type EntitySnapshot = {
|
|||||||
source: "inline" | "ref";
|
source: "inline" | "ref";
|
||||||
// Delta semantics for this commit:
|
// Delta semantics for this commit:
|
||||||
// - create/update/delete: this commit modifies the entity record
|
// - create/update/delete: this commit modifies the entity record
|
||||||
// - reference: this entity is referenced/linked (e.g., geometry<->entity, entity<->wiki) but not modified
|
// - reference: this entity is kept as-is (no modification in this commit). Relationship assignments live in
|
||||||
|
// join tables (geometry_entity / entity_wiki), not here.
|
||||||
operation?: EntitySnapshotOperation;
|
operation?: EntitySnapshotOperation;
|
||||||
name?: string;
|
name?: string;
|
||||||
slug?: string | null;
|
slug?: string | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
type_id?: string | null;
|
|
||||||
status?: number | null;
|
status?: number | null;
|
||||||
base_updated_at?: string;
|
base_updated_at?: string;
|
||||||
base_hash?: string;
|
base_hash?: string;
|
||||||
|
|||||||
@@ -5,13 +5,22 @@ import type { WikiSnapshot } from "@/uhm/types/wiki";
|
|||||||
export type EntityWikiLinkSnapshot = {
|
export type EntityWikiLinkSnapshot = {
|
||||||
entity_id: string;
|
entity_id: string;
|
||||||
wiki_id: string;
|
wiki_id: string;
|
||||||
operation?: "reference" | "delete";
|
// Relationship semantics (entity ↔ wiki).
|
||||||
|
// - binding: the link exists (assigned)
|
||||||
|
// - delete: the link is removed
|
||||||
|
operation?: "binding" | "delete";
|
||||||
};
|
};
|
||||||
|
|
||||||
// BackEndGo uses Projects/Commits/Submissions. "Section" is legacy naming in FE.
|
// BackEndGo uses Projects/Commits/Submissions. "Section" is legacy naming in FE.
|
||||||
export type ProjectStatus = string;
|
export type ProjectStatus = string;
|
||||||
export type ProjectSubmissionStatus = "PENDING" | "APPROVED" | "REJECTED" | string;
|
export type ProjectSubmissionStatus = "PENDING" | "APPROVED" | "REJECTED" | string;
|
||||||
|
|
||||||
|
// BackEndGo (new): project response includes submissions as a lightweight list.
|
||||||
|
export type SubmissionSimpleResponse = {
|
||||||
|
id: string;
|
||||||
|
status: ProjectSubmissionStatus;
|
||||||
|
};
|
||||||
|
|
||||||
export type ProjectState = {
|
export type ProjectState = {
|
||||||
// Derived state from ProjectResponse (not persisted as-is in API mới).
|
// Derived state from ProjectResponse (not persisted as-is in API mới).
|
||||||
status: ProjectStatus;
|
status: ProjectStatus;
|
||||||
@@ -25,7 +34,10 @@ export type Project = {
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
project_status?: string;
|
project_status?: string;
|
||||||
latest_commit_id?: string | null;
|
latest_commit_id?: string | null;
|
||||||
|
// Legacy (old BE): submission_ids?: string[]
|
||||||
|
// New BE: submissions?: [{id,status}]
|
||||||
submission_ids?: string[];
|
submission_ids?: string[];
|
||||||
|
submissions?: SubmissionSimpleResponse[];
|
||||||
locked_by?: string | null;
|
locked_by?: string | null;
|
||||||
user_id?: string;
|
user_id?: string;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
export type WikiDoc = unknown;
|
// BackEndGo snapshot expects wiki doc as a string (stored in DB as TEXT).
|
||||||
|
// FE stores Tiptap JSON as a JSON-stringified payload.
|
||||||
|
export type WikiDoc = string | null;
|
||||||
|
|
||||||
export type WikiSnapshotOperation = "create" | "update" | "delete" | "reference";
|
export type WikiSnapshotOperation = "create" | "update" | "delete" | "reference";
|
||||||
|
|
||||||
@@ -8,6 +10,7 @@ export type WikiSnapshot = {
|
|||||||
// Optional for backwards-compat with older commits. New commits should include it.
|
// Optional for backwards-compat with older commits. New commits should include it.
|
||||||
operation?: WikiSnapshotOperation;
|
operation?: WikiSnapshotOperation;
|
||||||
title: string;
|
title: string;
|
||||||
|
slug?: string | null;
|
||||||
doc: WikiDoc;
|
doc: WikiDoc;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user