Compare commits
12 Commits
41af501b51
...
add1728916
| Author | SHA1 | Date | |
|---|---|---|---|
| add1728916 | |||
|
|
a9b8c4ab8b | ||
|
|
c945a56a33 | ||
| 6f163c3730 | |||
|
|
ce4bc4f2a5 | ||
|
|
8b1df73797 | ||
|
|
a29a3a2049 | ||
|
|
f555909b09 | ||
|
|
34a5c3d041 | ||
|
|
fca188f0be | ||
|
|
12c351c68a | ||
|
|
a74047fd09 |
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`
|
||||||
11
api.ts
11
api.ts
@@ -58,4 +58,13 @@ export const API = {
|
|||||||
GET_COMMITS: (id: number | string) => `${API_URL_ROOT}/projects/${id}/commits`,
|
GET_COMMITS: (id: number | string) => `${API_URL_ROOT}/projects/${id}/commits`,
|
||||||
RESTORE_COMMIT: (id: number | string) => `${API_URL_ROOT}/projects/${id}/commits/restore`,
|
RESTORE_COMMIT: (id: number | string) => `${API_URL_ROOT}/projects/${id}/commits/restore`,
|
||||||
},
|
},
|
||||||
}
|
Submission: {
|
||||||
|
SEARCH: `${API_URL_ROOT}/submissions`,
|
||||||
|
GET_BY_ID: (id: number | string) => `${API_URL_ROOT}/submissions/${id}`,
|
||||||
|
UPDATE_STATUS: (id: number | string) => `${API_URL_ROOT}/submissions/${id}/status`,
|
||||||
|
DELETE: (id: number | string) => `${API_URL_ROOT}/submissions/${id}`,
|
||||||
|
},
|
||||||
|
Chatbot:{
|
||||||
|
CHAT: `${API_URL_ROOT}/chatbot/chat`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
248
commit_snapshot.md
Normal file
248
commit_snapshot.md
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
# Commit Snapshot (`commits.snapshot_json`) - Chuẩn Hiện Tại (FrontEndUser / UHM)
|
||||||
|
|
||||||
|
Tài liệu này mô tả **snapshot_json** mà `FrontEndUser` (module UHM editor) tạo ra khi bấm **Commit** trong `/editor/[id]`, và gửi lên endpoint `POST /projects/{id}/commits`.
|
||||||
|
|
||||||
|
Nguồn tham chiếu trong code (FrontEndUser):
|
||||||
|
|
||||||
|
- Types:
|
||||||
|
- `src/uhm/types/sections.ts` (`EditorSnapshot`, `EntityWikiLinkSnapshot`)
|
||||||
|
- `src/uhm/types/geo.ts` (`FeatureCollection`, `GeometrySnapshot`, `GeometryEntitySnapshot`)
|
||||||
|
- `src/uhm/types/entities.ts` (`EntitySnapshot`)
|
||||||
|
- `src/uhm/types/wiki.ts` (`WikiSnapshot`)
|
||||||
|
- Build/normalize snapshot:
|
||||||
|
- `src/uhm/lib/editor/snapshot/editorSnapshot.ts` (`buildEditorSnapshot`, `normalizeEditorSnapshot`)
|
||||||
|
|
||||||
|
## 1) Root Shape
|
||||||
|
|
||||||
|
FE hiện tại không dùng `schema_version`. `snapshot_json` là một object có các phần sau:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type EditorSnapshot = {
|
||||||
|
editor_feature_collection?: FeatureCollection;
|
||||||
|
entities?: EntitySnapshot[];
|
||||||
|
geometries?: GeometrySnapshot[];
|
||||||
|
geometry_entity?: GeometryEntitySnapshot[];
|
||||||
|
wikis?: WikiSnapshot[];
|
||||||
|
entity_wiki?: EntityWikiLinkSnapshot[];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Lưu ý:
|
||||||
|
|
||||||
|
- FE có thể **đọc** cả `entity_wiki` và legacy alias `entity_wikis` khi load snapshot (normalize), nhưng khi commit FE ghi `entity_wiki`.
|
||||||
|
- `editor_feature_collection` là nguồn để render editor/map. Các join table (`geometry_entity`, `entity_wiki`) là nguồn quan hệ.
|
||||||
|
|
||||||
|
## 2) Types (TypeScript) - Đúng Theo FE Hiện Tại
|
||||||
|
|
||||||
|
### 2.1 GeoJSON (editor_feature_collection)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type Geometry =
|
||||||
|
| { type: "Point"; coordinates: [number, number] }
|
||||||
|
| { type: "MultiPoint"; coordinates: [number, number][] }
|
||||||
|
| { type: "LineString"; coordinates: [number, number][] }
|
||||||
|
| { type: "MultiLineString"; coordinates: [number, number][][] }
|
||||||
|
| { type: "Polygon"; coordinates: [number, number][][] }
|
||||||
|
| { type: "MultiPolygon"; coordinates: [number, number][][][] };
|
||||||
|
|
||||||
|
export type FeatureId = string | number;
|
||||||
|
|
||||||
|
export type FeatureProperties = {
|
||||||
|
id: FeatureId;
|
||||||
|
type?: string | null;
|
||||||
|
geometry_preset?: string | null;
|
||||||
|
time_start?: number | null;
|
||||||
|
time_end?: number | null;
|
||||||
|
binding?: string[];
|
||||||
|
|
||||||
|
// UI-only / legacy fields (FE sẽ strip khi persist snapshot):
|
||||||
|
entity_id?: string | null;
|
||||||
|
entity_ids?: string[];
|
||||||
|
entity_name?: string | null;
|
||||||
|
entity_names?: string[];
|
||||||
|
entity_type_id?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Feature = {
|
||||||
|
type: "Feature";
|
||||||
|
properties: FeatureProperties;
|
||||||
|
geometry: Geometry;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FeatureCollection = {
|
||||||
|
type: "FeatureCollection";
|
||||||
|
features: Feature[];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Snapshot rows
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type SnapshotSource = "inline" | "ref";
|
||||||
|
|
||||||
|
export type EntitySnapshotOperation = "create" | "update" | "delete" | "reference";
|
||||||
|
export type GeometrySnapshotOperation = "create" | "update" | "delete" | "reference";
|
||||||
|
export type WikiSnapshotOperation = "create" | "update" | "delete" | "reference";
|
||||||
|
|
||||||
|
export type EntitySnapshot = {
|
||||||
|
id: string;
|
||||||
|
source: SnapshotSource;
|
||||||
|
operation?: EntitySnapshotOperation;
|
||||||
|
name?: string;
|
||||||
|
slug?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
status?: number | null;
|
||||||
|
base_updated_at?: string;
|
||||||
|
base_hash?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GeometrySnapshot = {
|
||||||
|
id: string;
|
||||||
|
source: SnapshotSource;
|
||||||
|
operation?: GeometrySnapshotOperation;
|
||||||
|
type?: string | null;
|
||||||
|
draw_geometry?: Geometry;
|
||||||
|
geometry?: Geometry; // legacy
|
||||||
|
binding?: string[];
|
||||||
|
time_start?: number | null;
|
||||||
|
time_end?: number | null;
|
||||||
|
bbox?: {
|
||||||
|
min_lng: number;
|
||||||
|
min_lat: number;
|
||||||
|
max_lng: number;
|
||||||
|
max_lat: number;
|
||||||
|
} | null;
|
||||||
|
base_updated_at?: string;
|
||||||
|
base_hash?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// FE stores wiki doc as a string (commonly HTML; in some flows it may be a JSON-stringified editor payload).
|
||||||
|
export type WikiDoc = string | null;
|
||||||
|
|
||||||
|
export type WikiSnapshot = {
|
||||||
|
id: string;
|
||||||
|
source: SnapshotSource;
|
||||||
|
operation?: WikiSnapshotOperation;
|
||||||
|
title: string;
|
||||||
|
slug?: string | null;
|
||||||
|
doc: WikiDoc;
|
||||||
|
updated_at?: string;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 Join tables
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type GeometryEntitySnapshot = {
|
||||||
|
geometry_id: string;
|
||||||
|
entity_id: string;
|
||||||
|
base_links_hash?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EntityWikiLinkSnapshot = {
|
||||||
|
entity_id: string;
|
||||||
|
wiki_id: string;
|
||||||
|
operation?: "reference" | "binding" | "delete";
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3) Quy Ước FE Khi Build Snapshot (buildEditorSnapshot)
|
||||||
|
|
||||||
|
### 3.1 Feature.properties entity fields bị strip
|
||||||
|
|
||||||
|
Khi persist snapshot, FE chủ động xoá các field denormalize trên feature properties:
|
||||||
|
`entity_id`, `entity_ids`, `entity_name`, `entity_names`, `entity_type_id`.
|
||||||
|
|
||||||
|
Quan hệ geometry ↔ entity chỉ nằm ở `geometry_entity[]`.
|
||||||
|
|
||||||
|
### 3.2 entities[]
|
||||||
|
|
||||||
|
FE cố gắng đảm bảo mọi entity có `name` không rỗng (fallback sang `id`) và có `source`.
|
||||||
|
|
||||||
|
`operation` được dùng như "delta" trong commit:
|
||||||
|
|
||||||
|
- `"create"|"update"|"delete"`: thay đổi record entity
|
||||||
|
- `"reference"`: đưa entity vào context snapshot (pin/link) nhưng commit không sửa record entity
|
||||||
|
|
||||||
|
### 3.3 geometries[]
|
||||||
|
|
||||||
|
FE sinh 1 `GeometrySnapshot` cho mỗi feature đang tồn tại trong `editor_feature_collection.features[]`:
|
||||||
|
|
||||||
|
- `id = String(feature.properties.id)`
|
||||||
|
- `source:"inline"`
|
||||||
|
- `draw_geometry = feature.geometry`
|
||||||
|
- `binding`, `time_start`, `time_end`, `bbox` (nếu tính được)
|
||||||
|
- `type`: FE hiện gửi **string code** (geo_type smallint) dưới dạng string
|
||||||
|
- `operation`:
|
||||||
|
- `"create"` nếu geometry mới
|
||||||
|
- `"update"` nếu geometry thay đổi
|
||||||
|
- `undefined` nếu geometry không đổi
|
||||||
|
|
||||||
|
Nếu feature bị xoá khỏi draft, FE thêm 1 row:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "id": "…", "source": "ref", "operation": "delete" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 geometry_entity[]
|
||||||
|
|
||||||
|
`geometry_entity` là danh sách quan hệ many-to-many geometry ↔ entity. Mỗi row là một cặp:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{ geometry_id: string; entity_id: string }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5 wikis[]
|
||||||
|
|
||||||
|
- Wiki `source:"ref"` (được add từ search): FE set `operation:"reference"` và `doc:null`.
|
||||||
|
- Wiki `source:"inline"` (được tạo/sửa trong editor):
|
||||||
|
- nếu UI set explicit `create|update|delete` thì giữ nguyên
|
||||||
|
- nếu không có operation:
|
||||||
|
- wiki mới: FE coi là `"create"`
|
||||||
|
- wiki cũ không đổi: FE gán `"reference"`
|
||||||
|
- wiki cũ có đổi nội dung: FE gán `"update"`
|
||||||
|
|
||||||
|
### 3.6 entity_wiki[]
|
||||||
|
|
||||||
|
Type trong FE cho UI state cho phép `"binding"` và `"delete"`.
|
||||||
|
|
||||||
|
Khi build snapshot để commit, FE map link “đang bật” về `"reference"` để tương thích với backend (một số backend chỉ chấp nhận `"reference"|"delete"`).
|
||||||
|
|
||||||
|
## 4) Ví Dụ snapshot_json (rút gọn)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"editor_feature_collection": {
|
||||||
|
"type": "FeatureCollection",
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"properties": { "id": "019e…", "type": "country", "time_start": 1000, "time_end": 1500 },
|
||||||
|
"geometry": { "type": "Polygon", "coordinates": [[[100, 10], [101, 10], [101, 11], [100, 10]]] }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"entities": [
|
||||||
|
{ "id": "019e…", "source": "inline", "operation": "reference", "name": "ent1", "description": null, "status": 1 }
|
||||||
|
],
|
||||||
|
"geometries": [
|
||||||
|
{ "id": "019e…", "source": "inline", "operation": "update", "type": "9", "draw_geometry": { "type": "Polygon", "coordinates": [] }, "binding": [], "time_start": 1000, "time_end": 1500, "bbox": null }
|
||||||
|
],
|
||||||
|
"geometry_entity": [
|
||||||
|
{ "geometry_id": "019e…", "entity_id": "019e…" }
|
||||||
|
],
|
||||||
|
"wikis": [
|
||||||
|
{ "id": "019e…", "source": "ref", "operation": "reference", "title": "Existing wiki", "doc": null, "updated_at": "2026-05-08T00:00:00.000Z" }
|
||||||
|
],
|
||||||
|
"entity_wiki": [
|
||||||
|
{ "entity_id": "019e…", "wiki_id": "019e…", "operation": "reference" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5) Compat Notes (khi load snapshot cũ)
|
||||||
|
|
||||||
|
FE normalize khi load snapshot:
|
||||||
|
|
||||||
|
- Nếu thấy `entity_wikis` (plural) sẽ đọc như `entity_wiki`.
|
||||||
|
- Nếu join link có `operation:"reference"` thì FE coi như link active (UI biểu diễn như “binding”).
|
||||||
@@ -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'],
|
||||||
@@ -37,4 +46,4 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
1085
package-lock.json
generated
1085
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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 ."
|
||||||
@@ -20,10 +20,14 @@
|
|||||||
"@reduxjs/toolkit": "^2.11.2",
|
"@reduxjs/toolkit": "^2.11.2",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tailwindcss/postcss": "^4.1.17",
|
"@tailwindcss/postcss": "^4.1.17",
|
||||||
|
"@tiptap/extension-link": "^2.26.1",
|
||||||
|
"@tiptap/react": "^2.26.1",
|
||||||
|
"@tiptap/starter-kit": "^2.26.1",
|
||||||
"apexcharts": "^4.7.0",
|
"apexcharts": "^4.7.0",
|
||||||
"autoprefixer": "^10.4.22",
|
"autoprefixer": "^10.4.22",
|
||||||
"axios": "^1.14.0",
|
"axios": "^1.14.0",
|
||||||
"flatpickr": "^4.6.13",
|
"flatpickr": "^4.6.13",
|
||||||
|
"maplibre-gl": "^5.20.2",
|
||||||
"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",
|
||||||
@@ -33,6 +37,7 @@
|
|||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^14.3.8",
|
||||||
"react-quill-new": "^3.8.3",
|
"react-quill-new": "^3.8.3",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"sweetalert2": "^11.26.24",
|
"sweetalert2": "^11.26.24",
|
||||||
"swiper": "^11.2.10",
|
"swiper": "^11.2.10",
|
||||||
|
|||||||
BIN
public/point.png
Normal file
BIN
public/point.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
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);
|
||||||
|
});
|
||||||
@@ -42,7 +42,7 @@ export default function ResetPasswordForm() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await apiCreateOTP(email);
|
await apiCreateOTP(email, 1);
|
||||||
toast.success("Mã OTP đã được gửi đến email của bạn!");
|
toast.success("Mã OTP đã được gửi đến email của bạn!");
|
||||||
setStep(2);
|
setStep(2);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -69,7 +69,7 @@ export default function ResetPasswordForm() {
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const verifyRes = await apiVerifyOTP(email, otp);
|
const verifyRes = await apiVerifyOTP(email, otp, 1);
|
||||||
const tokenId = verifyRes?.data?.token_id;
|
const tokenId = verifyRes?.data?.token_id;
|
||||||
|
|
||||||
if (!tokenId) {
|
if (!tokenId) {
|
||||||
|
|||||||
122
src/app/editor/[id]/featureCommands.ts
Normal file
122
src/app/editor/[id]/featureCommands.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
import type { Entity } from "@/uhm/types/entities";
|
||||||
|
import type { Feature, FeatureProperties } from "@/uhm/types/geo";
|
||||||
|
import { ApiError } from "@/uhm/api/http";
|
||||||
|
import { buildFeatureEntityPatch } from "@/uhm/lib/editor/entity/entityBinding";
|
||||||
|
import { buildGeometryMetadataPatch } from "@/uhm/lib/editor/geometry/geometryMetadata";
|
||||||
|
import { uniqueEntityIds } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
||||||
|
import type { GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
|
||||||
|
type EditorDraftApi = {
|
||||||
|
patchFeatureProperties: (id: FeatureProperties["id"], patch: Partial<FeatureProperties>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
editor: EditorDraftApi;
|
||||||
|
selectedFeature: Feature | null;
|
||||||
|
geometryMetaForm: GeometryMetaFormState;
|
||||||
|
setGeometryMetaForm: Dispatch<SetStateAction<GeometryMetaFormState>>;
|
||||||
|
selectedGeometryEntityIds: string[];
|
||||||
|
setSelectedGeometryEntityIds: Dispatch<SetStateAction<string[]>>;
|
||||||
|
entities: Entity[];
|
||||||
|
setIsEntitySubmitting: Dispatch<SetStateAction<boolean>>;
|
||||||
|
setEntityFormStatus: Dispatch<SetStateAction<string | null>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useFeatureCommands(options: Options) {
|
||||||
|
const {
|
||||||
|
editor,
|
||||||
|
selectedFeature,
|
||||||
|
geometryMetaForm,
|
||||||
|
setGeometryMetaForm,
|
||||||
|
selectedGeometryEntityIds,
|
||||||
|
setSelectedGeometryEntityIds,
|
||||||
|
entities,
|
||||||
|
setIsEntitySubmitting,
|
||||||
|
setEntityFormStatus,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const applyGeometryMetadata = useCallback(async (): Promise<{ ok: boolean; error?: string }> => {
|
||||||
|
if (!selectedFeature) {
|
||||||
|
const msg = "Hãy chọn một geometry trước.";
|
||||||
|
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;
|
||||||
|
try {
|
||||||
|
metadata = buildGeometryMetadataPatch(geometryMetaForm);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : "Thời gian không hợp lệ.";
|
||||||
|
setEntityFormStatus(msg);
|
||||||
|
return { ok: false, error: msg };
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsEntitySubmitting(true);
|
||||||
|
setEntityFormStatus(null);
|
||||||
|
try {
|
||||||
|
editor.patchFeatureProperties(selectedFeature.properties.id, metadata.patch);
|
||||||
|
setGeometryMetaForm(metadata.formState);
|
||||||
|
setEntityFormStatus("Đã cập nhật thuộc tính GEO. Commit khi sẵn sàng.");
|
||||||
|
return { ok: true };
|
||||||
|
} finally {
|
||||||
|
setIsEntitySubmitting(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
editor,
|
||||||
|
geometryMetaForm,
|
||||||
|
selectedFeature,
|
||||||
|
setEntityFormStatus,
|
||||||
|
setGeometryMetaForm,
|
||||||
|
setIsEntitySubmitting,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const applyEntitiesToSelectedGeometry = useCallback(async () => {
|
||||||
|
if (!selectedFeature) {
|
||||||
|
setEntityFormStatus("Hãy chọn một geometry trước.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityIds = uniqueEntityIds(selectedGeometryEntityIds);
|
||||||
|
setIsEntitySubmitting(true);
|
||||||
|
setEntityFormStatus(null);
|
||||||
|
try {
|
||||||
|
editor.patchFeatureProperties(
|
||||||
|
selectedFeature.properties.id,
|
||||||
|
buildFeatureEntityPatch(selectedFeature, entityIds, entities)
|
||||||
|
);
|
||||||
|
setSelectedGeometryEntityIds(entityIds);
|
||||||
|
setEntityFormStatus("Đã cập nhật danh sách entity. Commit khi sẵn sàng.");
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
setEntityFormStatus(`Lưu thất bại: ${err.body}`);
|
||||||
|
} else {
|
||||||
|
setEntityFormStatus("Lưu thất bại.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsEntitySubmitting(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
editor,
|
||||||
|
entities,
|
||||||
|
selectedFeature,
|
||||||
|
selectedGeometryEntityIds,
|
||||||
|
setEntityFormStatus,
|
||||||
|
setIsEntitySubmitting,
|
||||||
|
setSelectedGeometryEntityIds,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
applyGeometryMetadata,
|
||||||
|
applyEntitiesToSelectedGeometry,
|
||||||
|
};
|
||||||
|
}
|
||||||
1688
src/app/editor/[id]/page.tsx
Normal file
1688
src/app/editor/[id]/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
7
src/app/editor/page.tsx
Normal file
7
src/app/editor/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function EditorIndexPage() {
|
||||||
|
// Editor must be opened from a specific project (see /user/projects).
|
||||||
|
redirect("/user/projects");
|
||||||
|
}
|
||||||
|
|
||||||
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 (
|
||||||
|
data.features.find((feature) => String(feature.properties.id) === String(selectedFeatureId)) || null
|
||||||
|
);
|
||||||
|
}, [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 (
|
return (
|
||||||
<AdminLayout>
|
<div style={{ display: "flex", minHeight: "100vh" }}>
|
||||||
<div className=''>Page</div>
|
<div style={{ flex: 1, position: "relative", minHeight: "100vh" }}>
|
||||||
</AdminLayout>
|
{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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { apiGetCurrentUser } from "@/service/auth";
|
|||||||
import { setUserData } from "@/store/features/userSlice";
|
import { setUserData } from "@/store/features/userSlice";
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { useDispatch } from "react-redux";
|
import { useDispatch } from "react-redux";
|
||||||
|
import ChatbotWidget from "@/components/ui/chat/ChatbotWidget";
|
||||||
|
|
||||||
export default function AdminLayout({
|
export default function AdminLayout({
|
||||||
children,
|
children,
|
||||||
@@ -51,6 +52,7 @@ export default function AdminLayout({
|
|||||||
{/* Page Content */}
|
{/* Page Content */}
|
||||||
<div className="p-4 mx-auto max-w-(--breakpoint-2xl) md:p-6">{children}</div>
|
<div className="p-4 mx-auto max-w-(--breakpoint-2xl) md:p-6">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ChatbotWidget />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Swal from "sweetalert2";
|
|||||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
||||||
import { apiAddProjectMember, apiChangeProjectOwner, apiDeleteProject, apiGetProjectDetail, apiRemoveProjectMember, apiUpdateProject, apiUpdateProjectMemberRole } from "@/service/projectService";
|
import { apiAddProjectMember, apiChangeProjectOwner, apiDeleteProject, apiGetProjectDetail, apiRemoveProjectMember, apiUpdateProject, apiUpdateProjectMemberRole } from "@/service/projectService";
|
||||||
import Loading from "@/app/loading";
|
import Loading from "@/app/loading";
|
||||||
|
import Button from "@/components/ui/button/Button";
|
||||||
|
|
||||||
type TabType = "overview" | "members" | "settings";
|
type TabType = "overview" | "members" | "settings";
|
||||||
|
|
||||||
@@ -256,7 +257,7 @@ export default function ProjectDetailsPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
id: "overview",
|
id: "overview",
|
||||||
@@ -305,6 +306,14 @@ export default function ProjectDetailsPage() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
<Button size="sm" variant="outline" onClick={() => router.push(`/editor/${id}`)}>
|
||||||
|
Mo editor
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => router.push(`/editor/${id}?only=wiki`)}>
|
||||||
|
Editor only wiki
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
||||||
@@ -12,7 +12,9 @@ import Button from "@/components/ui/button/Button";
|
|||||||
import Label from "@/components/form/Label";
|
import Label from "@/components/form/Label";
|
||||||
import Badge from "@/components/ui/badge/Badge";
|
import Badge from "@/components/ui/badge/Badge";
|
||||||
import { CreateProjectPayload, Project } from "@/interface/project";
|
import { CreateProjectPayload, Project } from "@/interface/project";
|
||||||
import { apiCreateProject, getCurrentProject } from "@/service/projectService";
|
import { apiCreateProject, apiCreateProjectCommit, apiGetProjectCommits, getCurrentProject } from "@/service/projectService";
|
||||||
|
import { normalizeEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
||||||
|
import type { EditorSnapshot } from "@/uhm/types/sections";
|
||||||
|
|
||||||
export type ProjectSortColumn = "created_at" | "updated_at" | "title";
|
export type ProjectSortColumn = "created_at" | "updated_at" | "title";
|
||||||
|
|
||||||
@@ -21,12 +23,16 @@ export default function ProjectsPage() {
|
|||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [isExportingProjectId, setIsExportingProjectId] = useState<string | null>(null);
|
||||||
|
|
||||||
const [sortBy, setSortBy] = useState<ProjectSortColumn>("updated_at");
|
const [sortBy, setSortBy] = useState<ProjectSortColumn>("updated_at");
|
||||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
|
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
|
||||||
|
|
||||||
const { isOpen, openModal, closeModal } = useModal();
|
const { isOpen, openModal, closeModal } = useModal();
|
||||||
const [formData, setFormData] = useState<CreateProjectPayload>({ title: "", description: "", project_status: "PRIVATE" });
|
const [formData, setFormData] = useState<CreateProjectPayload>({ title: "", description: "", project_status: "PRIVATE" });
|
||||||
|
const importJsonInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const [importSnapshot, setImportSnapshot] = useState<EditorSnapshot | null>(null);
|
||||||
|
const [importSnapshotName, setImportSnapshotName] = useState<string | null>(null);
|
||||||
|
|
||||||
const fetchProjects = async () => {
|
const fetchProjects = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -58,11 +64,15 @@ export default function ProjectsPage() {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
await apiCreateProject(formData);
|
const created = await apiCreateProject(formData);
|
||||||
|
const projectId = created?.data?.id;
|
||||||
toast.success("Tạo dự án mới thành công!");
|
toast.success("Tạo dự án mới thành công!");
|
||||||
closeModal();
|
closeModal();
|
||||||
setFormData({ title: "", description: "", project_status: "PRIVATE" });
|
setFormData({ title: "", description: "", project_status: "PRIVATE" });
|
||||||
|
setImportSnapshot(null);
|
||||||
|
setImportSnapshotName(null);
|
||||||
fetchProjects();
|
fetchProjects();
|
||||||
|
if (projectId) router.push(`/editor/${projectId}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Lỗi tạo dự án:", error);
|
console.error("Lỗi tạo dự án:", error);
|
||||||
toast.error("Có lỗi xảy ra khi tạo dự án.");
|
toast.error("Có lỗi xảy ra khi tạo dự án.");
|
||||||
@@ -71,6 +81,103 @@ export default function ProjectsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePickImportJson = () => {
|
||||||
|
importJsonInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportJsonFile = async (file: File | null) => {
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const raw = JSON.parse(text) as unknown;
|
||||||
|
const normalized = normalizeEditorSnapshot(raw);
|
||||||
|
if (!normalized) {
|
||||||
|
toast.error("JSON snapshot không hợp lệ.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setImportSnapshot(normalized);
|
||||||
|
setImportSnapshotName(file.name);
|
||||||
|
toast.success("Đã nạp JSON snapshot. Bấm 'Tạo với JSON' để khởi tạo dự án.");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Import JSON failed", err);
|
||||||
|
toast.error("Không đọc được file JSON.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateProjectWithJson = async () => {
|
||||||
|
if (!formData.title.trim()) {
|
||||||
|
toast.warning("Vui lòng nhập tên dự án!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!importSnapshot) {
|
||||||
|
toast.warning("Chưa chọn JSON snapshot.");
|
||||||
|
handlePickImportJson();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
const created = await apiCreateProject(formData);
|
||||||
|
const projectId = created?.data?.id;
|
||||||
|
if (!projectId) {
|
||||||
|
toast.error("Tạo dự án thất bại: thiếu project id.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await apiCreateProjectCommit(projectId, {
|
||||||
|
edit_summary: "Init project from JSON",
|
||||||
|
snapshot_json: importSnapshot as any,
|
||||||
|
} as any);
|
||||||
|
toast.success("Tạo dự án (kèm JSON) thành công!");
|
||||||
|
closeModal();
|
||||||
|
setFormData({ title: "", description: "", project_status: "PRIVATE" });
|
||||||
|
setImportSnapshot(null);
|
||||||
|
setImportSnapshotName(null);
|
||||||
|
fetchProjects();
|
||||||
|
router.push(`/editor/${projectId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Lỗi tạo dự án với JSON:", error);
|
||||||
|
toast.error("Có lỗi xảy ra khi tạo dự án với JSON.");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportHeadSnapshot = async (project: Project) => {
|
||||||
|
const projectId = String(project.id || "").trim();
|
||||||
|
if (!projectId) return;
|
||||||
|
const headCommitId = project.latest_commit_id ? String(project.latest_commit_id) : "";
|
||||||
|
if (!headCommitId) {
|
||||||
|
toast.warning("Dự án chưa có head commit để export.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsExportingProjectId(projectId);
|
||||||
|
try {
|
||||||
|
const res: any = await apiGetProjectCommits(projectId);
|
||||||
|
const rawList = res?.data?.items ?? res?.data ?? res?.items ?? [];
|
||||||
|
const commits = Array.isArray(rawList) ? rawList : [];
|
||||||
|
const head = commits.find((c: any) => String(c?.id || "") === headCommitId) || null;
|
||||||
|
const snapshot = head?.snapshot_json ?? null;
|
||||||
|
if (!snapshot) {
|
||||||
|
toast.error("Không tìm thấy snapshot_json của head commit.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blob = new Blob([JSON.stringify(snapshot, null, 2)], { type: "application/json;charset=utf-8" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `project-${projectId}-head-${headCommitId}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
toast.success("Đã export JSON snapshot.");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Export snapshot failed", err);
|
||||||
|
toast.error("Export thất bại.");
|
||||||
|
} finally {
|
||||||
|
setIsExportingProjectId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSort = (column: ProjectSortColumn) => {
|
const handleSort = (column: ProjectSortColumn) => {
|
||||||
if (sortBy === column) {
|
if (sortBy === column) {
|
||||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||||
@@ -132,6 +239,11 @@ export default function ProjectsPage() {
|
|||||||
|
|
||||||
console.log(projects);
|
console.log(projects);
|
||||||
|
|
||||||
|
const importLabel = useMemo(() => {
|
||||||
|
if (!importSnapshotName) return "Chưa chọn JSON snapshot";
|
||||||
|
return `JSON: ${importSnapshotName}`;
|
||||||
|
}, [importSnapshotName]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto pb-10">
|
<div className="max-w-7xl mx-auto pb-10">
|
||||||
<PageBreadcrumb pageTitle="Quản lý dự án" />
|
<PageBreadcrumb pageTitle="Quản lý dự án" />
|
||||||
@@ -217,7 +329,31 @@ export default function ProjectsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center mt-4 md:mt-0 w-[120px] justify-end shrink-0">
|
<div className="flex items-center mt-4 md:mt-0 gap-3 w-[340px] justify-end shrink-0">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push(`/editor/${project.id}`)}
|
||||||
|
>
|
||||||
|
Editor
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isExportingProjectId === String(project.id)}
|
||||||
|
onClick={() => handleExportHeadSnapshot(project)}
|
||||||
|
// title="Export head commit snapshot_json"
|
||||||
|
>
|
||||||
|
ExportJSON
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push(`/editor/${project.id}?only=wiki`)}
|
||||||
|
>
|
||||||
|
Editor only wiki
|
||||||
|
</Button>
|
||||||
|
|
||||||
<div className="flex -space-x-2 overflow-hidden">
|
<div className="flex -space-x-2 overflow-hidden">
|
||||||
{project.members && project.members.length > 0 ? (
|
{project.members && project.members.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
@@ -319,11 +455,39 @@ export default function ProjectsPage() {
|
|||||||
placeholder="Mô tả ngắn gọn về dự án..."
|
placeholder="Mô tả ngắn gọn về dự án..."
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Khởi tạo từ JSON</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="sm" variant="outline" type="button" onClick={handlePickImportJson}>
|
||||||
|
Chọn JSON
|
||||||
|
</Button>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||||
|
{importLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={importJsonInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/json"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => handleImportJsonFile(e.target.files?.[0] || null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex items-center justify-end gap-3 mt-4">
|
<div className="flex items-center justify-end gap-3 mt-4">
|
||||||
<Button size="sm" variant="outline" type="button" onClick={closeModal}>Hủy</Button>
|
<Button size="sm" variant="outline" type="button" onClick={closeModal}>Hủy</Button>
|
||||||
<Button size="sm" type="submit" disabled={isSubmitting} className="bg-brand-500 hover:bg-brand-600 text-white">
|
<Button size="sm" type="submit" disabled={isSubmitting} className="bg-brand-500 hover:bg-brand-600 text-white">
|
||||||
{isSubmitting ? "Đang tạo..." : "Khởi tạo"}
|
{isSubmitting ? "Đang tạo..." : "Khởi tạo"}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="bg-gray-900 hover:bg-gray-800 text-white"
|
||||||
|
onClick={handleCreateProjectWithJson}
|
||||||
|
// title="Tạo dự án và tạo commit đầu tiên từ JSON snapshot"
|
||||||
|
>
|
||||||
|
Tạo với JSON
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import "yet-another-react-lightbox/styles.css";
|
|||||||
import "yet-another-react-lightbox/plugins/captions.css";
|
import "yet-another-react-lightbox/plugins/captions.css";
|
||||||
import { createHistorianCV } from "@/service/historianService";
|
import { createHistorianCV } from "@/service/historianService";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { newId } from "@/uhm/lib/id";
|
||||||
import Swal from "sweetalert2";
|
import Swal from "sweetalert2";
|
||||||
import { PresignedUrlResponse } from "@/interface/media";
|
import { PresignedUrlResponse } from "@/interface/media";
|
||||||
|
|
||||||
@@ -94,7 +95,7 @@ export default function RoleUpgrade() {
|
|||||||
const presigned = await getPresignedUrl(file);
|
const presigned = await getPresignedUrl(file);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: Math.random().toString(36).substring(7),
|
id: newId(),
|
||||||
file: file,
|
file: file,
|
||||||
previewUrl: isImage ? URL.createObjectURL(file) : "",
|
previewUrl: isImage ? URL.createObjectURL(file) : "",
|
||||||
name: file.name,
|
name: file.name,
|
||||||
|
|||||||
554
src/app/user/wikieditor/page.tsx
Normal file
554
src/app/user/wikieditor/page.tsx
Normal file
@@ -0,0 +1,554 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
||||||
|
import ComponentCard from "@/components/common/ComponentCard";
|
||||||
|
import Button from "@/components/ui/button/Button";
|
||||||
|
import Badge from "@/components/ui/badge/Badge";
|
||||||
|
import Label from "@/components/form/Label";
|
||||||
|
|
||||||
|
import { EditorContent, useEditor, type JSONContent } from "@tiptap/react";
|
||||||
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
|
import TiptapLink from "@tiptap/extension-link";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "uhm_wiki_draft_v1";
|
||||||
|
|
||||||
|
type TocItem = {
|
||||||
|
level: number;
|
||||||
|
text: string;
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WikiDraft = {
|
||||||
|
schema_version: 1;
|
||||||
|
title: string;
|
||||||
|
doc: JSONContent;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function slugify(input: string) {
|
||||||
|
return input
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, "")
|
||||||
|
.replace(/\s+/g, "-")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.slice(0, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
function textFromNode(node: any): string {
|
||||||
|
if (!node) return "";
|
||||||
|
if (node.type === "text") return node.text || "";
|
||||||
|
if (Array.isArray(node.content)) return node.content.map(textFromNode).join("");
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildToc(doc: JSONContent | null): TocItem[] {
|
||||||
|
if (!doc) return [];
|
||||||
|
const out: TocItem[] = [];
|
||||||
|
const seen = new Map<string, number>();
|
||||||
|
|
||||||
|
const walk = (node: any) => {
|
||||||
|
if (!node) return;
|
||||||
|
if (node.type === "heading") {
|
||||||
|
const level = Number(node.attrs?.level || 1);
|
||||||
|
const text = textFromNode(node).trim();
|
||||||
|
if (text) {
|
||||||
|
const base = slugify(text) || "heading";
|
||||||
|
const n = (seen.get(base) || 0) + 1;
|
||||||
|
seen.set(base, n);
|
||||||
|
const slug = n === 1 ? base : `${base}-${n}`;
|
||||||
|
out.push({ level, text, slug });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(node.content)) node.content.forEach(walk);
|
||||||
|
};
|
||||||
|
|
||||||
|
walk(doc);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInlineText(node: any, key: string) {
|
||||||
|
if (node.type !== "text") return null;
|
||||||
|
const marks: any[] = Array.isArray(node.marks) ? node.marks : [];
|
||||||
|
let el: React.ReactNode = node.text || "";
|
||||||
|
|
||||||
|
for (const m of marks) {
|
||||||
|
if (m.type === "bold") el = <strong key={`${key}-b`}>{el}</strong>;
|
||||||
|
else if (m.type === "italic") el = <em key={`${key}-i`}>{el}</em>;
|
||||||
|
else if (m.type === "link") {
|
||||||
|
const href = String(m.attrs?.href || "#");
|
||||||
|
el = (
|
||||||
|
<a
|
||||||
|
key={`${key}-a`}
|
||||||
|
href={href}
|
||||||
|
target={m.attrs?.target || "_blank"}
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-brand-600 dark:text-brand-400 underline underline-offset-2"
|
||||||
|
>
|
||||||
|
{el}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span key={key}>{el}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDoc(node: any, keyPrefix = "n", toc: TocItem[] = []) : React.ReactNode {
|
||||||
|
if (!node) return null;
|
||||||
|
const type = node.type;
|
||||||
|
const content: any[] = Array.isArray(node.content) ? node.content : [];
|
||||||
|
|
||||||
|
if (type === "doc") {
|
||||||
|
return <>{content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "paragraph") {
|
||||||
|
return (
|
||||||
|
<p key={keyPrefix} className="text-sm leading-6 text-gray-800 dark:text-gray-200">
|
||||||
|
{content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "heading") {
|
||||||
|
const level = Number(node.attrs?.level || 1);
|
||||||
|
const text = textFromNode(node).trim();
|
||||||
|
const slug = toc.find((t) => t.text === text)?.slug || slugify(text);
|
||||||
|
const cls =
|
||||||
|
level === 1
|
||||||
|
? "text-2xl font-bold"
|
||||||
|
: level === 2
|
||||||
|
? "text-xl font-semibold"
|
||||||
|
: "text-lg font-semibold";
|
||||||
|
return (
|
||||||
|
<div key={keyPrefix} className="mt-5">
|
||||||
|
<div id={slug} className={`${cls} text-gray-900 dark:text-gray-100`}>
|
||||||
|
{content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "bulletList") {
|
||||||
|
return (
|
||||||
|
<ul key={keyPrefix} className="list-disc pl-5 text-sm text-gray-800 dark:text-gray-200">
|
||||||
|
{content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "orderedList") {
|
||||||
|
return (
|
||||||
|
<ol key={keyPrefix} className="list-decimal pl-5 text-sm text-gray-800 dark:text-gray-200">
|
||||||
|
{content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "listItem") {
|
||||||
|
return <li key={keyPrefix}>{content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))}</li>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "blockquote") {
|
||||||
|
return (
|
||||||
|
<blockquote
|
||||||
|
key={keyPrefix}
|
||||||
|
className="border-l-4 border-gray-200 dark:border-gray-800 pl-4 text-sm text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
{content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))}
|
||||||
|
</blockquote>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "codeBlock") {
|
||||||
|
const code = content.map(textFromNode).join("");
|
||||||
|
return (
|
||||||
|
<pre
|
||||||
|
key={keyPrefix}
|
||||||
|
className="rounded-xl border border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-[#0d1117] p-4 overflow-auto text-xs"
|
||||||
|
>
|
||||||
|
<code>{code}</code>
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "hardBreak") return <br key={keyPrefix} />;
|
||||||
|
|
||||||
|
if (type === "text") return renderInlineText(node, keyPrefix);
|
||||||
|
|
||||||
|
// fallback: render children
|
||||||
|
return <span key={keyPrefix}>{content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ViewMode = "edit" | "split" | "preview";
|
||||||
|
|
||||||
|
export default function WikiEditorPage() {
|
||||||
|
const [view, setView] = useState<ViewMode>("split");
|
||||||
|
const [showJson, setShowJson] = useState(false);
|
||||||
|
const [title, setTitle] = useState("Untitled wiki");
|
||||||
|
const [docJson, setDocJson] = useState<JSONContent | null>(null);
|
||||||
|
const [savedAt, setSavedAt] = useState<string | null>(null);
|
||||||
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
|
const saveTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
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: "Write your wiki content here." }] },
|
||||||
|
{ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Section" }] },
|
||||||
|
{ type: "paragraph", content: [{ type: "text", text: "Use H1/H2/H3 and the TOC will follow." }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
onUpdate: ({ editor }) => {
|
||||||
|
setDocJson(editor.getJSON());
|
||||||
|
setIsDirty(true);
|
||||||
|
},
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
// Keep editor styling independent from whatever global typography the app uses.
|
||||||
|
class:
|
||||||
|
"tiptap-editor focus:outline-none min-h-[360px] px-4 py-3",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load draft
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return;
|
||||||
|
const parsed = JSON.parse(raw) as WikiDraft;
|
||||||
|
if (parsed && typeof parsed === "object" && parsed.schema_version === 1 && parsed.doc) {
|
||||||
|
setTitle(parsed.title || "Untitled wiki");
|
||||||
|
editor.commands.setContent(parsed.doc as JSONContent);
|
||||||
|
setDocJson(parsed.doc as JSONContent);
|
||||||
|
setSavedAt(parsed.updated_at || "loaded");
|
||||||
|
setIsDirty(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const toc = useMemo(() => buildToc(docJson), [docJson]);
|
||||||
|
|
||||||
|
const doSaveDraft = () => {
|
||||||
|
if (!editor) return;
|
||||||
|
const payload: WikiDraft = {
|
||||||
|
schema_version: 1,
|
||||||
|
title: title.trim() || "Untitled wiki",
|
||||||
|
doc: editor.getJSON(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
|
||||||
|
setSavedAt(new Date().toLocaleString("vi-VN"));
|
||||||
|
setIsDirty(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debounced autosave
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
if (!isDirty) return;
|
||||||
|
|
||||||
|
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
||||||
|
saveTimerRef.current = window.setTimeout(() => {
|
||||||
|
doSaveDraft();
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [editor, isDirty, title, docJson]);
|
||||||
|
|
||||||
|
const can = (cmd: () => boolean) => {
|
||||||
|
try {
|
||||||
|
return Boolean(editor && cmd());
|
||||||
|
} catch {
|
||||||
|
return 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 (
|
||||||
|
<div className="max-w-7xl mx-auto pb-10">
|
||||||
|
<PageBreadcrumb pageTitle="Wiki editor" paths={[{ name: "User", href: "/user" }]} />
|
||||||
|
|
||||||
|
<style jsx global>{`
|
||||||
|
.tiptap-editor {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.tiptap-editor hr {
|
||||||
|
margin: 1rem 0;
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid rgba(226, 232, 240, 1);
|
||||||
|
}
|
||||||
|
.dark .tiptap-editor hr {
|
||||||
|
border-top-color: rgba(30, 41, 59, 1);
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||||
|
<ComponentCard title="Wiki">
|
||||||
|
<div className="p-4 flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<Label>Title</Label>
|
||||||
|
<input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTitle(e.target.value);
|
||||||
|
setIsDirty(true);
|
||||||
|
}}
|
||||||
|
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"
|
||||||
|
placeholder="Wiki title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge size="sm" variant="light" color="info">
|
||||||
|
TipTap
|
||||||
|
</Badge>
|
||||||
|
{isDirty ? (
|
||||||
|
<Badge size="sm" variant="light" color="warning">
|
||||||
|
Unsaved
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge size="sm" variant="light" color="success">
|
||||||
|
Saved
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setShowJson((v) => !v)}>
|
||||||
|
{showJson ? "Hide JSON" : "Show JSON"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Last save: {savedAt || "-"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold text-gray-600 dark:text-gray-300 mb-2">TOC</div>
|
||||||
|
{toc.length === 0 ? (
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">No headings</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{toc.map((t) => (
|
||||||
|
<Link
|
||||||
|
key={t.slug}
|
||||||
|
href={`#${t.slug}`}
|
||||||
|
className={`text-xs hover:underline text-gray-700 dark:text-gray-300 ${
|
||||||
|
t.level === 1 ? "font-semibold" : t.level === 2 ? "pl-3" : "pl-6"
|
||||||
|
}`}
|
||||||
|
title={t.text}
|
||||||
|
>
|
||||||
|
{t.text}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-1 flex gap-2">
|
||||||
|
<Button size="sm" className="bg-brand-500 hover:bg-brand-600 text-white" onClick={doSaveDraft} disabled={!editor}>
|
||||||
|
Save now
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
window.localStorage.removeItem(STORAGE_KEY);
|
||||||
|
setSavedAt(null);
|
||||||
|
setIsDirty(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear draft
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ComponentCard>
|
||||||
|
|
||||||
|
<div className="lg:col-span-3 flex flex-col gap-6">
|
||||||
|
<ComponentCard title="Editor">
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setView("edit")}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setView("split")}>
|
||||||
|
Split
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setView("preview")}>
|
||||||
|
Preview
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="w-px h-7 bg-gray-200 dark:bg-gray-800 mx-1" />
|
||||||
|
|
||||||
|
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleBold().run()} disabled={!can(() => editor!.can().toggleBold())}>
|
||||||
|
B
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleItalic().run()} disabled={!can(() => editor!.can().toggleItalic())}>
|
||||||
|
I
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleHeading({ level: 1 }).run()}>
|
||||||
|
H1
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()}>
|
||||||
|
H2
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()}>
|
||||||
|
H3
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleBulletList().run()}>
|
||||||
|
Bullets
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleOrderedList().run()}>
|
||||||
|
Numbers
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleBlockquote().run()}>
|
||||||
|
Quote
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleCodeBlock().run()}>
|
||||||
|
Code
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={setLink} disabled={!editor}>
|
||||||
|
Link
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().undo().run()} disabled={!can(() => editor!.can().undo())}>
|
||||||
|
Undo
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().redo().run()} disabled={!can(() => editor!.can().redo())}>
|
||||||
|
Redo
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={view === "split" ? "grid grid-cols-1 lg:grid-cols-2 gap-4" : ""}>
|
||||||
|
{view !== "preview" ? (
|
||||||
|
<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>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{view !== "edit" ? (
|
||||||
|
<div className="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-[#0d1117] p-4">
|
||||||
|
<div className="text-xs font-semibold text-gray-600 dark:text-gray-300 mb-2">
|
||||||
|
Preview
|
||||||
|
</div>
|
||||||
|
{renderDoc(docJson, "p", toc)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ComponentCard>
|
||||||
|
|
||||||
|
{showJson ? (
|
||||||
|
<ComponentCard title="Document 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-[520px]">
|
||||||
|
{JSON.stringify({ title: title.trim() || "Untitled wiki", doc: docJson }, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</ComponentCard>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
src/auth/tokenStore.ts
Normal file
78
src/auth/tokenStore.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
export type StoredTokens = {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LS_KEY = "uhm_auth_tokens_v1";
|
||||||
|
|
||||||
|
let cached: StoredTokens | null = null;
|
||||||
|
|
||||||
|
function safeParseTokens(raw: string | null): StoredTokens | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(raw) as Partial<StoredTokens>;
|
||||||
|
if (!v || typeof v !== "object") return null;
|
||||||
|
if (typeof v.access_token !== "string" || typeof v.refresh_token !== "string") return null;
|
||||||
|
if (!v.access_token.trim() || !v.refresh_token.trim()) return null;
|
||||||
|
return { access_token: v.access_token, refresh_token: v.refresh_token };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredTokens(): StoredTokens | null {
|
||||||
|
if (cached) return cached;
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
cached = safeParseTokens(window.localStorage.getItem(LS_KEY));
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStoredTokens(tokens: StoredTokens | null): void {
|
||||||
|
cached = tokens;
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
if (!tokens) {
|
||||||
|
window.localStorage.removeItem(LS_KEY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.localStorage.setItem(LS_KEY, JSON.stringify(tokens));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAccessToken(): string | null {
|
||||||
|
return getStoredTokens()?.access_token ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRefreshToken(): string | null {
|
||||||
|
return getStoredTokens()?.refresh_token ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearStoredTokens(): void {
|
||||||
|
setStoredTokens(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper for dealing with CommonResponse where token payload shape is not strictly typed.
|
||||||
|
export function extractTokensFromResponsePayload(payload: any): StoredTokens | null {
|
||||||
|
const data = payload?.data ?? payload;
|
||||||
|
// Common shapes observed in various backends:
|
||||||
|
// - { status: true, data: { access_token, refresh_token } }
|
||||||
|
// - { data: { tokens: { access_token, refresh_token } } }
|
||||||
|
// - { data: { token: <access>, refresh_token } }
|
||||||
|
// - { accessToken, refreshToken }
|
||||||
|
const tokenContainer = data?.tokens ?? data?.token_set ?? data;
|
||||||
|
|
||||||
|
const access =
|
||||||
|
tokenContainer?.access_token ??
|
||||||
|
tokenContainer?.accessToken ??
|
||||||
|
tokenContainer?.token ??
|
||||||
|
tokenContainer?.access ??
|
||||||
|
null;
|
||||||
|
|
||||||
|
const refresh =
|
||||||
|
tokenContainer?.refresh_token ??
|
||||||
|
tokenContainer?.refreshToken ??
|
||||||
|
tokenContainer?.refresh ??
|
||||||
|
null;
|
||||||
|
if (typeof access === "string" && typeof refresh === "string" && access.trim() && refresh.trim()) {
|
||||||
|
return { access_token: access, refresh_token: refresh };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -112,8 +112,13 @@ 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;
|
||||||
|
const googleUrl = `${API.Auth.GOOGLE_LOGIN}?redirect=${encodeURIComponent(
|
||||||
|
redirectUrl
|
||||||
|
)}`;
|
||||||
|
router.push(googleUrl);
|
||||||
}}
|
}}
|
||||||
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,11 +163,16 @@ 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;
|
||||||
|
const googleUrl = `${API.Auth.GOOGLE_LOGIN}?redirect=${encodeURIComponent(
|
||||||
|
redirectUrl
|
||||||
|
)}`;
|
||||||
|
router.push(googleUrl);
|
||||||
}}
|
}}
|
||||||
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"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
width="20"
|
width="20"
|
||||||
height="20"
|
height="20"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "@fullcalendar/core";
|
} from "@fullcalendar/core";
|
||||||
import { useModal } from "@/hooks/useModal";
|
import { useModal } from "@/hooks/useModal";
|
||||||
import { Modal } from "@/components/ui/modal";
|
import { Modal } from "@/components/ui/modal";
|
||||||
|
import { newId } from "@/uhm/lib/id";
|
||||||
|
|
||||||
interface CalendarEvent extends EventInput {
|
interface CalendarEvent extends EventInput {
|
||||||
extendedProps: {
|
extendedProps: {
|
||||||
@@ -99,7 +100,7 @@ const Calendar: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
// Add new event
|
// Add new event
|
||||||
const newEvent: CalendarEvent = {
|
const newEvent: CalendarEvent = {
|
||||||
id: Date.now().toString(),
|
id: newId(),
|
||||||
title: eventTitle,
|
title: eventTitle,
|
||||||
start: eventStartDate,
|
start: eventStartDate,
|
||||||
end: eventEndDate,
|
end: eventEndDate,
|
||||||
|
|||||||
223
src/components/ui/chat/ChatbotWidget.tsx
Normal file
223
src/components/ui/chat/ChatbotWidget.tsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
|
import { ChatbotPayload } from "@/interface/chatbot";
|
||||||
|
import { apiChatbot } from "@/service/chatbotService";
|
||||||
|
|
||||||
|
type Message = {
|
||||||
|
id: string;
|
||||||
|
sender: "user" | "bot";
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ChatbotWidget({
|
||||||
|
projectId = "",
|
||||||
|
}: {
|
||||||
|
projectId?: string;
|
||||||
|
}) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [messages, setMessages] = useState<Message[]>([
|
||||||
|
{
|
||||||
|
id: "init",
|
||||||
|
sender: "bot",
|
||||||
|
text: "Xin chào! Tôi là trợ lý lịch sử thân thiện. Tôi có thể giúp gì cho bạn?",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
}, [messages, isOpen]);
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!input.trim()) return;
|
||||||
|
|
||||||
|
const userMessage: Message = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
sender: "user",
|
||||||
|
text: input.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
setMessages((prev) => [...prev, userMessage]);
|
||||||
|
setInput("");
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload: ChatbotPayload = {
|
||||||
|
project_id: projectId,
|
||||||
|
question: userMessage.text,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await apiChatbot(payload);
|
||||||
|
|
||||||
|
const botMessage: Message = {
|
||||||
|
id: (Date.now() + 1).toString(),
|
||||||
|
sender: "bot",
|
||||||
|
text: res?.status
|
||||||
|
? res?.data
|
||||||
|
: "Xin lỗi, tôi không thể trả lời lúc này.",
|
||||||
|
};
|
||||||
|
|
||||||
|
setMessages((prev) => [...prev, botMessage]);
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMessage: Message = {
|
||||||
|
id: (Date.now() + 1).toString(),
|
||||||
|
sender: "bot",
|
||||||
|
text:
|
||||||
|
error?.response?.data?.message ||
|
||||||
|
"Có lỗi xảy ra khi kết nối. Vui lòng thử lại sau.",
|
||||||
|
};
|
||||||
|
setMessages((prev) => [...prev, errorMessage]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-6 right-6 z-50">
|
||||||
|
{!isOpen && (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
className="w-14 h-14 bg-brand-500 hover:bg-brand-600 text-white rounded-full flex items-center justify-center shadow-[0_4px_14px_rgba(0,0,0,0.25)] transition-transform hover:scale-105"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
className="w-6 h-6"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Khung Chat */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="w-[360px] h-[520px] bg-white dark:bg-gray-900 rounded-2xl shadow-2xl flex flex-col border border-gray-200 dark:border-gray-800 overflow-hidden animate-in slide-in-from-bottom-4 duration-300">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-4 py-3 bg-brand-500 text-white flex items-center justify-between shadow-sm z-10">
|
||||||
|
<div className="font-semibold flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
className="w-5 h-5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23-.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0112 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Trợ lý lịch sử.
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="text-white hover:text-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={2}
|
||||||
|
stroke="currentColor"
|
||||||
|
className="w-5 h-5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nội dung Chat */}
|
||||||
|
<div className="flex-1 p-4 overflow-y-auto flex flex-col gap-3 bg-gray-50 dark:bg-[#0d1117] text-sm">
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className={`max-w-[85%] rounded-2xl px-4 py-2 shadow-sm ${
|
||||||
|
msg.sender === "user"
|
||||||
|
? "bg-brand-500 text-white self-end rounded-br-sm"
|
||||||
|
: "bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 border border-gray-100 dark:border-gray-700 self-start rounded-bl-sm"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{msg.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 self-start rounded-2xl rounded-bl-sm px-4 py-3 shadow-sm flex items-center gap-1.5 max-w-[80%]">
|
||||||
|
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
|
||||||
|
style={{ animationDelay: "0.2s" }}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
|
||||||
|
style={{ animationDelay: "0.4s" }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Khu vực Nhập Input */}
|
||||||
|
<div className="p-3 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Nhập câu hỏi..."
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex-1 bg-gray-100 dark:bg-gray-800 border-transparent focus:border-brand-500 focus:bg-white dark:focus:bg-gray-900 focus:ring-1 focus:ring-brand-500/20 rounded-full px-4 py-2.5 text-sm outline-none transition-all"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!input.trim() || isLoading}
|
||||||
|
className={`p-2.5 rounded-full transition-colors flex shrink-0 items-center justify-center ${
|
||||||
|
!input.trim() || isLoading
|
||||||
|
? "text-gray-400 bg-gray-100 dark:bg-gray-800 cursor-not-allowed"
|
||||||
|
: "bg-brand-500 text-white hover:bg-brand-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className="w-5 h-5"
|
||||||
|
>
|
||||||
|
<path d="M3.478 2.405a.75.75 0 00-.926.94l2.432 7.905H13.5a.75.75 0 010 1.5H4.984l-2.432 7.905a.75.75 0 00.926.94 60.519 60.519 0 0018.445-8.986.75.75 0 000-1.218A60.517 60.517 0 003.478 2.405z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
import axios from "axios"
|
import axios from "axios"
|
||||||
import { API_URL_ROOT } from "../../api"
|
import { API_URL_ROOT } from "../../api"
|
||||||
|
import {
|
||||||
|
clearStoredTokens,
|
||||||
|
extractTokensFromResponsePayload,
|
||||||
|
getAccessToken,
|
||||||
|
getRefreshToken,
|
||||||
|
setStoredTokens,
|
||||||
|
} from "@/auth/tokenStore"
|
||||||
|
|
||||||
const baseURL = API_URL_ROOT || "https://history-api.kain.id.vn"
|
const baseURL = API_URL_ROOT || "https://history-api.kain.id.vn"
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL,
|
baseURL,
|
||||||
|
// Support both cookie-based auth (httpOnly) and Bearer JWT.
|
||||||
withCredentials: true
|
withCredentials: true
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -19,12 +27,36 @@ const processQueue = (error?: any) => {
|
|||||||
queue = []
|
queue = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = getAccessToken()
|
||||||
|
if (token) {
|
||||||
|
const headers: any = config.headers || {}
|
||||||
|
// Do not override if caller set Authorization explicitly (case-insensitive).
|
||||||
|
const already =
|
||||||
|
typeof headers.get === "function"
|
||||||
|
? headers.get("Authorization")
|
||||||
|
: headers.Authorization || headers.authorization
|
||||||
|
if (!already) {
|
||||||
|
if (typeof headers.set === "function") headers.set("Authorization", `Bearer ${token}`)
|
||||||
|
else headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
config.headers = headers
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(res) => res,
|
(res) => {
|
||||||
|
// Opportunistically persist tokens from signin/refresh responses.
|
||||||
|
const tokens = extractTokensFromResponsePayload(res?.data)
|
||||||
|
if (tokens) setStoredTokens(tokens)
|
||||||
|
return res
|
||||||
|
},
|
||||||
async (err) => {
|
async (err) => {
|
||||||
const originalRequest = err.config
|
const originalRequest = err.config
|
||||||
|
|
||||||
if (err.response?.status === 401 && !originalRequest._retry) {
|
const url = String(originalRequest?.url || "")
|
||||||
|
if (err.response?.status === 401 && !originalRequest._retry && !url.includes("/auth/")) {
|
||||||
if (isRefreshing) {
|
if (isRefreshing) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
queue.push({
|
queue.push({
|
||||||
@@ -38,19 +70,55 @@ api.interceptors.response.use(
|
|||||||
isRefreshing = true
|
isRefreshing = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.post(
|
const refreshToken = getRefreshToken()
|
||||||
`${baseURL}/auth/refresh`,
|
|
||||||
{},
|
const tryHeaderRefresh = async () => {
|
||||||
{ withCredentials: true }
|
if (!refreshToken) return null
|
||||||
)
|
return axios.post(
|
||||||
|
`${baseURL}/auth/refresh`,
|
||||||
|
{},
|
||||||
|
{ headers: { Authorization: `Bearer ${refreshToken}` } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tryCookieRefresh = async () => {
|
||||||
|
return axios.post(`${baseURL}/auth/refresh`, {}, { withCredentials: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
let refreshRes: any = null
|
||||||
|
try {
|
||||||
|
refreshRes = (await tryHeaderRefresh()) || (await tryCookieRefresh())
|
||||||
|
} catch (e: any) {
|
||||||
|
// If header-based refresh fails (wrong token type), fall back to cookie refresh.
|
||||||
|
if (refreshToken && e?.response?.status === 401) {
|
||||||
|
refreshRes = await tryCookieRefresh()
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextTokens = extractTokensFromResponsePayload(refreshRes?.data)
|
||||||
|
if (nextTokens) setStoredTokens(nextTokens)
|
||||||
|
// Some backends may return only a new access token; keep refresh token.
|
||||||
|
else {
|
||||||
|
const maybeAccess = (refreshRes?.data?.data?.access_token ??
|
||||||
|
refreshRes?.data?.access_token) as unknown
|
||||||
|
if (typeof maybeAccess === "string" && maybeAccess.trim()) {
|
||||||
|
// Keep refresh token if we have one; otherwise rely on cookies.
|
||||||
|
if (refreshToken) setStoredTokens({ access_token: maybeAccess, refresh_token: refreshToken })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
processQueue()
|
processQueue()
|
||||||
|
|
||||||
return api(originalRequest)
|
return api(originalRequest)
|
||||||
} catch (refreshErr) {
|
} catch (refreshErr: any) {
|
||||||
processQueue(refreshErr)
|
processQueue(refreshErr)
|
||||||
|
// Only force logout when refresh token/session is truly invalid (401).
|
||||||
window.location.href = "/signin"
|
if (refreshErr?.response?.status === 401) {
|
||||||
|
clearStoredTokens()
|
||||||
|
window.location.href = "/signin"
|
||||||
|
}
|
||||||
|
|
||||||
return Promise.reject(refreshErr)
|
return Promise.reject(refreshErr)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -62,4 +130,4 @@ api.interceptors.response.use(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export default api
|
export default api
|
||||||
|
|||||||
9
src/interface/chatbot.ts
Normal file
9
src/interface/chatbot.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export interface ChatbotPayload {
|
||||||
|
project_id?: string;
|
||||||
|
question: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatbotResponse {
|
||||||
|
status: boolean;
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ export interface Project {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
project_status: "PRIVATE" | "PUBLIC" | "ARCHIVE";
|
project_status: "PRIVATE" | "PUBLIC" | "ARCHIVE";
|
||||||
|
latest_commit_id?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
is_deleted?: boolean;
|
is_deleted?: boolean;
|
||||||
@@ -14,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> {
|
||||||
@@ -79,4 +83,4 @@ export interface AddMemberPayload {
|
|||||||
|
|
||||||
export interface UpdateMemberRolePayload {
|
export interface UpdateMemberRolePayload {
|
||||||
role: "PRIVATE" | "PUBLIC" | "ARCHIVE",
|
role: "PRIVATE" | "PUBLIC" | "ARCHIVE",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import api from "@/config/config";
|
import api from "@/config/config";
|
||||||
import { API } from "../../api";
|
import { API } from "../../api";
|
||||||
|
import { clearStoredTokens, extractTokensFromResponsePayload, setStoredTokens } from "@/auth/tokenStore";
|
||||||
|
|
||||||
export const apiCreateOTP = async (email: string) => {
|
export const apiCreateOTP = async (email: string, token_type: number = 2) => {
|
||||||
const token_type = 2;
|
|
||||||
const response = await api.post(API.Auth.CREATEOTP, {
|
const response = await api.post(API.Auth.CREATEOTP, {
|
||||||
email,
|
email,
|
||||||
token_type
|
token_type
|
||||||
@@ -10,8 +10,8 @@ export const apiCreateOTP = async (email: string) => {
|
|||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const apiVerifyOTP = async (email: string, token: string) => {
|
export const apiVerifyOTP = async (email: string, token: string, token_type: number = 2) => {
|
||||||
const body = { email, token, token_type: 2 };
|
const body = { email, token, token_type };
|
||||||
const response = await api.post(API.Auth.VERIFYOTP, body);
|
const response = await api.post(API.Auth.VERIFYOTP, body);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
@@ -23,11 +23,14 @@ export const apiSignUp = async (payload: any) => {
|
|||||||
|
|
||||||
export const apiLogout = async () => {
|
export const apiLogout = async () => {
|
||||||
const response = await api.post(API.Auth.LOGOUT);
|
const response = await api.post(API.Auth.LOGOUT);
|
||||||
|
clearStoredTokens();
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const apiSignIn = async (payload: any) => {
|
export const apiSignIn = async (payload: any) => {
|
||||||
const response = await api.post(API.Auth.SIGNIN, payload);
|
const response = await api.post(API.Auth.SIGNIN, payload);
|
||||||
|
const tokens = extractTokensFromResponsePayload(response?.data);
|
||||||
|
if (tokens) setStoredTokens(tokens);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
8
src/service/chatbotService.ts
Normal file
8
src/service/chatbotService.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import api from "@/config/config";
|
||||||
|
import { API } from "../../api";
|
||||||
|
import { ChatbotPayload, ChatbotResponse } from "@/interface/chatbot";
|
||||||
|
|
||||||
|
export const apiChatbot = async (payload: ChatbotPayload): Promise<ChatbotResponse> => {
|
||||||
|
const response = await api.post(API.Chatbot.CHAT,payload);
|
||||||
|
return await response?.data;
|
||||||
|
};
|
||||||
35
src/uhm/api/auth.ts
Normal file
35
src/uhm/api/auth.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { API_ENDPOINTS } from "@/uhm/api/config";
|
||||||
|
import { jsonRequestInit, requestJson } from "@/uhm/api/http";
|
||||||
|
import { clearStoredTokens, setStoredTokens } from "@/auth/tokenStore";
|
||||||
|
|
||||||
|
export type AuthTokens = {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CurrentUser = {
|
||||||
|
id: string;
|
||||||
|
email?: string;
|
||||||
|
display_name?: string;
|
||||||
|
avatar_url?: string | null;
|
||||||
|
roles?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function signIn(email: string, password: string): Promise<AuthTokens> {
|
||||||
|
const res = await requestJson<AuthTokens>(
|
||||||
|
API_ENDPOINTS.authSignin,
|
||||||
|
jsonRequestInit("POST", { email, password }),
|
||||||
|
{ skipAuth: true }
|
||||||
|
);
|
||||||
|
if (res?.access_token && res?.refresh_token) setStoredTokens(res);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
await requestJson(API_ENDPOINTS.authLogout, { method: "POST" });
|
||||||
|
clearStoredTokens();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCurrentUser(): Promise<CurrentUser> {
|
||||||
|
return requestJson<CurrentUser>(API_ENDPOINTS.currentUser);
|
||||||
|
}
|
||||||
24
src/uhm/api/config.ts
Normal file
24
src/uhm/api/config.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// Production BackEndGo API base URL.
|
||||||
|
// For local development, override with NEXT_PUBLIC_API_BASE_URL (e.g. http://localhost:3344).
|
||||||
|
const FALLBACK_API_BASE_URL = "https://history-api.kain.id.vn";
|
||||||
|
|
||||||
|
export const API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL || FALLBACK_API_BASE_URL;
|
||||||
|
|
||||||
|
export const API_ENDPOINTS = {
|
||||||
|
geometries: `${API_BASE_URL}/geometries`,
|
||||||
|
entities: `${API_BASE_URL}/entities`,
|
||||||
|
wikis: `${API_BASE_URL}/wikis`,
|
||||||
|
// New API uses projects + commits + submissions (JWT-protected).
|
||||||
|
authSignin: `${API_BASE_URL}/auth/signin`,
|
||||||
|
authRefresh: `${API_BASE_URL}/auth/refresh`,
|
||||||
|
authLogout: `${API_BASE_URL}/auth/logout`,
|
||||||
|
currentUser: `${API_BASE_URL}/users/current`,
|
||||||
|
currentUserProjects: `${API_BASE_URL}/users/current/project`,
|
||||||
|
projects: `${API_BASE_URL}/projects`,
|
||||||
|
submissions: `${API_BASE_URL}/submissions`,
|
||||||
|
vectorTiles: `${API_BASE_URL}/tiles/{z}/{x}/{y}`,
|
||||||
|
rasterTiles: `${API_BASE_URL}/raster-tiles/{z}/{x}/{y}`,
|
||||||
|
vectorTilesMetadata: `${API_BASE_URL}/tiles/metadata`,
|
||||||
|
rasterTilesMetadata: `${API_BASE_URL}/raster-tiles/metadata`,
|
||||||
|
} as const;
|
||||||
32
src/uhm/api/entities.ts
Normal file
32
src/uhm/api/entities.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { API_ENDPOINTS } from "@/uhm/api/config";
|
||||||
|
import { requestJson } from "@/uhm/api/http";
|
||||||
|
import type { Entity } from "@/uhm/types/entities";
|
||||||
|
|
||||||
|
export type { Entity } from "@/uhm/types/entities";
|
||||||
|
|
||||||
|
export async function fetchEntities(query?: { q?: string }): Promise<Entity[]> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
// API mới dùng `name` thay vì `q`.
|
||||||
|
if (query?.q) {
|
||||||
|
params.set("name", query.q);
|
||||||
|
}
|
||||||
|
const suffix = params.toString();
|
||||||
|
const url = suffix ? `${API_ENDPOINTS.entities}?${suffix}` : API_ENDPOINTS.entities;
|
||||||
|
return requestJson<Entity[]>(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchEntitiesByName(
|
||||||
|
name: string,
|
||||||
|
options?: { limit?: number }
|
||||||
|
): Promise<Entity[]> {
|
||||||
|
const keyword = name.trim();
|
||||||
|
if (!keyword.length) return [];
|
||||||
|
|
||||||
|
const params = new URLSearchParams({ name: keyword });
|
||||||
|
if (options?.limit && Number.isFinite(options.limit)) {
|
||||||
|
params.set("limit", String(Math.trunc(options.limit)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// API mới không có `/entities/search`, search qua query string.
|
||||||
|
return requestJson<Entity[]>(`${API_ENDPOINTS.entities}?${params.toString()}`);
|
||||||
|
}
|
||||||
131
src/uhm/api/geometries.ts
Normal file
131
src/uhm/api/geometries.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { API_ENDPOINTS } from "@/uhm/api/config";
|
||||||
|
import { requestJson } from "@/uhm/api/http";
|
||||||
|
import type { GeometriesBBoxQuery } from "@/uhm/types/api";
|
||||||
|
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 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 {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
// API mới dùng snake_case
|
||||||
|
min_lng: String(params.minLng),
|
||||||
|
min_lat: String(params.minLat),
|
||||||
|
max_lng: String(params.maxLng),
|
||||||
|
max_lat: String(params.maxLat),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (params.time !== undefined) {
|
||||||
|
query.set("time", String(params.time));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.entity_id) {
|
||||||
|
query.set("entity_id", params.entity_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGeometriesByBBox(params: GeometriesBBoxQuery): Promise<FeatureCollection> {
|
||||||
|
const url = `${API_ENDPOINTS.geometries}?${buildBBoxQueryString(params)}`;
|
||||||
|
// API mới trả về list geometries, FE cần chuyển thành GeoJSON FeatureCollection.
|
||||||
|
const rows = await requestJson<GeometryRow[]>(url);
|
||||||
|
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 = {
|
||||||
|
id: string;
|
||||||
|
geo_type: number;
|
||||||
|
draw_geometry: unknown;
|
||||||
|
binding?: unknown;
|
||||||
|
time_start?: number;
|
||||||
|
time_end?: number;
|
||||||
|
bbox?: {
|
||||||
|
min_lng: number;
|
||||||
|
min_lat: number;
|
||||||
|
max_lng: number;
|
||||||
|
max_lat: number;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function geometriesToFeatureCollection(rows: GeometryRow[]): FeatureCollection {
|
||||||
|
const features: Feature[] = [];
|
||||||
|
|
||||||
|
for (const row of rows || []) {
|
||||||
|
const geometry = normalizeGeometry(row.draw_geometry);
|
||||||
|
if (!geometry) continue;
|
||||||
|
|
||||||
|
const binding = normalizeBinding(row.binding);
|
||||||
|
const typeKey = geoTypeCodeToTypeKey(row.geo_type) || null;
|
||||||
|
|
||||||
|
const properties: FeatureProperties = {
|
||||||
|
id: row.id,
|
||||||
|
type: typeKey,
|
||||||
|
time_start: row.time_start ?? null,
|
||||||
|
time_end: row.time_end ?? null,
|
||||||
|
binding: binding.length ? binding : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
features.push({
|
||||||
|
type: "Feature",
|
||||||
|
properties,
|
||||||
|
geometry,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: "FeatureCollection", features };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGeometry(value: unknown): Geometry | null {
|
||||||
|
if (!value || typeof value !== "object") return null;
|
||||||
|
const g = value as Record<string, unknown>;
|
||||||
|
if (typeof g.type !== "string") return null;
|
||||||
|
if (!("coordinates" in g)) return null;
|
||||||
|
return value as Geometry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBinding(value: unknown): string[] {
|
||||||
|
if (!value) return [];
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((v) => String(v)).filter((v) => v.length > 0);
|
||||||
|
}
|
||||||
|
// Some deployments may return binding as an object; ignore it for FE properties.binding.
|
||||||
|
return [];
|
||||||
|
}
|
||||||
218
src/uhm/api/http.ts
Normal file
218
src/uhm/api/http.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import type { ApiEnvelope } from "@/uhm/types/api";
|
||||||
|
import { API_ENDPOINTS } from "@/uhm/api/config";
|
||||||
|
import { getAccessToken, getRefreshToken, setStoredTokens, type StoredTokens, extractTokensFromResponsePayload } from "@/auth/tokenStore";
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
body: string;
|
||||||
|
errors: unknown[];
|
||||||
|
|
||||||
|
constructor(message: string, status: number, body: string, errors: unknown[] = []) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
this.status = status;
|
||||||
|
this.body = body;
|
||||||
|
this.errors = errors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// History API auth flow supports Bearer JWT and (in some deployments) cookie-based sessions.
|
||||||
|
|
||||||
|
type RequestJsonOptions = {
|
||||||
|
skipAuth?: boolean;
|
||||||
|
skipRefresh?: boolean;
|
||||||
|
authToken?: string | null; // Override bearer token (used for refresh).
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function requestJson<T>(
|
||||||
|
input: RequestInfo | URL,
|
||||||
|
init?: RequestInit,
|
||||||
|
options?: RequestJsonOptions
|
||||||
|
): Promise<T> {
|
||||||
|
return requestJsonInternal<T>(input, init, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jsonRequestInit(method: string, body: unknown): RequestInit {
|
||||||
|
return {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJsonInternal<T>(
|
||||||
|
input: RequestInfo | URL,
|
||||||
|
init?: RequestInit,
|
||||||
|
options?: RequestJsonOptions
|
||||||
|
): Promise<T> {
|
||||||
|
const nextInit = withAuthHeaders(init, options);
|
||||||
|
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.
|
||||||
|
if (
|
||||||
|
res.status === 401 &&
|
||||||
|
!options?.skipRefresh &&
|
||||||
|
!options?.skipAuth &&
|
||||||
|
typeof input === "string" &&
|
||||||
|
!String(input).includes("/auth/")
|
||||||
|
) {
|
||||||
|
const refreshed = await tryRefreshTokens();
|
||||||
|
if (refreshed) {
|
||||||
|
return requestJsonInternal<T>(input, init, { ...(options || {}), skipRefresh: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await parseJsonResponse(res);
|
||||||
|
const envelope = isApiEnvelopeLike<T>(payload) ? payload : null;
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const message = extractErrorMessage(payload, envelope) || `Request failed with status ${res.status}`;
|
||||||
|
const body = envelope ? stringifyPayload(envelope) : stringifyPayload(payload);
|
||||||
|
const errors = envelope?.errors ? normalizeErrors(envelope.errors) : [];
|
||||||
|
throw new ApiError(message, res.status, body, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope) {
|
||||||
|
const isError =
|
||||||
|
envelope.status === false ||
|
||||||
|
envelope.status === "error";
|
||||||
|
if (isError) {
|
||||||
|
const message = extractErrorMessage(payload, envelope) || "Request failed";
|
||||||
|
throw new ApiError(message, res.status, stringifyPayload(envelope), normalizeErrors(envelope.errors));
|
||||||
|
}
|
||||||
|
return (envelope.data ?? null) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseJsonResponse(res: Response): Promise<unknown> {
|
||||||
|
const text = await res.text();
|
||||||
|
if (!text.length) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isApiEnvelopeLike<T>(value: unknown): value is ApiEnvelope<T> {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||||
|
const source = value as Record<string, unknown>;
|
||||||
|
return "status" in source && ("data" in source || "message" in source || "errors" in source);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeErrors(value: unknown): unknown[] {
|
||||||
|
if (value == null) return [];
|
||||||
|
if (Array.isArray(value)) return value;
|
||||||
|
return [value];
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractErrorMessage(payload: unknown, envelope: ApiEnvelope<unknown> | null): string | null {
|
||||||
|
const msg =
|
||||||
|
(typeof envelope?.message === "string" && envelope.message.trim()) ||
|
||||||
|
(typeof (payload as any)?.message === "string" && String((payload as any).message).trim());
|
||||||
|
if (msg) return msg;
|
||||||
|
const errors = envelope?.errors ?? (payload as any)?.errors;
|
||||||
|
if (typeof errors === "string" && errors.trim()) return errors.trim();
|
||||||
|
if (Array.isArray(errors) && typeof errors[0] === "string") return errors[0];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyPayload(payload: unknown): string {
|
||||||
|
if (typeof payload === "string") return payload;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(payload);
|
||||||
|
} catch {
|
||||||
|
return String(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withAuthHeaders(init: RequestInit | undefined, options?: RequestJsonOptions): RequestInit | undefined {
|
||||||
|
const baseInit: RequestInit = {
|
||||||
|
...init,
|
||||||
|
credentials: init?.credentials ?? "include",
|
||||||
|
};
|
||||||
|
|
||||||
|
const headers = new Headers(baseInit.headers || undefined);
|
||||||
|
|
||||||
|
const override = options?.authToken;
|
||||||
|
if (override) {
|
||||||
|
headers.set("Authorization", `Bearer ${override}`);
|
||||||
|
return { ...baseInit, headers };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.skipAuth) return baseInit;
|
||||||
|
|
||||||
|
const access = getAccessToken();
|
||||||
|
if (access) headers.set("Authorization", `Bearer ${access}`);
|
||||||
|
return { ...baseInit, headers };
|
||||||
|
}
|
||||||
|
|
||||||
|
let refreshInFlight: Promise<boolean> | null = null;
|
||||||
|
|
||||||
|
async function tryRefreshTokens(): Promise<boolean> {
|
||||||
|
// Single-flight refresh for concurrent 401s.
|
||||||
|
if (refreshInFlight) return refreshInFlight;
|
||||||
|
refreshInFlight = (async () => {
|
||||||
|
try {
|
||||||
|
const refreshToken = getRefreshToken();
|
||||||
|
|
||||||
|
// Try header-based refresh first (per swagger), but fall back to cookie-based refresh if needed.
|
||||||
|
let payload: unknown;
|
||||||
|
try {
|
||||||
|
payload = await requestJsonInternal<unknown>(
|
||||||
|
API_ENDPOINTS.authRefresh,
|
||||||
|
{ method: "POST" },
|
||||||
|
refreshToken
|
||||||
|
? { skipRefresh: true, authToken: refreshToken }
|
||||||
|
: { skipRefresh: true, skipAuth: true }
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (refreshToken && err instanceof ApiError && err.status === 401) {
|
||||||
|
payload = await requestJsonInternal<unknown>(
|
||||||
|
API_ENDPOINTS.authRefresh,
|
||||||
|
{ method: "POST" },
|
||||||
|
{ skipRefresh: true, skipAuth: true }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = extractTokensFromResponsePayload(payload) as StoredTokens | null;
|
||||||
|
if (next) {
|
||||||
|
setStoredTokens(next);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: if server returns only access_token, keep existing refresh token (if any).
|
||||||
|
const maybeAccess = (payload as any)?.access_token ?? (payload as any)?.data?.access_token;
|
||||||
|
if (typeof maybeAccess === "string" && maybeAccess.trim()) {
|
||||||
|
if (refreshToken) setStoredTokens({ access_token: maybeAccess, refresh_token: refreshToken });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
try {
|
||||||
|
return await refreshInFlight;
|
||||||
|
} finally {
|
||||||
|
refreshInFlight = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
153
src/uhm/api/sections.ts
Normal file
153
src/uhm/api/sections.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { API_BASE_URL, API_ENDPOINTS } from "@/uhm/api/config";
|
||||||
|
import { ApiError, jsonRequestInit, requestJson } from "@/uhm/api/http";
|
||||||
|
import type {
|
||||||
|
CreateCommitInput,
|
||||||
|
CreateSectionInput,
|
||||||
|
EditorLoadResponse,
|
||||||
|
RestoreCommitInput,
|
||||||
|
Section,
|
||||||
|
SectionCommit,
|
||||||
|
SectionState,
|
||||||
|
SectionSubmission,
|
||||||
|
} from "@/uhm/types/sections";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
CreateCommitInput,
|
||||||
|
CreateSectionInput,
|
||||||
|
EditorLoadResponse,
|
||||||
|
RestoreCommitInput,
|
||||||
|
Section,
|
||||||
|
SectionCommit,
|
||||||
|
SectionState,
|
||||||
|
SectionSubmission,
|
||||||
|
} from "@/uhm/types/sections";
|
||||||
|
|
||||||
|
// Sections (API cũ) => Projects (API mới)
|
||||||
|
|
||||||
|
export async function fetchSections(): Promise<Section[]> {
|
||||||
|
// /users/current/project requires JWT.
|
||||||
|
return requestJson<Section[]>(API_ENDPOINTS.currentUserProjects);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSection(input: CreateSectionInput): Promise<Section> {
|
||||||
|
// POST /projects
|
||||||
|
return requestJson<Section>(API_ENDPOINTS.projects, jsonRequestInit("POST", input));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openSectionEditor(sectionId: string): Promise<EditorLoadResponse> {
|
||||||
|
// API mới không có endpoint "editor". FE tự load:
|
||||||
|
// 1) Project details
|
||||||
|
// 2) Project commits (to get snapshot_json of latest commit)
|
||||||
|
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 headCommitId = project.latest_commit_id ?? null;
|
||||||
|
const headCommit = headCommitId ? commits.find((c) => c.id === headCommitId) || null : null;
|
||||||
|
const snapshot = headCommit?.snapshot_json ?? null;
|
||||||
|
|
||||||
|
const state: SectionState = {
|
||||||
|
status: project.project_status || "ACTIVE",
|
||||||
|
head_commit_id: headCommitId,
|
||||||
|
locked_by: project.locked_by ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
section: project,
|
||||||
|
state,
|
||||||
|
commit: headCommit,
|
||||||
|
snapshot,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSectionCommit(
|
||||||
|
sectionId: string,
|
||||||
|
input: CreateCommitInput
|
||||||
|
): Promise<{ commit: SectionCommit; state: SectionState }> {
|
||||||
|
// POST /projects/{id}/commits
|
||||||
|
const commit = await requestJson<SectionCommit>(
|
||||||
|
`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}/commits`,
|
||||||
|
jsonRequestInit("POST", {
|
||||||
|
snapshot_json: input.snapshot,
|
||||||
|
edit_summary: input.edit_summary,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Refresh project state (latest_commit_id may have moved).
|
||||||
|
const project = await requestJson<Section>(`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}`);
|
||||||
|
const state: SectionState = {
|
||||||
|
status: project.project_status || "ACTIVE",
|
||||||
|
head_commit_id: project.latest_commit_id ?? null,
|
||||||
|
locked_by: project.locked_by ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { commit, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSectionCommits(sectionId: string): Promise<SectionCommit[]> {
|
||||||
|
return requestJson<SectionCommit[]>(`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}/commits`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreSectionCommit(
|
||||||
|
sectionId: string,
|
||||||
|
input: RestoreCommitInput
|
||||||
|
): Promise<{ commit: SectionCommit | null; state: SectionState }> {
|
||||||
|
// POST /projects/{id}/commits/restore
|
||||||
|
await requestJson(
|
||||||
|
`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}/commits/restore`,
|
||||||
|
jsonRequestInit("POST", { commit_id: input.commit_id })
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reload commits + project to determine new head commit.
|
||||||
|
const project = await requestJson<Section>(`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}`);
|
||||||
|
const commits = await fetchSectionCommits(sectionId);
|
||||||
|
const headCommitId = project.latest_commit_id ?? null;
|
||||||
|
const headCommit = headCommitId ? commits.find((c) => c.id === headCommitId) || null : null;
|
||||||
|
|
||||||
|
const state: SectionState = {
|
||||||
|
status: project.project_status || "ACTIVE",
|
||||||
|
head_commit_id: headCommitId,
|
||||||
|
locked_by: project.locked_by ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { commit: headCommit, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitSection(sectionId: string): Promise<SectionSubmission> {
|
||||||
|
// Submit latest commit of project
|
||||||
|
const project = await requestJson<Section>(`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}`);
|
||||||
|
const commitId = project.latest_commit_id;
|
||||||
|
if (!commitId) {
|
||||||
|
throw new Error("Project has no latest commit to submit");
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestJson<SectionSubmission>(
|
||||||
|
API_ENDPOINTS.submissions,
|
||||||
|
jsonRequestInit("POST", {
|
||||||
|
project_id: sectionId,
|
||||||
|
commit_id: commitId,
|
||||||
|
content: "",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSubmission(submissionId: string): Promise<unknown> {
|
||||||
|
return requestJson(
|
||||||
|
`${API_ENDPOINTS.submissions}/${encodeURIComponent(submissionId)}`,
|
||||||
|
{ method: "DELETE" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience for runtime logs/debug: expose effective base.
|
||||||
|
export const EFFECTIVE_API_BASE_URL = API_BASE_URL;
|
||||||
20
src/uhm/api/tiles.ts
Normal file
20
src/uhm/api/tiles.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { API_ENDPOINTS } from "@/uhm/api/config";
|
||||||
|
import { requestJson } from "@/uhm/api/http";
|
||||||
|
|
||||||
|
export type TileMetadata = Record<string, string>;
|
||||||
|
|
||||||
|
export function getVectorTileTemplateUrl(): string {
|
||||||
|
return API_ENDPOINTS.vectorTiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRasterTileTemplateUrl(): string {
|
||||||
|
return API_ENDPOINTS.rasterTiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchVectorTilesMetadata(): Promise<TileMetadata> {
|
||||||
|
return requestJson<TileMetadata>(API_ENDPOINTS.vectorTilesMetadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRasterTilesMetadata(): Promise<TileMetadata> {
|
||||||
|
return requestJson<TileMetadata>(API_ENDPOINTS.rasterTilesMetadata);
|
||||||
|
}
|
||||||
50
src/uhm/api/wikis.ts
Normal file
50
src/uhm/api/wikis.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { API_ENDPOINTS } from "@/uhm/api/config";
|
||||||
|
import { requestJson } from "@/uhm/api/http";
|
||||||
|
|
||||||
|
export type Wiki = {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
is_deleted?: boolean;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function searchWikisByTitle(title: string, options?: { limit?: number; cursor?: string; entityId?: string }): Promise<Wiki[]> {
|
||||||
|
const keyword = title.trim();
|
||||||
|
if (!keyword.length) return [];
|
||||||
|
|
||||||
|
const params = new URLSearchParams({ title: keyword });
|
||||||
|
if (options?.limit && Number.isFinite(options.limit)) params.set("limit", String(Math.trunc(options.limit)));
|
||||||
|
if (options?.cursor) params.set("cursor", options.cursor);
|
||||||
|
if (options?.entityId) params.set("entity_id", options.entityId);
|
||||||
|
|
||||||
|
return requestJson<Wiki[]>(`${API_ENDPOINTS.wikis}?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWikiById(id: string): Promise<Wiki> {
|
||||||
|
const wikiId = String(id || "").trim();
|
||||||
|
if (!wikiId) throw new Error("Missing wiki id");
|
||||||
|
return requestJson<Wiki>(`${API_ENDPOINTS.wikis}/${encodeURIComponent(wikiId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkWikiSlugExists(slug: string): Promise<boolean> {
|
||||||
|
const value = String(slug || "").trim();
|
||||||
|
if (!value.length) return false;
|
||||||
|
|
||||||
|
const params = new URLSearchParams({ slug: value });
|
||||||
|
const url = `${API_ENDPOINTS.wikis}/slug/exists?${params.toString()}`;
|
||||||
|
const payload = await requestJson<unknown>(url);
|
||||||
|
|
||||||
|
if (typeof payload === "boolean") return payload;
|
||||||
|
if (payload && typeof payload === "object") {
|
||||||
|
const anyPayload = payload as any;
|
||||||
|
if (typeof anyPayload.exists === "boolean") return anyPayload.exists;
|
||||||
|
if (typeof anyPayload.exists === "number") return anyPayload.exists !== 0;
|
||||||
|
if (typeof anyPayload.is_exists === "boolean") return anyPayload.is_exists;
|
||||||
|
if (typeof anyPayload.is_exists === "number") return anyPayload.is_exists !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Be conservative: unknown payload shape, treat as "exists" to prevent creating conflicting slugs.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
9
src/uhm/components/AuthPanel.tsx
Normal file
9
src/uhm/components/AuthPanel.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
// This component remains as a no-op placeholder for any legacy imports.
|
||||||
|
export default function AuthPanel() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
74
src/uhm/components/BackgroundLayersPanel.tsx
Normal file
74
src/uhm/components/BackgroundLayersPanel.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
BACKGROUND_LAYER_OPTIONS,
|
||||||
|
BackgroundLayerId,
|
||||||
|
BackgroundLayerVisibility,
|
||||||
|
} from "@/uhm/lib/backgroundLayers";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
visibility: BackgroundLayerVisibility;
|
||||||
|
onToggleLayer: (id: BackgroundLayerId) => void;
|
||||||
|
onShowAll: () => void;
|
||||||
|
onHideAll: () => void;
|
||||||
|
topContent?: ReactNode;
|
||||||
|
width?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BackgroundLayersPanel({
|
||||||
|
visibility,
|
||||||
|
onToggleLayer,
|
||||||
|
onShowAll,
|
||||||
|
onHideAll,
|
||||||
|
topContent,
|
||||||
|
width = 240,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
style={{
|
||||||
|
width,
|
||||||
|
background: "#111827",
|
||||||
|
color: "#e5e7eb",
|
||||||
|
borderLeft: "1px solid #1f2937",
|
||||||
|
padding: "12px",
|
||||||
|
height: "100vh",
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{topContent ? <div style={{ marginBottom: "12px" }}>{topContent}</div> : null}
|
||||||
|
|
||||||
|
<h3 style={{ margin: 0, marginBottom: "10px" }}>Map Layers</h3>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "10px 12px" }}>
|
||||||
|
{BACKGROUND_LAYER_OPTIONS.map((layer) => {
|
||||||
|
const on = Boolean(visibility[layer.id]);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={layer.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggleLayer(layer.id)}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
padding: 0,
|
||||||
|
margin: 0,
|
||||||
|
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"}
|
||||||
|
>
|
||||||
|
{layer.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
524
src/uhm/components/Editor.tsx
Normal file
524
src/uhm/components/Editor.tsx
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { UndoAction } from "@/uhm/lib/useEditorState";
|
||||||
|
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
mode: EditorMode;
|
||||||
|
setMode: (mode: EditorMode) => void;
|
||||||
|
entityStatus?: string | null;
|
||||||
|
onUndo: () => void;
|
||||||
|
onCommit: () => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
onRestoreCommit: (commitId: string) => void;
|
||||||
|
isSaving: boolean;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
sectionTitle: string;
|
||||||
|
sectionStatus: string;
|
||||||
|
commitTitle: string;
|
||||||
|
commitNote: string;
|
||||||
|
onCommitTitleChange: (title: string) => void;
|
||||||
|
onCommitNoteChange: (note: string) => void;
|
||||||
|
commitCount: number;
|
||||||
|
hasHeadCommit: boolean;
|
||||||
|
headCommitId: string | null;
|
||||||
|
latestCommitLabel: string | null;
|
||||||
|
commits: Array<{
|
||||||
|
id: string;
|
||||||
|
created_at?: string;
|
||||||
|
edit_summary: string;
|
||||||
|
user_id: string;
|
||||||
|
}>;
|
||||||
|
changesCount: number;
|
||||||
|
undoStack: UndoAction[];
|
||||||
|
createdEntities: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}>;
|
||||||
|
createdGeometries: Array<{
|
||||||
|
id: string | number;
|
||||||
|
geometryType: string;
|
||||||
|
semanticType?: string | null;
|
||||||
|
entityNames: string[];
|
||||||
|
}>;
|
||||||
|
width?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Editor({
|
||||||
|
mode,
|
||||||
|
setMode,
|
||||||
|
entityStatus,
|
||||||
|
onUndo,
|
||||||
|
onCommit,
|
||||||
|
onSubmit,
|
||||||
|
onRestoreCommit,
|
||||||
|
isSaving,
|
||||||
|
isSubmitting,
|
||||||
|
sectionTitle,
|
||||||
|
sectionStatus,
|
||||||
|
commitTitle,
|
||||||
|
commitNote,
|
||||||
|
onCommitTitleChange,
|
||||||
|
onCommitNoteChange,
|
||||||
|
commitCount,
|
||||||
|
hasHeadCommit,
|
||||||
|
headCommitId,
|
||||||
|
latestCommitLabel,
|
||||||
|
commits,
|
||||||
|
changesCount,
|
||||||
|
undoStack,
|
||||||
|
createdEntities,
|
||||||
|
createdGeometries,
|
||||||
|
width = 280,
|
||||||
|
}: Props) {
|
||||||
|
const toggleMode = (newMode: EditorMode) => {
|
||||||
|
if (mode === newMode) {
|
||||||
|
setMode("idle");
|
||||||
|
} else {
|
||||||
|
setMode(newMode);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const recentUndoLabels = (() => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const labels: string[] = [];
|
||||||
|
for (let i = undoStack.length - 1; i >= 0 && labels.length < 8; i -= 1) {
|
||||||
|
const label = formatUndoLabel(undoStack[i]);
|
||||||
|
if (seen.has(label)) continue;
|
||||||
|
seen.add(label);
|
||||||
|
labels.push(label);
|
||||||
|
}
|
||||||
|
return labels.reverse();
|
||||||
|
})();
|
||||||
|
|
||||||
|
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%",
|
||||||
|
padding: "8px 10px",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: 850,
|
||||||
|
fontSize: 12,
|
||||||
|
}) as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width,
|
||||||
|
height: "100vh",
|
||||||
|
overflowY: "auto",
|
||||||
|
background: "#0b1220",
|
||||||
|
color: "white",
|
||||||
|
padding: "12px 12px 20px",
|
||||||
|
borderRight: "1px solid #1f2937",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ position: "sticky", top: 0, zIndex: 5, background: "#0b1220", paddingBottom: 10 }}>
|
||||||
|
<div style={{ fontWeight: 950, fontSize: 14, marginBottom: 10 }}>Editor</div>
|
||||||
|
|
||||||
|
<Panel title="Project" defaultOpen>
|
||||||
|
<div style={{ fontSize: 12, color: "#cbd5e1", lineHeight: 1.4 }}>
|
||||||
|
<div style={{ color: "white", fontWeight: 850, overflowWrap: "anywhere" }}>{sectionTitle}</div>
|
||||||
|
<div style={{ marginTop: 6 }}>
|
||||||
|
Status: <span style={{ color: "#e2e8f0" }}>{sectionStatus}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 6 }}>
|
||||||
|
Commits: <span style={{ color: "#e2e8f0" }}>{commitCount}</span>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<Panel title="Tools" defaultOpen>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||||
|
<button style={modeButtonStyle("select")} onClick={() => toggleMode("select")} title="Select">
|
||||||
|
Select
|
||||||
|
</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
|
||||||
|
style={{
|
||||||
|
...modeButtonStyle("idle"),
|
||||||
|
background: "#111827",
|
||||||
|
}}
|
||||||
|
onClick={() => setMode("idle")}
|
||||||
|
title="Tắt tool hiện tại"
|
||||||
|
>
|
||||||
|
Idle
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
...modeButtonStyle("idle"),
|
||||||
|
background: "#334155",
|
||||||
|
}}
|
||||||
|
onClick={onUndo}
|
||||||
|
title="Undo thao tác gần nhất"
|
||||||
|
>
|
||||||
|
Undo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{entityStatus ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 10,
|
||||||
|
padding: "10px",
|
||||||
|
background: "#111827",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid #7f1d1d",
|
||||||
|
color: "#fecaca",
|
||||||
|
fontSize: 12,
|
||||||
|
overflowWrap: "anywhere",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entityStatus}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Panel title="Commit" defaultOpen>
|
||||||
|
<input
|
||||||
|
value={commitTitle}
|
||||||
|
onChange={(event) => onCommitTitleChange(event.target.value)}
|
||||||
|
placeholder="Commit title"
|
||||||
|
disabled={isSaving || isSubmitting}
|
||||||
|
style={textInputStyle}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
value={commitNote}
|
||||||
|
onChange={(event) => onCommitNoteChange(event.target.value)}
|
||||||
|
placeholder="Commit note"
|
||||||
|
disabled={isSaving || isSubmitting}
|
||||||
|
rows={3}
|
||||||
|
style={textAreaStyle}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
...primaryButtonStyle,
|
||||||
|
marginTop: 8,
|
||||||
|
background: isSaving || isSubmitting || changesCount <= 0 ? "#475569" : "#0f766e",
|
||||||
|
cursor: isSaving || isSubmitting || changesCount <= 0 ? "not-allowed" : "pointer",
|
||||||
|
opacity: changesCount <= 0 ? 0.75 : 1,
|
||||||
|
}}
|
||||||
|
onClick={onCommit}
|
||||||
|
disabled={isSaving || isSubmitting || changesCount <= 0}
|
||||||
|
title={changesCount <= 0 ? "Khong co thay doi de commit" : undefined}
|
||||||
|
>
|
||||||
|
Commit ({changesCount})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
...primaryButtonStyle,
|
||||||
|
marginTop: 8,
|
||||||
|
background: isSubmitting || !hasHeadCommit ? "#475569" : "#16a34a",
|
||||||
|
cursor: isSubmitting || !hasHeadCommit ? "not-allowed" : "pointer",
|
||||||
|
opacity: !hasHeadCommit ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={isSubmitting || !hasHeadCommit}
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel title="Commit History" badge={String(commits.length)} defaultOpen={false}>
|
||||||
|
{commits.length === 0 ? (
|
||||||
|
<div style={{ color: "#64748b", fontSize: 12 }}>Chưa có commit</div>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: 12 }}>
|
||||||
|
{commits.slice(0, 8).map((commit) => {
|
||||||
|
const isHead = Boolean(headCommitId && commit.id === headCommitId);
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={commit.id}
|
||||||
|
style={{
|
||||||
|
padding: "8px 0",
|
||||||
|
borderBottom: "1px solid #1f2937",
|
||||||
|
color: "#e2e8f0",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{flex:1}}>
|
||||||
|
<div
|
||||||
|
title={formatCommitTitle(commit)}
|
||||||
|
style={{
|
||||||
|
fontWeight: 750,
|
||||||
|
color: "#f8fafc",
|
||||||
|
overflowWrap: "anywhere",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatCommitTitle(commit)}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 3, color: "#94a3b8" }}>
|
||||||
|
{commit.created_at ? new Date(commit.created_at).toLocaleString() : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
marginTop: 6,
|
||||||
|
padding: "6px 8px",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "1px solid #334155",
|
||||||
|
background: isHead ? "#0b1220" : "#334155",
|
||||||
|
color: "white",
|
||||||
|
cursor: isSaving || isSubmitting || isHead ? "not-allowed" : "pointer",
|
||||||
|
opacity: isHead ? 0.65 : 1,
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
onClick={() => onRestoreCommit(commit.id)}
|
||||||
|
disabled={isSaving || isSubmitting || isHead}
|
||||||
|
title={isHead ? "Đang là head commit" : "Restore snapshot từ commit này (FE-only)"}
|
||||||
|
>
|
||||||
|
Restore
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel title="Undo List" badge={String(recentUndoLabels.length)} defaultOpen={false}>
|
||||||
|
{recentUndoLabels.length === 0 ? (
|
||||||
|
<div style={{ color: "#94a3b8", fontSize: 13 }}>Chưa có thao tác</div>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: 13, color: "#e2e8f0" }}>
|
||||||
|
{recentUndoLabels.map((label, idx) => (
|
||||||
|
<li key={`${label}-${idx}`} style={{ padding: "6px 0", borderBottom: "1px solid #1f2937" }}>
|
||||||
|
{label}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel title="This Session" defaultOpen={false}>
|
||||||
|
<div style={{ fontSize: 13, color: "#cbd5e1", marginBottom: 6 }}>
|
||||||
|
Entities ({createdEntities.length})
|
||||||
|
</div>
|
||||||
|
{createdEntities.length === 0 ? (
|
||||||
|
<div style={{ color: "#64748b", fontSize: 12, marginBottom: 10 }}>Chưa tạo entity mới</div>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: 12, marginBottom: 10 }}>
|
||||||
|
{createdEntities.map((entity) => (
|
||||||
|
<li
|
||||||
|
key={entity.id}
|
||||||
|
style={{ padding: "6px 0", borderBottom: "1px solid #1f2937", color: "#e2e8f0" }}
|
||||||
|
title={entity.id}
|
||||||
|
>
|
||||||
|
{entity.name}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ fontSize: 13, color: "#cbd5e1", marginBottom: 6 }}>
|
||||||
|
Geometries mới chưa commit ({createdGeometries.length})
|
||||||
|
</div>
|
||||||
|
{createdGeometries.length === 0 ? (
|
||||||
|
<div style={{ color: "#64748b", fontSize: 12 }}>Chưa có geometry mới chờ commit</div>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: 12 }}>
|
||||||
|
{createdGeometries.map((geometry) => (
|
||||||
|
<li
|
||||||
|
key={String(geometry.id)}
|
||||||
|
style={{ padding: "6px 0", borderBottom: "1px solid #1f2937", color: "#e2e8f0" }}
|
||||||
|
>
|
||||||
|
#{geometry.id} [{geometry.geometryType}]{" "}
|
||||||
|
{geometry.semanticType ? `- ${geometry.semanticType}` : ""}
|
||||||
|
{geometry.entityNames.length ? ` | ${geometry.entityNames.join(", ")}` : ""}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
</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) {
|
||||||
|
switch (action.type) {
|
||||||
|
case "create":
|
||||||
|
return `Thêm mới #${action.id}`;
|
||||||
|
case "delete":
|
||||||
|
return `Xóa #${action.feature.properties.id}`;
|
||||||
|
case "update":
|
||||||
|
return `Chỉnh sửa #${action.id}`;
|
||||||
|
case "properties":
|
||||||
|
return `Cập nhật thuộc tính #${action.id}`;
|
||||||
|
case "snapshot_entities":
|
||||||
|
case "snapshot_wikis":
|
||||||
|
case "snapshot_entity_wiki":
|
||||||
|
return action.label;
|
||||||
|
default:
|
||||||
|
return "Tác vụ";
|
||||||
|
}
|
||||||
|
}
|
||||||
298
src/uhm/components/EntityWikiBindingsPanel.tsx
Normal file
298
src/uhm/components/EntityWikiBindingsPanel.tsx
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import type { Entity } from "@/uhm/types/entities";
|
||||||
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
|
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
||||||
|
|
||||||
|
type EntityChoice = { id: string; name: string };
|
||||||
|
type WikiChoice = { id: string; title: string; operation?: string };
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
entities: EntityChoice[];
|
||||||
|
wikis: WikiSnapshot[];
|
||||||
|
links: EntityWikiLinkSnapshot[];
|
||||||
|
setLinks: React.Dispatch<React.SetStateAction<EntityWikiLinkSnapshot[]>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function wikiTitle(w: WikiSnapshot): string {
|
||||||
|
const t = String(w.title || "").trim();
|
||||||
|
return t.length ? t : "Untitled wiki";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EntityWikiBindingsPanel({ entities, wikis, links, setLinks }: Props) {
|
||||||
|
const [activeEntityId, setActiveEntityId] = useState<string>("");
|
||||||
|
const [activeWikiId, setActiveWikiId] = useState<string>("");
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
|
const wikiChoices: WikiChoice[] = useMemo(
|
||||||
|
() =>
|
||||||
|
(wikis || [])
|
||||||
|
.filter((w) => w && typeof w.id === "string" && w.id.trim().length > 0)
|
||||||
|
.map((w) => ({ id: w.id, title: wikiTitle(w), operation: w.operation })),
|
||||||
|
[wikis]
|
||||||
|
);
|
||||||
|
|
||||||
|
const entityChoices = useMemo(() => {
|
||||||
|
const cleaned = (entities || []).filter((e) => e && typeof e.id === "string" && e.id.trim().length > 0);
|
||||||
|
cleaned.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
return cleaned;
|
||||||
|
}, [entities]);
|
||||||
|
|
||||||
|
// Don't auto-select entity. The user must explicitly pick one.
|
||||||
|
// Only clear the selection if the currently selected entity is no longer available.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeEntityId) return;
|
||||||
|
const stillExists = entityChoices.some((e) => e.id === activeEntityId);
|
||||||
|
if (!stillExists) {
|
||||||
|
setActiveEntityId("");
|
||||||
|
setActiveWikiId("");
|
||||||
|
}
|
||||||
|
}, [activeEntityId, entityChoices]);
|
||||||
|
|
||||||
|
const activeLinks = useMemo(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const l of links || []) {
|
||||||
|
if (!l || l.entity_id !== activeEntityId) continue;
|
||||||
|
if (l.operation === "delete") continue;
|
||||||
|
set.add(l.wiki_id);
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}, [activeEntityId, links]);
|
||||||
|
|
||||||
|
const toggle = (wikiId: string) => {
|
||||||
|
if (!activeEntityId) return;
|
||||||
|
const id = String(wikiId || "").trim();
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
setLinks((prev) => {
|
||||||
|
const idx = prev.findIndex((l) => l.entity_id === activeEntityId && l.wiki_id === id);
|
||||||
|
// If link exists (reference/binding), unlink by removing the row entirely.
|
||||||
|
if (idx >= 0 && prev[idx]?.operation !== "delete") {
|
||||||
|
return prev.filter((_, i) => i !== idx);
|
||||||
|
}
|
||||||
|
// If link doesn't exist, add as a new binding (create for relation).
|
||||||
|
return [
|
||||||
|
...prev.filter((l) => !(l.entity_id === activeEntityId && l.wiki_id === id)),
|
||||||
|
{ entity_id: activeEntityId, wiki_id: id, operation: "binding" },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeWikiLinked = activeEntityId && activeWikiId ? activeLinks.has(activeWikiId) : false;
|
||||||
|
const activeWikiChoice = activeWikiId ? wikiChoices.find((w) => w.id === activeWikiId) || null : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "10px",
|
||||||
|
background: "#0b1220",
|
||||||
|
borderRadius: "8px",
|
||||||
|
border: "1px solid #1f2937",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: "14px" }}>Entity ↔ Wiki</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{links.length}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCollapsed((v) => !v)}
|
||||||
|
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",
|
||||||
|
}}
|
||||||
|
title={collapsed ? "Mo panel" : "Thu gon panel"}
|
||||||
|
aria-label={collapsed ? "Mo panel Entity Wiki" : "Thu gon panel Entity Wiki"}
|
||||||
|
>
|
||||||
|
{collapsed ? <PlusIcon /> : <MinusIcon />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{collapsed ? null : (
|
||||||
|
<div style={{ marginTop: "10px", display: "grid", gap: "8px" }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: "12px", color: "#94a3b8", marginBottom: "6px" }}>Entity</div>
|
||||||
|
<select
|
||||||
|
value={activeEntityId}
|
||||||
|
onChange={(e) => setActiveEntityId(e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
border: "1px solid #1f2937",
|
||||||
|
background: "#0b1220",
|
||||||
|
color: "#e5e7eb",
|
||||||
|
borderRadius: "6px",
|
||||||
|
padding: "8px 10px",
|
||||||
|
fontSize: "12px",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Select entity…</option>
|
||||||
|
{entityChoices.map((e) => (
|
||||||
|
<option key={e.id} value={e.id}>
|
||||||
|
{e.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: "12px", color: "#94a3b8", marginBottom: "6px" }}>Wikis</div>
|
||||||
|
<div style={{ display: "grid", gap: "8px" }}>
|
||||||
|
<select
|
||||||
|
value={activeWikiId}
|
||||||
|
onChange={(e) => setActiveWikiId(e.target.value)}
|
||||||
|
disabled={wikiChoices.length === 0}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
border: "1px solid #1f2937",
|
||||||
|
background: "#0b1220",
|
||||||
|
color: "#e5e7eb",
|
||||||
|
borderRadius: "6px",
|
||||||
|
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={{
|
||||||
|
padding: "8px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #1f2937",
|
||||||
|
background: "#111827",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
title={id}
|
||||||
|
>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: "#e5e7eb",
|
||||||
|
fontSize: 12,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{w?.title || "Untitled wiki"}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#94a3b8", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||||
|
{id}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{activeLinks.size > 8 ? (
|
||||||
|
<div style={{ fontSize: 12, color: "#94a3b8" }}>+{activeLinks.size - 8} more…</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 12, color: "#94a3b8" }}>No wiki linked yet.</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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 MinusIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
258
src/uhm/components/GeometryBindingPanel.tsx
Normal file
258
src/uhm/components/GeometryBindingPanel.tsx
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
type GeometryChoice = {
|
||||||
|
id: string;
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
geometries: GeometryChoice[];
|
||||||
|
selectedGeometryId: string | null;
|
||||||
|
selectedGeometryBindingIds: string[];
|
||||||
|
onToggleBindGeometryForSelectedGeometry?: (geometryId: string, nextChecked: boolean) => void;
|
||||||
|
statusText?: string | null;
|
||||||
|
bindingFilterEnabled: boolean;
|
||||||
|
onBindingFilterEnabledChange: (next: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function GeometryBindingPanel({
|
||||||
|
geometries,
|
||||||
|
selectedGeometryId,
|
||||||
|
selectedGeometryBindingIds,
|
||||||
|
onToggleBindGeometryForSelectedGeometry,
|
||||||
|
statusText,
|
||||||
|
bindingFilterEnabled,
|
||||||
|
onBindingFilterEnabledChange,
|
||||||
|
}: Props) {
|
||||||
|
const canBindToggle =
|
||||||
|
Boolean(selectedGeometryId) && typeof onToggleBindGeometryForSelectedGeometry === "function";
|
||||||
|
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const cleaned = (geometries || [])
|
||||||
|
.filter((g) => g && typeof g.id === "string" && g.id.trim().length > 0)
|
||||||
|
.map((g) => ({ id: g.id.trim(), label: (g.label || "").trim() }));
|
||||||
|
cleaned.sort((a, b) => a.id.localeCompare(b.id));
|
||||||
|
return cleaned;
|
||||||
|
}, [geometries]);
|
||||||
|
|
||||||
|
const bindingSet = useMemo(() => new Set(selectedGeometryBindingIds || []), [selectedGeometryBindingIds]);
|
||||||
|
|
||||||
|
const visibleRows = rows.slice(0, 12);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "10px",
|
||||||
|
background: "#0b1220",
|
||||||
|
borderRadius: "8px",
|
||||||
|
border: "1px solid #1f2937",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: "14px", whiteSpace: "nowrap" }}>Geometry Binding</div>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
cursor: "pointer",
|
||||||
|
userSelect: "none",
|
||||||
|
}}
|
||||||
|
title={bindingFilterEnabled ? "Đang ẩn geo theo binding" : "Đang hiển thị tất cả geo"}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={bindingFilterEnabled}
|
||||||
|
onChange={(e) => onBindingFilterEnabledChange(e.target.checked)}
|
||||||
|
style={{ width: 14, height: 14 }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 12, color: "#94a3b8", whiteSpace: "nowrap" }}>Filter</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{rows.length}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCollapsed((v) => !v)}
|
||||||
|
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",
|
||||||
|
}}
|
||||||
|
title={collapsed ? "Mở panel" : "Thu gọn panel"}
|
||||||
|
aria-label={collapsed ? "Mở panel Geometry Binding" : "Thu gọn panel Geometry Binding"}
|
||||||
|
>
|
||||||
|
{collapsed ? <PlusIcon /> : <MinusIcon />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{collapsed ? null : rows.length ? (
|
||||||
|
<div style={{ marginTop: "10px", display: "grid", gap: "6px" }}>
|
||||||
|
{visibleRows
|
||||||
|
.filter((g) => g.id !== selectedGeometryId)
|
||||||
|
.map((g) => {
|
||||||
|
const isBound = bindingSet.has(g.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={g.id}
|
||||||
|
style={{
|
||||||
|
padding: "8px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #1f2937",
|
||||||
|
background: "transparent",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
opacity: canBindToggle ? 1 : 0.75,
|
||||||
|
}}
|
||||||
|
title={g.id}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "#e5e7eb",
|
||||||
|
fontWeight: 700,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{g.label || g.id}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "11px",
|
||||||
|
color: "#94a3b8",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{g.id}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canBindToggle ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={isBound ? "Unbind from selected geometry" : "Bind to selected geometry"}
|
||||||
|
onClick={() => onToggleBindGeometryForSelectedGeometry!(g.id, !isBound)}
|
||||||
|
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={
|
||||||
|
isBound
|
||||||
|
? `Unbind geometry ${g.id} from selected geometry`
|
||||||
|
: `Bind geometry ${g.id} to selected geometry`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isBound ? <UnlockIcon /> : <LockIcon />}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{rows.length > visibleRows.length ? (
|
||||||
|
<div style={{ fontSize: "12px", color: "#94a3b8" }}>
|
||||||
|
+{rows.length - visibleRows.length} more…
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>
|
||||||
|
No geometry yet for this project.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{collapsed ? null : statusText ? (
|
||||||
|
<div style={{ marginTop: 10, fontSize: 12, color: "#93c5fd" }}>
|
||||||
|
{statusText}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 MinusIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
1847
src/uhm/components/Map.tsx
Normal file
1847
src/uhm/components/Map.tsx
Normal file
File diff suppressed because it is too large
Load Diff
423
src/uhm/components/ProjectEntityRefsPanel.tsx
Normal file
423
src/uhm/components/ProjectEntityRefsPanel.tsx
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
||||||
|
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||||
|
import type { EntityFormState } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
entityRefs: EntitySnapshot[];
|
||||||
|
entityForm: EntityFormState;
|
||||||
|
onEntityFormChange: (key: keyof EntityFormState, value: string) => void;
|
||||||
|
isEntitySubmitting: boolean;
|
||||||
|
onCreateEntityOnly: () => void;
|
||||||
|
onUpdateEntity?: (entityId: string, payload: { name: string; description: string | null }) => void;
|
||||||
|
entityFormStatus: string | null;
|
||||||
|
selectedGeometryEntityIds?: string[];
|
||||||
|
hasSelectedGeometry?: boolean;
|
||||||
|
onToggleBindEntityForSelectedGeometry?: (entityId: string, nextChecked: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProjectEntityRefsPanel({
|
||||||
|
entityRefs,
|
||||||
|
entityForm,
|
||||||
|
onEntityFormChange,
|
||||||
|
isEntitySubmitting,
|
||||||
|
onCreateEntityOnly,
|
||||||
|
onUpdateEntity,
|
||||||
|
entityFormStatus,
|
||||||
|
selectedGeometryEntityIds,
|
||||||
|
hasSelectedGeometry,
|
||||||
|
onToggleBindEntityForSelectedGeometry,
|
||||||
|
}: Props) {
|
||||||
|
const canBindToggle =
|
||||||
|
Boolean(hasSelectedGeometry) &&
|
||||||
|
Array.isArray(selectedGeometryEntityIds) &&
|
||||||
|
typeof onToggleBindEntityForSelectedGeometry === "function";
|
||||||
|
|
||||||
|
const canEditEntity = typeof onUpdateEntity === "function";
|
||||||
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const [activeEntityId, setActiveEntityId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const activeEntity = useMemo(
|
||||||
|
() => (activeEntityId ? entityRefs.find((e) => String(e.id) === String(activeEntityId)) || null : null),
|
||||||
|
[activeEntityId, entityRefs]
|
||||||
|
);
|
||||||
|
const [editName, setEditName] = useState("");
|
||||||
|
const [editDescription, setEditDescription] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeEntityId) return;
|
||||||
|
if (!entityRefs.some((e) => String(e.id) === String(activeEntityId))) {
|
||||||
|
setActiveEntityId(null);
|
||||||
|
}
|
||||||
|
}, [activeEntityId, entityRefs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeEntity) return;
|
||||||
|
setEditName(typeof activeEntity.name === "string" ? activeEntity.name : "");
|
||||||
|
setEditDescription(activeEntity.description == null ? "" : String(activeEntity.description));
|
||||||
|
}, [activeEntity?.description, activeEntity?.id, activeEntity?.name]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "10px",
|
||||||
|
background: "#0b1220",
|
||||||
|
borderRadius: "8px",
|
||||||
|
border: "1px solid #1f2937",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: "14px" }}>Entities</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{entityRefs.length}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCollapsed((v) => !v)}
|
||||||
|
title={collapsed ? "Mo panel" : "Thu gon panel"}
|
||||||
|
aria-label={collapsed ? "Mo panel Entities" : "Thu gon panel Entities"}
|
||||||
|
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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{collapsed ? <PlusIcon /> : <MinusIcon />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{collapsed ? null : entityRefs.length ? (
|
||||||
|
<div style={{ marginTop: "10px", display: "grid", gap: "6px" }}>
|
||||||
|
{entityRefs.slice(0, 8).map((e) => (
|
||||||
|
<div
|
||||||
|
key={e.id}
|
||||||
|
style={{
|
||||||
|
padding: "8px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: activeEntityId === String(e.id) ? "1px solid #2563eb" : "1px solid #1f2937",
|
||||||
|
background: "transparent",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveEntityId(String(e.id))}
|
||||||
|
title="Chon de sua"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
textAlign: "left",
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
padding: 0,
|
||||||
|
cursor: canEditEntity ? "pointer" : "default",
|
||||||
|
}}
|
||||||
|
disabled={!canEditEntity}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: "12px", color: "#e5e7eb", fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||||
|
{e.name || e.id}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "11px", color: "#94a3b8", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||||
|
{e.id}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{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}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>No entity ref yet for this project.</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{collapsed ? null : canEditEntity && activeEntity ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "10px",
|
||||||
|
display: "grid",
|
||||||
|
gap: "8px",
|
||||||
|
border: "1px solid #0f766e",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "8px",
|
||||||
|
background: "#0f172a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
||||||
|
<div style={{ color: "#a7f3d0", fontWeight: 700, fontSize: "12px" }}>
|
||||||
|
Sua entity
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveEntityId(null)}
|
||||||
|
title="Dong"
|
||||||
|
aria-label="Dong sua entity"
|
||||||
|
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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: 11, color: "#94a3b8", overflowWrap: "anywhere" }}>
|
||||||
|
{String(activeEntity.id)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
value={editName}
|
||||||
|
onChange={(event) => setEditName(event.target.value)}
|
||||||
|
placeholder="Ten entity"
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={entityInputStyle}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={editDescription}
|
||||||
|
onChange={(event) => setEditDescription(event.target.value)}
|
||||||
|
placeholder="Description"
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={entityInputStyle}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onUpdateEntity!(String(activeEntity.id), { name: editName, description: editDescription.trim().length ? editDescription : null })}
|
||||||
|
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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Luu entity
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{collapsed ? null : (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 MinusIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M5 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
413
src/uhm/components/SelectedGeometryPanel.tsx
Normal file
413
src/uhm/components/SelectedGeometryPanel.tsx
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type CSSProperties, useMemo, useState } from "react";
|
||||||
|
import { Entity } from "@/uhm/api/entities";
|
||||||
|
import { Feature } from "@/uhm/lib/useEditorState";
|
||||||
|
import {
|
||||||
|
EntityGeometryPreset,
|
||||||
|
EntityTypeGroupId,
|
||||||
|
EntityTypeOption,
|
||||||
|
findEntityTypeOption,
|
||||||
|
groupEntityTypeOptions,
|
||||||
|
} from "@/uhm/lib/entityTypeOptions";
|
||||||
|
import type { GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
selectedFeature: Feature | null;
|
||||||
|
selectedFeatureEntitySummary: string;
|
||||||
|
selectedFeatureBindingSummary: string;
|
||||||
|
entities: Entity[];
|
||||||
|
selectedGeometryEntityIds: string[];
|
||||||
|
onEntityIdsChange: (values: string[]) => void;
|
||||||
|
entityTypeOptions: EntityTypeOption[];
|
||||||
|
geometryMetaForm: GeometryMetaFormState;
|
||||||
|
onGeometryMetaFormChange: (key: keyof GeometryMetaFormState, value: string) => void;
|
||||||
|
isEntitySubmitting: boolean;
|
||||||
|
onApplyGeometryMetadata: () => Promise<{ ok: boolean; error?: string }>;
|
||||||
|
changeCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SelectedGeometryPanel({
|
||||||
|
selectedFeature,
|
||||||
|
selectedFeatureEntitySummary,
|
||||||
|
selectedFeatureBindingSummary,
|
||||||
|
entities,
|
||||||
|
selectedGeometryEntityIds,
|
||||||
|
onEntityIdsChange,
|
||||||
|
entityTypeOptions,
|
||||||
|
geometryMetaForm,
|
||||||
|
onGeometryMetaFormChange,
|
||||||
|
isEntitySubmitting,
|
||||||
|
onApplyGeometryMetadata,
|
||||||
|
changeCount,
|
||||||
|
}: Props) {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
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 featureGeometryPreset = resolveFeatureGeometryPreset(selectedFeature);
|
||||||
|
const allowedGroupIds = getAllowedGroupIdsForPreset(featureGeometryPreset);
|
||||||
|
const groupedGeoTypeOptions = groupedEntityTypeOptions.filter((group) =>
|
||||||
|
allowedGroupIds.includes(group.id)
|
||||||
|
);
|
||||||
|
const selectedTypeOption = findEntityTypeOption(geometryMetaForm.type_key);
|
||||||
|
const hasCurrentVisibleTypeOption = groupedGeoTypeOptions.some((group) =>
|
||||||
|
group.options.some((option) => option.value === geometryMetaForm.type_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "10px",
|
||||||
|
background: "#0b1220",
|
||||||
|
borderRadius: "8px",
|
||||||
|
border: "1px solid #1f2937",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, marginBottom: "8px" }}>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: "14px" }}>
|
||||||
|
Entity & Geometry
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCollapsed((v) => !v)}
|
||||||
|
title={collapsed ? "Mo panel" : "Thu gon panel"}
|
||||||
|
aria-label={collapsed ? "Mo panel Selected Geometry" : "Thu gon panel Selected Geometry"}
|
||||||
|
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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{collapsed ? <PlusIcon /> : <MinusIcon />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{collapsed ? null : (
|
||||||
|
<div style={{ display: "grid", gap: "8px", fontSize: "13px" }}>
|
||||||
|
<div style={{ color: "#e2e8f0" }}>
|
||||||
|
ID: {String(selectedFeature.properties.id)}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#cbd5e1" }}>
|
||||||
|
Entities hiện tại: {selectedFeatureEntitySummary}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#cbd5e1" }}>
|
||||||
|
Binding hiện tại: {selectedFeatureBindingSummary}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#cbd5e1" }}>
|
||||||
|
Geometry preset: {formatGeometryPresetLabel(featureGeometryPreset)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
|
||||||
|
Entities đã chọn:
|
||||||
|
</div>
|
||||||
|
{selectedGeometryEntityIds.length ? (
|
||||||
|
<div style={{ display: "grid", gap: "6px" }}>
|
||||||
|
{selectedGeometryEntityIds.map((entityId) => {
|
||||||
|
const entity = entities.find((item) => item.id === entityId) || null;
|
||||||
|
const label = entity?.name
|
||||||
|
? `${entity.name} (${entityId})`
|
||||||
|
: entityId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={entityId}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: "8px",
|
||||||
|
background: "#111827",
|
||||||
|
border: "1px solid #334155",
|
||||||
|
borderRadius: "6px",
|
||||||
|
padding: "6px 8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: "#e2e8f0" }}>{label}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
onEntityIdsChange(
|
||||||
|
selectedGeometryEntityIds.filter((id) => id !== entityId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={removeButtonStyle}
|
||||||
|
>
|
||||||
|
Bỏ
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
|
||||||
|
Chưa có entity nào được gắn.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gap: "8px",
|
||||||
|
border: "1px solid #243244",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "8px",
|
||||||
|
background: "#0f172a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ color: "#e2e8f0", fontWeight: 700, fontSize: "12px" }}>
|
||||||
|
Thuộc tính GEO
|
||||||
|
</div>
|
||||||
|
<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.
|
||||||
|
</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
|
||||||
|
value={geometryMetaForm.time_start}
|
||||||
|
onChange={(event) => onGeometryMetaFormChange("time_start", event.target.value)}
|
||||||
|
placeholder="time_start"
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={entityInputStyle}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={geometryMetaForm.time_end}
|
||||||
|
onChange={(event) => onGeometryMetaFormChange("time_end", event.target.value)}
|
||||||
|
placeholder="time_end"
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={entityInputStyle}
|
||||||
|
/>
|
||||||
|
{/*<input*/}
|
||||||
|
{/* value={geometryMetaForm.binding}*/}
|
||||||
|
{/* onChange={(event) => onGeometryMetaFormChange("binding", event.target.value)}*/}
|
||||||
|
{/* placeholder="binding (geometry ids, comma separated)"*/}
|
||||||
|
{/* disabled={isEntitySubmitting}*/}
|
||||||
|
{/* style={entityInputStyle}*/}
|
||||||
|
{/*/>*/}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleApplyGeoMeta}
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={primaryGeometryButtonStyle}
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
{visibleGeoApplyFeedback ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
color:
|
||||||
|
visibleGeoApplyFeedback.kind === "ok" ? "#22c55e" : "#fca5a5",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{visibleGeoApplyFeedback.text}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{changeCount > 0 ? (
|
||||||
|
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
|
||||||
|
Thay đổi sẽ vào lịch sử khi Commit.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityInputStyle: CSSProperties = {
|
||||||
|
width: "100%",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #334155",
|
||||||
|
background: "#111827",
|
||||||
|
color: "#f8fafc",
|
||||||
|
padding: "6px 8px",
|
||||||
|
fontSize: "13px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeButtonStyle: CSSProperties = {
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "6px",
|
||||||
|
padding: "4px 8px",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: "#7f1d1d",
|
||||||
|
color: "#ffffff",
|
||||||
|
fontSize: "12px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const primaryGeometryButtonStyle: CSSProperties = {
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "6px",
|
||||||
|
padding: "7px 8px",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: "#0f766e",
|
||||||
|
color: "#ffffff",
|
||||||
|
fontWeight: 600,
|
||||||
|
};
|
||||||
|
|
||||||
|
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 MinusIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFeatureGeometryPreset(feature: Feature): EntityGeometryPreset {
|
||||||
|
const explicitPreset = normalizeGeometryPreset(feature.properties.geometry_preset);
|
||||||
|
if (explicitPreset) return explicitPreset;
|
||||||
|
|
||||||
|
const semanticType = normalizeTypeId(feature.properties.type) || normalizeTypeId(feature.properties.entity_type_id);
|
||||||
|
if (semanticType) {
|
||||||
|
const option = findEntityTypeOption(semanticType);
|
||||||
|
if (option) return option.geometryPreset;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapGeometryTypeToPreset(feature.geometry.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGeometryPreset(value: unknown): EntityGeometryPreset | null {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (
|
||||||
|
normalized === "point" ||
|
||||||
|
normalized === "line" ||
|
||||||
|
normalized === "polygon" ||
|
||||||
|
normalized === "circle-area"
|
||||||
|
) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTypeId(value: unknown): string | null {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
return normalized.length ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapGeometryTypeToPreset(
|
||||||
|
geometryType: Feature["geometry"]["type"]
|
||||||
|
): EntityGeometryPreset {
|
||||||
|
if (geometryType === "Point" || geometryType === "MultiPoint") {
|
||||||
|
return "point";
|
||||||
|
}
|
||||||
|
if (geometryType === "LineString" || geometryType === "MultiLineString") {
|
||||||
|
return "line";
|
||||||
|
}
|
||||||
|
return "polygon";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAllowedGroupIdsForPreset(
|
||||||
|
geometryPreset: EntityGeometryPreset
|
||||||
|
): EntityTypeGroupId[] {
|
||||||
|
if (geometryPreset === "point") {
|
||||||
|
return ["point"];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (geometryPreset === "line") {
|
||||||
|
return ["line"];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (geometryPreset === "circle-area") {
|
||||||
|
return ["circle"];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ["polygon"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatGeometryPresetLabel(preset: EntityGeometryPreset | null): string {
|
||||||
|
if (preset === "point") return "point - Điểm";
|
||||||
|
if (preset === "line") return "line - Tuyến";
|
||||||
|
if (preset === "circle-area") return "circle - Tròn";
|
||||||
|
if (preset === "polygon") return "polygon - Đa giác";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
161
src/uhm/components/TimelineBar.tsx
Normal file
161
src/uhm/components/TimelineBar.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FIXED_TIMELINE_END_YEAR, FIXED_TIMELINE_START_YEAR, clampYearValue } from "@/uhm/lib/timeline";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
year: number;
|
||||||
|
onYearChange: (year: number) => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
statusText?: string | null;
|
||||||
|
filterEnabled?: boolean;
|
||||||
|
onFilterEnabledChange?: (enabled: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TimelineBar({
|
||||||
|
year,
|
||||||
|
onYearChange,
|
||||||
|
isLoading,
|
||||||
|
disabled,
|
||||||
|
statusText,
|
||||||
|
filterEnabled,
|
||||||
|
onFilterEnabledChange,
|
||||||
|
}: Props) {
|
||||||
|
const lower = FIXED_TIMELINE_START_YEAR;
|
||||||
|
const upper = FIXED_TIMELINE_END_YEAR;
|
||||||
|
const effectiveDisabled = disabled;
|
||||||
|
const safeYear = clampYearValue(year, lower, upper);
|
||||||
|
|
||||||
|
const helperText = isLoading
|
||||||
|
? "Đang tải geometry theo mốc thời gian..."
|
||||||
|
: statusText || null;
|
||||||
|
|
||||||
|
const handleYearChange = (nextYear: number) => {
|
||||||
|
onYearChange(clampYearValue(Math.trunc(nextYear), lower, upper));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: "18px",
|
||||||
|
right: "18px",
|
||||||
|
bottom: "16px",
|
||||||
|
zIndex: 10,
|
||||||
|
background: "rgba(15, 23, 42, 0.9)",
|
||||||
|
border: "1px solid rgba(148, 163, 184, 0.3)",
|
||||||
|
borderRadius: "10px",
|
||||||
|
padding: "10px 12px",
|
||||||
|
color: "#e2e8f0",
|
||||||
|
backdropFilter: "blur(2px)",
|
||||||
|
}}
|
||||||
|
title={helperText || undefined}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
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
|
||||||
|
type="range"
|
||||||
|
min={lower}
|
||||||
|
max={upper}
|
||||||
|
step={1}
|
||||||
|
value={safeYear}
|
||||||
|
onChange={(event) => handleYearChange(Number(event.target.value))}
|
||||||
|
disabled={effectiveDisabled}
|
||||||
|
aria-label="Timeline year"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
accentColor: "#22c55e",
|
||||||
|
cursor: effectiveDisabled ? "not-allowed" : "pointer",
|
||||||
|
opacity: effectiveDisabled ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ color: "#94a3b8", minWidth: 44, textAlign: "right" }}>
|
||||||
|
{formatYear(upper)}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={lower}
|
||||||
|
max={upper}
|
||||||
|
step={1}
|
||||||
|
value={safeYear}
|
||||||
|
onChange={(event) => handleYearChange(Number(event.target.value))}
|
||||||
|
disabled={effectiveDisabled}
|
||||||
|
aria-label="Timeline exact year"
|
||||||
|
style={{
|
||||||
|
width: "128px",
|
||||||
|
border: "1px solid rgba(148, 163, 184, 0.45)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
padding: "6px 8px",
|
||||||
|
background: "rgba(15, 23, 42, 0.7)",
|
||||||
|
color: "#f8fafc",
|
||||||
|
fontSize: "13px",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatYear(year: number): string {
|
||||||
|
if (year < 0) {
|
||||||
|
return `${Math.abs(year)} TCN`;
|
||||||
|
}
|
||||||
|
return `${year}`;
|
||||||
|
}
|
||||||
134
src/uhm/components/UnifiedSearchBar.tsx
Normal file
134
src/uhm/components/UnifiedSearchBar.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, 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;
|
||||||
|
debounceMs?: number;
|
||||||
|
onLocalQueryChange?: (query: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function UnifiedSearchBar({
|
||||||
|
kind,
|
||||||
|
onKindChange,
|
||||||
|
query,
|
||||||
|
onQueryChange,
|
||||||
|
disabledGeo,
|
||||||
|
debounceMs = 300,
|
||||||
|
onLocalQueryChange,
|
||||||
|
}: Props) {
|
||||||
|
// Local input state to avoid propagating query changes (and triggering API) on every keystroke.
|
||||||
|
const [localQuery, setLocalQuery] = useState(query);
|
||||||
|
const debounceTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// Keep local input in sync when parent updates `query` externally (e.g. reset, preset, navigation).
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalQuery(query);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onLocalQueryChange?.(localQuery);
|
||||||
|
}, [localQuery, onLocalQueryChange]);
|
||||||
|
|
||||||
|
// Debounce propagation upwards.
|
||||||
|
useEffect(() => {
|
||||||
|
if (localQuery === query) return;
|
||||||
|
|
||||||
|
if (debounceTimerRef.current != null) window.clearTimeout(debounceTimerRef.current);
|
||||||
|
debounceTimerRef.current = window.setTimeout(() => {
|
||||||
|
onQueryChange(localQuery);
|
||||||
|
}, debounceMs);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (debounceTimerRef.current != null) window.clearTimeout(debounceTimerRef.current);
|
||||||
|
debounceTimerRef.current = null;
|
||||||
|
};
|
||||||
|
}, [localQuery, query, onQueryChange, debounceMs]);
|
||||||
|
|
||||||
|
const commitNow = () => {
|
||||||
|
if (debounceTimerRef.current != null) window.clearTimeout(debounceTimerRef.current);
|
||||||
|
debounceTimerRef.current = null;
|
||||||
|
if (localQuery !== query) onQueryChange(localQuery);
|
||||||
|
};
|
||||||
|
|
||||||
|
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={localQuery}
|
||||||
|
onChange={(e) => setLocalQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") commitNow();
|
||||||
|
}}
|
||||||
|
onBlur={() => commitNow()}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
619
src/uhm/components/WikiSidebarPanel.tsx
Normal file
619
src/uhm/components/WikiSidebarPanel.tsx
Normal file
@@ -0,0 +1,619 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState, type ComponentProps } from "react";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import "react-quill-new/dist/quill.snow.css";
|
||||||
|
|
||||||
|
import { Modal } from "@/components/ui/modal";
|
||||||
|
import Button from "@/components/ui/button/Button";
|
||||||
|
import Label from "@/components/form/Label";
|
||||||
|
|
||||||
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
|
import { newId } from "@/uhm/lib/id";
|
||||||
|
import type ReactQuill from "react-quill-new";
|
||||||
|
import { checkWikiSlugExists } from "@/uhm/api/wikis";
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
projectId: string;
|
||||||
|
wikis: WikiSnapshot[];
|
||||||
|
setWikis: React.Dispatch<React.SetStateAction<WikiSnapshot[]>>;
|
||||||
|
autoOpen?: boolean;
|
||||||
|
requestedActiveId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function clampTitle(title: string) {
|
||||||
|
const t = title.trim();
|
||||||
|
return t.length ? t.slice(0, 120) : "Untitled wiki";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen, requestedActiveId }: Props) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const activeWiki = useMemo(() => wikis.find((w) => w.id === activeId) || null, [activeId, wikis]);
|
||||||
|
|
||||||
|
const [wikiTitle, setWikiTitle] = useState("");
|
||||||
|
const [wikiSlug, setWikiSlug] = useState("");
|
||||||
|
const [wikiDocHtml, setWikiDocHtml] = useState("");
|
||||||
|
const [wikiSaveError, setWikiSaveError] = useState<string | null>(null);
|
||||||
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
|
const [createTitle, setCreateTitle] = useState("");
|
||||||
|
const [createSlug, setCreateSlug] = useState("");
|
||||||
|
const [createSlugTouched, setCreateSlugTouched] = useState(false);
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
const [isCheckingCreateSlug, setIsCheckingCreateSlug] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoOpen) return;
|
||||||
|
// open once on mount
|
||||||
|
setOpen(true);
|
||||||
|
}, [autoOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!requestedActiveId) return;
|
||||||
|
if (wikis.some((w) => w.id === requestedActiveId)) {
|
||||||
|
setActiveId(requestedActiveId);
|
||||||
|
}
|
||||||
|
}, [requestedActiveId, wikis]);
|
||||||
|
|
||||||
|
// keep editor content in sync when switching wiki
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
setWikiTitle(activeWiki?.title || "");
|
||||||
|
setWikiSlug(typeof activeWiki?.slug === "string" ? activeWiki.slug : "");
|
||||||
|
setWikiDocHtml(normalizeWikiDocForQuill(activeWiki?.doc || null));
|
||||||
|
setWikiSaveError(null);
|
||||||
|
}, [activeWiki?.doc, activeWiki?.slug, activeWiki?.title, open]);
|
||||||
|
|
||||||
|
const ensureActive = () => {
|
||||||
|
if (activeId && wikis.some((w) => w.id === activeId)) return;
|
||||||
|
setActiveId(wikis[0]?.id || null);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ensureActive();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [wikis.length]);
|
||||||
|
|
||||||
|
const openEditor = () => {
|
||||||
|
if (!wikis.length) {
|
||||||
|
const id = newId();
|
||||||
|
const seed: WikiSnapshot = {
|
||||||
|
id,
|
||||||
|
source: "inline",
|
||||||
|
operation: "create",
|
||||||
|
title: "Untitled wiki",
|
||||||
|
slug: null,
|
||||||
|
doc: "",
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
setWikis((prev) => [seed, ...prev]);
|
||||||
|
setActiveId(id);
|
||||||
|
}
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createWikiAndOpen = (title?: string, slug?: string | null) => {
|
||||||
|
const id = newId();
|
||||||
|
const seedTitle = clampTitle(title || "Untitled wiki");
|
||||||
|
const seed: WikiSnapshot = {
|
||||||
|
id,
|
||||||
|
source: "inline",
|
||||||
|
operation: "create",
|
||||||
|
title: seedTitle,
|
||||||
|
slug: slug ?? null,
|
||||||
|
doc: "",
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
setWikis((prev) => [seed, ...prev]);
|
||||||
|
setActiveId(id);
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateWikiFromPanel = async () => {
|
||||||
|
const title = clampTitle(createTitle);
|
||||||
|
const slug = normalizeWikiSlugInput(createSlug);
|
||||||
|
if (!slug) {
|
||||||
|
setCreateError("Slug la bat buoc. Hay thu mot slug khac.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCheckingCreateSlug(true);
|
||||||
|
setCreateError(null);
|
||||||
|
try {
|
||||||
|
const exists = await checkWikiSlugExists(slug);
|
||||||
|
if (exists) {
|
||||||
|
setCreateError("Slug da ton tai. Hay thu slug khac.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createWikiAndOpen(title, slug);
|
||||||
|
setCreateTitle("");
|
||||||
|
setCreateSlug("");
|
||||||
|
setCreateSlugTouched(false);
|
||||||
|
setIsCreateOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : "Khong check duoc slug.";
|
||||||
|
setCreateError(msg);
|
||||||
|
} finally {
|
||||||
|
setIsCheckingCreateSlug(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeWiki = (id: string) => {
|
||||||
|
setWikis((prev) => prev.filter((w) => w.id !== id));
|
||||||
|
if (activeId === id) setActiveId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveWiki = async () => {
|
||||||
|
if (!activeId) return;
|
||||||
|
const payload = wikiDocHtml;
|
||||||
|
const nextTitle = clampTitle(wikiTitle);
|
||||||
|
const nextSlug = normalizeWikiSlugInput(wikiSlug);
|
||||||
|
|
||||||
|
const current = wikis.find((w) => w.id === activeId) || null;
|
||||||
|
// Check uniqueness only when creating a brand-new wiki.
|
||||||
|
if (current?.operation === "create" && nextSlug) {
|
||||||
|
try {
|
||||||
|
const exists = await checkWikiSlugExists(nextSlug);
|
||||||
|
if (exists) {
|
||||||
|
setWikiSaveError("Slug da ton tai. Hay thu slug khac.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : "Khong check duoc slug.";
|
||||||
|
setWikiSaveError(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setWikiSaveError(null);
|
||||||
|
setWikis((prev) =>
|
||||||
|
prev.map((w) =>
|
||||||
|
w.id !== activeId
|
||||||
|
? w
|
||||||
|
: {
|
||||||
|
...w,
|
||||||
|
source: w.source,
|
||||||
|
operation: w.operation === "create" ? "create" : "update",
|
||||||
|
title: nextTitle,
|
||||||
|
slug: nextSlug,
|
||||||
|
doc: payload,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "10px",
|
||||||
|
background: "#0b1220",
|
||||||
|
borderRadius: "8px",
|
||||||
|
border: "1px solid #1f2937",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: "14px" }}>Wiki</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{wikis.length}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCollapsed((v) => !v)}
|
||||||
|
title={collapsed ? "Mo panel" : "Thu gon panel"}
|
||||||
|
aria-label={collapsed ? "Mo panel Wiki" : "Thu gon panel 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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{collapsed ? <PlusIcon /> : <MinusIcon />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{collapsed ? null : wikis.length ? (
|
||||||
|
<div style={{ marginTop: "10px", display: "grid", gap: "6px" }}>
|
||||||
|
{wikis.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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setActiveId(w.id);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
textAlign: "left",
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
color: "#e5e7eb",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "12px",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
title={w.title}
|
||||||
|
>
|
||||||
|
{w.title}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeWiki(w.id)}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
background: "#111827",
|
||||||
|
color: "#fca5a5",
|
||||||
|
cursor: "pointer",
|
||||||
|
borderRadius: "6px",
|
||||||
|
padding: "6px 8px",
|
||||||
|
fontSize: "12px",
|
||||||
|
}}
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
Del
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{wikis.length > 8 ? (
|
||||||
|
<div style={{ fontSize: "12px", color: "#94a3b8" }}>+{wikis.length - 8} more…</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>
|
||||||
|
No wiki yet for this project.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{collapsed ? null : (
|
||||||
|
<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) => {
|
||||||
|
const next = !v;
|
||||||
|
if (next) {
|
||||||
|
setCreateError(null);
|
||||||
|
setIsCheckingCreateSlug(false);
|
||||||
|
setCreateSlugTouched(false);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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) => {
|
||||||
|
const nextTitle = e.target.value;
|
||||||
|
setCreateTitle(nextTitle);
|
||||||
|
setCreateError(null);
|
||||||
|
if (!createSlugTouched) {
|
||||||
|
setCreateSlug(slugifyWikiTitle(nextTitle));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Tieu de wiki"
|
||||||
|
disabled={isCheckingCreateSlug}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #334155",
|
||||||
|
background: "#111827",
|
||||||
|
color: "#f8fafc",
|
||||||
|
padding: "6px 8px",
|
||||||
|
fontSize: "13px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={createSlug}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCreateSlugTouched(true);
|
||||||
|
setCreateSlug(e.target.value);
|
||||||
|
setCreateError(null);
|
||||||
|
}}
|
||||||
|
placeholder="Slug"
|
||||||
|
disabled={isCheckingCreateSlug}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #334155",
|
||||||
|
background: "#111827",
|
||||||
|
color: "#f8fafc",
|
||||||
|
padding: "6px 8px",
|
||||||
|
fontSize: "13px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCreateWikiFromPanel}
|
||||||
|
disabled={isCheckingCreateSlug}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "6px",
|
||||||
|
padding: "7px 8px",
|
||||||
|
cursor: isCheckingCreateSlug ? "not-allowed" : "pointer",
|
||||||
|
background: "#2563eb",
|
||||||
|
color: "#ffffff",
|
||||||
|
fontWeight: 600,
|
||||||
|
opacity: isCheckingCreateSlug ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Tạo wiki mới
|
||||||
|
</button>
|
||||||
|
{createError ? (
|
||||||
|
<div style={{ color: "#fca5a5", fontSize: 12 }}>
|
||||||
|
{createError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
showCloseButton={false}
|
||||||
|
// Defensive: even if Modal defaults change, keep wiki popup free of the "X" close button.
|
||||||
|
className="max-w-[1100px] m-4 [&>button]:hidden"
|
||||||
|
>
|
||||||
|
<div className="p-6 bg-white rounded-3xl dark:bg-gray-900">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">Project</div>
|
||||||
|
<div className="text-sm font-mono break-all text-gray-700 dark:text-gray-200">{projectId}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" className="bg-brand-500 hover:bg-brand-600 text-white" onClick={saveWiki} disabled={!activeId}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<div className="text-xs font-semibold text-gray-700 dark:text-gray-200 mb-2">Wikis</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{wikis.map((w) => (
|
||||||
|
<button
|
||||||
|
key={w.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveId(w.id)}
|
||||||
|
className={`text-left rounded-xl border px-3 py-2 text-sm transition ${
|
||||||
|
w.id === activeId
|
||||||
|
? "border-brand-500 bg-brand-50 dark:bg-brand-500/10"
|
||||||
|
: "border-gray-200 dark:border-gray-800 bg-white dark:bg-[#0d1117]"
|
||||||
|
}`}
|
||||||
|
title={w.title}
|
||||||
|
>
|
||||||
|
<div className="font-medium truncate">{w.title}</div>
|
||||||
|
<div className="text-[11px] text-gray-500 dark:text-gray-400 truncate">{w.id}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<Button size="sm" variant="outline" onClick={openEditor}>
|
||||||
|
+ New wiki
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>Title</Label>
|
||||||
|
<input
|
||||||
|
value={wikiTitle}
|
||||||
|
onChange={(e) => setWikiTitle(e.target.value)}
|
||||||
|
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"
|
||||||
|
placeholder="Wiki title"
|
||||||
|
disabled={!activeId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Slug</Label>
|
||||||
|
<input
|
||||||
|
value={wikiSlug}
|
||||||
|
onChange={(e) => setWikiSlug(e.target.value)}
|
||||||
|
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"
|
||||||
|
placeholder="wiki-slug"
|
||||||
|
disabled={!activeId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{wikiSaveError ? (
|
||||||
|
<div className="text-xs text-red-600 dark:text-red-300">
|
||||||
|
{wikiSaveError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-[#0d1117] overflow-hidden">
|
||||||
|
<ReactQuillEditor
|
||||||
|
theme="snow"
|
||||||
|
value={wikiDocHtml}
|
||||||
|
onChange={(content: string) => setWikiDocHtml(content)}
|
||||||
|
modules={QUILL_MODULES}
|
||||||
|
className="min-h-[320px]"
|
||||||
|
placeholder="Nhap noi dung wiki..."
|
||||||
|
readOnly={!activeId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Stored in snapshot_json on commit. This page does not write to DB yet.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</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 MinusIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M5 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 normalizeWikiSlugInput(raw: string): string | null {
|
||||||
|
const s = raw.trim();
|
||||||
|
return s.length ? s : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugifyWikiTitle(raw: string): string {
|
||||||
|
const input = String(raw || "").trim();
|
||||||
|
if (!input.length) return "";
|
||||||
|
return input
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+/, "")
|
||||||
|
.replace(/-+$/, "")
|
||||||
|
.slice(0, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
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("'", "'");
|
||||||
|
}
|
||||||
29
src/uhm/lib/backgroundLayers.ts
Normal file
29
src/uhm/lib/backgroundLayers.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export const BACKGROUND_LAYER_OPTIONS = [
|
||||||
|
{ id: "raster-base-layer", label: "Raster" },
|
||||||
|
{ id: "graticules-line", label: "Graticules" },
|
||||||
|
{ id: "land", label: "Land" },
|
||||||
|
{ id: "bg-countries-fill", label: "Countries" },
|
||||||
|
{ id: "bg-country-borders-line", label: "Country Borders" },
|
||||||
|
{ id: "country-labels", label: "Country Labels" },
|
||||||
|
{ id: "regions-line", label: "Regions" },
|
||||||
|
{ id: "lakes-fill", label: "Lakes" },
|
||||||
|
{ id: "rivers-line", label: "Rivers" },
|
||||||
|
{ id: "geolines-line", label: "Geolines" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type BackgroundLayerId = (typeof BACKGROUND_LAYER_OPTIONS)[number]["id"];
|
||||||
|
export type BackgroundLayerVisibility = Record<BackgroundLayerId, boolean>;
|
||||||
|
|
||||||
|
// Tạo map visibility mặc định cho toàn bộ background layers.
|
||||||
|
function buildBackgroundLayerVisibility(value: boolean): BackgroundLayerVisibility {
|
||||||
|
return BACKGROUND_LAYER_OPTIONS.reduce((acc, option) => {
|
||||||
|
acc[option.id] = value;
|
||||||
|
return acc;
|
||||||
|
}, {} as BackgroundLayerVisibility);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_BACKGROUND_LAYER_VISIBILITY =
|
||||||
|
buildBackgroundLayerVisibility(true);
|
||||||
|
|
||||||
|
export const HIDDEN_BACKGROUND_LAYER_VISIBILITY =
|
||||||
|
buildBackgroundLayerVisibility(false);
|
||||||
58
src/uhm/lib/editor/background/backgroundVisibilityStorage.ts
Normal file
58
src/uhm/lib/editor/background/backgroundVisibilityStorage.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
BACKGROUND_LAYER_OPTIONS,
|
||||||
|
BackgroundLayerVisibility,
|
||||||
|
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
||||||
|
} from "@/uhm/lib/backgroundLayers";
|
||||||
|
|
||||||
|
const BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY = "uhm.backgroundLayerVisibility.v1";
|
||||||
|
|
||||||
|
export function loadBackgroundLayerVisibilityFromStorage(): BackgroundLayerVisibility {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY);
|
||||||
|
if (!raw) {
|
||||||
|
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
const normalized = normalizeBackgroundLayerVisibility(parsed);
|
||||||
|
return normalized || { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Load background layer visibility from storage failed", err);
|
||||||
|
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistBackgroundLayerVisibility(visibility: BackgroundLayerVisibility) {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY,
|
||||||
|
JSON.stringify(visibility)
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Persist background layer visibility failed", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBackgroundLayerVisibility(raw: unknown): BackgroundLayerVisibility | null {
|
||||||
|
if (!raw || typeof raw !== "object") return null;
|
||||||
|
|
||||||
|
const source = raw as Record<string, unknown>;
|
||||||
|
const next: BackgroundLayerVisibility = {
|
||||||
|
...DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const layer of BACKGROUND_LAYER_OPTIONS) {
|
||||||
|
const value = source[layer.id];
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
next[layer.id] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
55
src/uhm/lib/editor/draft/draftDiff.ts
Normal file
55
src/uhm/lib/editor/draft/draftDiff.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import type {
|
||||||
|
Feature,
|
||||||
|
FeatureCollection,
|
||||||
|
FeatureProperties,
|
||||||
|
Geometry,
|
||||||
|
} from "@/uhm/types/geo";
|
||||||
|
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
|
||||||
|
|
||||||
|
export const deepClone = <T,>(obj: T): T => JSON.parse(JSON.stringify(obj));
|
||||||
|
|
||||||
|
export function geometryEquals(a: Geometry | undefined, b: Geometry | undefined): boolean {
|
||||||
|
if (!a || !b) return false;
|
||||||
|
return JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function featureEquals(a: Feature | undefined, b: Feature | undefined): boolean {
|
||||||
|
if (!a || !b) return false;
|
||||||
|
return JSON.stringify(a.geometry) === JSON.stringify(b.geometry) &&
|
||||||
|
JSON.stringify(a.properties) === JSON.stringify(b.properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildInitialMap(fc: FeatureCollection) {
|
||||||
|
const map = new Map<FeatureProperties["id"], Feature>();
|
||||||
|
for (const feature of fc.features) {
|
||||||
|
map.set(feature.properties.id, deepClone(feature));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function diffDraftToInitial(
|
||||||
|
draft: FeatureCollection,
|
||||||
|
initialMap: Map<FeatureProperties["id"], Feature>
|
||||||
|
) {
|
||||||
|
const next = new Map<FeatureProperties["id"], Change>();
|
||||||
|
const seen = new Set<FeatureProperties["id"]>();
|
||||||
|
|
||||||
|
for (const feature of draft.features) {
|
||||||
|
const id = feature.properties.id;
|
||||||
|
seen.add(id);
|
||||||
|
const initialFeature = initialMap.get(id);
|
||||||
|
if (!initialFeature) {
|
||||||
|
next.set(id, { action: "create", feature: deepClone(feature) });
|
||||||
|
} else if (!featureEquals(initialFeature, feature)) {
|
||||||
|
next.set(id, { action: "update", id, geometry: deepClone(feature.geometry) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id] of initialMap.entries()) {
|
||||||
|
if (!seen.has(id)) {
|
||||||
|
next.set(id, { action: "delete", id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
21
src/uhm/lib/editor/draft/editorTypes.ts
Normal file
21
src/uhm/lib/editor/draft/editorTypes.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type {
|
||||||
|
Feature,
|
||||||
|
FeatureProperties,
|
||||||
|
Geometry,
|
||||||
|
GeometryChange,
|
||||||
|
} from "@/uhm/types/geo";
|
||||||
|
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||||
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
|
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
||||||
|
|
||||||
|
export type Change = GeometryChange;
|
||||||
|
|
||||||
|
export type UndoAction =
|
||||||
|
| { type: "update"; id: FeatureProperties["id"]; prevGeometry: Geometry }
|
||||||
|
| { type: "properties"; id: FeatureProperties["id"]; prevProperties: FeatureProperties }
|
||||||
|
| { type: "delete"; feature: Feature }
|
||||||
|
| { type: "create"; id: FeatureProperties["id"] }
|
||||||
|
// Snapshot-scoped undo (affects commit snapshot but not GeoJSON draft directly)
|
||||||
|
| { type: "snapshot_entities"; label: string; prev: EntitySnapshot[] }
|
||||||
|
| { type: "snapshot_wikis"; label: string; prev: WikiSnapshot[] }
|
||||||
|
| { type: "snapshot_entity_wiki"; label: string; prev: EntityWikiLinkSnapshot[] };
|
||||||
31
src/uhm/lib/editor/draft/useDraftState.ts
Normal file
31
src/uhm/lib/editor/draft/useDraftState.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { FeatureCollection } from "@/uhm/types/geo";
|
||||||
|
import { deepClone } from "@/uhm/lib/editor/draft/draftDiff";
|
||||||
|
|
||||||
|
export function useDraftState(initialData: FeatureCollection) {
|
||||||
|
// Draft hiện tại (React state) để UI re-render khi dữ liệu thay đổi.
|
||||||
|
const [draft, setDraft] = useState<FeatureCollection>(() => deepClone(initialData));
|
||||||
|
// Draft ref để đọc giá trị mới nhất trong event handlers/engines mà không cần deps.
|
||||||
|
const draftRef = useRef<FeatureCollection>(deepClone(initialData));
|
||||||
|
|
||||||
|
const commitDraft = useCallback((nextDraft: FeatureCollection) => {
|
||||||
|
const cloned = deepClone(nextDraft);
|
||||||
|
draftRef.current = cloned;
|
||||||
|
setDraft(cloned);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
draftRef.current = draft;
|
||||||
|
}, [draft]);
|
||||||
|
|
||||||
|
const resetDraft = useCallback((nextDraft: FeatureCollection) => {
|
||||||
|
commitDraft(nextDraft);
|
||||||
|
}, [commitDraft]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
draft,
|
||||||
|
draftRef,
|
||||||
|
commitDraft,
|
||||||
|
resetDraft,
|
||||||
|
};
|
||||||
|
}
|
||||||
93
src/uhm/lib/editor/draft/useUndoStack.ts
Normal file
93
src/uhm/lib/editor/draft/useUndoStack.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import type { UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
|
||||||
|
import { geometryEquals } from "@/uhm/lib/editor/draft/draftDiff";
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
applyUndoAction: (action: UndoAction) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useUndoStack(options: Options) {
|
||||||
|
const { applyUndoAction } = options;
|
||||||
|
// Stack thao tác undo (append-only, pop khi undo).
|
||||||
|
const [undoStack, setUndoStack] = useState<UndoAction[]>([]);
|
||||||
|
|
||||||
|
const pushUndo = useCallback((action: UndoAction) => {
|
||||||
|
setUndoStack((prev) => {
|
||||||
|
const last = prev[prev.length - 1];
|
||||||
|
if (isSameUndo(last, action)) return prev;
|
||||||
|
return [...prev, action];
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const undo = useCallback(() => {
|
||||||
|
let applied = false;
|
||||||
|
setUndoStack((prev) => {
|
||||||
|
if (applied) return prev;
|
||||||
|
if (!prev.length) return prev;
|
||||||
|
|
||||||
|
const last = prev[prev.length - 1];
|
||||||
|
const remaining = prev.slice(0, -1);
|
||||||
|
applied = true;
|
||||||
|
|
||||||
|
const didApply = applyUndoAction(last);
|
||||||
|
return didApply ? remaining : prev;
|
||||||
|
});
|
||||||
|
}, [applyUndoAction]);
|
||||||
|
|
||||||
|
const clearUndo = useCallback(() => {
|
||||||
|
setUndoStack([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
undoStack,
|
||||||
|
pushUndo,
|
||||||
|
undo,
|
||||||
|
clearUndo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameUndo(a: UndoAction | undefined, b: UndoAction) {
|
||||||
|
if (!a) return false;
|
||||||
|
if (a.type !== b.type) return false;
|
||||||
|
switch (a.type) {
|
||||||
|
case "create": {
|
||||||
|
const next = b as Extract<UndoAction, { type: "create" }>;
|
||||||
|
return a.id === next.id;
|
||||||
|
}
|
||||||
|
case "delete": {
|
||||||
|
const next = b as Extract<UndoAction, { type: "delete" }>;
|
||||||
|
return (
|
||||||
|
a.feature.properties.id === next.feature.properties.id &&
|
||||||
|
geometryEquals(a.feature.geometry, next.feature.geometry)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "update": {
|
||||||
|
const next = b as Extract<UndoAction, { type: "update" }>;
|
||||||
|
return (
|
||||||
|
a.id === next.id &&
|
||||||
|
geometryEquals(a.prevGeometry, next.prevGeometry)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "properties": {
|
||||||
|
const next = b as Extract<UndoAction, { type: "properties" }>;
|
||||||
|
return (
|
||||||
|
a.id === next.id &&
|
||||||
|
JSON.stringify(a.prevProperties) === JSON.stringify(next.prevProperties)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "snapshot_entities": {
|
||||||
|
const next = b as Extract<UndoAction, { type: "snapshot_entities" }>;
|
||||||
|
return a.label === next.label && JSON.stringify(a.prev) === JSON.stringify(next.prev);
|
||||||
|
}
|
||||||
|
case "snapshot_wikis": {
|
||||||
|
const next = b as Extract<UndoAction, { type: "snapshot_wikis" }>;
|
||||||
|
return a.label === next.label && JSON.stringify(a.prev) === JSON.stringify(next.prev);
|
||||||
|
}
|
||||||
|
case "snapshot_entity_wiki": {
|
||||||
|
const next = b as Extract<UndoAction, { type: "snapshot_entity_wiki" }>;
|
||||||
|
return a.label === next.label && JSON.stringify(a.prev) === JSON.stringify(next.prev);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/uhm/lib/editor/entity/entityBinding.ts
Normal file
61
src/uhm/lib/editor/entity/entityBinding.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import type { Entity } from "@/uhm/types/entities";
|
||||||
|
import type { Feature, FeatureProperties } from "@/uhm/types/geo";
|
||||||
|
import { normalizeFeatureEntityIds } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
||||||
|
import { newId } from "@/uhm/lib/id";
|
||||||
|
|
||||||
|
export function mergeEntitySearchResults(
|
||||||
|
remoteRows: Entity[],
|
||||||
|
localRows: Entity[]
|
||||||
|
): Entity[] {
|
||||||
|
const merged: Entity[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const row of localRows) {
|
||||||
|
if (!row.id || seen.has(row.id)) continue;
|
||||||
|
seen.add(row.id);
|
||||||
|
merged.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of remoteRows) {
|
||||||
|
if (!row.id || seen.has(row.id)) continue;
|
||||||
|
seen.add(row.id);
|
||||||
|
merged.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatEntityNamesForDisplay(feature: Feature, entities: Entity[]): string {
|
||||||
|
const entityIds = normalizeFeatureEntityIds(feature);
|
||||||
|
if (!entityIds.length) return "Chưa gắn";
|
||||||
|
|
||||||
|
const names = entityIds
|
||||||
|
.map((id) => entities.find((entity) => entity.id === id)?.name || id)
|
||||||
|
.filter((name) => name.trim().length > 0);
|
||||||
|
return names.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildClientEntityId(): string {
|
||||||
|
return newId();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFeatureEntityPatch(
|
||||||
|
_feature: Feature,
|
||||||
|
entityIds: string[],
|
||||||
|
entities: Entity[]
|
||||||
|
): Partial<FeatureProperties> {
|
||||||
|
const primaryEntityId = entityIds[0] || null;
|
||||||
|
const primaryEntity = primaryEntityId
|
||||||
|
? entities.find((entity) => entity.id === primaryEntityId) || null
|
||||||
|
: null;
|
||||||
|
const entityNames = entityIds
|
||||||
|
.map((id) => entities.find((entity) => entity.id === id)?.name || "")
|
||||||
|
.filter((name) => name.length > 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
entity_id: primaryEntityId,
|
||||||
|
entity_ids: entityIds,
|
||||||
|
entity_name: primaryEntity?.name || null,
|
||||||
|
entity_names: entityNames,
|
||||||
|
};
|
||||||
|
}
|
||||||
52
src/uhm/lib/editor/geometry/geometryMetadata.ts
Normal file
52
src/uhm/lib/editor/geometry/geometryMetadata.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import type { Feature, FeatureProperties } from "@/uhm/types/geo";
|
||||||
|
import type { GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
import {
|
||||||
|
normalizeFeatureBindingIds,
|
||||||
|
parseBindingInput,
|
||||||
|
} from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
||||||
|
|
||||||
|
export type GeometryMetadataPatch = {
|
||||||
|
patch: Partial<FeatureProperties>;
|
||||||
|
formState: GeometryMetaFormState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildGeometryMetadataPatch(form: GeometryMetaFormState): GeometryMetadataPatch {
|
||||||
|
const typeKey = form.type_key.trim();
|
||||||
|
const timeStart = parseOptionalYearInput(form.time_start, "time_start");
|
||||||
|
const timeEnd = parseOptionalYearInput(form.time_end, "time_end");
|
||||||
|
if (timeStart !== null && timeEnd !== null && timeStart > timeEnd) {
|
||||||
|
throw new Error("time_start phải <= time_end.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindingIds = parseBindingInput(form.binding);
|
||||||
|
return {
|
||||||
|
patch: {
|
||||||
|
type: typeKey.length ? typeKey : undefined,
|
||||||
|
time_start: timeStart,
|
||||||
|
time_end: timeEnd,
|
||||||
|
binding: bindingIds,
|
||||||
|
},
|
||||||
|
formState: {
|
||||||
|
type_key: typeKey,
|
||||||
|
time_start: timeStart != null ? String(timeStart) : "",
|
||||||
|
time_end: timeEnd != null ? String(timeEnd) : "",
|
||||||
|
binding: bindingIds.join(", "),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBindingIdsForDisplay(feature: Feature): string {
|
||||||
|
const bindingIds = normalizeFeatureBindingIds(feature);
|
||||||
|
if (!bindingIds.length) return "Không có";
|
||||||
|
return bindingIds.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalYearInput(raw: string, fieldName: string): number | null {
|
||||||
|
const value = raw.trim();
|
||||||
|
if (!value.length) return null;
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
throw new Error(`${fieldName} phải là số.`);
|
||||||
|
}
|
||||||
|
return Math.trunc(parsed);
|
||||||
|
}
|
||||||
408
src/uhm/lib/editor/section/useSectionCommands.ts
Normal file
408
src/uhm/lib/editor/section/useSectionCommands.ts
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
import { ApiError } from "@/uhm/api/http";
|
||||||
|
import {
|
||||||
|
createSection,
|
||||||
|
createSectionCommit,
|
||||||
|
fetchSectionCommits,
|
||||||
|
fetchSections,
|
||||||
|
openSectionEditor,
|
||||||
|
submitSection,
|
||||||
|
} from "@/uhm/api/sections";
|
||||||
|
import { buildEditorSnapshot, normalizeEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
||||||
|
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
|
||||||
|
import type { Feature, FeatureCollection, FeatureId, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
|
||||||
|
import type { EditorSnapshot, Section, SectionCommit, SectionState, EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
||||||
|
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||||
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
|
|
||||||
|
type EditorDraftApi = {
|
||||||
|
draft: FeatureCollection;
|
||||||
|
buildPayload: () => Change[];
|
||||||
|
clearChanges: () => void;
|
||||||
|
hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
editor: EditorDraftApi;
|
||||||
|
editorUserId: string;
|
||||||
|
emptyFeatureCollection: FeatureCollection;
|
||||||
|
activeSection: Section | null;
|
||||||
|
sectionState: SectionState | null;
|
||||||
|
selectedSectionId: string;
|
||||||
|
newSectionTitle: string;
|
||||||
|
pendingSaveCount: number;
|
||||||
|
snapshotEntities: EntitySnapshot[];
|
||||||
|
snapshotWikis: WikiSnapshot[];
|
||||||
|
snapshotEntityWikiLinks: EntityWikiLinkSnapshot[];
|
||||||
|
baselineSnapshot: EditorSnapshot | null;
|
||||||
|
commitTitle: string;
|
||||||
|
commitNote: string;
|
||||||
|
setActiveSection: Dispatch<SetStateAction<Section | null>>;
|
||||||
|
setSelectedSectionId: Dispatch<SetStateAction<string>>;
|
||||||
|
setSectionState: Dispatch<SetStateAction<SectionState | null>>;
|
||||||
|
setBaselineSnapshot: Dispatch<SetStateAction<EditorSnapshot | null>>;
|
||||||
|
setInitialData: Dispatch<SetStateAction<FeatureCollection>>;
|
||||||
|
setSectionCommits: Dispatch<SetStateAction<SectionCommit[]>>;
|
||||||
|
setSnapshotEntities: Dispatch<SetStateAction<EntitySnapshot[]>>;
|
||||||
|
setSnapshotWikis: Dispatch<SetStateAction<WikiSnapshot[]>>;
|
||||||
|
setSnapshotEntityWikiLinks: Dispatch<SetStateAction<EntityWikiLinkSnapshot[]>>;
|
||||||
|
setSelectedFeatureId: Dispatch<SetStateAction<FeatureId | null>>;
|
||||||
|
setEntityFormStatus: Dispatch<SetStateAction<string | null>>;
|
||||||
|
setEntityStatus: Dispatch<SetStateAction<string | null>>;
|
||||||
|
setIsSaving: Dispatch<SetStateAction<boolean>>;
|
||||||
|
setIsSubmitting: Dispatch<SetStateAction<boolean>>;
|
||||||
|
setIsOpeningSection: Dispatch<SetStateAction<boolean>>;
|
||||||
|
setAvailableSections: Dispatch<SetStateAction<Section[]>>;
|
||||||
|
setNewSectionTitle: Dispatch<SetStateAction<string>>;
|
||||||
|
setCommitTitle: Dispatch<SetStateAction<string>>;
|
||||||
|
setCommitNote: Dispatch<SetStateAction<string>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useSectionCommands(options: Options) {
|
||||||
|
const openSectionForEditing = useCallback(async (sectionId: string) => {
|
||||||
|
const editorPayload = await openSectionEditor(sectionId);
|
||||||
|
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 nextInitialData = sessionSnapshot?.editor_feature_collection || options.emptyFeatureCollection;
|
||||||
|
|
||||||
|
options.setActiveSection(editorPayload.section);
|
||||||
|
options.setSelectedSectionId(editorPayload.section.id);
|
||||||
|
options.setSectionState(editorPayload.state);
|
||||||
|
options.setBaselineSnapshot(sessionSnapshot);
|
||||||
|
options.setInitialData(nextInitialData);
|
||||||
|
options.setSectionCommits(commits);
|
||||||
|
options.setSnapshotEntities(sessionSnapshot?.entities || []);
|
||||||
|
options.setSnapshotWikis(sessionSnapshot?.wikis || []);
|
||||||
|
options.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []);
|
||||||
|
options.setSelectedFeatureId(null);
|
||||||
|
options.setEntityFormStatus(null);
|
||||||
|
}, [options]);
|
||||||
|
|
||||||
|
const commitSection = useCallback(async () => {
|
||||||
|
if (!options.activeSection || !options.sectionState) {
|
||||||
|
options.setEntityStatus("Chưa mở được section editor.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (options.pendingSaveCount <= 0) {
|
||||||
|
options.setEntityStatus("Không có thay đổi để Commit.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const geometryChanges = options.editor.buildPayload();
|
||||||
|
options.setIsSaving(true);
|
||||||
|
options.setEntityStatus(null);
|
||||||
|
try {
|
||||||
|
const snapshot = buildEditorSnapshot({
|
||||||
|
section: options.activeSection,
|
||||||
|
draft: options.editor.draft,
|
||||||
|
changes: geometryChanges,
|
||||||
|
snapshotEntities: options.snapshotEntities,
|
||||||
|
snapshotWikis: options.snapshotWikis,
|
||||||
|
snapshotEntityWikiLinks: options.snapshotEntityWikiLinks,
|
||||||
|
previousSnapshot: options.baselineSnapshot,
|
||||||
|
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, {
|
||||||
|
snapshot,
|
||||||
|
edit_summary: editSummary,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionSnapshot = toEditorSessionSnapshot(snapshot);
|
||||||
|
options.setSectionState(result.state);
|
||||||
|
options.setBaselineSnapshot(sessionSnapshot);
|
||||||
|
options.setSnapshotEntities(sessionSnapshot.entities || []);
|
||||||
|
options.setSnapshotWikis(sessionSnapshot.wikis || []);
|
||||||
|
options.setSnapshotEntityWikiLinks(sessionSnapshot.entity_wiki || []);
|
||||||
|
options.setInitialData(options.editor.draft);
|
||||||
|
options.editor.clearChanges();
|
||||||
|
options.setCommitTitle("");
|
||||||
|
options.setCommitNote("");
|
||||||
|
options.setSectionCommits(await fetchSectionCommits(options.activeSection.id));
|
||||||
|
options.setEntityFormStatus("Đã tạo commit.");
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
console.error("Commit failed", err.body);
|
||||||
|
options.setEntityStatus(`Commit thất bại: ${err.body}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("Commit error", err);
|
||||||
|
options.setEntityStatus("Commit thất bại.");
|
||||||
|
} finally {
|
||||||
|
options.setIsSaving(false);
|
||||||
|
}
|
||||||
|
}, [options]);
|
||||||
|
|
||||||
|
const openSelectedSection = useCallback(async () => {
|
||||||
|
const sectionId = options.selectedSectionId.trim();
|
||||||
|
if (!sectionId) {
|
||||||
|
options.setEntityStatus("Hãy chọn section để mở.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (options.pendingSaveCount > 0) {
|
||||||
|
const confirmed = window.confirm("Section hiện tại có thay đổi chưa Commit. Mở section khác sẽ bỏ các thay đổi này. Tiếp tục?");
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.setIsOpeningSection(true);
|
||||||
|
options.setEntityStatus(null);
|
||||||
|
try {
|
||||||
|
await openSectionForEditing(sectionId);
|
||||||
|
options.setEntityStatus("Đã mở section để chỉnh sửa.");
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
options.setEntityStatus(`Mở section thất bại: ${err.body}`);
|
||||||
|
} else {
|
||||||
|
options.setEntityStatus("Mở section thất bại.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
options.setIsOpeningSection(false);
|
||||||
|
}
|
||||||
|
}, [openSectionForEditing, options]);
|
||||||
|
|
||||||
|
const createAndOpenSection = useCallback(async () => {
|
||||||
|
const title = options.newSectionTitle.trim();
|
||||||
|
if (!title) {
|
||||||
|
options.setEntityStatus("Tên section là bắt buộc.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (options.pendingSaveCount > 0) {
|
||||||
|
const confirmed = window.confirm("Section hiện tại có thay đổi chưa Commit. Tạo section mới sẽ bỏ các thay đổi này. Tiếp tục?");
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.setIsOpeningSection(true);
|
||||||
|
options.setEntityStatus(null);
|
||||||
|
try {
|
||||||
|
const section = await createSection({
|
||||||
|
title,
|
||||||
|
description: null,
|
||||||
|
});
|
||||||
|
const sections = await fetchSections();
|
||||||
|
options.setAvailableSections(sections);
|
||||||
|
options.setNewSectionTitle("");
|
||||||
|
await openSectionForEditing(section.id);
|
||||||
|
options.setEntityStatus("Đã tạo và mở section mới.");
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
options.setEntityStatus(`Tạo section thất bại: ${err.body}`);
|
||||||
|
} else {
|
||||||
|
options.setEntityStatus("Tạo section thất bại.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
options.setIsOpeningSection(false);
|
||||||
|
}
|
||||||
|
}, [openSectionForEditing, options]);
|
||||||
|
|
||||||
|
const submitCurrentSection = useCallback(async () => {
|
||||||
|
if (!options.activeSection || !options.sectionState?.head_commit_id) {
|
||||||
|
options.setEntityStatus("Section hiện tại chưa có head để submit.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (options.pendingSaveCount > 0) {
|
||||||
|
options.setEntityStatus("Hãy Commit các thay đổi trước khi Submit.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.setIsSubmitting(true);
|
||||||
|
options.setEntityStatus(null);
|
||||||
|
try {
|
||||||
|
const submission = await submitSection(options.activeSection.id);
|
||||||
|
options.setEntityStatus(`Đã submit, submission ${submission.id}.`);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
options.setEntityStatus(`Submit thất bại: ${err.body}`);
|
||||||
|
} else {
|
||||||
|
options.setEntityStatus("Submit thất bại.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
options.setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [options]);
|
||||||
|
|
||||||
|
const restoreCommit = useCallback(async (commitId: string) => {
|
||||||
|
if (!options.activeSection || !options.sectionState) {
|
||||||
|
options.setEntityStatus("Chưa mở được section editor.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (options.pendingSaveCount > 0) {
|
||||||
|
options.setEntityStatus("Hãy Commit hoặc Undo thay đổi hiện tại trước khi Restore.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.setIsSaving(true);
|
||||||
|
options.setEntityStatus(null);
|
||||||
|
try {
|
||||||
|
// FE-only restore: load snapshot from selected commit and apply to editor state.
|
||||||
|
// Do NOT move project's head commit on backend.
|
||||||
|
const commits = await fetchSectionCommits(options.activeSection.id);
|
||||||
|
const target = commits.find((c: SectionCommit) => c.id === commitId) || null;
|
||||||
|
if (!target) {
|
||||||
|
options.setEntityStatus("Không tìm thấy commit để restore.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = normalizeEditorSnapshot(target.snapshot_json);
|
||||||
|
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) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
options.setEntityStatus(`Restore thất bại: ${err.body}`);
|
||||||
|
} else {
|
||||||
|
options.setEntityStatus("Restore thất bại.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
options.setIsSaving(false);
|
||||||
|
}
|
||||||
|
}, [options]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
openSectionForEditing,
|
||||||
|
commitSection,
|
||||||
|
openSelectedSection,
|
||||||
|
createAndOpenSection,
|
||||||
|
submitCurrentSection,
|
||||||
|
restoreCommit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toEditorSessionSnapshot(snapshot: EditorSnapshot): EditorSnapshot {
|
||||||
|
return {
|
||||||
|
...snapshot,
|
||||||
|
entities: toEditorSessionEntities(snapshot.entities),
|
||||||
|
geometries: toEditorSessionGeometries(snapshot.geometries),
|
||||||
|
geometry_entity: toEditorSessionGeometryEntity(snapshot.geometry_entity),
|
||||||
|
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"))
|
||||||
|
.filter((e) => (e as any).operation !== "delete")
|
||||||
|
.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 toEditorSessionGeometries(input: EditorSnapshot["geometries"]): GeometrySnapshot[] {
|
||||||
|
const rows = Array.isArray(input) ? input : [];
|
||||||
|
return rows
|
||||||
|
.filter((g) => g && (typeof (g as any).id === "string" || typeof (g as any).id === "number"))
|
||||||
|
.filter((g) => (g as any).operation !== "delete")
|
||||||
|
.map((g) => {
|
||||||
|
const { operation: _op, ...rest } = g as any;
|
||||||
|
const id = String((g as any).id);
|
||||||
|
const source: GeometrySnapshot["source"] = (g as any).source === "inline" ? "inline" : "ref";
|
||||||
|
return {
|
||||||
|
...(rest as Omit<GeometrySnapshot, "id" | "source" | "operation">),
|
||||||
|
id,
|
||||||
|
source,
|
||||||
|
operation: "reference",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toEditorSessionGeometryEntity(input: EditorSnapshot["geometry_entity"]): GeometryEntitySnapshot[] {
|
||||||
|
const rows = Array.isArray(input) ? input : [];
|
||||||
|
const deduped = new globalThis.Map<string, GeometryEntitySnapshot>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row) continue;
|
||||||
|
if ((row as any).operation === "delete") continue;
|
||||||
|
const geometry_id = typeof (row as any).geometry_id === "string" || typeof (row as any).geometry_id === "number"
|
||||||
|
? String((row as any).geometry_id).trim()
|
||||||
|
: "";
|
||||||
|
const entity_id = typeof (row as any).entity_id === "string" || typeof (row as any).entity_id === "number"
|
||||||
|
? String((row as any).entity_id).trim()
|
||||||
|
: "";
|
||||||
|
if (!geometry_id || !entity_id) continue;
|
||||||
|
const key = `${geometry_id}::${entity_id}`;
|
||||||
|
deduped.set(key, { geometry_id, entity_id, operation: "reference", base_links_hash: (row as any).base_links_hash });
|
||||||
|
}
|
||||||
|
return Array.from(deduped.values()).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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
.filter((w) => (w as any).operation !== "delete")
|
||||||
|
.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: "reference" });
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
41
src/uhm/lib/editor/session/sessionTypes.ts
Normal file
41
src/uhm/lib/editor/session/sessionTypes.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type { EntityGeometryPreset } from "@/uhm/lib/entityTypeOptions";
|
||||||
|
|
||||||
|
export type EditorMode =
|
||||||
|
| "idle"
|
||||||
|
| "draw"
|
||||||
|
| "select"
|
||||||
|
| "add-point"
|
||||||
|
| "add-line"
|
||||||
|
| "add-path"
|
||||||
|
| "add-circle";
|
||||||
|
|
||||||
|
export type TimelineRange = {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EntityFormState = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GeometryMetaFormState = {
|
||||||
|
type_key: string;
|
||||||
|
time_start: string;
|
||||||
|
time_end: string;
|
||||||
|
binding: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PendingEntityCreate = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
status: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreatedEntitySummary = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GeometryPreset = EntityGeometryPreset;
|
||||||
21
src/uhm/lib/editor/session/useBackgroundSessionState.ts
Normal file
21
src/uhm/lib/editor/session/useBackgroundSessionState.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
BackgroundLayerVisibility,
|
||||||
|
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
||||||
|
} from "@/uhm/lib/backgroundLayers";
|
||||||
|
|
||||||
|
export function useBackgroundSessionState() {
|
||||||
|
// Trạng thái bật/tắt layer nền (khởi tạo default hidden; sẽ load từ storage ở page).
|
||||||
|
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
|
||||||
|
() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })
|
||||||
|
);
|
||||||
|
// Đảm bảo đã load visibility trước khi render map thật.
|
||||||
|
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundVisibility,
|
||||||
|
setBackgroundVisibility,
|
||||||
|
isBackgroundVisibilityReady,
|
||||||
|
setIsBackgroundVisibilityReady,
|
||||||
|
};
|
||||||
|
}
|
||||||
74
src/uhm/lib/editor/session/useEntitySessionState.ts
Normal file
74
src/uhm/lib/editor/session/useEntitySessionState.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { Entity } from "@/uhm/types/entities";
|
||||||
|
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||||
|
import type { FeatureId } from "@/uhm/types/geo";
|
||||||
|
import type {
|
||||||
|
EntityFormState,
|
||||||
|
GeometryMetaFormState,
|
||||||
|
} from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
|
||||||
|
export function useEntitySessionState() {
|
||||||
|
// Entity catalog loaded from backend (global list, used for search/lookup).
|
||||||
|
const [entityCatalog, setEntityCatalog] = useState<Entity[]>([]);
|
||||||
|
// Snapshot entity store for the current editor session (single source of truth for snapshot.entities).
|
||||||
|
const [snapshotEntities, setSnapshotEntities] = useState<EntitySnapshot[]>([]);
|
||||||
|
// Thông báo trạng thái/lỗi liên quan entity/session.
|
||||||
|
const [entityStatus, setEntityStatus] = useState<string | null>(null);
|
||||||
|
// Feature đang được chọn để thao tác bind entities/metadata.
|
||||||
|
const [selectedFeatureId, setSelectedFeatureId] = useState<FeatureId | null>(null);
|
||||||
|
// Form tạo entity mới (độc lập).
|
||||||
|
const [entityForm, setEntityForm] = useState<EntityFormState>({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
});
|
||||||
|
// Danh sách entity IDs đang chọn để bind vào geometry hiện tại.
|
||||||
|
const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState<string[]>([]);
|
||||||
|
// Form metadata geometry (time range + binding ids).
|
||||||
|
const [geometryMetaForm, setGeometryMetaForm] = useState<GeometryMetaFormState>({
|
||||||
|
type_key: "",
|
||||||
|
time_start: "",
|
||||||
|
time_end: "",
|
||||||
|
binding: "",
|
||||||
|
});
|
||||||
|
// Cờ loading khi apply entity/metadata (local submit).
|
||||||
|
const [isEntitySubmitting, setIsEntitySubmitting] = useState(false);
|
||||||
|
// Thông báo trạng thái/lỗi cho form entity/metadata.
|
||||||
|
const [entityFormStatus, setEntityFormStatus] = useState<string | null>(null);
|
||||||
|
// Keyword search entity theo name.
|
||||||
|
const [entitySearchQuery, setEntitySearchQuery] = useState("");
|
||||||
|
// Kết quả search entity để user chọn.
|
||||||
|
const [entitySearchResults, setEntitySearchResults] = useState<Entity[]>([]);
|
||||||
|
// Entity ID đang được chọn trong dropdown kết quả search.
|
||||||
|
const [selectedSearchEntityId, setSelectedSearchEntityId] = useState<string | null>(null);
|
||||||
|
// Cờ loading khi search entity.
|
||||||
|
const [isEntitySearchLoading, setIsEntitySearchLoading] = useState(false);
|
||||||
|
|
||||||
|
return {
|
||||||
|
entityCatalog,
|
||||||
|
setEntityCatalog,
|
||||||
|
snapshotEntities,
|
||||||
|
setSnapshotEntities,
|
||||||
|
entityStatus,
|
||||||
|
setEntityStatus,
|
||||||
|
selectedFeatureId,
|
||||||
|
setSelectedFeatureId,
|
||||||
|
entityForm,
|
||||||
|
setEntityForm,
|
||||||
|
selectedGeometryEntityIds,
|
||||||
|
setSelectedGeometryEntityIds,
|
||||||
|
geometryMetaForm,
|
||||||
|
setGeometryMetaForm,
|
||||||
|
isEntitySubmitting,
|
||||||
|
setIsEntitySubmitting,
|
||||||
|
entityFormStatus,
|
||||||
|
setEntityFormStatus,
|
||||||
|
entitySearchQuery,
|
||||||
|
setEntitySearchQuery,
|
||||||
|
entitySearchResults,
|
||||||
|
setEntitySearchResults,
|
||||||
|
selectedSearchEntityId,
|
||||||
|
setSelectedSearchEntityId,
|
||||||
|
isEntitySearchLoading,
|
||||||
|
setIsEntitySearchLoading,
|
||||||
|
};
|
||||||
|
}
|
||||||
85
src/uhm/lib/editor/session/useSectionSessionState.ts
Normal file
85
src/uhm/lib/editor/session/useSectionSessionState.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
import type { EditorSnapshot, Section, SectionCommit, SectionState } from "@/uhm/types/sections";
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
defaultEditorUserId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SectionTask = "idle" | "saving" | "submitting" | "opening-section";
|
||||||
|
|
||||||
|
export function useSectionSessionState(options: Options) {
|
||||||
|
// Single state machine cho các tác vụ async của section (saving/submitting/opening).
|
||||||
|
const [sectionTask, setSectionTask] = useState<SectionTask>("idle");
|
||||||
|
const setTaskFlag = useCallback((task: Exclude<SectionTask, "idle">, next: SetStateAction<boolean>) => {
|
||||||
|
setSectionTask((prev) => {
|
||||||
|
const currentValue = prev === task;
|
||||||
|
const nextValue = typeof next === "function" ? next(currentValue) : next;
|
||||||
|
if (nextValue) return task;
|
||||||
|
return prev === task ? "idle" : prev;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isSaving = sectionTask === "saving";
|
||||||
|
const isSubmitting = sectionTask === "submitting";
|
||||||
|
const isOpeningSection = sectionTask === "opening-section";
|
||||||
|
const setIsSaving: Dispatch<SetStateAction<boolean>> = useCallback((next) => {
|
||||||
|
setTaskFlag("saving", next);
|
||||||
|
}, [setTaskFlag]);
|
||||||
|
const setIsSubmitting: Dispatch<SetStateAction<boolean>> = useCallback((next) => {
|
||||||
|
setTaskFlag("submitting", next);
|
||||||
|
}, [setTaskFlag]);
|
||||||
|
const setIsOpeningSection: Dispatch<SetStateAction<boolean>> = useCallback((next) => {
|
||||||
|
setTaskFlag("opening-section", next);
|
||||||
|
}, [setTaskFlag]);
|
||||||
|
|
||||||
|
// Danh sách sections để user chọn mở.
|
||||||
|
const [availableSections, setAvailableSections] = useState<Section[]>([]);
|
||||||
|
// Section ID đang được chọn trong dropdown.
|
||||||
|
const [selectedSectionId, setSelectedSectionId] = useState("");
|
||||||
|
// Title section mới (để create).
|
||||||
|
const [newSectionTitle, setNewSectionTitle] = useState("");
|
||||||
|
// Input title cho commit.
|
||||||
|
const [commitTitle, setCommitTitle] = useState("");
|
||||||
|
// Input note cho commit.
|
||||||
|
const [commitNote, setCommitNote] = useState("");
|
||||||
|
// User ID dùng để gắn vào commit/submit/lock.
|
||||||
|
const [editorUserIdInput, setEditorUserIdInput] = useState(options.defaultEditorUserId);
|
||||||
|
// Section đang mở để edit (null nếu chưa mở).
|
||||||
|
const [activeSection, setActiveSection] = useState<Section | null>(null);
|
||||||
|
// Trạng thái section (version/head/status/lock).
|
||||||
|
const [sectionState, setSectionState] = useState<SectionState | null>(null);
|
||||||
|
// Danh sách commits của section đang mở.
|
||||||
|
const [sectionCommits, setSectionCommits] = useState<SectionCommit[]>([]);
|
||||||
|
// Baseline snapshot currently loaded for this editor session.
|
||||||
|
const [baselineSnapshot, setBaselineSnapshot] = useState<EditorSnapshot | null>(null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSaving,
|
||||||
|
setIsSaving,
|
||||||
|
isSubmitting,
|
||||||
|
setIsSubmitting,
|
||||||
|
isOpeningSection,
|
||||||
|
setIsOpeningSection,
|
||||||
|
availableSections,
|
||||||
|
setAvailableSections,
|
||||||
|
selectedSectionId,
|
||||||
|
setSelectedSectionId,
|
||||||
|
newSectionTitle,
|
||||||
|
setNewSectionTitle,
|
||||||
|
commitTitle,
|
||||||
|
setCommitTitle,
|
||||||
|
commitNote,
|
||||||
|
setCommitNote,
|
||||||
|
editorUserIdInput,
|
||||||
|
setEditorUserIdInput,
|
||||||
|
activeSection,
|
||||||
|
setActiveSection,
|
||||||
|
sectionState,
|
||||||
|
setSectionState,
|
||||||
|
sectionCommits,
|
||||||
|
setSectionCommits,
|
||||||
|
baselineSnapshot,
|
||||||
|
setBaselineSnapshot,
|
||||||
|
};
|
||||||
|
}
|
||||||
42
src/uhm/lib/editor/session/useTimelineState.ts
Normal file
42
src/uhm/lib/editor/session/useTimelineState.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { TimelineRange } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
import { clampYearValue } from "@/uhm/lib/timeline";
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
currentYear: number;
|
||||||
|
fallbackTimelineRange: TimelineRange;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useTimelineState(options: Options) {
|
||||||
|
// Năm timeline "đã chốt" để fetch dữ liệu.
|
||||||
|
const [timelineYear, setTimelineYear] = useState<number>(() =>
|
||||||
|
clampYearValue(
|
||||||
|
options.currentYear,
|
||||||
|
options.fallbackTimelineRange.min,
|
||||||
|
options.fallbackTimelineRange.max
|
||||||
|
)
|
||||||
|
);
|
||||||
|
// Năm timeline đang chỉnh (debounce rồi đẩy sang timelineYear).
|
||||||
|
const [timelineDraftYear, setTimelineDraftYear] = useState<number>(() =>
|
||||||
|
clampYearValue(
|
||||||
|
options.currentYear,
|
||||||
|
options.fallbackTimelineRange.min,
|
||||||
|
options.fallbackTimelineRange.max
|
||||||
|
)
|
||||||
|
);
|
||||||
|
// Cờ loading khi fetch theo timeline.
|
||||||
|
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
|
||||||
|
// Thông báo trạng thái/lỗi khi fetch theo timeline.
|
||||||
|
const [timelineStatus, setTimelineStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
timelineYear,
|
||||||
|
setTimelineYear,
|
||||||
|
timelineDraftYear,
|
||||||
|
setTimelineDraftYear,
|
||||||
|
isTimelineLoading,
|
||||||
|
setIsTimelineLoading,
|
||||||
|
timelineStatus,
|
||||||
|
setTimelineStatus,
|
||||||
|
};
|
||||||
|
}
|
||||||
9
src/uhm/lib/editor/session/useWikiSessionState.ts
Normal file
9
src/uhm/lib/editor/session/useWikiSessionState.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
|
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
||||||
|
|
||||||
|
export function useWikiSessionState() {
|
||||||
|
const [snapshotWikis, setSnapshotWikis] = useState<WikiSnapshot[]>([]);
|
||||||
|
const [snapshotEntityWikiLinks, setSnapshotEntityWikiLinks] = useState<EntityWikiLinkSnapshot[]>([]);
|
||||||
|
return { snapshotWikis, setSnapshotWikis, snapshotEntityWikiLinks, setSnapshotEntityWikiLinks };
|
||||||
|
}
|
||||||
752
src/uhm/lib/editor/snapshot/editorSnapshot.ts
Normal file
752
src/uhm/lib/editor/snapshot/editorSnapshot.ts
Normal file
@@ -0,0 +1,752 @@
|
|||||||
|
import { DEFAULT_ENTITY_TYPE_ID } from "@/uhm/lib/entityTypeOptions";
|
||||||
|
import { geoTypeCodeToTypeKey, typeKeyToGeoTypeCode } from "@/uhm/lib/geoTypeMap";
|
||||||
|
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
|
||||||
|
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||||
|
import type { EntitySnapshotOperation } from "@/uhm/types/entities";
|
||||||
|
import type { Feature, FeatureCollection, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
|
||||||
|
import type { EditorSnapshot, Section } from "@/uhm/types/sections";
|
||||||
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
|
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
||||||
|
|
||||||
|
type UnknownRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is UnknownRecord {
|
||||||
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeEntitySnapshotOperation(op: unknown): EntitySnapshotOperation {
|
||||||
|
if (typeof op !== "string") return "reference";
|
||||||
|
const v = op.trim();
|
||||||
|
if (v === "create" || v === "update" || v === "delete" || v === "reference") return v;
|
||||||
|
// Defensive: legacy/buggy data sometimes concatenates words (e.g. "reference delete").
|
||||||
|
// Never guess "delete" here; prefer non-destructive behavior.
|
||||||
|
return "reference";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStringId(value: unknown): string {
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (typeof value === "number") return String(value);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRefId(value: unknown): string {
|
||||||
|
if (!isRecord(value)) return "";
|
||||||
|
return typeof value.id === "string" ? value.id : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
||||||
|
if (!isRecord(raw)) return null;
|
||||||
|
const snapshot = raw as UnknownRecord;
|
||||||
|
|
||||||
|
// Accept legacy snapshots (v1) and new ones (v2+). We only require that a FeatureCollection,
|
||||||
|
// if present, is structurally valid. Everything else is treated as optional.
|
||||||
|
const fcRaw = snapshot.editor_feature_collection;
|
||||||
|
const fc: FeatureCollection | undefined =
|
||||||
|
isRecord(fcRaw) && fcRaw.type === "FeatureCollection" && Array.isArray(fcRaw.features)
|
||||||
|
? (fcRaw as unknown as FeatureCollection)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const entitiesRaw = snapshot.entities;
|
||||||
|
const entities: EntitySnapshot[] | undefined = Array.isArray(entitiesRaw)
|
||||||
|
? entitiesRaw
|
||||||
|
.filter(isRecord)
|
||||||
|
.map((e) => {
|
||||||
|
const id = getStringId(e.id);
|
||||||
|
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 refId = getRefId(e.ref);
|
||||||
|
const source: "inline" | "ref" =
|
||||||
|
existingSource || (refId || opRaw === "reference" ? "ref" : "inline");
|
||||||
|
const rest: UnknownRecord = { ...e };
|
||||||
|
delete rest.ref;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(rest as unknown as Omit<EntitySnapshot, "id" | "source" | "operation">),
|
||||||
|
id,
|
||||||
|
source,
|
||||||
|
operation,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const geometriesRaw = snapshot.geometries;
|
||||||
|
const geometries: GeometrySnapshot[] | undefined = Array.isArray(geometriesRaw)
|
||||||
|
? geometriesRaw
|
||||||
|
.filter(isRecord)
|
||||||
|
.map((g) => {
|
||||||
|
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 refId = getRefId(g.ref);
|
||||||
|
const hasInlineGeometry = "draw_geometry" in g || "geometry" in g;
|
||||||
|
const source: "inline" | "ref" = existingSource || (refId || !hasInlineGeometry ? "ref" : "inline");
|
||||||
|
const rest: UnknownRecord = { ...g };
|
||||||
|
delete rest.ref;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(rest as unknown as Omit<GeometrySnapshot, "id" | "source" | "operation">),
|
||||||
|
id,
|
||||||
|
source,
|
||||||
|
operation,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const wikisRaw = snapshot.wikis;
|
||||||
|
const wikis: WikiSnapshot[] | undefined = Array.isArray(wikisRaw)
|
||||||
|
? wikisRaw
|
||||||
|
.filter(isRecord)
|
||||||
|
.map((w) => {
|
||||||
|
const id = typeof w.id === "string" ? w.id : "";
|
||||||
|
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 refId = getRefId(w.ref);
|
||||||
|
const source: "inline" | "ref" =
|
||||||
|
existingSource || (refId || opRaw === "reference" ? "ref" : "inline");
|
||||||
|
const rest: UnknownRecord = { ...w };
|
||||||
|
delete rest.ref;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(rest as unknown as Omit<WikiSnapshot, "id" | "source" | "operation">),
|
||||||
|
id,
|
||||||
|
source,
|
||||||
|
operation,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Legacy snapshots used link_scopes[{geometry_id, operation, entity_ids[]}]. New snapshots prefer
|
||||||
|
// geometry_entity[{geometry_id, entity_id}]. If geometry_entity is missing but link_scopes exists,
|
||||||
|
// migrate it by expanding each entity_id into a join row.
|
||||||
|
const geometryEntityRaw = snapshot.geometry_entity;
|
||||||
|
const geometryEntity: GeometryEntitySnapshot[] | undefined = Array.isArray(geometryEntityRaw)
|
||||||
|
? geometryEntityRaw
|
||||||
|
.filter(isRecord)
|
||||||
|
.map((r) => {
|
||||||
|
const geometry_id = getStringId(r.geometry_id);
|
||||||
|
const entity_id = typeof r.entity_id === "string" ? r.entity_id : "";
|
||||||
|
return {
|
||||||
|
...(r as unknown as Omit<GeometryEntitySnapshot, "geometry_id" | "entity_id">),
|
||||||
|
geometry_id,
|
||||||
|
entity_id,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((r) => r.geometry_id.length > 0 && r.entity_id.length > 0)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const legacyLinkScopes = snapshot.link_scopes;
|
||||||
|
const migratedGeometryEntity: GeometryEntitySnapshot[] | undefined =
|
||||||
|
!geometryEntity && Array.isArray(legacyLinkScopes)
|
||||||
|
? legacyLinkScopes
|
||||||
|
.filter(isRecord)
|
||||||
|
.flatMap((s) => {
|
||||||
|
const geometry_id = getStringId(s.geometry_id);
|
||||||
|
const entity_ids = Array.isArray(s.entity_ids)
|
||||||
|
? s.entity_ids.filter((x): x is string => typeof x === "string" && x.trim().length > 0)
|
||||||
|
: [];
|
||||||
|
return entity_ids.map((entity_id) => ({ geometry_id, entity_id: entity_id.trim() }))
|
||||||
|
.filter((row) => row.geometry_id.length > 0 && row.entity_id.length > 0);
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const entityWikisRaw = snapshot.entity_wiki ?? snapshot.entity_wikis;
|
||||||
|
const entityWikis: EntityWikiLinkSnapshot[] | undefined = Array.isArray(entityWikisRaw)
|
||||||
|
? entityWikisRaw
|
||||||
|
.filter(isRecord)
|
||||||
|
.map((r) => {
|
||||||
|
const entity_id = typeof r.entity_id === "string" ? r.entity_id : "";
|
||||||
|
const wiki_id = typeof r.wiki_id === "string" ? r.wiki_id : "";
|
||||||
|
const opRaw = typeof r.operation === "string" ? r.operation.trim() : "";
|
||||||
|
const isDeleted =
|
||||||
|
typeof r.is_deleted === "number"
|
||||||
|
? r.is_deleted === 1
|
||||||
|
: typeof r.is_deleted === "boolean"
|
||||||
|
? r.is_deleted
|
||||||
|
: false;
|
||||||
|
const operation: EntityWikiLinkSnapshot["operation"] =
|
||||||
|
isDeleted || opRaw === "delete"
|
||||||
|
? "delete"
|
||||||
|
: opRaw === "binding"
|
||||||
|
? "binding"
|
||||||
|
: "reference";
|
||||||
|
return { entity_id, wiki_id, operation };
|
||||||
|
})
|
||||||
|
.filter((r) => r.entity_id.length > 0 && r.wiki_id.length > 0)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// For editor UX, re-hydrate entity ids on features from geometry_entity. Snapshot persistence does not
|
||||||
|
// store entity_id/entity_ids/entity_names on features anymore.
|
||||||
|
const fcForEditor: FeatureCollection | undefined = (() => {
|
||||||
|
if (!fc) return undefined;
|
||||||
|
const hasLinks = Boolean(geometryEntity || migratedGeometryEntity);
|
||||||
|
const links = geometryEntity || migratedGeometryEntity || [];
|
||||||
|
const byGeom = new Map<string, string[]>();
|
||||||
|
for (const row of links) {
|
||||||
|
if ((row as any)?.operation === "delete") continue;
|
||||||
|
const list = byGeom.get(row.geometry_id) || [];
|
||||||
|
list.push(row.entity_id);
|
||||||
|
byGeom.set(row.geometry_id, list);
|
||||||
|
}
|
||||||
|
const entityNameById = new Map<string, string>();
|
||||||
|
for (const row of entities || []) {
|
||||||
|
const id = typeof row?.id === "string" ? row.id : "";
|
||||||
|
if (!id) continue;
|
||||||
|
const name = typeof (row as any)?.name === "string" ? String((row as any).name).trim() : "";
|
||||||
|
if (name) entityNameById.set(id, name);
|
||||||
|
}
|
||||||
|
const geometryById = new Map<string, GeometrySnapshot>();
|
||||||
|
for (const row of geometries || []) {
|
||||||
|
const id = typeof row?.id === "string" ? row.id : "";
|
||||||
|
if (!id) continue;
|
||||||
|
geometryById.set(id, row);
|
||||||
|
}
|
||||||
|
const cloned = JSON.parse(JSON.stringify(fc)) as FeatureCollection;
|
||||||
|
for (const feature of cloned.features) {
|
||||||
|
const gid = String(feature.properties.id);
|
||||||
|
const entity_ids = byGeom.get(gid) || [];
|
||||||
|
if (entity_ids.length || hasLinks) {
|
||||||
|
const props = feature.properties as unknown as UnknownRecord;
|
||||||
|
props.entity_ids = entity_ids;
|
||||||
|
props.entity_id = entity_ids[0] || null;
|
||||||
|
|
||||||
|
// Generate denormalized names for UI/map usage.
|
||||||
|
const primaryId = entity_ids[0] || null;
|
||||||
|
const primaryName = primaryId ? (entityNameById.get(primaryId) || "") : "";
|
||||||
|
const names = entity_ids.map((id) => entityNameById.get(id) || "").filter((n) => n.length > 0);
|
||||||
|
props.entity_name = primaryName || null;
|
||||||
|
props.entity_names = names;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate geometry metadata onto feature properties (optional in persisted snapshot).
|
||||||
|
const geo = geometryById.get(gid) || null;
|
||||||
|
if (geo) {
|
||||||
|
const p = feature.properties as unknown as UnknownRecord;
|
||||||
|
// type (semantic key) is derived from geometries[].type (numeric code in string form).
|
||||||
|
const typeCode = typeof geo.type === "string" && geo.type.trim().length ? Number(geo.type) : NaN;
|
||||||
|
const typeKey = geoTypeCodeToTypeKey(Number.isFinite(typeCode) ? typeCode : null);
|
||||||
|
if (typeKey) p.type = typeKey;
|
||||||
|
if (Array.isArray(geo.binding) && geo.binding.length) p.binding = geo.binding;
|
||||||
|
if (typeof geo.time_start === "number") p.time_start = geo.time_start;
|
||||||
|
if (typeof geo.time_end === "number") p.time_end = geo.time_end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cloned;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(snapshot as unknown as EditorSnapshot),
|
||||||
|
editor_feature_collection: fcForEditor,
|
||||||
|
entities,
|
||||||
|
geometries,
|
||||||
|
wikis,
|
||||||
|
geometry_entity: geometryEntity || migratedGeometryEntity,
|
||||||
|
entity_wiki: entityWikis,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildEditorSnapshot(options: {
|
||||||
|
section: Section;
|
||||||
|
draft: FeatureCollection;
|
||||||
|
changes: Change[];
|
||||||
|
snapshotEntities: EntitySnapshot[];
|
||||||
|
snapshotWikis: WikiSnapshot[];
|
||||||
|
snapshotEntityWikiLinks: EntityWikiLinkSnapshot[];
|
||||||
|
previousSnapshot: EditorSnapshot | null;
|
||||||
|
hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean;
|
||||||
|
}): EditorSnapshot {
|
||||||
|
const changedIds = new Set(options.changes.map((change) =>
|
||||||
|
String(change.action === "create" ? change.feature.properties.id : change.id)
|
||||||
|
));
|
||||||
|
const deletedIds = new Set(
|
||||||
|
options.changes
|
||||||
|
.filter((change): change is Extract<Change, { action: "delete" }> => change.action === "delete")
|
||||||
|
.map((change) => String(change.id))
|
||||||
|
);
|
||||||
|
const currentDraftIds = new Set(options.draft.features.map((feature) => String(feature.properties.id)));
|
||||||
|
const previousFeatures = new globalThis.Map<string, Feature>();
|
||||||
|
for (const feature of options.previousSnapshot?.editor_feature_collection?.features || []) {
|
||||||
|
previousFeatures.set(String(feature.properties.id), feature);
|
||||||
|
if (!currentDraftIds.has(String(feature.properties.id))) {
|
||||||
|
deletedIds.add(String(feature.properties.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousGeometryOps = new globalThis.Map<string, GeometrySnapshot["operation"]>();
|
||||||
|
for (const item of options.previousSnapshot?.geometries || []) {
|
||||||
|
const id = typeof item.id === "string" || typeof item.id === "number" ? String(item.id) : "";
|
||||||
|
const operation = item.operation;
|
||||||
|
if (id && operation) previousGeometryOps.set(id, operation);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityRows = new globalThis.Map<string, EntitySnapshot>();
|
||||||
|
|
||||||
|
// Persist inline entity records across commits even when they're not currently bound.
|
||||||
|
// 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",
|
||||||
|
operation: "reference",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const row of options.snapshotEntities || []) {
|
||||||
|
if (!row) continue;
|
||||||
|
const id = typeof row.id === "string" || typeof row.id === "number" ? String(row.id) : "";
|
||||||
|
if (!id) continue;
|
||||||
|
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";
|
||||||
|
const opRaw = sanitizeEntitySnapshotOperation((cloned as any).operation);
|
||||||
|
// Editor state should delete objects by removing them from the list.
|
||||||
|
// Keep this defensive guard to avoid emitting delete markers unexpectedly.
|
||||||
|
if (opRaw === "delete") continue;
|
||||||
|
const operation: EntitySnapshot["operation"] = source === "ref" ? "reference" : opRaw;
|
||||||
|
entityRows.set(id, {
|
||||||
|
...cloned,
|
||||||
|
id,
|
||||||
|
source,
|
||||||
|
name,
|
||||||
|
operation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entities referenced by wiki links should be present as "reference" too.
|
||||||
|
for (const link of options.snapshotEntityWikiLinks || []) {
|
||||||
|
const id = typeof link?.entity_id === "string" ? link.entity_id : "";
|
||||||
|
if (!id || entityRows.has(id)) continue;
|
||||||
|
entityRows.set(id, {
|
||||||
|
id,
|
||||||
|
source: "ref",
|
||||||
|
operation: "reference",
|
||||||
|
name: id,
|
||||||
|
slug: null,
|
||||||
|
description: null,
|
||||||
|
status: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const feature of options.draft.features) {
|
||||||
|
for (const entityId of normalizeFeatureEntityIds(feature)) {
|
||||||
|
if (entityRows.has(entityId)) continue;
|
||||||
|
entityRows.set(entityId, {
|
||||||
|
id: entityId,
|
||||||
|
source: "ref",
|
||||||
|
operation: "reference",
|
||||||
|
name: entityId,
|
||||||
|
slug: null,
|
||||||
|
description: null,
|
||||||
|
status: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const geometries: GeometrySnapshot[] = options.draft.features.map((feature) => {
|
||||||
|
const id = String(feature.properties.id);
|
||||||
|
const previousOperation = previousGeometryOps.get(id);
|
||||||
|
const previousFeature = previousFeatures.get(id);
|
||||||
|
const changedFromPreviousSnapshot = previousFeature
|
||||||
|
? JSON.stringify(previousFeature) !== JSON.stringify(feature)
|
||||||
|
: false;
|
||||||
|
const operation: GeometrySnapshot["operation"] =
|
||||||
|
previousOperation === "create"
|
||||||
|
? "create"
|
||||||
|
: !previousFeature && (options.previousSnapshot || !options.hasPersistedFeature(feature.properties.id))
|
||||||
|
? "create"
|
||||||
|
: changedIds.has(id) || changedFromPreviousSnapshot
|
||||||
|
? "update"
|
||||||
|
: "reference";
|
||||||
|
const bbox = getFeatureBBox(feature);
|
||||||
|
const typeKey = feature.properties.type || getDefaultTypeIdForFeature(feature);
|
||||||
|
const typeCode = typeKeyToGeoTypeCode(typeKey);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
operation,
|
||||||
|
source: "inline",
|
||||||
|
// 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,
|
||||||
|
binding: normalizeFeatureBindingIds(feature),
|
||||||
|
time_start: feature.properties.time_start ?? null,
|
||||||
|
time_end: feature.properties.time_end ?? null,
|
||||||
|
bbox: bbox
|
||||||
|
? {
|
||||||
|
min_lng: bbox.minLng,
|
||||||
|
min_lat: bbox.minLat,
|
||||||
|
max_lng: bbox.maxLng,
|
||||||
|
max_lat: bbox.maxLat,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const id of deletedIds) {
|
||||||
|
geometries.push({
|
||||||
|
id,
|
||||||
|
source: "ref",
|
||||||
|
operation: "delete",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const baselineGeometryEntity = new globalThis.Map<string, string | undefined>();
|
||||||
|
for (const row of options.previousSnapshot?.geometry_entity || []) {
|
||||||
|
if (!row) continue;
|
||||||
|
if ((row as any).operation === "delete") continue;
|
||||||
|
const geometry_id = typeof row.geometry_id === "string" || typeof row.geometry_id === "number" ? String(row.geometry_id).trim() : "";
|
||||||
|
const entity_id = typeof row.entity_id === "string" || typeof row.entity_id === "number" ? String(row.entity_id).trim() : "";
|
||||||
|
if (!geometry_id || !entity_id) continue;
|
||||||
|
baselineGeometryEntity.set(`${geometry_id}::${entity_id}`, (row as any).base_links_hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentGeometryEntityRows: GeometryEntitySnapshot[] = [];
|
||||||
|
const currentGeometryEntityKeys = new Set<string>();
|
||||||
|
for (const feature of options.draft.features) {
|
||||||
|
const geometry_id = String(feature.properties.id).trim();
|
||||||
|
if (!geometry_id) continue;
|
||||||
|
for (const entity_id of normalizeFeatureEntityIds(feature)) {
|
||||||
|
const key = `${geometry_id}::${entity_id}`;
|
||||||
|
if (currentGeometryEntityKeys.has(key)) continue;
|
||||||
|
currentGeometryEntityKeys.add(key);
|
||||||
|
currentGeometryEntityRows.push({
|
||||||
|
geometry_id,
|
||||||
|
entity_id,
|
||||||
|
operation: baselineGeometryEntity.has(key) ? "reference" : "binding",
|
||||||
|
base_links_hash: baselineGeometryEntity.get(key),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relations removed during this session are emitted as "delete" operations.
|
||||||
|
// NOTE: The editor state itself should remove the relation row; the commit payload is the delta.
|
||||||
|
for (const [key, base_links_hash] of baselineGeometryEntity.entries()) {
|
||||||
|
if (currentGeometryEntityKeys.has(key)) continue;
|
||||||
|
const [geometry_id, entity_id] = key.split("::");
|
||||||
|
if (!geometry_id || !entity_id) continue;
|
||||||
|
currentGeometryEntityRows.push({ geometry_id, entity_id, operation: "delete", base_links_hash });
|
||||||
|
}
|
||||||
|
|
||||||
|
const geometryEntity = dedupeAndSortGeometryEntity(currentGeometryEntityRows);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
for (const feature of draftForSnapshot.features) {
|
||||||
|
const p = feature.properties as unknown as UnknownRecord;
|
||||||
|
// Do not send generate-only fields on the API payload. These are re-generated on load.
|
||||||
|
delete p.type;
|
||||||
|
delete p.time_start;
|
||||||
|
delete p.time_end;
|
||||||
|
delete p.binding;
|
||||||
|
delete p.entity_id;
|
||||||
|
delete p.entity_ids;
|
||||||
|
delete p.entity_name;
|
||||||
|
delete p.entity_names;
|
||||||
|
delete p.entity_type_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousWikis = new globalThis.Map<string, WikiSnapshot>();
|
||||||
|
for (const item of options.previousSnapshot?.wikis || []) {
|
||||||
|
if (!item || typeof item !== "object") continue;
|
||||||
|
if ((item as any).operation === "delete") continue;
|
||||||
|
const id = (item as WikiSnapshot).id;
|
||||||
|
if (typeof id === "string" && id.length > 0) previousWikis.set(id, item as WikiSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wikis in snapshot_json are treated as current state (not a delta-table like geometries[]).
|
||||||
|
// Operation semantics:
|
||||||
|
// - create/update/delete: this commit changes the wiki itself
|
||||||
|
// - reference: this wiki is a ref used for linking (entity<->wiki), not a modification
|
||||||
|
const wikisCurrent: WikiSnapshot[] = (options.snapshotWikis || [])
|
||||||
|
.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") 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) => {
|
||||||
|
const prev = previousWikis.get(w.id) || null;
|
||||||
|
const cloned = JSON.parse(JSON.stringify(w)) as WikiSnapshot;
|
||||||
|
|
||||||
|
// Ref wiki: always mark as reference (used for linking, not changed here).
|
||||||
|
if (cloned.source === "ref") {
|
||||||
|
cloned.operation = "reference";
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline wiki: if explicitly marked create/update/delete by UI, keep it.
|
||||||
|
if (cloned.operation === "create" || cloned.operation === "update" || cloned.operation === "delete") {
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline wiki with no explicit operation:
|
||||||
|
// Keep a valid operation value, because backend validation may require it (oneof).
|
||||||
|
if (!prev) {
|
||||||
|
// New wiki that somehow has no op set: treat as create.
|
||||||
|
cloned.operation = "create";
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
|
||||||
|
const changed = (() => {
|
||||||
|
try {
|
||||||
|
const prevComparable = { title: prev.title, doc: prev.doc };
|
||||||
|
const nextComparable = { title: cloned.title, doc: cloned.doc };
|
||||||
|
return JSON.stringify(prevComparable) !== JSON.stringify(nextComparable);
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
cloned.operation = changed ? "update" : "reference";
|
||||||
|
return cloned;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wikis removed during this session are emitted as "delete" operations.
|
||||||
|
const currentWikiIds = new Set(wikisCurrent.map((w) => w.id));
|
||||||
|
const deletedWikis: WikiSnapshot[] = [];
|
||||||
|
for (const prev of previousWikis.values()) {
|
||||||
|
if (!prev?.id) continue;
|
||||||
|
if (currentWikiIds.has(prev.id)) continue;
|
||||||
|
deletedWikis.push({
|
||||||
|
id: prev.id,
|
||||||
|
source: prev.source === "inline" ? "inline" : "ref",
|
||||||
|
operation: "delete",
|
||||||
|
title: typeof prev.title === "string" ? prev.title : "Untitled wiki",
|
||||||
|
slug: (prev as any).slug ?? null,
|
||||||
|
doc: (prev as any).doc ?? null,
|
||||||
|
updated_at: (prev as any).updated_at ?? undefined,
|
||||||
|
} as WikiSnapshot);
|
||||||
|
}
|
||||||
|
const wikis = [...wikisCurrent, ...deletedWikis];
|
||||||
|
|
||||||
|
const baselineEntityWiki = new Set<string>();
|
||||||
|
for (const row of options.previousSnapshot?.entity_wiki || []) {
|
||||||
|
if (!row || typeof (row as any).entity_id !== "string" || typeof (row as any).wiki_id !== "string") continue;
|
||||||
|
if ((row as any).operation === "delete") continue;
|
||||||
|
const entity_id = (row as any).entity_id.trim();
|
||||||
|
const wiki_id = (row as any).wiki_id.trim();
|
||||||
|
if (!entity_id || !wiki_id) continue;
|
||||||
|
baselineEntityWiki.add(`${entity_id}::${wiki_id}`);
|
||||||
|
}
|
||||||
|
const currentEntityWikiKeys = new Set<string>();
|
||||||
|
const entityWikisDelta: EntityWikiLinkSnapshot[] = [];
|
||||||
|
for (const l of options.snapshotEntityWikiLinks || []) {
|
||||||
|
if (!l || typeof l.entity_id !== "string" || typeof l.wiki_id !== "string") continue;
|
||||||
|
if (l.operation === "delete") continue;
|
||||||
|
const entity_id = l.entity_id.trim();
|
||||||
|
const wiki_id = l.wiki_id.trim();
|
||||||
|
if (!entity_id || !wiki_id) continue;
|
||||||
|
const key = `${entity_id}::${wiki_id}`;
|
||||||
|
if (currentEntityWikiKeys.has(key)) continue;
|
||||||
|
currentEntityWikiKeys.add(key);
|
||||||
|
entityWikisDelta.push({
|
||||||
|
entity_id,
|
||||||
|
wiki_id,
|
||||||
|
operation: baselineEntityWiki.has(key) ? "reference" : "binding",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const key of baselineEntityWiki) {
|
||||||
|
if (currentEntityWikiKeys.has(key)) continue;
|
||||||
|
const [entity_id, wiki_id] = key.split("::");
|
||||||
|
if (!entity_id || !wiki_id) continue;
|
||||||
|
entityWikisDelta.push({ entity_id, wiki_id, operation: "delete" });
|
||||||
|
}
|
||||||
|
const entityWikis = dedupeAndSortEntityWiki(entityWikisDelta);
|
||||||
|
|
||||||
|
return {
|
||||||
|
editor_feature_collection: draftForSnapshot,
|
||||||
|
entities: Array.from(entityRows.values())
|
||||||
|
.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
source: e.source,
|
||||||
|
operation: e.operation,
|
||||||
|
name: typeof e.name === "string" ? e.name : undefined,
|
||||||
|
description: typeof (e as any).description === "string" ? (e as any).description : (e as any).description ?? null,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => String(a.id).localeCompare(String(b.id))),
|
||||||
|
geometries: geometries.slice().sort((a, b) => String(a.id).localeCompare(String(b.id))),
|
||||||
|
geometry_entity: geometryEntity,
|
||||||
|
wikis: wikis
|
||||||
|
.map((w) => ({
|
||||||
|
id: w.id,
|
||||||
|
source: w.source,
|
||||||
|
operation: w.operation,
|
||||||
|
title: w.title,
|
||||||
|
slug: (w as any).slug ?? null,
|
||||||
|
doc: (w as any).doc ?? null,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||||
|
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 opRaw = (row as any).operation;
|
||||||
|
const operation: GeometryEntitySnapshot["operation"] =
|
||||||
|
opRaw === "delete"
|
||||||
|
? "delete"
|
||||||
|
: opRaw === "binding" || opRaw === "reference"
|
||||||
|
? opRaw
|
||||||
|
: undefined;
|
||||||
|
const key = `${geometry_id}::${entity_id}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
deduped.push({ ...row, geometry_id, entity_id, operation });
|
||||||
|
}
|
||||||
|
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 opRaw = row.operation;
|
||||||
|
const operation: EntityWikiLinkSnapshot["operation"] =
|
||||||
|
opRaw === "delete"
|
||||||
|
? "delete"
|
||||||
|
: opRaw === "binding" || opRaw === "reference"
|
||||||
|
? opRaw
|
||||||
|
: "reference";
|
||||||
|
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 {
|
||||||
|
const preset = feature.properties.geometry_preset;
|
||||||
|
if (preset === "line") return "defense_line";
|
||||||
|
if (preset === "point") return "city";
|
||||||
|
if (preset === "circle-area") return "war";
|
||||||
|
if (preset === "polygon") return DEFAULT_ENTITY_TYPE_ID;
|
||||||
|
|
||||||
|
const geometryType = feature.geometry.type;
|
||||||
|
if (geometryType === "LineString" || geometryType === "MultiLineString") {
|
||||||
|
return "defense_line";
|
||||||
|
}
|
||||||
|
if (geometryType === "Point" || geometryType === "MultiPoint") {
|
||||||
|
return "city";
|
||||||
|
}
|
||||||
|
return DEFAULT_ENTITY_TYPE_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeFeatureEntityIds(feature: Feature): string[] {
|
||||||
|
const fromArray = Array.isArray(feature.properties.entity_ids)
|
||||||
|
? feature.properties.entity_ids.filter((id): id is string => typeof id === "string" && id.trim().length > 0)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (fromArray.length) {
|
||||||
|
return uniqueEntityIds(fromArray);
|
||||||
|
}
|
||||||
|
|
||||||
|
const single = feature.properties.entity_id;
|
||||||
|
if (typeof single === "string" && single.trim().length > 0) {
|
||||||
|
return [single.trim()];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeFeatureBindingIds(feature: Feature): string[] {
|
||||||
|
const rawBinding = feature.properties.binding;
|
||||||
|
if (!Array.isArray(rawBinding)) return [];
|
||||||
|
return uniqueEntityIds(rawBinding
|
||||||
|
.map((id) => {
|
||||||
|
if (typeof id !== "string" && typeof id !== "number") return "";
|
||||||
|
return String(id).trim();
|
||||||
|
})
|
||||||
|
.filter((id) => id.length > 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBindingInput(raw: string): string[] {
|
||||||
|
if (!raw.trim().length) return [];
|
||||||
|
return uniqueEntityIds(
|
||||||
|
raw
|
||||||
|
.split(/[,\n]/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter((item) => item.length > 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uniqueEntityIds(ids: string[]): string[] {
|
||||||
|
const deduped: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const rawId of ids) {
|
||||||
|
const id = rawId.trim();
|
||||||
|
if (!id || seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
deduped.push(id);
|
||||||
|
}
|
||||||
|
return deduped;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFeatureBBox(feature: Feature): { minLng: number; minLat: number; maxLng: number; maxLat: number } | null {
|
||||||
|
const points = collectCoordinatePairs(feature.geometry.coordinates);
|
||||||
|
if (!points.length) return null;
|
||||||
|
let minLng = Number.POSITIVE_INFINITY;
|
||||||
|
let minLat = Number.POSITIVE_INFINITY;
|
||||||
|
let maxLng = Number.NEGATIVE_INFINITY;
|
||||||
|
let maxLat = Number.NEGATIVE_INFINITY;
|
||||||
|
for (const [lng, lat] of points) {
|
||||||
|
minLng = Math.min(minLng, lng);
|
||||||
|
minLat = Math.min(minLat, lat);
|
||||||
|
maxLng = Math.max(maxLng, lng);
|
||||||
|
maxLat = Math.max(maxLat, lat);
|
||||||
|
}
|
||||||
|
return { minLng, minLat, maxLng, maxLat };
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectCoordinatePairs(value: unknown): Array<[number, number]> {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
if (
|
||||||
|
value.length >= 2 &&
|
||||||
|
typeof value[0] === "number" &&
|
||||||
|
typeof value[1] === "number" &&
|
||||||
|
Number.isFinite(value[0]) &&
|
||||||
|
Number.isFinite(value[1])
|
||||||
|
) {
|
||||||
|
return [[value[0], value[1]]];
|
||||||
|
}
|
||||||
|
return value.flatMap((item) => collectCoordinatePairs(item));
|
||||||
|
}
|
||||||
247
src/uhm/lib/engine/circleEngine.ts
Normal file
247
src/uhm/lib/engine/circleEngine.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
import { Geometry } from "@/uhm/lib/useEditorState";
|
||||||
|
import type { ModeGetter } from "@/uhm/lib/engine/engineTypes";
|
||||||
|
|
||||||
|
const EARTH_RADIUS_METERS = 6371008.8;
|
||||||
|
const CIRCLE_SEGMENTS = 72;
|
||||||
|
const MIN_RADIUS_METERS = 1;
|
||||||
|
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Khởi tạo engine vẽ circle bằng thao tác kéo chuột từ tâm ra biên.
|
||||||
|
export function initCircle(
|
||||||
|
map: maplibregl.Map,
|
||||||
|
getMode: ModeGetter,
|
||||||
|
onComplete: (geometry: Geometry) => void
|
||||||
|
) {
|
||||||
|
let center: [number, number] | null = null;
|
||||||
|
let radiusMeters = 0;
|
||||||
|
let isDragging = false;
|
||||||
|
let dragPanDisabledByCircle = false;
|
||||||
|
|
||||||
|
// Xóa dữ liệu preview circle trên map.
|
||||||
|
const clearPreview = () => {
|
||||||
|
(map.getSource("draw-circle-preview") as maplibregl.GeoJSONSource | undefined)?.setData(
|
||||||
|
EMPTY_PREVIEW
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bật lại drag pan nếu trước đó bị tắt khi đang kéo vẽ circle.
|
||||||
|
const releaseDragPan = () => {
|
||||||
|
if (!dragPanDisabledByCircle) return;
|
||||||
|
dragPanDisabledByCircle = false;
|
||||||
|
if (!map.dragPan.isEnabled()) {
|
||||||
|
map.dragPan.enable();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset toàn bộ trạng thái vẽ circle tạm thời.
|
||||||
|
const resetDrawingState = () => {
|
||||||
|
center = null;
|
||||||
|
radiusMeters = 0;
|
||||||
|
isDragging = false;
|
||||||
|
clearPreview();
|
||||||
|
releaseDragPan();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cập nhật polygon preview theo tâm và bán kính hiện tại.
|
||||||
|
const updatePreview = () => {
|
||||||
|
if (!center || radiusMeters < MIN_RADIUS_METERS) {
|
||||||
|
clearPreview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ring = buildCircleRing(center, radiusMeters, CIRCLE_SEGMENTS);
|
||||||
|
(map.getSource("draw-circle-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
type: "Feature",
|
||||||
|
properties: {},
|
||||||
|
geometry: {
|
||||||
|
type: "Polygon",
|
||||||
|
coordinates: [ring],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bắt đầu phiên vẽ circle khi nhấn chuột trái.
|
||||||
|
const onMouseDown = (e: maplibregl.MapMouseEvent) => {
|
||||||
|
if (getMode() !== "add-circle") return;
|
||||||
|
if ((e.originalEvent as MouseEvent | undefined)?.button !== 0) return;
|
||||||
|
|
||||||
|
center = [e.lngLat.lng, e.lngLat.lat];
|
||||||
|
radiusMeters = 0;
|
||||||
|
isDragging = true;
|
||||||
|
clearPreview();
|
||||||
|
|
||||||
|
if (map.dragPan.isEnabled()) {
|
||||||
|
map.dragPan.disable();
|
||||||
|
dragPanDisabledByCircle = true;
|
||||||
|
} else {
|
||||||
|
dragPanDisabledByCircle = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cập nhật bán kính theo vị trí chuột trong lúc kéo.
|
||||||
|
const onMouseMove = (e: maplibregl.MapMouseEvent) => {
|
||||||
|
const canvas = map.getCanvas();
|
||||||
|
if (getMode() !== "add-circle") {
|
||||||
|
if (canvas.style.cursor === "crosshair") {
|
||||||
|
canvas.style.cursor = "";
|
||||||
|
}
|
||||||
|
if (isDragging) {
|
||||||
|
resetDrawingState();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.style.cursor = "crosshair";
|
||||||
|
if (!isDragging || !center) return;
|
||||||
|
|
||||||
|
radiusMeters = distanceMeters(center, [e.lngLat.lng, e.lngLat.lat]);
|
||||||
|
updatePreview();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hoàn tất circle và trả geometry cho callback.
|
||||||
|
const finishCircle = () => {
|
||||||
|
if (!isDragging || !center) {
|
||||||
|
resetDrawingState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (radiusMeters < MIN_RADIUS_METERS) {
|
||||||
|
resetDrawingState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ring = buildCircleRing(center, radiusMeters, CIRCLE_SEGMENTS);
|
||||||
|
onComplete({
|
||||||
|
type: "Polygon",
|
||||||
|
coordinates: [ring],
|
||||||
|
});
|
||||||
|
resetDrawingState();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Kết thúc thao tác kéo bằng mouseup chuột trái.
|
||||||
|
const onMouseUp = (e: maplibregl.MapMouseEvent) => {
|
||||||
|
if (getMode() !== "add-circle") return;
|
||||||
|
if ((e.originalEvent as MouseEvent | undefined)?.button !== 0) return;
|
||||||
|
finishCircle();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hủy phiên vẽ circle khi nhấn Escape.
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (getMode() !== "add-circle") return;
|
||||||
|
if (e.key !== "Escape") return;
|
||||||
|
e.preventDefault();
|
||||||
|
resetDrawingState();
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on("mousedown", onMouseDown);
|
||||||
|
map.on("mousemove", onMouseMove);
|
||||||
|
map.on("mouseup", onMouseUp);
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
map.off("mousedown", onMouseDown);
|
||||||
|
map.off("mousemove", onMouseMove);
|
||||||
|
map.off("mouseup", onMouseUp);
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
resetDrawingState();
|
||||||
|
if (map.getCanvas().style.cursor === "crosshair") {
|
||||||
|
map.getCanvas().style.cursor = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
cleanup,
|
||||||
|
cancel: resetDrawingState,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tạo vòng polygon xấp xỉ hình tròn từ tâm, bán kính và số phân đoạn.
|
||||||
|
function buildCircleRing(
|
||||||
|
center: [number, number],
|
||||||
|
radiusMeters: number,
|
||||||
|
segments: number
|
||||||
|
): [number, number][] {
|
||||||
|
const ring: [number, number][] = [];
|
||||||
|
for (let i = 0; i <= segments; i += 1) {
|
||||||
|
const bearingDeg = (i / segments) * 360; // Chia đều 360 do quanh tâm để tạo các điểm trên vòng tròn.
|
||||||
|
ring.push(destinationPoint(center, radiusMeters, bearingDeg));
|
||||||
|
}
|
||||||
|
return ring;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tính khoảng cách hai điểm theo công thức Haversine (đơn vị mét).
|
||||||
|
function distanceMeters(a: [number, number], b: [number, number]): number {
|
||||||
|
const lat1 = toRad(a[1]);
|
||||||
|
const lat2 = toRad(b[1]);
|
||||||
|
const dLat = lat2 - lat1; // Delta vĩ độ (radian).
|
||||||
|
const dLng = toRad(b[0] - a[0]); // Delta kinh độ (radian).
|
||||||
|
|
||||||
|
const sinLat = Math.sin(dLat / 2); // Thành phần sin(dLat/2) của công thức Haversine.
|
||||||
|
const sinLng = Math.sin(dLng / 2); // Thành phần sin(dLng/2) của công thức Haversine.
|
||||||
|
const h =
|
||||||
|
sinLat * sinLat +
|
||||||
|
Math.cos(lat1) * Math.cos(lat2) * sinLng * sinLng; // h = haversine(d/R), độ lớn cung tròn chuẩn hóa.
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h)); // Góc tâm (radian) giữa hai điểm trên mặt cầu.
|
||||||
|
return EARTH_RADIUS_METERS * c; // Khoảng cách cung tròn: d = R * c.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tính tọa độ điểm đích từ tâm, khoảng cách và góc phương vị.
|
||||||
|
function destinationPoint(
|
||||||
|
center: [number, number],
|
||||||
|
distance: number,
|
||||||
|
bearingDeg: number
|
||||||
|
): [number, number] {
|
||||||
|
const lat1 = toRad(center[1]);
|
||||||
|
const lng1 = toRad(center[0]);
|
||||||
|
const bearing = toRad(bearingDeg);
|
||||||
|
const angularDistance = distance / EARTH_RADIUS_METERS; // d/R: khoảng cách góc trên mặt cầu.
|
||||||
|
|
||||||
|
const sinLat1 = Math.sin(lat1);
|
||||||
|
const cosLat1 = Math.cos(lat1);
|
||||||
|
const sinAngular = Math.sin(angularDistance);
|
||||||
|
const cosAngular = Math.cos(angularDistance);
|
||||||
|
|
||||||
|
const sinLat2 =
|
||||||
|
sinLat1 * cosAngular +
|
||||||
|
cosLat1 * sinAngular * Math.cos(bearing); // Công thức vĩ độ điểm đích theo great-circle.
|
||||||
|
const lat2 = Math.asin(clamp(sinLat2, -1, 1)); // Kẹp [-1,1] để tránh sai số số học trước khi asin.
|
||||||
|
|
||||||
|
const y = Math.sin(bearing) * sinAngular * cosLat1; // Tử số atan2 cho biến thiên kinh độ.
|
||||||
|
const x = cosAngular - sinLat1 * Math.sin(lat2); // Mẫu số atan2 cho biến thiên kinh độ.
|
||||||
|
const lng2 = lng1 + Math.atan2(y, x); // Kinh độ đích = kinh độ gốc + delta kinh độ.
|
||||||
|
|
||||||
|
return [normalizeLng(toDeg(lng2)), toDeg(lat2)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chuẩn hóa kinh độ về miền [-180, 180].
|
||||||
|
function normalizeLng(lng: number): number {
|
||||||
|
let normalized = ((lng + 540) % 360) - 180; // Wrap về khoảng [-180, 180).
|
||||||
|
if (normalized === -180) normalized = 180;
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kẹp giá trị trong đoạn [min, max].
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
if (value < min) return min;
|
||||||
|
if (value > max) return max;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Đổi đơn vị góc từ độ sang radian.
|
||||||
|
function toRad(value: number): number {
|
||||||
|
return (value * Math.PI) / 180; // Đổi độ sang radian.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Đổi đơn vị góc từ radian sang độ.
|
||||||
|
function toDeg(value: number): number {
|
||||||
|
return (value * 180) / Math.PI; // Đổi radian sang độ.
|
||||||
|
}
|
||||||
127
src/uhm/lib/engine/drawingEngine.ts
Normal file
127
src/uhm/lib/engine/drawingEngine.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
import { Geometry } from "@/uhm/lib/useEditorState";
|
||||||
|
import type { ModeGetter } from "@/uhm/lib/engine/engineTypes";
|
||||||
|
|
||||||
|
// Khởi tạo engine vẽ polygon tự do theo chuỗi click.
|
||||||
|
export function initDrawing(
|
||||||
|
map: maplibregl.Map,
|
||||||
|
getMode: ModeGetter,
|
||||||
|
onComplete: (geometry: Geometry) => void
|
||||||
|
) {
|
||||||
|
let coords: [number, number][] = [];
|
||||||
|
|
||||||
|
const clearPreview = () => {
|
||||||
|
(map.getSource("draw-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelDrawing = () => {
|
||||||
|
coords = [];
|
||||||
|
clearPreview();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Đóng vòng polygon nếu điểm cuối chưa trùng điểm đầu.
|
||||||
|
function closePolygon(c: [number, number][]) {
|
||||||
|
if (c.length < 3) return c;
|
||||||
|
const first = c[0];
|
||||||
|
const last = c[c.length - 1];
|
||||||
|
|
||||||
|
if (first[0] !== last[0] || first[1] !== last[1]) {
|
||||||
|
return [...c, first];
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cập nhật layer preview trong lúc đang vẽ.
|
||||||
|
function update(c: [number, number][]) {
|
||||||
|
const closed = closePolygon(c);
|
||||||
|
|
||||||
|
(map.getSource("draw-preview") as maplibregl.GeoJSONSource)?.setData({
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
type: "Feature",
|
||||||
|
properties: {},
|
||||||
|
geometry: {
|
||||||
|
type: "Polygon",
|
||||||
|
coordinates: [closed],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ghi nhận đỉnh polygon mới khi click map.
|
||||||
|
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
||||||
|
if (getMode() !== "draw") return;
|
||||||
|
|
||||||
|
coords.push([e.lngLat.lng, e.lngLat.lat] as [number, number]);
|
||||||
|
update(coords);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render preview polygon với điểm chuột hiện tại.
|
||||||
|
function onMove(e: maplibregl.MapLayerMouseEvent) {
|
||||||
|
if (getMode() !== "draw" || coords.length === 0) return;
|
||||||
|
|
||||||
|
const preview: [number, number][] = [
|
||||||
|
...coords,
|
||||||
|
[e.lngLat.lng, e.lngLat.lat] as [number, number],
|
||||||
|
];
|
||||||
|
update(preview);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hoàn tất polygon, trả geometry ra ngoài và reset preview.
|
||||||
|
function finishDrawing() {
|
||||||
|
if (getMode() !== "draw" || coords.length < 3) return;
|
||||||
|
|
||||||
|
const geometry: Geometry = {
|
||||||
|
type: "Polygon",
|
||||||
|
coordinates: [closePolygon(coords)],
|
||||||
|
};
|
||||||
|
|
||||||
|
onComplete(geometry);
|
||||||
|
cancelDrawing();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lắng nghe Enter để chốt polygon.
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (getMode() !== "draw") return;
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
finishDrawing();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
cancelDrawing();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Backspace") {
|
||||||
|
e.preventDefault();
|
||||||
|
coords = coords.slice(0, -1);
|
||||||
|
if (coords.length) {
|
||||||
|
update(coords);
|
||||||
|
} else {
|
||||||
|
clearPreview();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
map.on("click", onClick);
|
||||||
|
map.on("mousemove", onMove);
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
map.off("click", onClick);
|
||||||
|
map.off("mousemove", onMove);
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
cancelDrawing();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
cleanup,
|
||||||
|
cancel: cancelDrawing,
|
||||||
|
};
|
||||||
|
}
|
||||||
226
src/uhm/lib/engine/editingEngine.ts
Normal file
226
src/uhm/lib/engine/editingEngine.ts
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
import { Geometry } from "@/uhm/lib/useEditorState";
|
||||||
|
|
||||||
|
export type EditingHandle = {
|
||||||
|
id: string | number;
|
||||||
|
ring: [number, number][];
|
||||||
|
original: Geometry;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EditingAPI = {
|
||||||
|
beginEditing: (feature: maplibregl.MapGeoJSONFeature) => void;
|
||||||
|
clearEditing: () => void;
|
||||||
|
bindEditEvents: (map: maplibregl.Map) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tạo engine chỉnh sửa polygon đã có (kéo đỉnh, thêm đỉnh, commit/cancel).
|
||||||
|
export function createEditingEngine(options: {
|
||||||
|
mapRef: React.MutableRefObject<maplibregl.Map | null>;
|
||||||
|
onUpdate: (id: string | number, geometry: Geometry) => void;
|
||||||
|
}) {
|
||||||
|
const { mapRef, onUpdate } = options;
|
||||||
|
const editingRef = { current: null as EditingHandle | null };
|
||||||
|
const dragStateRef = { current: null as { idx: number } | null };
|
||||||
|
const modifierRef = { current: { ctrl: false, meta: false } };
|
||||||
|
|
||||||
|
// Hủy trạng thái chỉnh sửa hiện tại và dọn hai source edit.
|
||||||
|
const clearEditing = () => {
|
||||||
|
editingRef.current = null;
|
||||||
|
dragStateRef.current = null;
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
const empty: GeoJSON.FeatureCollection = { type: "FeatureCollection", features: [] };
|
||||||
|
(map.getSource("edit-shape") as maplibregl.GeoJSONSource | undefined)?.setData(empty);
|
||||||
|
(map.getSource("edit-handles") as maplibregl.GeoJSONSource | undefined)?.setData(empty);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Đồng bộ polygon tạm và các handle point lên map source.
|
||||||
|
const updateEditSources = () => {
|
||||||
|
const editing = editingRef.current;
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!editing || !map) return;
|
||||||
|
|
||||||
|
const closedRing = [...editing.ring, editing.ring[0]];
|
||||||
|
const shape: GeoJSON.FeatureCollection<GeoJSON.Polygon> = {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
type: "Feature",
|
||||||
|
geometry: { type: "Polygon", coordinates: [closedRing] },
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const handles: GeoJSON.FeatureCollection<GeoJSON.Point> = {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: editing.ring.map((c, idx) => ({
|
||||||
|
type: "Feature",
|
||||||
|
geometry: { type: "Point", coordinates: c },
|
||||||
|
properties: { idx },
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
(map.getSource("edit-shape") as maplibregl.GeoJSONSource | undefined)?.setData(shape);
|
||||||
|
(map.getSource("edit-handles") as maplibregl.GeoJSONSource | undefined)?.setData(handles);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Chốt chỉnh sửa và emit geometry mới cho caller.
|
||||||
|
const finishEditing = () => {
|
||||||
|
const editing = editingRef.current;
|
||||||
|
if (!editing) return;
|
||||||
|
const geometry: Geometry = {
|
||||||
|
type: "Polygon",
|
||||||
|
coordinates: [[...editing.ring, editing.ring[0]]],
|
||||||
|
};
|
||||||
|
onUpdate(editing.id, geometry);
|
||||||
|
clearEditing();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Thoát chế độ chỉnh sửa mà không lưu thay đổi.
|
||||||
|
const cancelEditing = () => {
|
||||||
|
clearEditing();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bắt đầu chỉnh sửa từ feature polygon được chọn.
|
||||||
|
const beginEditing = (feature: maplibregl.MapGeoJSONFeature) => {
|
||||||
|
if (feature.geometry.type !== "Polygon") return;
|
||||||
|
const coords = (feature.geometry.coordinates?.[0] ?? []) as [number, number][];
|
||||||
|
if (coords.length < 4) return;
|
||||||
|
|
||||||
|
// remove duplicated closing point
|
||||||
|
const ring = coords.slice(0, -1).map((c) => [c[0], c[1]] as [number, number]);
|
||||||
|
editingRef.current = {
|
||||||
|
id: feature.id ?? feature.properties?.id,
|
||||||
|
ring,
|
||||||
|
original: feature.geometry as Geometry,
|
||||||
|
};
|
||||||
|
updateEditSources();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Kiểm tra trạng thái nhấn phím modifier để bật thao tác chèn đỉnh.
|
||||||
|
const isModifierPressed = (e?: maplibregl.MapLayerMouseEvent | maplibregl.MapMouseEvent) => {
|
||||||
|
const oe = e?.originalEvent as MouseEvent | undefined;
|
||||||
|
return (
|
||||||
|
modifierRef.current.ctrl ||
|
||||||
|
modifierRef.current.meta ||
|
||||||
|
!!oe?.ctrlKey ||
|
||||||
|
!!oe?.metaKey
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gắn toàn bộ sự kiện phục vụ chỉnh sửa hình.
|
||||||
|
const bindEditEvents = (map: maplibregl.Map) => {
|
||||||
|
// Bắt đầu kéo một handle point.
|
||||||
|
const onHandleDown = (e: maplibregl.MapLayerMouseEvent) => {
|
||||||
|
if (!editingRef.current) return;
|
||||||
|
const feature = e.features?.[0];
|
||||||
|
const idx = feature?.properties?.idx;
|
||||||
|
if (idx === undefined) return;
|
||||||
|
e.preventDefault();
|
||||||
|
dragStateRef.current = { idx };
|
||||||
|
map.getCanvas().style.cursor = "grabbing";
|
||||||
|
map.dragPan.disable();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cập nhật vị trí đỉnh trong lúc kéo chuột.
|
||||||
|
const onHandleMove = (e: maplibregl.MapMouseEvent) => {
|
||||||
|
const drag = dragStateRef.current;
|
||||||
|
const editing = editingRef.current;
|
||||||
|
if (!drag || !editing) return;
|
||||||
|
|
||||||
|
editing.ring[drag.idx] = [e.lngLat.lng, e.lngLat.lat];
|
||||||
|
updateEditSources();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Kết thúc kéo đỉnh và khôi phục trạng thái tương tác map.
|
||||||
|
const stopDragging = () => {
|
||||||
|
dragStateRef.current = null;
|
||||||
|
map.getCanvas().style.cursor = "";
|
||||||
|
map.dragPan.enable();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bắt phím điều khiển phiên chỉnh sửa (Enter/Escape + modifier flags).
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Control") {
|
||||||
|
modifierRef.current.ctrl = true;
|
||||||
|
} else if (e.key === "Meta") {
|
||||||
|
modifierRef.current.meta = true;
|
||||||
|
}
|
||||||
|
if (!editingRef.current) return;
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
finishEditing();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
cancelEditing();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hạ cờ modifier khi nhả phím.
|
||||||
|
const onKeyUp = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Control") {
|
||||||
|
modifierRef.current.ctrl = false;
|
||||||
|
} else if (e.key === "Meta") {
|
||||||
|
modifierRef.current.meta = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Chèn thêm một đỉnh mới vào ring tại vị trí gần điểm click nhất.
|
||||||
|
const onInsertHandle = (e: maplibregl.MapLayerMouseEvent) => {
|
||||||
|
if (!editingRef.current) return;
|
||||||
|
if (!isModifierPressed(e)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const editing = editingRef.current;
|
||||||
|
const ring = editing.ring;
|
||||||
|
const click = [e.lngLat.lng, e.lngLat.lat] as [number, number];
|
||||||
|
let nearestIdx = 0;
|
||||||
|
let bestDist = Number.POSITIVE_INFINITY;
|
||||||
|
ring.forEach((pt, idx) => {
|
||||||
|
const dx = pt[0] - click[0];
|
||||||
|
const dy = pt[1] - click[1];
|
||||||
|
const d = dx * dx + dy * dy; // Dùng khoảng cách Euclid bình phương để so sánh nhanh, không cần sqrt.
|
||||||
|
if (d < bestDist) {
|
||||||
|
bestDist = d;
|
||||||
|
nearestIdx = idx;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const insertIdx = nearestIdx + 1;
|
||||||
|
ring.splice(insertIdx, 0, click);
|
||||||
|
dragStateRef.current = { idx: insertIdx };
|
||||||
|
map.getCanvas().style.cursor = "grabbing";
|
||||||
|
map.dragPan.disable();
|
||||||
|
updateEditSources();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ngắt kéo nếu con trỏ rời canvas.
|
||||||
|
const onCanvasLeave = () => {
|
||||||
|
stopDragging();
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on("mousedown", "edit-handles-circle", onHandleDown);
|
||||||
|
map.on("mousedown", "edit-shape-line", onInsertHandle);
|
||||||
|
map.on("mousemove", onHandleMove);
|
||||||
|
map.on("mouseup", stopDragging);
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
document.addEventListener("keyup", onKeyUp);
|
||||||
|
map.getCanvas().addEventListener("mouseleave", onCanvasLeave);
|
||||||
|
|
||||||
|
map.on("remove", () => {
|
||||||
|
map.off("mousedown", "edit-handles-circle", onHandleDown);
|
||||||
|
map.off("mousedown", "edit-shape-line", onInsertHandle);
|
||||||
|
map.off("mousemove", onHandleMove);
|
||||||
|
map.off("mouseup", stopDragging);
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
document.removeEventListener("keyup", onKeyUp);
|
||||||
|
map.getCanvas().removeEventListener("mouseleave", onCanvasLeave);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
beginEditing,
|
||||||
|
clearEditing,
|
||||||
|
bindEditEvents,
|
||||||
|
updateEditSources,
|
||||||
|
editingRef,
|
||||||
|
dragStateRef,
|
||||||
|
};
|
||||||
|
}
|
||||||
4
src/uhm/lib/engine/engineTypes.ts
Normal file
4
src/uhm/lib/engine/engineTypes.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
|
||||||
|
export type ModeGetter = () => EditorMode;
|
||||||
|
|
||||||
140
src/uhm/lib/engine/lineEngine.ts
Normal file
140
src/uhm/lib/engine/lineEngine.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
import { Geometry } from "@/uhm/lib/useEditorState";
|
||||||
|
import type { ModeGetter } from "@/uhm/lib/engine/engineTypes";
|
||||||
|
|
||||||
|
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Khởi tạo engine vẽ line (gấp khúc, không mũi tên).
|
||||||
|
export function initLine(
|
||||||
|
map: maplibregl.Map,
|
||||||
|
getMode: ModeGetter,
|
||||||
|
onComplete: (geometry: Geometry) => void
|
||||||
|
) {
|
||||||
|
let coords: [number, number][] = [];
|
||||||
|
|
||||||
|
// Xóa dữ liệu preview line.
|
||||||
|
const clearPreview = () => {
|
||||||
|
(map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData(
|
||||||
|
EMPTY_PREVIEW
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hủy phiên vẽ line hiện tại.
|
||||||
|
const cancelLine = () => {
|
||||||
|
coords = [];
|
||||||
|
clearPreview();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cập nhật line preview theo danh sách tọa độ tạm.
|
||||||
|
const updatePreview = (lineCoords: [number, number][]) => {
|
||||||
|
if (lineCoords.length < 2) {
|
||||||
|
clearPreview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
(map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
type: "Feature",
|
||||||
|
properties: {},
|
||||||
|
geometry: {
|
||||||
|
type: "LineString",
|
||||||
|
coordinates: lineCoords,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Chốt line khi đủ số đỉnh tối thiểu.
|
||||||
|
const finishLine = () => {
|
||||||
|
if (getMode() !== "add-line" || coords.length < 2) return;
|
||||||
|
|
||||||
|
const geometry: Geometry = {
|
||||||
|
type: "LineString",
|
||||||
|
coordinates: [...coords],
|
||||||
|
};
|
||||||
|
|
||||||
|
onComplete(geometry);
|
||||||
|
cancelLine();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Xóa đỉnh cuối cùng trong line đang vẽ.
|
||||||
|
const removeLastVertex = () => {
|
||||||
|
if (!coords.length) return;
|
||||||
|
coords = coords.slice(0, -1);
|
||||||
|
updatePreview(coords);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Thêm một đỉnh line khi click map.
|
||||||
|
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||||
|
if (getMode() !== "add-line") return;
|
||||||
|
|
||||||
|
coords.push([e.lngLat.lng, e.lngLat.lat]);
|
||||||
|
updatePreview(coords);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cập nhật preview động theo vị trí chuột.
|
||||||
|
const onMove = (e: maplibregl.MapLayerMouseEvent) => {
|
||||||
|
const canvas = map.getCanvas();
|
||||||
|
|
||||||
|
if (getMode() !== "add-line") {
|
||||||
|
if (coords.length) {
|
||||||
|
cancelLine();
|
||||||
|
}
|
||||||
|
if (canvas.style.cursor === "crosshair") {
|
||||||
|
canvas.style.cursor = "";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.style.cursor = "crosshair";
|
||||||
|
if (coords.length === 0) return;
|
||||||
|
updatePreview([...coords, [e.lngLat.lng, e.lngLat.lat]]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Xử lý phím nóng Enter/Escape/Backspace cho chế độ vẽ line.
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (getMode() !== "add-line") return;
|
||||||
|
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
finishLine();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
cancelLine();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "Backspace") {
|
||||||
|
e.preventDefault();
|
||||||
|
removeLastVertex();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on("click", onClick);
|
||||||
|
map.on("mousemove", onMove);
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
map.off("click", onClick);
|
||||||
|
map.off("mousemove", onMove);
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
cancelLine();
|
||||||
|
if (map.getCanvas().style.cursor === "crosshair") {
|
||||||
|
map.getCanvas().style.cursor = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
cleanup,
|
||||||
|
cancel: cancelLine,
|
||||||
|
};
|
||||||
|
}
|
||||||
142
src/uhm/lib/engine/pathEngine.ts
Normal file
142
src/uhm/lib/engine/pathEngine.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
import { Geometry } from "@/uhm/lib/useEditorState";
|
||||||
|
import type { ModeGetter } from "@/uhm/lib/engine/engineTypes";
|
||||||
|
|
||||||
|
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Khởi tạo engine vẽ path (gấp khúc, sẽ render có mũi tên ở layer path).
|
||||||
|
export function initPath(
|
||||||
|
map: maplibregl.Map,
|
||||||
|
getMode: ModeGetter,
|
||||||
|
onComplete: (geometry: Geometry) => void
|
||||||
|
) {
|
||||||
|
let coords: [number, number][] = [];
|
||||||
|
|
||||||
|
// Xóa dữ liệu preview path.
|
||||||
|
const clearPreview = () => {
|
||||||
|
(map.getSource("draw-path-preview") as maplibregl.GeoJSONSource | undefined)?.setData(
|
||||||
|
EMPTY_PREVIEW
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cập nhật path preview theo danh sách tọa độ tạm.
|
||||||
|
const updatePreview = (lineCoords: [number, number][]) => {
|
||||||
|
if (lineCoords.length < 2) {
|
||||||
|
clearPreview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
(map.getSource("draw-path-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
type: "Feature",
|
||||||
|
properties: {},
|
||||||
|
geometry: {
|
||||||
|
type: "LineString",
|
||||||
|
coordinates: lineCoords,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Chốt path khi đủ số đỉnh tối thiểu.
|
||||||
|
const finishPath = () => {
|
||||||
|
if (getMode() !== "add-path" || coords.length < 2) return;
|
||||||
|
|
||||||
|
const geometry: Geometry = {
|
||||||
|
type: "LineString",
|
||||||
|
coordinates: [...coords],
|
||||||
|
};
|
||||||
|
|
||||||
|
onComplete(geometry);
|
||||||
|
coords = [];
|
||||||
|
clearPreview();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hủy phiên vẽ path hiện tại.
|
||||||
|
const cancelPath = () => {
|
||||||
|
coords = [];
|
||||||
|
clearPreview();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Xóa đỉnh cuối cùng của path đang vẽ.
|
||||||
|
const removeLastVertex = () => {
|
||||||
|
if (coords.length === 0) return;
|
||||||
|
coords = coords.slice(0, -1);
|
||||||
|
updatePreview(coords);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Thêm một đỉnh path khi click map.
|
||||||
|
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||||
|
if (getMode() !== "add-path") return;
|
||||||
|
|
||||||
|
coords.push([e.lngLat.lng, e.lngLat.lat]);
|
||||||
|
updatePreview(coords);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cập nhật preview path động theo vị trí chuột.
|
||||||
|
const onMove = (e: maplibregl.MapLayerMouseEvent) => {
|
||||||
|
const canvas = map.getCanvas();
|
||||||
|
|
||||||
|
if (getMode() !== "add-path") {
|
||||||
|
if (coords.length) {
|
||||||
|
cancelPath();
|
||||||
|
}
|
||||||
|
if (canvas.style.cursor === "crosshair") {
|
||||||
|
canvas.style.cursor = "";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.style.cursor = "crosshair";
|
||||||
|
if (coords.length === 0) return;
|
||||||
|
|
||||||
|
updatePreview([...coords, [e.lngLat.lng, e.lngLat.lat]]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Xử lý phím nóng Enter/Escape/Backspace cho chế độ vẽ path.
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (getMode() !== "add-path") return;
|
||||||
|
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
finishPath();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
cancelPath();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "Backspace") {
|
||||||
|
e.preventDefault();
|
||||||
|
removeLastVertex();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on("click", onClick);
|
||||||
|
map.on("mousemove", onMove);
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
map.off("click", onClick);
|
||||||
|
map.off("mousemove", onMove);
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
cancelPath();
|
||||||
|
if (map.getCanvas().style.cursor === "crosshair") {
|
||||||
|
map.getCanvas().style.cursor = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
cleanup,
|
||||||
|
cancel: cancelPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
45
src/uhm/lib/engine/pointEngine.ts
Normal file
45
src/uhm/lib/engine/pointEngine.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
import { Geometry } from "@/uhm/lib/useEditorState";
|
||||||
|
import type { ModeGetter } from "@/uhm/lib/engine/engineTypes";
|
||||||
|
|
||||||
|
// Khởi tạo engine thêm point bằng click đơn.
|
||||||
|
export function initPoint(
|
||||||
|
map: maplibregl.Map,
|
||||||
|
getMode: ModeGetter,
|
||||||
|
onComplete: (geometry: Geometry) => void
|
||||||
|
) {
|
||||||
|
// Thêm point mới khi đang ở chế độ add-point.
|
||||||
|
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
||||||
|
if (getMode() !== "add-point") return;
|
||||||
|
|
||||||
|
const geometry: Geometry = {
|
||||||
|
type: "Point",
|
||||||
|
coordinates: [e.lngLat.lng, e.lngLat.lat],
|
||||||
|
};
|
||||||
|
|
||||||
|
onComplete?.(geometry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cập nhật trạng thái con trỏ theo mode add-point.
|
||||||
|
function onMove() {
|
||||||
|
const canvas = map.getCanvas();
|
||||||
|
if (getMode() === "add-point") {
|
||||||
|
canvas.style.cursor = "crosshair";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (canvas.style.cursor === "crosshair") {
|
||||||
|
canvas.style.cursor = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
map.on("click", onClick);
|
||||||
|
map.on("mousemove", onMove);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.off("click", onClick);
|
||||||
|
map.off("mousemove", onMove);
|
||||||
|
if (map.getCanvas().style.cursor === "crosshair") {
|
||||||
|
map.getCanvas().style.cursor = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
258
src/uhm/lib/engine/selectingEngine.ts
Normal file
258
src/uhm/lib/engine/selectingEngine.ts
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
import type { ModeGetter } from "@/uhm/lib/engine/engineTypes";
|
||||||
|
|
||||||
|
// Khởi tạo engine chọn feature và context menu edit/delete.
|
||||||
|
export function initSelect(
|
||||||
|
map: maplibregl.Map,
|
||||||
|
getMode: ModeGetter,
|
||||||
|
onDelete?: (id: string | number) => void,
|
||||||
|
onEdit?: (feature: maplibregl.MapGeoJSONFeature) => void,
|
||||||
|
onSelectId?: (id: string | number | null) => void
|
||||||
|
) {
|
||||||
|
const SELECTABLE_LAYERS = [
|
||||||
|
"countries-fill",
|
||||||
|
"countries-line",
|
||||||
|
"routes-line",
|
||||||
|
"routes-path-arrow-fill",
|
||||||
|
"routes-path-arrow-line",
|
||||||
|
"routes-path-hit",
|
||||||
|
"places-circle",
|
||||||
|
"places-symbol",
|
||||||
|
] as const;
|
||||||
|
const FEATURE_STATE_SOURCES = [
|
||||||
|
"countries",
|
||||||
|
"places",
|
||||||
|
"path-arrow-shapes",
|
||||||
|
] as const;
|
||||||
|
const selectedIds = new Set<number | string>();
|
||||||
|
const hasContextActions = Boolean(onDelete || onEdit);
|
||||||
|
let contextMenu: HTMLDivElement | null = null;
|
||||||
|
let docClickHandler: ((ev: MouseEvent) => void) | null = null;
|
||||||
|
|
||||||
|
// Bỏ highlight feature-state của toàn bộ đối tượng đang chọn.
|
||||||
|
function clearSelection(emit = true) {
|
||||||
|
if (!selectedIds.size) return;
|
||||||
|
selectedIds.forEach((id) => setSelectionStateForId(id, false));
|
||||||
|
selectedIds.clear();
|
||||||
|
if (emit) {
|
||||||
|
onSelectId?.(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chọn hoặc toggle đối tượng; giữ Alt để chọn cộng dồn/tắt chọn.
|
||||||
|
function selectFeature(feature: maplibregl.MapGeoJSONFeature, additive: boolean) {
|
||||||
|
const id = feature.id ?? feature.properties?.id;
|
||||||
|
if (id === undefined || id === null) return;
|
||||||
|
|
||||||
|
if (!additive) {
|
||||||
|
clearSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (additive && selectedIds.has(id)) {
|
||||||
|
// Alt + click on an already selected feature removes it from the selection
|
||||||
|
setSelectionStateForId(id, false);
|
||||||
|
selectedIds.delete(id);
|
||||||
|
onSelectId?.(selectedIds.size === 1 ? Array.from(selectedIds)[0] : null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectionStateForId(id, true);
|
||||||
|
selectedIds.add(id);
|
||||||
|
onSelectId?.(selectedIds.size === 1 ? id : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chọn feature theo click trái, hỗ trợ additive bằng Alt.
|
||||||
|
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
||||||
|
if (getMode() !== "select") return;
|
||||||
|
const selectableLayers = getSelectableLayers();
|
||||||
|
if (!selectableLayers.length) return;
|
||||||
|
|
||||||
|
const features = map.queryRenderedFeatures(e.point, {
|
||||||
|
layers: selectableLayers,
|
||||||
|
}) as maplibregl.MapGeoJSONFeature[];
|
||||||
|
|
||||||
|
if (!features.length) {
|
||||||
|
clearSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const additive = !!e.originalEvent?.altKey;
|
||||||
|
selectFeature(features[0], additive);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hiển thị menu ngữ cảnh (sửa/xóa) khi click chuột phải.
|
||||||
|
// Mở menu thao tác khi click phải lên feature.
|
||||||
|
function onRightClick(e: maplibregl.MapLayerMouseEvent) {
|
||||||
|
if (getMode() !== "select") return;
|
||||||
|
const selectableLayers = getSelectableLayers();
|
||||||
|
if (!selectableLayers.length) return;
|
||||||
|
|
||||||
|
e.preventDefault(); // block browser menu
|
||||||
|
|
||||||
|
const features = map.queryRenderedFeatures(e.point, {
|
||||||
|
layers: selectableLayers,
|
||||||
|
}) as maplibregl.MapGeoJSONFeature[];
|
||||||
|
|
||||||
|
if (!features.length) return;
|
||||||
|
|
||||||
|
const feature = features[0];
|
||||||
|
const id = feature.id ?? feature.properties?.id;
|
||||||
|
if (id === undefined || id === null) return;
|
||||||
|
|
||||||
|
// if right-clicked item not selected, make it the sole selection
|
||||||
|
if (!selectedIds.has(id)) {
|
||||||
|
clearSelection();
|
||||||
|
selectFeature(feature, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
showContextMenu(
|
||||||
|
e.originalEvent?.clientX ?? e.point.x,
|
||||||
|
e.originalEvent?.clientY ?? e.point.y,
|
||||||
|
feature
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Đổi cursor pointer khi hover lên đối tượng có thể chọn.
|
||||||
|
function onMove(e: maplibregl.MapLayerMouseEvent) {
|
||||||
|
if (getMode() !== "select") return;
|
||||||
|
const selectableLayers = getSelectableLayers();
|
||||||
|
if (!selectableLayers.length) {
|
||||||
|
map.getCanvas().style.cursor = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const features = map.queryRenderedFeatures(e.point, {
|
||||||
|
layers: selectableLayers,
|
||||||
|
});
|
||||||
|
|
||||||
|
map.getCanvas().style.cursor = features.length ? "pointer" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectableLayers(): string[] {
|
||||||
|
return SELECTABLE_LAYERS.filter((layerId) => Boolean(map.getLayer(layerId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSelectionStateForId(id: string | number, selected: boolean) {
|
||||||
|
for (const source of FEATURE_STATE_SOURCES) {
|
||||||
|
if (!map.getSource(source)) continue;
|
||||||
|
map.setFeatureState({ source, id }, { selected });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
map.on("click", onClick);
|
||||||
|
map.on("mousemove", onMove);
|
||||||
|
if (hasContextActions) {
|
||||||
|
map.on("contextmenu", onRightClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
map.off("click", onClick);
|
||||||
|
map.off("mousemove", onMove);
|
||||||
|
if (hasContextActions) {
|
||||||
|
map.off("contextmenu", onRightClick);
|
||||||
|
}
|
||||||
|
clearSelection(false);
|
||||||
|
hideContextMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
cleanup,
|
||||||
|
clearSelection,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ẩn và dọn dẹp context menu hiện tại.
|
||||||
|
function hideContextMenu() {
|
||||||
|
if (contextMenu) {
|
||||||
|
contextMenu.remove();
|
||||||
|
contextMenu = null;
|
||||||
|
}
|
||||||
|
if (docClickHandler) {
|
||||||
|
document.removeEventListener("click", docClickHandler);
|
||||||
|
docClickHandler = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render menu ngữ cảnh tối giản gần vị trí con trỏ.
|
||||||
|
function showContextMenu(
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
clickedFeature: maplibregl.MapGeoJSONFeature
|
||||||
|
) {
|
||||||
|
hideContextMenu();
|
||||||
|
|
||||||
|
const menu = document.createElement("div");
|
||||||
|
menu.style.position = "fixed";
|
||||||
|
menu.style.left = `${x}px`;
|
||||||
|
menu.style.top = `${y}px`;
|
||||||
|
menu.style.background = "#0f172a";
|
||||||
|
menu.style.color = "white";
|
||||||
|
menu.style.border = "1px solid #1f2937";
|
||||||
|
menu.style.borderRadius = "6px";
|
||||||
|
menu.style.boxShadow = "0 4px 12px rgba(0,0,0,0.2)";
|
||||||
|
menu.style.zIndex = "9999";
|
||||||
|
menu.style.minWidth = "120px";
|
||||||
|
menu.style.fontSize = "14px";
|
||||||
|
menu.style.padding = "4px 0";
|
||||||
|
|
||||||
|
// Tạo một item thao tác trong context menu.
|
||||||
|
const createItem = (label: string, onClick: () => void) => {
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.textContent = label;
|
||||||
|
item.style.padding = "8px 12px";
|
||||||
|
item.style.cursor = "pointer";
|
||||||
|
item.onmouseenter = () => (item.style.background = "#1f2937");
|
||||||
|
item.onmouseleave = () => (item.style.background = "transparent");
|
||||||
|
item.onclick = () => {
|
||||||
|
onClick();
|
||||||
|
hideContextMenu();
|
||||||
|
};
|
||||||
|
return item;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedCount = selectedIds.size || 1;
|
||||||
|
let hasMenuItems = false;
|
||||||
|
|
||||||
|
if (
|
||||||
|
selectedCount === 1 &&
|
||||||
|
clickedFeature.source === "countries" &&
|
||||||
|
clickedFeature.geometry?.type === "Polygon" &&
|
||||||
|
onEdit
|
||||||
|
) {
|
||||||
|
const single = clickedFeature;
|
||||||
|
menu.appendChild(createItem("Chỉnh sửa", () => onEdit(single)));
|
||||||
|
hasMenuItems = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onDelete) {
|
||||||
|
menu.appendChild(
|
||||||
|
createItem(
|
||||||
|
selectedCount > 1 ? `Xóa ${selectedCount} mục` : "Xóa",
|
||||||
|
() => {
|
||||||
|
const ids = selectedIds.size
|
||||||
|
? Array.from(selectedIds)
|
||||||
|
: [clickedFeature.id ?? clickedFeature.properties?.id];
|
||||||
|
ids.forEach((eachId) => {
|
||||||
|
if (eachId !== undefined && eachId !== null) onDelete(eachId);
|
||||||
|
});
|
||||||
|
clearSelection();
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
hasMenuItems = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasMenuItems) return;
|
||||||
|
|
||||||
|
document.body.appendChild(menu);
|
||||||
|
contextMenu = menu;
|
||||||
|
|
||||||
|
// Đóng menu khi click ra ngoài vùng menu.
|
||||||
|
const onDocClick = (ev: MouseEvent) => {
|
||||||
|
if (!menu.contains(ev.target as Node)) {
|
||||||
|
hideContextMenu();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
docClickHandler = onDocClick;
|
||||||
|
setTimeout(() => document.addEventListener("click", onDocClick), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
122
src/uhm/lib/entityTypeOptions.ts
Normal file
122
src/uhm/lib/entityTypeOptions.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
export type EntityTypeGroupId =
|
||||||
|
| "line"
|
||||||
|
| "polygon"
|
||||||
|
| "circle"
|
||||||
|
| "point";
|
||||||
|
|
||||||
|
export type EntityGeometryPreset = "line" | "polygon" | "circle-area" | "point";
|
||||||
|
|
||||||
|
export type EntityTypeGroup = {
|
||||||
|
id: EntityTypeGroupId;
|
||||||
|
label: string;
|
||||||
|
geometryLabel: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EntityTypeOption = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
groupId: EntityTypeGroupId;
|
||||||
|
groupLabel: string;
|
||||||
|
geometryPreset: EntityGeometryPreset;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ENTITY_TYPE_GROUPS: EntityTypeGroup[] = [
|
||||||
|
{
|
||||||
|
id: "line",
|
||||||
|
label: "line - Tuyến",
|
||||||
|
geometryLabel: "Line",
|
||||||
|
description: "Các tuyến line/path (gấp khúc).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "polygon",
|
||||||
|
label: "polygon - Đa giác",
|
||||||
|
geometryLabel: "Polygon",
|
||||||
|
description: "Vùng lãnh thổ dạng đa giác.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "circle",
|
||||||
|
label: "circle - Tròn",
|
||||||
|
geometryLabel: "Circle",
|
||||||
|
description: "Vùng sự kiện theo bán kính ảnh hưởng.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "point",
|
||||||
|
label: "point - Điểm",
|
||||||
|
geometryLabel: "Point",
|
||||||
|
description: "Địa điểm đơn lẻ.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const GROUP_BY_ID: Record<EntityTypeGroupId, EntityTypeGroup> = {
|
||||||
|
line: ENTITY_TYPE_GROUPS[0],
|
||||||
|
polygon: ENTITY_TYPE_GROUPS[1],
|
||||||
|
circle: ENTITY_TYPE_GROUPS[2],
|
||||||
|
point: ENTITY_TYPE_GROUPS[3],
|
||||||
|
};
|
||||||
|
|
||||||
|
const RAW_ENTITY_TYPE_OPTIONS: Array<{
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
groupId: EntityTypeGroupId;
|
||||||
|
geometryPreset: EntityGeometryPreset;
|
||||||
|
}> = [
|
||||||
|
{ value: "defense_line", label: "Defense Line", groupId: "line", geometryPreset: "line" },
|
||||||
|
|
||||||
|
{ value: "attack_route", label: "Attack Route", groupId: "line", geometryPreset: "line" },
|
||||||
|
{ value: "retreat_route", label: "Retreat Route", groupId: "line", geometryPreset: "line" },
|
||||||
|
{ value: "invasion_route", label: "Invasion Route", groupId: "line", geometryPreset: "line" },
|
||||||
|
{ value: "migration_route", label: "Migration Route", groupId: "line", geometryPreset: "line" },
|
||||||
|
{ value: "refugee_route", label: "Refugee Route", groupId: "line", geometryPreset: "line" },
|
||||||
|
{ value: "trade_route", label: "Trade Route", groupId: "line", geometryPreset: "line" },
|
||||||
|
{ value: "shipping_route", label: "Shipping Route", groupId: "line", geometryPreset: "line" },
|
||||||
|
|
||||||
|
{ value: "country", label: "Country", groupId: "polygon", geometryPreset: "polygon" },
|
||||||
|
{ value: "state", label: "State", groupId: "polygon", geometryPreset: "polygon" },
|
||||||
|
{ value: "empire", label: "Empire", groupId: "polygon", geometryPreset: "polygon" },
|
||||||
|
{ value: "kingdom", label: "Kingdom", groupId: "polygon", geometryPreset: "polygon" },
|
||||||
|
|
||||||
|
{ value: "war", label: "War", groupId: "circle", geometryPreset: "circle-area" },
|
||||||
|
{ value: "battle", label: "Battle", groupId: "circle", geometryPreset: "circle-area" },
|
||||||
|
{ value: "civilization", label: "Civilization", groupId: "circle", geometryPreset: "circle-area" },
|
||||||
|
{ value: "rebellion_zone", label: "Rebellion Zone", groupId: "circle", geometryPreset: "circle-area" },
|
||||||
|
|
||||||
|
{ value: "person_deathplace", label: "Person Deathplace", groupId: "point", geometryPreset: "point" },
|
||||||
|
{ value: "person_birthplace", label: "Person Birthplace", groupId: "point", geometryPreset: "point" },
|
||||||
|
{ value: "person_activity", label: "Person Activity", groupId: "point", geometryPreset: "point" },
|
||||||
|
{ value: "temple", label: "Temple", groupId: "point", geometryPreset: "point" },
|
||||||
|
{ value: "capital", label: "Capital", groupId: "point", geometryPreset: "point" },
|
||||||
|
{ value: "city", label: "City", groupId: "point", geometryPreset: "point" },
|
||||||
|
{ value: "fortress", label: "Fortress", groupId: "point", geometryPreset: "point" },
|
||||||
|
{ value: "castle", label: "Castle", groupId: "point", geometryPreset: "point" },
|
||||||
|
{ value: "ruin", label: "Ruin", groupId: "point", geometryPreset: "point" },
|
||||||
|
{ value: "port", label: "Port", groupId: "point", geometryPreset: "point" },
|
||||||
|
{ value: "bridge", label: "Bridge", groupId: "point", geometryPreset: "point" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ENTITY_TYPE_OPTIONS: EntityTypeOption[] = RAW_ENTITY_TYPE_OPTIONS.map((item) => ({
|
||||||
|
...item,
|
||||||
|
groupLabel: GROUP_BY_ID[item.groupId].label,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const DEFAULT_ENTITY_TYPE_ID = "country";
|
||||||
|
|
||||||
|
// Gom option theo group để render select phân nhóm.
|
||||||
|
export function groupEntityTypeOptions(options: EntityTypeOption[] = ENTITY_TYPE_OPTIONS): Array<{
|
||||||
|
id: EntityTypeGroupId;
|
||||||
|
label: string;
|
||||||
|
geometryLabel: string;
|
||||||
|
description: string;
|
||||||
|
options: EntityTypeOption[];
|
||||||
|
}> {
|
||||||
|
return ENTITY_TYPE_GROUPS.map((group) => ({
|
||||||
|
...group,
|
||||||
|
options: options.filter((option) => option.groupId === group.id),
|
||||||
|
})).filter((group) => group.options.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tìm option theo type id, trả null nếu không tồn tại.
|
||||||
|
export function findEntityTypeOption(typeId: string | null | undefined): EntityTypeOption | null {
|
||||||
|
if (!typeId) return null;
|
||||||
|
return ENTITY_TYPE_OPTIONS.find((option) => option.value === typeId) || null;
|
||||||
|
}
|
||||||
14
src/uhm/lib/geo/constants.ts
Normal file
14
src/uhm/lib/geo/constants.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { FeatureCollection } from "@/uhm/types/geo";
|
||||||
|
|
||||||
|
export const WORLD_BBOX = {
|
||||||
|
minLng: -180,
|
||||||
|
minLat: -90,
|
||||||
|
maxLng: 180,
|
||||||
|
maxLat: 90,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const EMPTY_FEATURE_COLLECTION: FeatureCollection = {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [],
|
||||||
|
};
|
||||||
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
8
src/uhm/lib/id.ts
Normal file
8
src/uhm/lib/id.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { v7 as uuidv7 } from "uuid";
|
||||||
|
|
||||||
|
// Centralized ID generator for all client-created identifiers in FrontEndUser.
|
||||||
|
// UUIDv7 is time-ordered (RFC 9562) and works well for sorting by creation time.
|
||||||
|
export function newId(): string {
|
||||||
|
return uuidv7();
|
||||||
|
}
|
||||||
|
|
||||||
13
src/uhm/lib/map/constants.ts
Normal file
13
src/uhm/lib/map/constants.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export const DEFAULT_POINT_ICON_ID = "point-icon-default";
|
||||||
|
export const POINT_ICON_URL = "/point.png";
|
||||||
|
export const PATH_ARROW_ICON_ID = "path-arrow-icon";
|
||||||
|
|
||||||
|
export const MAP_MIN_ZOOM = 2;
|
||||||
|
export const MAP_MAX_ZOOM = 10;
|
||||||
|
|
||||||
|
export const RASTER_BASE_SOURCE_ID = "rasterBase";
|
||||||
|
export const RASTER_BASE_LAYER_ID = "raster-base-layer";
|
||||||
|
export const RASTER_BASE_INSERT_BEFORE_LAYER_ID = "graticules-line";
|
||||||
|
|
||||||
|
export const PATH_ARROW_SOURCE_ID = "path-arrow-shapes";
|
||||||
|
export const FEATURE_STATE_SOURCE_IDS = ["countries", "places", PATH_ARROW_SOURCE_ID] as const;
|
||||||
76
src/uhm/lib/map/style.ts
Normal file
76
src/uhm/lib/map/style.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
|
||||||
|
export const COUNTRY_COLOR_KEY_EXPRESSION: maplibregl.ExpressionSpecification = [
|
||||||
|
"coalesce",
|
||||||
|
["get", "MAPCOLOR7"],
|
||||||
|
["get", "MAPCOLOR9"],
|
||||||
|
["get", "scalerank"],
|
||||||
|
0,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const COUNTRY_FILL_COLOR_EXPRESSION: maplibregl.ExpressionSpecification = [
|
||||||
|
"match",
|
||||||
|
COUNTRY_COLOR_KEY_EXPRESSION,
|
||||||
|
1, "#ef4444",
|
||||||
|
2, "#f97316",
|
||||||
|
3, "#f59e0b",
|
||||||
|
4, "#22c55e",
|
||||||
|
5, "#06b6d4",
|
||||||
|
6, "#3b82f6",
|
||||||
|
7, "#8b5cf6",
|
||||||
|
8, "#a855f7",
|
||||||
|
9, "#d946ef",
|
||||||
|
10, "#14b8a6",
|
||||||
|
"#64748b",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const POLYGON_FILL_BY_TYPE: Record<string, string> = {
|
||||||
|
country: "#2563eb",
|
||||||
|
state: "#0ea5e9",
|
||||||
|
empire: "#f59e0b",
|
||||||
|
kingdom: "#d97706",
|
||||||
|
war: "#dc2626",
|
||||||
|
battle: "#f43f5e",
|
||||||
|
civilization: "#14b8a6",
|
||||||
|
rebellion_zone: "#7c3aed",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POLYGON_STROKE_BY_TYPE: Record<string, string> = {
|
||||||
|
country: "#1e3a8a",
|
||||||
|
state: "#0c4a6e",
|
||||||
|
empire: "#7c2d12",
|
||||||
|
kingdom: "#9a3412",
|
||||||
|
war: "#7f1d1d",
|
||||||
|
battle: "#9f1239",
|
||||||
|
civilization: "#134e4a",
|
||||||
|
rebellion_zone: "#4c1d95",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POLYGON_OPACITY_BY_TYPE: Record<string, number> = {
|
||||||
|
war: 0.3,
|
||||||
|
battle: 0.34,
|
||||||
|
civilization: 0.38,
|
||||||
|
rebellion_zone: 0.32,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LINE_COLOR_BY_TYPE: Record<string, string> = {
|
||||||
|
defense_line: "#f97316",
|
||||||
|
attack_route: "#ef4444",
|
||||||
|
retreat_route: "#94a3b8",
|
||||||
|
invasion_route: "#b91c1c",
|
||||||
|
migration_route: "#0ea5e9",
|
||||||
|
refugee_route: "#06b6d4",
|
||||||
|
trade_route: "#eab308",
|
||||||
|
shipping_route: "#2563eb",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PATH_RENDER_BY_TYPE: Record<string, boolean> = {
|
||||||
|
attack_route: true,
|
||||||
|
retreat_route: true,
|
||||||
|
invasion_route: true,
|
||||||
|
migration_route: true,
|
||||||
|
refugee_route: true,
|
||||||
|
trade_route: true,
|
||||||
|
shipping_route: true,
|
||||||
|
};
|
||||||
|
|
||||||
25
src/uhm/lib/timeline.ts
Normal file
25
src/uhm/lib/timeline.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { TimelineRange } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
|
||||||
|
// Single source of truth for the app-wide timeline range.
|
||||||
|
export const FIXED_TIMELINE_START_YEAR = -2000;
|
||||||
|
export const FIXED_TIMELINE_END_YEAR = 2000;
|
||||||
|
|
||||||
|
export const FIXED_TIMELINE_RANGE: TimelineRange = {
|
||||||
|
min: FIXED_TIMELINE_START_YEAR,
|
||||||
|
max: FIXED_TIMELINE_END_YEAR,
|
||||||
|
};
|
||||||
|
|
||||||
|
// UI debounce when user drags timeline before triggering data fetch.
|
||||||
|
export const TIMELINE_DEBOUNCE_MS = 180;
|
||||||
|
|
||||||
|
export function clampYearValue(year: number, minYear: number, maxYear: number): number {
|
||||||
|
const lower = Math.min(minYear, maxYear);
|
||||||
|
const upper = Math.max(minYear, maxYear);
|
||||||
|
if (year < lower) return lower;
|
||||||
|
if (year > upper) return upper;
|
||||||
|
return year;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampYearToFixedRange(year: number): number {
|
||||||
|
return clampYearValue(year, FIXED_TIMELINE_START_YEAR, FIXED_TIMELINE_END_YEAR);
|
||||||
|
}
|
||||||
52
src/uhm/lib/useEditorSessionState.ts
Normal file
52
src/uhm/lib/useEditorSessionState.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { FeatureCollection } from "@/uhm/types/geo";
|
||||||
|
import { useBackgroundSessionState } from "@/uhm/lib/editor/session/useBackgroundSessionState";
|
||||||
|
import { useEntitySessionState } from "@/uhm/lib/editor/session/useEntitySessionState";
|
||||||
|
import { useSectionSessionState } from "@/uhm/lib/editor/session/useSectionSessionState";
|
||||||
|
import { useTimelineState } from "@/uhm/lib/editor/session/useTimelineState";
|
||||||
|
import { useWikiSessionState } from "@/uhm/lib/editor/session/useWikiSessionState";
|
||||||
|
import type { EditorMode, TimelineRange } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
EditorMode,
|
||||||
|
EntityFormState,
|
||||||
|
GeometryMetaFormState,
|
||||||
|
TimelineRange,
|
||||||
|
} from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
emptyFeatureCollection: FeatureCollection;
|
||||||
|
defaultEditorUserId: string;
|
||||||
|
fallbackTimelineRange: TimelineRange;
|
||||||
|
currentYear: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useEditorSessionState(options: Options) {
|
||||||
|
// Mode thao tác map/editor hiện tại.
|
||||||
|
const [mode, setMode] = useState<EditorMode>("idle");
|
||||||
|
// FeatureCollection "gốc" của session hiện tại (global timeline hoặc section snapshot).
|
||||||
|
const [initialData, setInitialData] = useState<FeatureCollection>(options.emptyFeatureCollection);
|
||||||
|
|
||||||
|
const section = useSectionSessionState({
|
||||||
|
defaultEditorUserId: options.defaultEditorUserId,
|
||||||
|
});
|
||||||
|
const entity = useEntitySessionState();
|
||||||
|
const timeline = useTimelineState({
|
||||||
|
currentYear: options.currentYear,
|
||||||
|
fallbackTimelineRange: options.fallbackTimelineRange,
|
||||||
|
});
|
||||||
|
const background = useBackgroundSessionState();
|
||||||
|
const wiki = useWikiSessionState();
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
setMode,
|
||||||
|
initialData,
|
||||||
|
setInitialData,
|
||||||
|
...section,
|
||||||
|
...entity,
|
||||||
|
...timeline,
|
||||||
|
...background,
|
||||||
|
...wiki,
|
||||||
|
};
|
||||||
|
}
|
||||||
282
src/uhm/lib/useEditorState.ts
Normal file
282
src/uhm/lib/useEditorState.ts
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react";
|
||||||
|
import type {
|
||||||
|
Feature,
|
||||||
|
FeatureCollection,
|
||||||
|
FeatureProperties,
|
||||||
|
Geometry,
|
||||||
|
} from "@/uhm/types/geo";
|
||||||
|
import { buildInitialMap, deepClone, diffDraftToInitial } from "@/uhm/lib/editor/draft/draftDiff";
|
||||||
|
import { useDraftState } from "@/uhm/lib/editor/draft/useDraftState";
|
||||||
|
import { useUndoStack } from "@/uhm/lib/editor/draft/useUndoStack";
|
||||||
|
import type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
|
||||||
|
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||||
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
|
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
||||||
|
|
||||||
|
export type { Feature, FeatureCollection, FeatureProperties, Geometry } from "@/uhm/types/geo";
|
||||||
|
export type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
|
||||||
|
|
||||||
|
type SnapshotUndoApi = {
|
||||||
|
snapshotEntitiesRef: { current: EntitySnapshot[] };
|
||||||
|
setSnapshotEntities: Dispatch<SetStateAction<EntitySnapshot[]>>;
|
||||||
|
snapshotWikisRef: { current: WikiSnapshot[] };
|
||||||
|
setSnapshotWikis: Dispatch<SetStateAction<WikiSnapshot[]>>;
|
||||||
|
snapshotEntityWikiLinksRef: { current: EntityWikiLinkSnapshot[] };
|
||||||
|
setSnapshotEntityWikiLinks: Dispatch<SetStateAction<EntityWikiLinkSnapshot[]>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// State trung tâm của editor:
|
||||||
|
// - draft: dữ liệu nguồn để render UI
|
||||||
|
// - changes: map các thay đổi chờ lưu
|
||||||
|
// - undoStack: lịch sử thao tác tối thiểu để hoàn tác
|
||||||
|
export function useEditorState(initialData: FeatureCollection, snapshotUndo?: SnapshotUndoApi) {
|
||||||
|
const { draft, draftRef, commitDraft, resetDraft } = useDraftState(initialData);
|
||||||
|
|
||||||
|
// Map baseline (id -> feature) để diff draft hiện tại ra changes.
|
||||||
|
const initialMapRef = useRef<Map<FeatureProperties["id"], Feature>>(
|
||||||
|
buildInitialMap(initialData)
|
||||||
|
);
|
||||||
|
// Version counter để ép diff recalculation sau khi reset/clear baseline.
|
||||||
|
const [baselineVersion, setBaselineVersion] = useState(0);
|
||||||
|
|
||||||
|
const applyUndoAction = useCallback((action: UndoAction): boolean => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "create": {
|
||||||
|
commitDraft({
|
||||||
|
...draftRef.current,
|
||||||
|
features: draftRef.current.features.filter((feature) =>
|
||||||
|
feature.properties.id !== action.id
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "delete": {
|
||||||
|
const feature = deepClone(action.feature);
|
||||||
|
commitDraft({
|
||||||
|
...draftRef.current,
|
||||||
|
features: [...draftRef.current.features, feature],
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "update": {
|
||||||
|
const idx = draftRef.current.features.findIndex((feature) =>
|
||||||
|
feature.properties.id === action.id
|
||||||
|
);
|
||||||
|
if (idx === -1) return false;
|
||||||
|
const nextFeatures = [...draftRef.current.features];
|
||||||
|
nextFeatures[idx] = {
|
||||||
|
...nextFeatures[idx],
|
||||||
|
geometry: deepClone(action.prevGeometry),
|
||||||
|
};
|
||||||
|
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "properties": {
|
||||||
|
const idx = draftRef.current.features.findIndex((feature) =>
|
||||||
|
feature.properties.id === action.id
|
||||||
|
);
|
||||||
|
if (idx === -1) return false;
|
||||||
|
const nextFeatures = [...draftRef.current.features];
|
||||||
|
nextFeatures[idx] = {
|
||||||
|
...nextFeatures[idx],
|
||||||
|
properties: deepClone(action.prevProperties),
|
||||||
|
};
|
||||||
|
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "snapshot_entities": {
|
||||||
|
if (!snapshotUndo) return false;
|
||||||
|
snapshotUndo.setSnapshotEntities(deepClone(action.prev));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "snapshot_wikis": {
|
||||||
|
if (!snapshotUndo) return false;
|
||||||
|
snapshotUndo.setSnapshotWikis(deepClone(action.prev));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "snapshot_entity_wiki": {
|
||||||
|
if (!snapshotUndo) return false;
|
||||||
|
snapshotUndo.setSnapshotEntityWikiLinks(deepClone(action.prev));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [commitDraft, draftRef, snapshotUndo]);
|
||||||
|
|
||||||
|
const { undoStack, pushUndo, undo, clearUndo } = useUndoStack({ applyUndoAction });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
resetDraft(deepClone(initialData));
|
||||||
|
clearUndo();
|
||||||
|
initialMapRef.current = buildInitialMap(initialData);
|
||||||
|
setBaselineVersion((version) => version + 1);
|
||||||
|
}, [clearUndo, initialData, resetDraft]);
|
||||||
|
|
||||||
|
const changes = useMemo(() => {
|
||||||
|
const baseline = initialMapRef.current;
|
||||||
|
return diffDraftToInitial(draft, baseline);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [draft, baselineVersion]);
|
||||||
|
const changeCount = useMemo(() => changes.size, [changes]);
|
||||||
|
|
||||||
|
function createFeature(feature: Feature) {
|
||||||
|
const featureClone = deepClone(feature);
|
||||||
|
commitDraft({
|
||||||
|
...draftRef.current,
|
||||||
|
features: [...draftRef.current.features, featureClone],
|
||||||
|
});
|
||||||
|
pushUndo({ type: "create", id: featureClone.properties.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchFeatureProperties(
|
||||||
|
id: FeatureProperties["id"],
|
||||||
|
patch: Partial<FeatureProperties>
|
||||||
|
) {
|
||||||
|
const idx = draftRef.current.features.findIndex((feature) => feature.properties.id === id);
|
||||||
|
if (idx === -1) return;
|
||||||
|
|
||||||
|
const nextFeatures = [...draftRef.current.features];
|
||||||
|
const prevProperties = deepClone(nextFeatures[idx].properties);
|
||||||
|
nextFeatures[idx] = {
|
||||||
|
...nextFeatures[idx],
|
||||||
|
properties: {
|
||||||
|
...nextFeatures[idx].properties,
|
||||||
|
...deepClone(patch),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (JSON.stringify(prevProperties) === JSON.stringify(nextFeatures[idx].properties)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pushUndo({ type: "properties", id, prevProperties });
|
||||||
|
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFeature(id: FeatureProperties["id"], newGeometry: Geometry) {
|
||||||
|
const idx = draftRef.current.features.findIndex((feature) => feature.properties.id === id);
|
||||||
|
if (idx === -1) return;
|
||||||
|
|
||||||
|
const prevFeature = draftRef.current.features[idx];
|
||||||
|
const prevGeometry = deepClone(prevFeature.geometry);
|
||||||
|
const nextFeatures = [...draftRef.current.features];
|
||||||
|
nextFeatures[idx] = {
|
||||||
|
...prevFeature,
|
||||||
|
geometry: deepClone(newGeometry),
|
||||||
|
};
|
||||||
|
|
||||||
|
pushUndo({ type: "update", id, prevGeometry });
|
||||||
|
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteFeature(id: FeatureProperties["id"]) {
|
||||||
|
const idx = draftRef.current.features.findIndex((feature) => feature.properties.id === id);
|
||||||
|
if (idx === -1) return;
|
||||||
|
|
||||||
|
const feature = draftRef.current.features[idx];
|
||||||
|
const nextFeatures = [...draftRef.current.features];
|
||||||
|
nextFeatures.splice(idx, 1);
|
||||||
|
|
||||||
|
pushUndo({ type: "delete", feature: deepClone(feature) });
|
||||||
|
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload(): Change[] {
|
||||||
|
return Array.from(changes.values()).map((change) => deepClone(change));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearChanges() {
|
||||||
|
clearUndo();
|
||||||
|
initialMapRef.current = buildInitialMap(draftRef.current);
|
||||||
|
setBaselineVersion((version) => version + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPersistedFeature(id: FeatureProperties["id"]) {
|
||||||
|
return initialMapRef.current.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const setSnapshotEntitiesUndoable = useCallback((
|
||||||
|
next: SetStateAction<EntitySnapshot[]>,
|
||||||
|
label = "Cập nhật entities"
|
||||||
|
) => {
|
||||||
|
if (!snapshotUndo) return;
|
||||||
|
snapshotUndo.setSnapshotEntities((prev) => {
|
||||||
|
const prevClone = deepClone(prev);
|
||||||
|
const computed = typeof next === "function" ? (next as (p: EntitySnapshot[]) => EntitySnapshot[])(prev) : next;
|
||||||
|
let changed = true;
|
||||||
|
try {
|
||||||
|
changed = JSON.stringify(prev) !== JSON.stringify(computed);
|
||||||
|
} catch {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
pushUndo({ type: "snapshot_entities", label, prev: prevClone });
|
||||||
|
}
|
||||||
|
return computed;
|
||||||
|
});
|
||||||
|
}, [pushUndo, snapshotUndo]);
|
||||||
|
|
||||||
|
const setSnapshotWikisUndoable = useCallback((
|
||||||
|
next: SetStateAction<WikiSnapshot[]>,
|
||||||
|
label = "Cập nhật wikis"
|
||||||
|
) => {
|
||||||
|
if (!snapshotUndo) return;
|
||||||
|
snapshotUndo.setSnapshotWikis((prev) => {
|
||||||
|
const prevClone = deepClone(prev);
|
||||||
|
const computed = typeof next === "function" ? (next as (p: WikiSnapshot[]) => WikiSnapshot[])(prev) : next;
|
||||||
|
let changed = true;
|
||||||
|
try {
|
||||||
|
changed = JSON.stringify(prev) !== JSON.stringify(computed);
|
||||||
|
} catch {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
pushUndo({ type: "snapshot_wikis", label, prev: prevClone });
|
||||||
|
}
|
||||||
|
return computed;
|
||||||
|
});
|
||||||
|
}, [pushUndo, snapshotUndo]);
|
||||||
|
|
||||||
|
const setSnapshotEntityWikiLinksUndoable = useCallback((
|
||||||
|
next: SetStateAction<EntityWikiLinkSnapshot[]>,
|
||||||
|
label = "Cập nhật entity-wiki"
|
||||||
|
) => {
|
||||||
|
if (!snapshotUndo) return;
|
||||||
|
snapshotUndo.setSnapshotEntityWikiLinks((prev) => {
|
||||||
|
const prevClone = deepClone(prev);
|
||||||
|
const computed = typeof next === "function"
|
||||||
|
? (next as (p: EntityWikiLinkSnapshot[]) => EntityWikiLinkSnapshot[])(prev)
|
||||||
|
: next;
|
||||||
|
let changed = true;
|
||||||
|
try {
|
||||||
|
changed = JSON.stringify(prev) !== JSON.stringify(computed);
|
||||||
|
} catch {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
pushUndo({ type: "snapshot_entity_wiki", label, prev: prevClone });
|
||||||
|
}
|
||||||
|
return computed;
|
||||||
|
});
|
||||||
|
}, [pushUndo, snapshotUndo]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
draft,
|
||||||
|
changes,
|
||||||
|
undoStack,
|
||||||
|
changeCount,
|
||||||
|
createFeature,
|
||||||
|
patchFeatureProperties,
|
||||||
|
updateFeature,
|
||||||
|
deleteFeature,
|
||||||
|
undo,
|
||||||
|
buildPayload,
|
||||||
|
clearChanges,
|
||||||
|
hasPersistedFeature,
|
||||||
|
// Snapshot undo helpers (no-op if snapshotUndo not provided)
|
||||||
|
setSnapshotEntities: setSnapshotEntitiesUndoable,
|
||||||
|
setSnapshotWikis: setSnapshotWikisUndoable,
|
||||||
|
setSnapshotEntityWikiLinks: setSnapshotEntityWikiLinksUndoable,
|
||||||
|
};
|
||||||
|
}
|
||||||
18
src/uhm/types/api.ts
Normal file
18
src/uhm/types/api.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export type ApiEnvelope<T> = {
|
||||||
|
// API cũ: "success" | "error"
|
||||||
|
// API mới: boolean (true/false)
|
||||||
|
status: boolean | "success" | "error" | string;
|
||||||
|
data?: T;
|
||||||
|
message?: string;
|
||||||
|
errors?: unknown;
|
||||||
|
pagination?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GeometriesBBoxQuery = {
|
||||||
|
minLng: number;
|
||||||
|
minLat: number;
|
||||||
|
maxLng: number;
|
||||||
|
maxLat: number;
|
||||||
|
time?: number;
|
||||||
|
entity_id?: string;
|
||||||
|
};
|
||||||
116
src/uhm/types/commit_snapshot.ts
Normal file
116
src/uhm/types/commit_snapshot.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
|
||||||
|
// ---- Root request ----
|
||||||
|
|
||||||
|
export type CreateCommitRequest = {
|
||||||
|
snapshot_json: CommitSnapshot;
|
||||||
|
edit_summary: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Snapshot root ----
|
||||||
|
|
||||||
|
export type CommitSnapshot = {
|
||||||
|
editor_feature_collection: FeatureCollection;
|
||||||
|
entities: EntitySnapshot[];
|
||||||
|
geometries: GeometrySnapshot[];
|
||||||
|
geometry_entity: GeometryEntitySnapshot[];
|
||||||
|
wikis: WikiSnapshot[];
|
||||||
|
entity_wiki: EntityWikiLinkSnapshot[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- GeoJSON / FeatureCollection ----
|
||||||
|
|
||||||
|
export type Geometry =
|
||||||
|
| { type: "Point"; coordinates: [number, number] }
|
||||||
|
| { type: "MultiPoint"; coordinates: [number, number][] }
|
||||||
|
| { type: "LineString"; coordinates: [number, number][] }
|
||||||
|
| { type: "MultiLineString"; coordinates: [number, number][][] }
|
||||||
|
| { type: "Polygon"; coordinates: [number, number][][] }
|
||||||
|
| { type: "MultiPolygon"; coordinates: [number, number][][][] };
|
||||||
|
|
||||||
|
export type FeatureId = string | number;
|
||||||
|
|
||||||
|
export type FeatureProperties = {
|
||||||
|
id: FeatureId;
|
||||||
|
type?: string | null; //generate
|
||||||
|
geometry_preset?: string | null;
|
||||||
|
time_start?: number | null; //generate
|
||||||
|
time_end?: number | null; //generate
|
||||||
|
binding?: string[]; //generate
|
||||||
|
|
||||||
|
// Legacy/UI-only fields should not be relied on by the backend.
|
||||||
|
// FE strips these when building snapshot_json, but we keep them optional here
|
||||||
|
// because older snapshots may still contain them.
|
||||||
|
entity_id?: string | null; //generate
|
||||||
|
entity_ids?: string[]; //generate
|
||||||
|
entity_name?: string | null; //generate
|
||||||
|
entity_names?: string[]; //generate
|
||||||
|
entity_type_id?: string | null; //generate
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Feature = {
|
||||||
|
type: "Feature";
|
||||||
|
properties: FeatureProperties;
|
||||||
|
geometry: Geometry;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FeatureCollection = {
|
||||||
|
type: "FeatureCollection";
|
||||||
|
features: Feature[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Snapshot rows ----
|
||||||
|
|
||||||
|
export type SnapshotSource = "inline" | "ref";
|
||||||
|
|
||||||
|
export type SnapshotOperation = "create" | "update" | "delete" | "reference";
|
||||||
|
|
||||||
|
export type EntitySnapshot = {
|
||||||
|
id: string;
|
||||||
|
source: SnapshotSource;
|
||||||
|
operation?: SnapshotOperation;
|
||||||
|
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GeometrySnapshot = {
|
||||||
|
id: string;
|
||||||
|
source: SnapshotSource;
|
||||||
|
operation?: SnapshotOperation;
|
||||||
|
type?: string | null;
|
||||||
|
draw_geometry?: Geometry;
|
||||||
|
binding?: string[];
|
||||||
|
time_start?: number | null;
|
||||||
|
time_end?: number | null;
|
||||||
|
bbox?: {
|
||||||
|
min_lng: number;
|
||||||
|
min_lat: number;
|
||||||
|
max_lng: number;
|
||||||
|
max_lat: number;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GeometryEntitySnapshot = {
|
||||||
|
geometry_id: string;
|
||||||
|
entity_id: string;
|
||||||
|
operation?: "reference" | "delete" | "binding";
|
||||||
|
};
|
||||||
|
|
||||||
|
// FE stores wiki doc as a string (often HTML) or null for ref-only rows.
|
||||||
|
export type WikiDoc = string | null;
|
||||||
|
|
||||||
|
export type WikiSnapshot = {
|
||||||
|
id: string;
|
||||||
|
source: SnapshotSource;
|
||||||
|
operation?: SnapshotOperation;
|
||||||
|
|
||||||
|
title: string;
|
||||||
|
slug?: string | null;
|
||||||
|
doc: WikiDoc;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EntityWikiLinkSnapshot = {
|
||||||
|
entity_id: string;
|
||||||
|
wiki_id: string;
|
||||||
|
operation?: "reference" | "delete" | "binding";
|
||||||
|
};
|
||||||
37
src/uhm/types/entities.ts
Normal file
37
src/uhm/types/entities.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
export type Entity = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
// API mới
|
||||||
|
description?: string | null;
|
||||||
|
thumbnail_url?: string | null;
|
||||||
|
is_deleted?: boolean;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
|
||||||
|
// API cũ / snapshot editor (giữ optional để không phá flow editor snapshot)
|
||||||
|
slug?: string | null;
|
||||||
|
type_id?: string | null;
|
||||||
|
status?: number | null;
|
||||||
|
geometry_count?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EntitySnapshotOperation = "create" | "update" | "delete" | "reference";
|
||||||
|
|
||||||
|
export type EntitySnapshot = {
|
||||||
|
id: string;
|
||||||
|
// Where this entity's data comes from.
|
||||||
|
// - inline: data is embedded in snapshot_json
|
||||||
|
// - ref: data should be fetched externally by id (DB/global)
|
||||||
|
source: "inline" | "ref";
|
||||||
|
// Delta semantics for this commit:
|
||||||
|
// - create/update/delete: this commit modifies the entity record
|
||||||
|
// - 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;
|
||||||
|
name?: string;
|
||||||
|
slug?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
status?: number | null;
|
||||||
|
base_updated_at?: string;
|
||||||
|
base_hash?: string;
|
||||||
|
};
|
||||||
74
src/uhm/types/geo.ts
Normal file
74
src/uhm/types/geo.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import type { EntityGeometryPreset } from "@/uhm/lib/entityTypeOptions";
|
||||||
|
|
||||||
|
export type Geometry =
|
||||||
|
| { type: "Point"; coordinates: [number, number] }
|
||||||
|
| { type: "MultiPoint"; coordinates: [number, number][] }
|
||||||
|
| { type: "LineString"; coordinates: [number, number][] }
|
||||||
|
| { type: "MultiLineString"; coordinates: [number, number][][] }
|
||||||
|
| { type: "Polygon"; coordinates: [number, number][][] }
|
||||||
|
| { type: "MultiPolygon"; coordinates: [number, number][][][] };
|
||||||
|
|
||||||
|
export type FeatureId = string | number;
|
||||||
|
|
||||||
|
export type FeatureProperties = {
|
||||||
|
id: FeatureId;
|
||||||
|
type?: string | null;
|
||||||
|
geometry_preset?: EntityGeometryPreset | null;
|
||||||
|
time_start?: number | null;
|
||||||
|
time_end?: number | null;
|
||||||
|
binding?: string[];
|
||||||
|
entity_id?: string | null;
|
||||||
|
entity_ids?: string[];
|
||||||
|
entity_name?: string | null;
|
||||||
|
entity_names?: string[];
|
||||||
|
entity_type_id?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Feature = {
|
||||||
|
type: "Feature";
|
||||||
|
properties: FeatureProperties;
|
||||||
|
geometry: Geometry;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FeatureCollection = {
|
||||||
|
type: "FeatureCollection";
|
||||||
|
features: Feature[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GeometrySnapshotOperation = "create" | "update" | "delete" | "reference";
|
||||||
|
|
||||||
|
export type GeometrySnapshot = {
|
||||||
|
id: string;
|
||||||
|
source: "inline" | "ref";
|
||||||
|
operation?: GeometrySnapshotOperation;
|
||||||
|
type?: string | null;
|
||||||
|
draw_geometry?: Geometry;
|
||||||
|
geometry?: Geometry;
|
||||||
|
binding?: string[];
|
||||||
|
time_start?: number | null;
|
||||||
|
time_end?: number | null;
|
||||||
|
bbox?: {
|
||||||
|
min_lng: number;
|
||||||
|
min_lat: number;
|
||||||
|
max_lng: number;
|
||||||
|
max_lat: number;
|
||||||
|
} | null;
|
||||||
|
base_updated_at?: string;
|
||||||
|
base_hash?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Snapshot join table (geometry ↔ entity).
|
||||||
|
export type GeometryEntitySnapshot = {
|
||||||
|
geometry_id: string;
|
||||||
|
entity_id: string;
|
||||||
|
// Relationship semantics (geometry ↔ entity).
|
||||||
|
// - reference/binding: the link exists (assigned)
|
||||||
|
// - delete: the link is removed
|
||||||
|
operation?: "reference" | "binding" | "delete";
|
||||||
|
base_links_hash?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GeometryChange =
|
||||||
|
| { action: "create"; feature: Feature }
|
||||||
|
| { action: "update"; id: FeatureId; geometry: Geometry }
|
||||||
|
| { action: "delete"; id: FeatureId };
|
||||||
120
src/uhm/types/sections.ts
Normal file
120
src/uhm/types/sections.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||||
|
import type { FeatureCollection, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
|
||||||
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
|
|
||||||
|
export type EntityWikiLinkSnapshot = {
|
||||||
|
entity_id: string;
|
||||||
|
wiki_id: string;
|
||||||
|
// Relationship semantics (entity ↔ wiki).
|
||||||
|
// - reference/binding: the link exists (assigned)
|
||||||
|
// - delete: the link is removed
|
||||||
|
operation?: "reference" | "binding" | "delete";
|
||||||
|
};
|
||||||
|
|
||||||
|
// BackEndGo uses Projects/Commits/Submissions. "Section" is legacy naming in FE.
|
||||||
|
export type ProjectStatus = 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 = {
|
||||||
|
// Derived state from ProjectResponse (not persisted as-is in API mới).
|
||||||
|
status: ProjectStatus;
|
||||||
|
head_commit_id: string | null;
|
||||||
|
locked_by?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Project = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
project_status?: string;
|
||||||
|
latest_commit_id?: string | null;
|
||||||
|
// Legacy (old BE): submission_ids?: string[]
|
||||||
|
// New BE: submissions?: [{id,status}]
|
||||||
|
submission_ids?: string[];
|
||||||
|
submissions?: SubmissionSimpleResponse[];
|
||||||
|
locked_by?: string | null;
|
||||||
|
user_id?: string;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
state?: {
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectCommit = {
|
||||||
|
id: string;
|
||||||
|
project_id: string;
|
||||||
|
snapshot_json: EditorSnapshot;
|
||||||
|
snapshot_hash: string;
|
||||||
|
user_id: string;
|
||||||
|
edit_summary: string;
|
||||||
|
created_at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectSubmission = {
|
||||||
|
id: string;
|
||||||
|
project_id: string;
|
||||||
|
commit_id: string;
|
||||||
|
user_id: string;
|
||||||
|
created_at?: string;
|
||||||
|
status: ProjectSubmissionStatus;
|
||||||
|
reviewed_by?: string | null;
|
||||||
|
reviewed_at?: string | null;
|
||||||
|
review_note?: string | null;
|
||||||
|
content?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EditorSnapshot = {
|
||||||
|
// Legacy: before BEGo flow moved fully to project/commit records, FE stored a minimal "section" ref
|
||||||
|
// inside snapshot_json. New snapshots omit this entirely.
|
||||||
|
section?: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
editor_feature_collection?: FeatureCollection;
|
||||||
|
entities?: EntitySnapshot[];
|
||||||
|
geometries?: GeometrySnapshot[];
|
||||||
|
// Join table geometry ↔ entity (many-to-many).
|
||||||
|
geometry_entity?: GeometryEntitySnapshot[];
|
||||||
|
wikis?: WikiSnapshot[];
|
||||||
|
entity_wiki?: EntityWikiLinkSnapshot[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Alias for clearer naming at API boundary: commits.snapshot_json is this shape.
|
||||||
|
export type CommitSnapshot = EditorSnapshot;
|
||||||
|
|
||||||
|
export type EditorLoadResponse = {
|
||||||
|
section: Project;
|
||||||
|
state: ProjectState;
|
||||||
|
commit: ProjectCommit | null;
|
||||||
|
snapshot: EditorSnapshot | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateSectionInput = {
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
status?: "PRIVATE" | "PUBLIC" | "ARCHIVE";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateCommitInput = {
|
||||||
|
snapshot: EditorSnapshot;
|
||||||
|
edit_summary: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RestoreCommitInput = {
|
||||||
|
commit_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Legacy aliases (to reduce churn in existing FE code). Prefer Project* names above.
|
||||||
|
export type SectionStatus = ProjectStatus;
|
||||||
|
export type SectionSubmissionStatus = ProjectSubmissionStatus;
|
||||||
|
export type SectionState = ProjectState;
|
||||||
|
export type Section = Project;
|
||||||
|
export type SectionCommit = ProjectCommit;
|
||||||
|
export type SectionSubmission = ProjectSubmission;
|
||||||
16
src/uhm/types/wiki.ts
Normal file
16
src/uhm/types/wiki.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// 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 WikiSnapshot = {
|
||||||
|
id: string;
|
||||||
|
source: "inline" | "ref";
|
||||||
|
// Optional for backwards-compat with older commits. New commits should include it.
|
||||||
|
operation?: WikiSnapshotOperation;
|
||||||
|
title: string;
|
||||||
|
slug?: string | null;
|
||||||
|
doc: WikiDoc;
|
||||||
|
updated_at?: string;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user