pre view wiki
All checks were successful
Build and Release / release (push) Successful in 33s

This commit is contained in:
taDuc
2026-05-02 21:13:29 +07:00
parent a74047fd09
commit 12c351c68a
23 changed files with 3052 additions and 117 deletions

378
commit_snapshot.md Normal file
View File

@@ -0,0 +1,378 @@
# Commit Snapshot (`commits.snapshot_json`) - Cấu Trúc Hiện Tại
Tài liệu này mô tả **commit snapshot** đang được lưu trong `BackEndGo.commits.snapshot_json` (JSONB) và được `FrontEndAdmin` tạo ra khi bấm **Commit** trong `/editor`.
Mục tiêu: nhìn vào đây là hiểu commit snapshot gồm những phần nào, ý nghĩa ra sao, và `source`/`operation` có vai trò gì.
Nguồn tham chiếu trong code:
- Type snapshot: `FrontEndAdmin/src/uhm/types/sections.ts` (`EditorSnapshot`)
- Build snapshot khi commit: `FrontEndAdmin/src/uhm/lib/editor/snapshot/editorSnapshot.ts` (`buildEditorSnapshot`)
## 1) Schema tổng quan (v1)
Hiện tại snapshot được ghi với `schema_version: 1`.
```ts
export type CommitSnapshotV1 = {
schema_version: 1;
// Project/section đang được edit (FE vẫn giữ tên "section" cho compatibility)
section: { id: string; title: string };
// GeoJSON draft để render map + làm nguồn dựng geometries/link_scopes
editor_feature_collection?: FeatureCollection;
// Operation-based rows
entities?: EntitySnapshot[];
geometries?: GeometrySnapshot[];
link_scopes?: LinkScopeSnapshot[];
// Wiki list (tiptap JSON hoặc reference)
wikis?: WikiSnapshot[];
// Join table inside snapshot: links between entities and wikis (project-level)
entity_wikis?: EntityWikiLinkSnapshot[];
};
```
## 2) `operation` có những giá trị nào?
Trong commit snapshot hiện tại có 4 nơi dùng `operation`:
1. `entities[].operation`:
- `create` | `update` | `delete` | `reference`
2. `geometries[].operation`:
- `create` | `update` | `delete` | `reference`
3. `link_scopes[].operation`:
- `reference`
4. `wikis[].operation`:
- `create` | `update` | `delete` | `reference`
Ghi chú về semantics:
- `create/update/delete`: bản ghi bị thay đổi trong commit này
- `reference`: bản ghi được đưa vào snapshot để làm đầu mối **nối (link)** (vd: geometry↔entity, entity↔wiki), không phải “không đổi”
Ngoài ra snapshot có `entity_wikis[]` để nối entity <-> wiki.
## 3) Sơ đồ trực quan (Mermaid)
```mermaid
classDiagram
class CommitSnapshotV1 {
+number schema_version
+SectionRef section
+FeatureCollection editor_feature_collection?
+EntitySnapshot[] entities?
+GeometrySnapshot[] geometries?
+LinkScopeSnapshot[] link_scopes?
+WikiSnapshot[] wikis?
+EntityWikiLinkSnapshot[] entity_wikis?
}
class SectionRef {
+string id
+string title
}
class FeatureCollection {
+string type // "FeatureCollection"
+Feature[] features
}
class Feature {
+string type // "Feature"
+FeatureProperties properties
+Geometry geometry
}
class FeatureProperties {
+string|number id
+string type?
+number time_start?
+number time_end?
+string[] binding?
+string entity_id?
+string[] entity_ids?
+string entity_name?
+string[] entity_names?
+string entity_type_id?
}
class EntitySnapshot {
+string id
+string source? // inline|ref
+Ref ref?
+string operation? // create|update|delete|reference
+string name?
+string slug?
+string description?
+string type_id?
+number status?
+number is_deleted?
+string base_updated_at?
+string base_hash?
}
class GeometrySnapshot {
+string id
+string source? // inline|ref
+Ref ref?
+string operation? // create|update|delete|reference
+string type?
+Geometry draw_geometry?
+string[] binding?
+number time_start?
+number time_end?
+BBox bbox?
+number is_deleted?
+string base_updated_at?
+string base_hash?
}
class BBox {
+number min_lng
+number min_lat
+number max_lng
+number max_lat
}
class LinkScopeSnapshot {
+string geometry_id
+string operation // reference
+string[] entity_ids
+string base_links_hash?
}
class WikiSnapshot {
+string id
+string source? // inline|ref
+Ref ref?
+string operation? // create|update|delete|reference
+string title
+any doc
+string updated_at?
+number is_deleted?
}
class EntityWikiLinkSnapshot {
+string entity_id
+string wiki_id
+string operation? // reference|delete
+number is_deleted?
}
class Ref {
+string id
}
CommitSnapshotV1 --> SectionRef
CommitSnapshotV1 --> FeatureCollection
FeatureCollection --> Feature
Feature --> FeatureProperties
CommitSnapshotV1 --> EntitySnapshot
CommitSnapshotV1 --> GeometrySnapshot
CommitSnapshotV1 --> LinkScopeSnapshot
CommitSnapshotV1 --> WikiSnapshot
CommitSnapshotV1 --> EntityWikiLinkSnapshot
```
## 4) Ý nghĩa từng phần
### 4.1 `section`
Chỉ là “ref” tối thiểu để biết commit này thuộc project nào:
- `section.id` = `project_id`
- `section.title` = title tại thời điểm commit (phục vụ UI)
### 4.2 `editor_feature_collection`
GeoJSON `FeatureCollection` là nguồn để:
- render map trong editor
- build `geometries[]` + `link_scopes[]` khi commit
Trong thực tế, nó là “bản đồ draft state” của commit.
### 4.3 `entities[]`
`entities[]` là tập các entity rows kèm `source`/`operation`. Trong `buildEditorSnapshot` hiện tại, nó được dựng từ:
1. `pending entities` tạo trong editor:
- `source: "inline"`, `operation: "create"`
2. `projectEntityRefs` (entity được user “pin” vào project từ thanh search):
- `source: "ref"`, `ref: {id}`, `operation: "reference"`
3. Các entity IDs đang được gắn vào geometries trong `editor_feature_collection` (nếu chưa có trong list):
- `source: "ref"`, `ref: {id}`, `operation: "reference"`
4. Các entity IDs xuất hiện trong `entity_wikis[]`:
- `source: "ref"`, `ref: {id}`, `operation: "reference"`
=> Nghĩa là: `entities[]` trong commit snapshot hiện tại hoạt động như một “danh sách entity liên quan tới project”, không nhất thiết phải gắn vào một geometry cụ thể.
### 4.4 `geometries[]`
Mỗi `Feature` trong `editor_feature_collection.features[]` sẽ sinh ra một `GeometrySnapshot` row:
- `id`: `String(feature.properties.id)`
- `draw_geometry`: lấy từ `feature.geometry`
- `type`: `feature.properties.type || getDefaultTypeIdForFeature(feature)`
- `binding`: normalize từ `feature.properties.binding`
- `time_start/time_end`
- `bbox`: tính từ geometry
- `is_deleted: 0`
`operation` được suy ra dựa vào `changes` + so sánh với snapshot trước:
- `create`: feature mới
- `update`: feature thay đổi
- (không có `operation`): feature không đổi (không delta trong commit)
- `delete`: feature bị xoá khỏi draft (FE sẽ thêm 1 row `{ id, operation:"delete", is_deleted:1 }`)
### 4.5 `link_scopes[]`
FE build link scopes từ GeoJSON features:
- `geometry_id = String(feature.properties.id)`
- `operation = "reference"`
- `entity_ids` lấy từ `feature.properties.entity_ids` hoặc `entity_id`
Chỉ add scope nếu `entity_ids.length > 0`.
### 4.6 `wikis[]`
`wikis[]` là danh sách wiki của project tại thời điểm commit.
Type hiện tại:
```ts
export type WikiSnapshot = {
id: string;
source?: "inline" | "ref";
ref?: { id: string };
operation?: "create" | "update" | "delete" | "reference";
title: string;
doc: unknown; // tiptap JSON doc (inline) hoặc null (reference)
updated_at?: string;
is_deleted?: number;
};
```
Quy ước FE đang dùng:
- Wiki tạo mới trong editor: `operation: "create"`, `doc` là tiptap JSON.
- Wiki sửa: `operation: "update"`, `doc` là tiptap JSON.
- Wiki không đổi so với snapshot trước: thường **không có** `operation` (không delta).
- Wiki add từ thanh search (wiki đã tồn tại trong DB): `source:"ref"`, `ref:{id}`, `operation:"reference"`, **`doc` có thể là `null`**.
Ghi chú quan trọng:
- Hiện tại FE **chưa generate “delete rows” cho wikis** (khác với geometries). Khi bạn remove một wiki khỏi list thì snapshot mới sẽ đơn giản là không còn wiki đó nữa.
### 4.7 `entity_wikis[]` (bảng nối Entity ↔ Wiki)
`entity_wikis[]` là bảng nối trong snapshot để thể hiện “wiki nào thuộc entity nào” ở mức project/commit.
```ts
export type EntityWikiLinkSnapshot = {
entity_id: string;
wiki_id: string;
operation?: "reference" | "delete";
is_deleted?: number;
};
```
FE hiện dùng panel “Entity ↔ Wiki” để toggle link:
- Tick checkbox => `{ operation:"reference", is_deleted:0 }`
- Untick checkbox => `{ operation:"delete", is_deleted:1 }`
## 5) Ví dụ JSON (rút gọn)
Ví dụ dưới đây thể hiện:
- 1 geometry gắn entity `e_1`
- 1 entity ref “pin” vào project (`e_2`) dù chưa gắn geometry
- 1 wiki inline và 1 wiki reference (search từ DB)
```json
{
"schema_version": 1,
"section": { "id": "019d...project", "title": "Project A" },
"editor_feature_collection": {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {
"id": "g_1",
"type": "city",
"time_start": 1200,
"time_end": 1300,
"entity_ids": ["e_1"],
"entity_names": ["Ha Noi"]
},
"geometry": { "type": "Point", "coordinates": [105.8, 21.0] }
}
]
},
"entities": [
{ "id": "e_2", "operation": "reference", "name": "Pinned Entity", "is_deleted": 0 },
{ "id": "e_1", "operation": "reference", "name": "Ha Noi", "type_id": "city", "status": 1, "is_deleted": 0 }
],
"geometries": [
{
"id": "g_1",
"operation": "update",
"type": "city",
"draw_geometry": { "type": "Point", "coordinates": [105.8, 21.0] },
"binding": [],
"time_start": 1200,
"time_end": 1300,
"bbox": { "min_lng": 105.8, "min_lat": 21.0, "max_lng": 105.8, "max_lat": 21.0 },
"is_deleted": 0
}
],
"link_scopes": [{ "geometry_id": "g_1", "operation": "reference", "entity_ids": ["e_1"] }],
"wikis": [
{
"id": "w_inline_1",
"source": "inline",
"operation": "create",
"title": "Overview",
"doc": { "type": "doc", "content": [{ "type": "paragraph" }] }
},
{
"id": "019d...wiki_from_db",
"source": "ref",
"ref": { "id": "019d...wiki_from_db" },
"operation": "reference",
"title": "Existing Wiki (DB)",
"doc": null
}
],
"entity_wikis": [
{ "entity_id": "e_1", "wiki_id": "w_inline_1", "operation": "reference", "is_deleted": 0 }
]
}
```
## 6) Các điểm cần chốt khi muốn đi xa hơn với “ref”
Để `source:"ref"` thực sự “lấy từ bên ngoài snapshot”, cần thống nhất:
1. Wiki DB format:
- BackEndGo `wikis.content` hiện là `TEXT`, trong khi editor wiki dùng TipTap JSON (`doc`).
- Nếu muốn `ref` load content khi cần, phải chốt format lưu trữ (JSON string / HTML / Markdown).
2. Semantics `operation:"reference"`:
- `reference` được dùng theo nghĩa “đầu mối để nối (link)” và thường đi kèm `source:"ref"` (ref tới DB/global).
- Các bản ghi inline không thay đổi nên **không có `operation`** (không delta).

732
package-lock.json generated
View File

@@ -19,6 +19,9 @@
"@reduxjs/toolkit": "^2.11.2",
"@tailwindcss/forms": "^0.5.10",
"@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",
"autoprefixer": "^10.4.22",
"axios": "^1.14.0",
@@ -2893,6 +2896,16 @@
"node": ">=12.4.0"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@react-dnd/asap": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz",
@@ -2976,6 +2989,12 @@
}
}
},
"node_modules/@remirror/core-constants": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
"integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
"license": "MIT"
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -3602,6 +3621,410 @@
"tailwindcss": "4.2.2"
}
},
"node_modules/@tiptap/core": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.2.tgz",
"integrity": "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.27.2.tgz",
"integrity": "sha512-oIGZgiAeA4tG3YxbTDfrmENL4/CIwGuP3THtHsNhwRqwsl9SfMk58Ucopi2GXTQSdYXpRJ0ahE6nPqB5D6j/Zw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.27.2.tgz",
"integrity": "sha512-bR7J5IwjCGQ0s3CIxyMvOCnMFMzIvsc5OVZKscTN5UkXzFsaY6muUAIqtKxayBUucjtUskm5qZowJITCeCb1/A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-bubble-menu": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.27.2.tgz",
"integrity": "sha512-VkwlCOcr0abTBGzjPXklJ92FCowG7InU8+Od9FyApdLNmn0utRYGRhw0Zno6VgE9EYr1JY4BRnuSa5f9wlR72w==",
"license": "MIT",
"dependencies": {
"tippy.js": "^6.3.7"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.27.2.tgz",
"integrity": "sha512-gmFuKi97u5f8uFc/GQs+zmezjiulZmFiDYTh3trVoLRoc2SAHOjGEB7qxdx7dsqmMN7gwiAWAEVurLKIi1lnnw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-code": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.27.2.tgz",
"integrity": "sha512-7X9AgwqiIGXoZX7uvdHQsGsjILnN/JaEVtqfXZnPECzKGaWHeK/Ao4sYvIIIffsyZJA8k5DC7ny2/0sAgr2TuA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.2.tgz",
"integrity": "sha512-KgvdQHS4jXr79aU3wZOGBIZYYl9vCB7uDEuRFV4so2rYrfmiYMw3T8bTnlNEEGe4RUeAms1i4fdwwvQp9nR1Dw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-document": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.27.2.tgz",
"integrity": "sha512-CFhAYsPnyYnosDC4639sCJnBUnYH4Cat9qH5NZWHVvdgtDwu8GZgZn2eSzaKSYXWH1vJ9DSlCK+7UyC3SNXIBA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.27.2.tgz",
"integrity": "sha512-oEu/OrktNoQXq1x29NnH/GOIzQZm8ieTQl3FK27nxfBPA89cNoH4mFEUmBL5/OFIENIjiYG3qWpg6voIqzswNw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-floating-menu": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.27.2.tgz",
"integrity": "sha512-GUN6gPIGXS7ngRJOwdSmtBRBDt9Kt9CM/9pSwKebhLJ+honFoNA+Y6IpVyDvvDMdVNgBchiJLs6qA5H97gAePQ==",
"license": "MIT",
"dependencies": {
"tippy.js": "^6.3.7"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.27.2.tgz",
"integrity": "sha512-/c9VF1HBxj+AP54XGVgCmD9bEGYc5w5OofYCFQgM7l7PB1J00A4vOke0oPkHJnqnOOyPlFaxO/7N6l3XwFcnKA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.27.2.tgz",
"integrity": "sha512-kSRVGKlCYK6AGR0h8xRkk0WOFGXHIIndod3GKgWU49APuIGDiXd8sziXsSlniUsWmqgDmDXcNnSzPcV7AQ8YNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.27.2.tgz",
"integrity": "sha512-iM3yeRWuuQR/IRQ1djwNooJGfn9Jts9zF43qZIUf+U2NY8IlvdNsk2wTOdBgh6E0CamrStPxYGuln3ZS4fuglw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-history": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.27.2.tgz",
"integrity": "sha512-+hSyqERoFNTWPiZx4/FCyZ/0eFqB9fuMdTB4AC/q9iwu3RNWAQtlsJg5230bf/qmyO6bZxRUc0k8p4hrV6ybAw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.27.2.tgz",
"integrity": "sha512-WGWUSgX+jCsbtf9Y9OCUUgRZYuwjVoieW5n6mAUohJ9/6gc6sGIOrUpBShf+HHo6WD+gtQjRd+PssmX3NPWMpg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.27.2.tgz",
"integrity": "sha512-1OFsw2SZqfaqx5Fa5v90iNlPRcqyt+lVSjBwTDzuPxTPFY4Q0mL89mKgkq2gVHYNCiaRkXvFLDxaSvBWbmthgg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-link": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.27.2.tgz",
"integrity": "sha512-bnP61qkr0Kj9Cgnop1hxn2zbOCBzNtmawxr92bVTOE31fJv6FhtCnQiD6tuPQVGMYhcmAj7eihtvuEMFfqEPcQ==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.3.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.27.2.tgz",
"integrity": "sha512-eJNee7IEGXMnmygM5SdMGDC8m/lMWmwNGf9fPCK6xk0NxuQRgmZHL6uApKcdH6gyNcRPHCqvTTkhEP7pbny/fg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.27.2.tgz",
"integrity": "sha512-M7A4tLGJcLPYdLC4CI2Gwl8LOrENQW59u3cMVa+KkwG1hzSJyPsbDpa1DI6oXPC2WtYiTf22zrbq3gVvH+KA2w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.27.2.tgz",
"integrity": "sha512-elYVn2wHJJ+zB9LESENWOAfI4TNT0jqEN34sMA/hCtA4im1ZG2DdLHwkHIshj/c4H0dzQhmsS/YmNC5Vbqab/A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.27.2.tgz",
"integrity": "sha512-HHIjhafLhS2lHgfAsCwC1okqMsQzR4/mkGDm4M583Yftyjri1TNA7lzhzXWRFWiiMfJxKtdjHjUAQaHuteRTZw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-text": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.2.tgz",
"integrity": "sha512-Xk7nYcigljAY0GO9hAQpZ65ZCxqOqaAlTPDFcKerXmlkQZP/8ndx95OgUb1Xf63kmPOh3xypurGS2is3v0MXSA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-text-style": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz",
"integrity": "sha512-Omk+uxjJLyEY69KStpCw5fA9asvV+MGcAX2HOxyISDFoLaL49TMrNjhGAuz09P1L1b0KGXo4ml7Q3v/Lfy4WPA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/pm": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz",
"integrity": "sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1",
"prosemirror-commands": "^1.6.2",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
"prosemirror-inputrules": "^1.4.0",
"prosemirror-keymap": "^1.2.2",
"prosemirror-markdown": "^1.13.1",
"prosemirror-menu": "^1.2.4",
"prosemirror-model": "^1.23.0",
"prosemirror-schema-basic": "^1.2.3",
"prosemirror-schema-list": "^1.4.1",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.4",
"prosemirror-trailing-node": "^3.0.0",
"prosemirror-transform": "^1.10.2",
"prosemirror-view": "^1.37.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/react": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.27.2.tgz",
"integrity": "sha512-0EAs8Cpkfbvben1PZ34JN2Nd79Dhioynm2jML27DBbf1VWPk+FFWFGTMLUT0bu+Np5iVxio8fqV9t0mc4D6thA==",
"license": "MIT",
"dependencies": {
"@tiptap/extension-bubble-menu": "^2.27.2",
"@tiptap/extension-floating-menu": "^2.27.2",
"@types/use-sync-external-store": "^0.0.6",
"fast-deep-equal": "^3",
"use-sync-external-store": "^1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tiptap/starter-kit": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.27.2.tgz",
"integrity": "sha512-bb0gJvPoDuyRUQ/iuN52j1//EtWWttw+RXAv1uJxfR0uKf8X7uAqzaOOgwjknoCIDC97+1YHwpGdnRjpDkOBxw==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "^2.27.2",
"@tiptap/extension-blockquote": "^2.27.2",
"@tiptap/extension-bold": "^2.27.2",
"@tiptap/extension-bullet-list": "^2.27.2",
"@tiptap/extension-code": "^2.27.2",
"@tiptap/extension-code-block": "^2.27.2",
"@tiptap/extension-document": "^2.27.2",
"@tiptap/extension-dropcursor": "^2.27.2",
"@tiptap/extension-gapcursor": "^2.27.2",
"@tiptap/extension-hard-break": "^2.27.2",
"@tiptap/extension-heading": "^2.27.2",
"@tiptap/extension-history": "^2.27.2",
"@tiptap/extension-horizontal-rule": "^2.27.2",
"@tiptap/extension-italic": "^2.27.2",
"@tiptap/extension-list-item": "^2.27.2",
"@tiptap/extension-ordered-list": "^2.27.2",
"@tiptap/extension-paragraph": "^2.27.2",
"@tiptap/extension-strike": "^2.27.2",
"@tiptap/extension-text": "^2.27.2",
"@tiptap/extension-text-style": "^2.27.2",
"@tiptap/pm": "^2.27.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -3645,6 +4068,28 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
"license": "MIT"
},
"node_modules/@types/markdown-it": {
"version": "14.1.2",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
"license": "MIT",
"dependencies": {
"@types/linkify-it": "^5",
"@types/mdurl": "^2"
}
},
"node_modules/@types/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.19.37",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
@@ -4350,7 +4795,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/aria-query": {
@@ -4997,6 +5441,12 @@
}
}
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -5547,7 +5997,6 @@
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
@@ -5753,7 +6202,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -7658,6 +8106,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/linkifyjs": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz",
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
"license": "MIT"
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -7782,6 +8245,23 @@
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
}
},
"node_modules/markdown-it": {
"version": "14.1.1",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
"integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.4.0",
"linkify-it": "^5.0.0",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -7798,6 +8278,12 @@
"dev": true,
"license": "CC0-1.0"
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
"license": "MIT"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -8198,6 +8684,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/own-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
@@ -8436,6 +8928,204 @@
"react-is": "^16.13.1"
}
},
"node_modules/prosemirror-changeset": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
"integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
"license": "MIT",
"dependencies": {
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-collab": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz",
"integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-inputrules": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-markdown": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz",
"integrity": "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==",
"license": "MIT",
"dependencies": {
"@types/markdown-it": "^14.0.0",
"markdown-it": "^14.0.0",
"prosemirror-model": "^1.25.0"
}
},
"node_modules/prosemirror-menu": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.2.tgz",
"integrity": "sha512-6VgUJTYod0nMBlCaYJGhXGLu7Gt4AvcwcOq0YfJCY/6Uh+3S7UsWhpy6rJFCBFOmonq1hD8KyWOtZhkppd4YPg==",
"license": "MIT",
"dependencies": {
"crelt": "^1.0.0",
"prosemirror-commands": "^1.0.0",
"prosemirror-history": "^1.0.0",
"prosemirror-state": "^1.0.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.25.4",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT",
"peer": true,
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-basic": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz",
"integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.25.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.10.5",
"prosemirror-view": "^1.41.4"
}
},
"node_modules/prosemirror-trailing-node": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz",
"integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==",
"license": "MIT",
"dependencies": {
"@remirror/core-constants": "3.0.0",
"escape-string-regexp": "^4.0.0"
},
"peerDependencies": {
"prosemirror-model": "^1.22.1",
"prosemirror-state": "^1.4.2",
"prosemirror-view": "^1.33.8"
}
},
"node_modules/prosemirror-transform": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.41.8",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz",
"integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/protocol-buffers-schema": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
@@ -8461,6 +9151,15 @@
"node": ">=6"
}
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -8867,6 +9566,12 @@
"node": ">=0.10.0"
}
},
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -9576,6 +10281,15 @@
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
"license": "ISC"
},
"node_modules/tippy.js": {
"version": "6.3.7",
"resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz",
"integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==",
"license": "MIT",
"dependencies": {
"@popperjs/core": "^2.9.0"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -9764,6 +10478,12 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/unbox-primitive": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
@@ -9928,6 +10648,12 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/warning": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/warning/-/warning-3.0.0.tgz",

View File

@@ -20,6 +20,9 @@
"@reduxjs/toolkit": "^2.11.2",
"@tailwindcss/forms": "^0.5.10",
"@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",
"autoprefixer": "^10.4.22",
"axios": "^1.14.0",

View File

@@ -1,12 +1,15 @@
"use client";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useParams, useRouter } from "next/navigation";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import Map from "@/uhm/components/Map";
import Editor from "@/uhm/components/Editor";
import BackgroundLayersPanel from "@/uhm/components/BackgroundLayersPanel";
import TimelineBar from "@/uhm/components/TimelineBar";
import SelectedGeometryPanel from "@/uhm/components/SelectedGeometryPanel";
import WikiSidebarPanel from "@/uhm/components/WikiSidebarPanel";
import ProjectEntityRefsPanel from "@/uhm/components/ProjectEntityRefsPanel";
import EntityWikiBindingsPanel from "@/uhm/components/EntityWikiBindingsPanel";
import { Entity, fetchEntities, searchEntitiesByName } from "@/uhm/api/entities";
import { ApiError } from "@/uhm/api/http";
import { fetchCurrentUser } from "@/uhm/api/auth";
@@ -63,8 +66,11 @@ const DEFAULT_EDITOR_USER_ID = "local-editor";
export default function Page() {
const params = useParams();
const router = useRouter();
const searchParams = useSearchParams();
const projectId = String(params.id || "");
const openedProjectIdRef = useRef<string | null>(null);
const autoOpenWiki = searchParams.get("only") === "wiki";
const wikiOnly = autoOpenWiki;
const {
mode,
@@ -99,6 +105,8 @@ export default function Page() {
setLastSectionSnapshot,
persistedEntities,
setPersistedEntities,
projectEntityRefs,
setProjectEntityRefs,
pendingEntityCreates,
setPendingEntityCreates,
createdEntities,
@@ -137,6 +145,10 @@ export default function Page() {
setBackgroundVisibility,
isBackgroundVisibilityReady,
setIsBackgroundVisibilityReady,
wikis,
setWikis,
entityWikiLinks,
setEntityWikiLinks,
} = useEditorSessionState({
emptyFeatureCollection: EMPTY_FEATURE_COLLECTION,
defaultEditorUserId: DEFAULT_EDITOR_USER_ID,
@@ -154,6 +166,20 @@ export default function Page() {
() => mergeEntitiesWithPending(persistedEntities, pendingEntityCreates),
[persistedEntities, pendingEntityCreates]
);
const projectEntityChoices = useMemo(() => {
const ids = new Set<string>();
for (const ref of projectEntityRefs) ids.add(String(ref.id));
for (const feature of editor.draft.features) {
for (const id of normalizeFeatureEntityIds(feature)) ids.add(id);
}
const rows = Array.from(ids).map((id) => {
const found = entities.find((e) => e.id === id) || null;
return { id, name: found?.name || id };
});
rows.sort((a, b) => a.name.localeCompare(b.name));
return rows;
}, [editor.draft.features, entities, projectEntityRefs]);
const selectedFeature =
selectedFeatureId === null
? null
@@ -186,6 +212,17 @@ export default function Page() {
return rows;
}, [editor.changes, entities]);
const wikiDirty = useMemo(() => {
const prev = lastSectionSnapshot?.wikis || [];
try {
return JSON.stringify(prev) !== JSON.stringify(wikis);
} catch {
return true;
}
}, [lastSectionSnapshot?.wikis, wikis]);
const pendingSaveCount = editor.changeCount + pendingEntityCreates.length + (wikiDirty ? 1 : 0);
const sectionCommands = useSectionCommands({
editor,
editorUserId,
@@ -194,8 +231,11 @@ export default function Page() {
sectionState,
selectedSectionId,
newSectionTitle,
pendingSaveCount: editor.changeCount + pendingEntityCreates.length,
pendingSaveCount,
pendingEntityCreates,
projectEntityRefs,
wikis,
entityWikiLinks,
lastSectionSnapshot,
commitTitle,
commitNote,
@@ -206,7 +246,10 @@ export default function Page() {
setInitialData,
setSectionCommits,
setPendingEntityCreates,
setProjectEntityRefs,
setCreatedEntities,
setWikis,
setEntityWikiLinks,
setEntityFormStatus,
setSelectedFeatureId,
setEntityStatus,
@@ -659,7 +702,6 @@ export default function Page() {
}
};
const pendingSaveCount = editor.changeCount + pendingEntityCreates.length;
const headCommit = sectionState?.head_commit_id
? sectionCommits.find((commit) => commit.id === sectionState.head_commit_id) || null
: null;
@@ -705,6 +747,7 @@ export default function Page() {
createdGeometries={createdGeometries}
/>
{!wikiOnly ? (
<div style={{ flex: 1, position: "relative", minHeight: "100vh" }}>
{isBackgroundVisibilityReady ? (
<Map
@@ -728,6 +771,10 @@ export default function Page() {
statusText={timelineStatusText}
/>
</div>
) : (
// Wiki-only mode: avoid mounting Map/Timeline (WebGL + geometry fetching) to reduce lag.
<div style={{ flex: 1, minHeight: "100vh", background: "#0b1220" }} />
)}
<BackgroundLayersPanel
visibility={backgroundVisibility}
@@ -735,6 +782,16 @@ export default function Page() {
onShowAll={handleShowAllBackgroundLayers}
onHideAll={handleHideAllBackgroundLayers}
topContent={
<div style={{ display: "grid", gap: "12px" }}>
<WikiSidebarPanel
projectId={projectId}
wikis={wikis}
setWikis={setWikis}
autoOpen={autoOpenWiki}
/>
<ProjectEntityRefsPanel entityRefs={projectEntityRefs} setEntityRefs={setProjectEntityRefs} />
<EntityWikiBindingsPanel entities={projectEntityChoices} wikis={wikis} links={entityWikiLinks} setLinks={setEntityWikiLinks} />
{!wikiOnly ? (
<SelectedGeometryPanel
selectedFeature={selectedFeature}
selectedFeatureEntitySummary={
@@ -769,6 +826,8 @@ export default function Page() {
changeCount={editor.changeCount}
entityFormStatus={entityFormStatus}
/>
) : null}
</div>
}
/>
</div>

View File

@@ -311,6 +311,9 @@ export default function ProjectDetailsPage() {
<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>

View File

@@ -217,7 +217,7 @@ export default function ProjectsPage() {
</div>
</div>
<div className="flex items-center mt-4 md:mt-0 gap-10 w-[240px] 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"
@@ -225,6 +225,13 @@ export default function ProjectsPage() {
>
Editor
</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">
{project.members && project.members.length > 0 ? (

View 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>
);
}

View File

@@ -8,6 +8,7 @@ export const 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`,

30
src/uhm/api/wikis.ts Normal file
View File

@@ -0,0 +1,30 @@
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)}`);
}

View File

@@ -0,0 +1,166 @@
"use client";
import { 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 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]);
const activeLinks = useMemo(() => {
const set = new Set<string>();
for (const l of links || []) {
if (!l || l.entity_id !== activeEntityId) continue;
if (l.is_deleted) 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 next = [...prev];
const idx = next.findIndex((l) => l.entity_id === activeEntityId && l.wiki_id === id);
if (idx >= 0) {
const existing = next[idx];
const currentlyOn = !existing.is_deleted;
next[idx] = {
...existing,
operation: currentlyOn ? "delete" : "reference",
is_deleted: currentlyOn ? 1 : 0,
};
return next;
}
next.push({
entity_id: activeEntityId,
wiki_id: id,
operation: "reference",
is_deleted: 0,
});
return next;
});
};
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={{ fontSize: "12px", color: "#94a3b8" }}>{links.length}</div>
</div>
<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>
{!wikiChoices.length ? (
<div style={{ fontSize: "12px", color: "#94a3b8" }}>No wiki in project yet.</div>
) : !activeEntityId ? (
<div style={{ fontSize: "12px", color: "#94a3b8" }}>Pick an entity to bind wikis.</div>
) : (
<div style={{ display: "grid", gap: "6px" }}>
{wikiChoices.slice(0, 12).map((w) => {
const checked = activeLinks.has(w.id);
const isRefWiki = (wikis.find((x) => x.id === w.id)?.source || "inline") === "ref";
return (
<label
key={w.id}
style={{
display: "flex",
alignItems: "center",
gap: "8px",
padding: "8px",
borderRadius: "6px",
border: "1px solid #1f2937",
cursor: "pointer",
background: checked ? "#111827" : "transparent",
}}
title={w.id}
>
<input type="checkbox" checked={checked} onChange={() => toggle(w.id)} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: "#e5e7eb", fontSize: "12px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{w.title}
{isRefWiki ? " (ref)" : ""}
</div>
<div style={{ color: "#94a3b8", fontSize: "11px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{w.id}
</div>
</div>
</label>
);
})}
{wikiChoices.length > 12 ? (
<div style={{ fontSize: "12px", color: "#94a3b8" }}>+{wikiChoices.length - 12} more</div>
) : null}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -267,6 +267,8 @@ export default function Map({
maxZoom: MAP_MAX_ZOOM,
style: {
version: 8,
// Needed for symbol/text layers (country labels).
glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
sources: {
base: {
type: "vector",
@@ -339,6 +341,78 @@ export default function Map({
"line-opacity": 0.85,
},
},
{
id: "country-labels",
type: "symbol",
source: "base",
// New tiles build uses NaturalEarth label points layer name.
// If your tile pipeline exposes a different name, adjust here.
"source-layer": "ne_10m_admin_0_label_points",
minzoom: 0,
layout: {
"text-field": [
"coalesce",
["get", "sr_subunit"],
["get", "NAME_EN"],
["get", "NAME"],
["get", "ADMIN"],
["get", "name"],
"",
],
"text-size": [
"interpolate",
["linear"],
["zoom"],
0, 10,
3, 11,
6, 14,
],
"text-max-width": 10,
"text-allow-overlap": false,
"symbol-placement": "point",
},
paint: {
"text-color": "#e2e8f0",
"text-halo-color": "#0b1220",
"text-halo-width": 1.2,
"text-halo-blur": 0.5,
},
},
// Fallback for tile pipelines that expose country labels under a generic "labels" layer.
// Hidden/shown together with "country-labels" toggle.
{
id: "country-labels-alt",
type: "symbol",
source: "base",
"source-layer": "labels",
minzoom: 0,
layout: {
"text-field": [
"coalesce",
["get", "name"],
["get", "title"],
["get", "NAME"],
"",
],
"text-size": [
"interpolate",
["linear"],
["zoom"],
0, 10,
3, 11,
6, 14,
],
"text-max-width": 10,
"text-allow-overlap": false,
"symbol-placement": "point",
},
paint: {
"text-color": "#e2e8f0",
"text-halo-color": "#0b1220",
"text-halo-width": 1.2,
"text-halo-blur": 0.5,
},
},
{
id: "regions-line",
type: "line",
@@ -1112,6 +1186,15 @@ function applyBackgroundLayerVisibility(
visibility[layer.id] ? "visible" : "none"
);
}
// Keep fallback country label layer in sync with the primary toggle.
if (map.getLayer("country-labels-alt")) {
map.setLayoutProperty(
"country-labels-alt",
"visibility",
visibility["country-labels"] ? "visible" : "none"
);
}
}
function syncRasterBaseVisibility(map: maplibregl.Map, shouldShow: boolean) {

View File

@@ -0,0 +1,181 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import type { Entity } from "@/uhm/types/entities";
import type { EntitySnapshot } from "@/uhm/types/entities";
import { searchEntitiesByName } from "@/uhm/api/entities";
type Props = {
entityRefs: EntitySnapshot[];
setEntityRefs: React.Dispatch<React.SetStateAction<EntitySnapshot[]>>;
};
export default function ProjectEntityRefsPanel({ entityRefs, setEntityRefs }: Props) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<Entity[]>([]);
const [isSearching, setIsSearching] = useState(false);
const searchRequestRef = useState(() => ({ id: 0 }))[0];
const existingIds = useMemo(() => new Set(entityRefs.map((e) => String(e.id))), [entityRefs]);
useEffect(() => {
const keyword = query.trim();
if (!keyword.length) {
setResults([]);
setIsSearching(false);
return;
}
let disposed = false;
const requestId = ++searchRequestRef.id;
const t = window.setTimeout(async () => {
setIsSearching(true);
try {
const rows = await searchEntitiesByName(keyword, { limit: 20 });
if (disposed || requestId !== searchRequestRef.id) return;
setResults(rows);
} catch (err) {
if (disposed || requestId !== searchRequestRef.id) return;
console.error("Search entities failed", err);
setResults([]);
} finally {
if (disposed || requestId !== searchRequestRef.id) return;
setIsSearching(false);
}
}, 250);
return () => {
disposed = true;
window.clearTimeout(t);
};
}, [query, searchRequestRef]);
const addRef = (e: Entity) => {
const id = String(e.id || "").trim();
if (!id) return;
if (existingIds.has(id)) return;
setEntityRefs((prev) => [
{
id,
source: "ref",
ref: { id },
operation: "reference",
name: e.name,
description: e.description ?? null,
is_deleted: 0,
},
...prev,
]);
};
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={{ fontSize: "12px", color: "#94a3b8" }}>{entityRefs.length}</div>
</div>
<div style={{ marginTop: "10px" }}>
<div style={{ fontSize: "12px", color: "#94a3b8", marginBottom: "6px" }}>Add existing entity</div>
<input
value={query}
onChange={(ev) => setQuery(ev.target.value)}
placeholder="Search by name…"
style={{
width: "100%",
border: "1px solid #1f2937",
background: "#0b1220",
color: "#e5e7eb",
borderRadius: "6px",
padding: "8px 10px",
fontSize: "12px",
outline: "none",
}}
/>
{isSearching ? (
<div style={{ marginTop: "6px", fontSize: "12px", color: "#94a3b8" }}>Searching</div>
) : null}
{!isSearching && query.trim().length > 0 ? (
<div style={{ marginTop: "6px", display: "grid", gap: "6px" }}>
{results.slice(0, 8).map((r) => (
<div
key={r.id}
style={{
display: "flex",
alignItems: "center",
gap: "8px",
padding: "8px",
borderRadius: "6px",
border: "1px solid #1f2937",
background: "transparent",
opacity: existingIds.has(r.id) ? 0.55 : 1,
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: "#e5e7eb", fontSize: "12px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{r.name}
</div>
<div style={{ color: "#94a3b8", fontSize: "11px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{r.id}
</div>
</div>
<button
type="button"
onClick={() => addRef(r)}
disabled={existingIds.has(r.id)}
style={{
border: "none",
background: "#111827",
color: existingIds.has(r.id) ? "#64748b" : "#93c5fd",
cursor: existingIds.has(r.id) ? "not-allowed" : "pointer",
borderRadius: "6px",
padding: "6px 8px",
fontSize: "12px",
fontWeight: 700,
flex: "0 0 auto",
}}
>
Add
</button>
</div>
))}
{!results.length ? <div style={{ fontSize: "12px", color: "#94a3b8" }}>No results.</div> : null}
</div>
) : null}
</div>
{entityRefs.length ? (
<div style={{ marginTop: "10px", display: "grid", gap: "6px" }}>
{entityRefs.slice(0, 8).map((e) => (
<div
key={e.id}
style={{
padding: "8px",
borderRadius: "6px",
border: "1px solid #1f2937",
background: "transparent",
}}
>
<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>
</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>
)}
</div>
);
}

View File

@@ -0,0 +1,587 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { EditorContent, useEditor, type JSONContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import TiptapLink from "@tiptap/extension-link";
import { searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
import { Modal } from "@/components/ui/modal";
import Button from "@/components/ui/button/Button";
import Badge from "@/components/ui/badge/Badge";
import Label from "@/components/form/Label";
import type { WikiSnapshot } from "@/uhm/types/wiki";
type Props = {
projectId: string;
wikis: WikiSnapshot[];
setWikis: React.Dispatch<React.SetStateAction<WikiSnapshot[]>>;
autoOpen?: boolean;
};
function newId() {
try {
return crypto.randomUUID();
} catch {
return `wiki_${Date.now()}_${Math.random().toString(16).slice(2)}`;
}
}
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 }: Props) {
const [open, setOpen] = useState(false);
const [activeId, setActiveId] = useState<string | null>(null);
const activeWiki = useMemo(() => wikis.find((w) => w.id === activeId) || null, [activeId, wikis]);
const [wikiTitle, setWikiTitle] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<Wiki[]>([]);
const [isSearching, setIsSearching] = useState(false);
const searchRequestRef = useState(() => ({ id: 0 }))[0];
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
}),
TiptapLink.configure({
openOnClick: false,
autolink: true,
linkOnPaste: true,
}),
],
content: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "" }] }] },
editorProps: {
attributes: {
class: "tiptap-editor focus:outline-none min-h-[320px] px-4 py-3",
},
},
});
useEffect(() => {
if (!autoOpen) return;
// open once on mount
setOpen(true);
}, [autoOpen]);
// keep editor content in sync when switching wiki
useEffect(() => {
if (!editor) return;
if (!open) return;
const doc = (activeWiki?.doc || null) as JSONContent | null;
editor.commands.setContent(
(doc && typeof doc === "object" ? doc : { type: "doc", content: [{ type: "paragraph" }] }) as any
);
setWikiTitle(activeWiki?.title || "");
}, [activeWiki?.doc, activeWiki?.title, editor, 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]);
useEffect(() => {
const keyword = searchQuery.trim();
if (!keyword.length) {
setSearchResults([]);
setIsSearching(false);
return;
}
let disposed = false;
const requestId = ++searchRequestRef.id;
const t = window.setTimeout(async () => {
setIsSearching(true);
try {
const rows = await searchWikisByTitle(keyword, { limit: 12 });
if (disposed || requestId !== searchRequestRef.id) return;
setSearchResults(rows);
} catch (err) {
if (disposed || requestId !== searchRequestRef.id) return;
console.error("Search wikis failed", err);
setSearchResults([]);
} finally {
if (disposed || requestId !== searchRequestRef.id) return;
setIsSearching(false);
}
}, 250);
return () => {
disposed = true;
window.clearTimeout(t);
};
}, [searchQuery, searchRequestRef]);
const addWikiRef = (wiki: Wiki) => {
const id = String(wiki.id || "").trim();
if (!id) return;
if (wikis.some((w) => w.id === id)) {
setActiveId(id);
return;
}
const title = (wiki.title || "").trim() || "Untitled wiki";
setWikis((prev) => [
{
id,
source: "ref",
ref: { id },
operation: "reference",
title,
doc: null,
updated_at: wiki.updated_at,
},
...prev,
]);
setActiveId(id);
};
const openEditor = () => {
if (!wikis.length) {
const id = newId();
const seed: WikiSnapshot = {
id,
source: "inline",
operation: "create",
title: "Untitled wiki",
doc: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "" }] }] },
updated_at: new Date().toISOString(),
};
setWikis((prev) => [seed, ...prev]);
setActiveId(id);
}
setOpen(true);
};
const createWiki = () => {
const id = newId();
const next: WikiSnapshot = {
id,
source: "inline",
operation: "create",
title: "Untitled wiki",
doc: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "" }] }] },
updated_at: new Date().toISOString(),
};
setWikis((prev) => [next, ...prev]);
setActiveId(id);
setOpen(true);
};
const removeWiki = (id: string) => {
setWikis((prev) => prev.filter((w) => w.id !== id));
if (activeId === id) setActiveId(null);
};
const saveWiki = () => {
if (!editor || !activeId) return;
const payload = editor.getJSON();
const nextTitle = clampTitle(wikiTitle);
setWikis((prev) =>
prev.map((w) =>
w.id !== activeId
? w
: {
...w,
source: w.source || "inline",
operation: w.operation === "create" ? "create" : "update",
title: nextTitle,
doc: payload,
updated_at: new Date().toISOString(),
}
)
);
setOpen(false);
};
const setLink = () => {
if (!editor) return;
const prev = editor.getAttributes("link")?.href as string | undefined;
const href = window.prompt("Link URL", prev || "https://");
if (href == null) return;
const next = href.trim();
if (!next.length) {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
return;
}
editor.chain().focus().extendMarkRange("link").setLink({ href: next }).run();
};
return (
<div
style={{
padding: "10px",
background: "#0b1220",
borderRadius: "8px",
border: "1px solid #1f2937",
}}
>
<style jsx global>{`
.tiptap-editor p {
margin: 0.5rem 0;
line-height: 1.65;
font-size: 0.95rem;
}
.tiptap-editor h1 {
margin: 1rem 0 0.5rem;
font-size: 1.5rem;
font-weight: 800;
line-height: 1.25;
}
.tiptap-editor h2 {
margin: 0.9rem 0 0.4rem;
font-size: 1.25rem;
font-weight: 700;
line-height: 1.3;
}
.tiptap-editor h3 {
margin: 0.8rem 0 0.35rem;
font-size: 1.1rem;
font-weight: 700;
line-height: 1.35;
}
.tiptap-editor ul,
.tiptap-editor ol {
margin: 0.6rem 0;
padding-left: 1.25rem;
}
.tiptap-editor li {
margin: 0.2rem 0;
}
.tiptap-editor blockquote {
margin: 0.75rem 0;
padding-left: 0.75rem;
border-left: 4px solid rgba(148, 163, 184, 0.55);
color: rgba(100, 116, 139, 1);
}
.dark .tiptap-editor blockquote {
border-left-color: rgba(71, 85, 105, 1);
color: rgba(148, 163, 184, 1);
}
.tiptap-editor code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
"Liberation Mono", "Courier New", monospace;
font-size: 0.85em;
padding: 0.1rem 0.25rem;
border-radius: 0.35rem;
background: rgba(148, 163, 184, 0.15);
}
.tiptap-editor pre {
margin: 0.8rem 0;
padding: 0.9rem 1rem;
border-radius: 0.75rem;
border: 1px solid rgba(226, 232, 240, 1);
background: rgba(248, 250, 252, 1);
overflow: auto;
}
.dark .tiptap-editor pre {
border-color: rgba(30, 41, 59, 1);
background: rgba(13, 17, 23, 1);
}
.tiptap-editor pre code {
background: transparent;
padding: 0;
}
.tiptap-editor a {
text-decoration: underline;
text-underline-offset: 2px;
}
`}</style>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
<div style={{ fontWeight: 700, fontSize: "14px" }}>Wiki</div>
<Badge size="sm" variant="light" color="info">
{wikis.length}
</Badge>
</div>
<div style={{ display: "flex", gap: "8px", marginTop: "10px" }}>
<button
type="button"
onClick={openEditor}
style={{
flex: 1,
border: "none",
borderRadius: "6px",
padding: "8px",
cursor: "pointer",
background: "#2563eb",
color: "white",
fontWeight: 700,
}}
>
Open wiki editor
</button>
<button
type="button"
onClick={createWiki}
title="New wiki"
style={{
width: "42px",
border: "none",
borderRadius: "6px",
padding: "8px",
cursor: "pointer",
background: "#1f2937",
color: "white",
fontWeight: 900,
}}
>
+
</button>
</div>
<div style={{ marginTop: "10px" }}>
<div style={{ fontSize: "12px", color: "#94a3b8", marginBottom: "6px" }}>Add existing wiki</div>
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search by title…"
style={{
width: "100%",
border: "1px solid #1f2937",
background: "#0b1220",
color: "#e5e7eb",
borderRadius: "6px",
padding: "8px 10px",
fontSize: "12px",
outline: "none",
}}
/>
{isSearching ? (
<div style={{ marginTop: "6px", fontSize: "12px", color: "#94a3b8" }}>Searching</div>
) : null}
{!isSearching && searchQuery.trim().length > 0 ? (
<div style={{ marginTop: "6px", display: "grid", gap: "6px" }}>
{searchResults.slice(0, 8).map((w) => (
<div
key={w.id}
style={{
display: "flex",
alignItems: "center",
gap: "8px",
padding: "8px",
borderRadius: "6px",
border: "1px solid #1f2937",
background: "transparent",
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: "#e5e7eb", fontSize: "12px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{(w.title || "").trim() || "Untitled wiki"}
</div>
<div style={{ color: "#94a3b8", fontSize: "11px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{w.id}
</div>
</div>
<button
type="button"
onClick={() => addWikiRef(w)}
style={{
border: "none",
background: "#111827",
color: "#93c5fd",
cursor: "pointer",
borderRadius: "6px",
padding: "6px 8px",
fontSize: "12px",
fontWeight: 700,
flex: "0 0 auto",
}}
>
Add
</button>
</div>
))}
{!searchResults.length ? (
<div style={{ fontSize: "12px", color: "#94a3b8" }}>No results.</div>
) : null}
</div>
) : null}
</div>
{wikis.length ? (
<div style={{ marginTop: "10px", display: "grid", gap: "8px" }}>
{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: w.id === activeId ? "#111827" : "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>
)}
<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={!editor || !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={createWiki}>
+ 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 className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleBold().run()} disabled={!editor}>
B
</Button>
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleItalic().run()} disabled={!editor}>
I
</Button>
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleHeading({ level: 1 }).run()} disabled={!editor}>
H1
</Button>
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()} disabled={!editor}>
H2
</Button>
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()} disabled={!editor}>
H3
</Button>
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleBulletList().run()} disabled={!editor}>
Bullets
</Button>
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleOrderedList().run()} disabled={!editor}>
Numbers
</Button>
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleBlockquote().run()} disabled={!editor}>
Quote
</Button>
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleCodeBlock().run()} disabled={!editor}>
Code
</Button>
<Button size="sm" variant="outline" onClick={setLink} disabled={!editor}>
Link
</Button>
</div>
<div className="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-[#0d1117]">
{editor ? <EditorContent editor={editor} /> : <div className="p-4 text-sm text-gray-500">Loading editor...</div>}
</div>
</div>
</div>
</div>
<div 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>
);
}

View File

@@ -4,6 +4,7 @@ export const BACKGROUND_LAYER_OPTIONS = [
{ 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" },

View File

@@ -14,7 +14,9 @@ import { buildEditorSnapshot, normalizeEditorSnapshot } from "@/uhm/lib/editor/s
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
import type { CreatedEntitySummary, PendingEntityCreate } from "@/uhm/lib/editor/session/sessionTypes";
import type { Feature, FeatureCollection, FeatureId } from "@/uhm/types/geo";
import type { EditorSnapshot, Section, SectionCommit, SectionState } from "@/uhm/types/sections";
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;
@@ -33,6 +35,9 @@ type Options = {
newSectionTitle: string;
pendingSaveCount: number;
pendingEntityCreates: PendingEntityCreate[];
projectEntityRefs: EntitySnapshot[];
wikis: WikiSnapshot[];
entityWikiLinks: EntityWikiLinkSnapshot[];
lastSectionSnapshot: EditorSnapshot | null;
commitTitle: string;
commitNote: string;
@@ -43,7 +48,10 @@ type Options = {
setInitialData: Dispatch<SetStateAction<FeatureCollection>>;
setSectionCommits: Dispatch<SetStateAction<SectionCommit[]>>;
setPendingEntityCreates: Dispatch<SetStateAction<PendingEntityCreate[]>>;
setProjectEntityRefs: Dispatch<SetStateAction<EntitySnapshot[]>>;
setCreatedEntities: Dispatch<SetStateAction<CreatedEntitySummary[]>>;
setWikis: Dispatch<SetStateAction<WikiSnapshot[]>>;
setEntityWikiLinks: Dispatch<SetStateAction<EntityWikiLinkSnapshot[]>>;
setSelectedFeatureId: Dispatch<SetStateAction<FeatureId | null>>;
setEntityFormStatus: Dispatch<SetStateAction<string | null>>;
setEntityStatus: Dispatch<SetStateAction<string | null>>;
@@ -71,6 +79,9 @@ export function useSectionCommands(options: Options) {
options.setSectionCommits(commits);
options.setPendingEntityCreates([]);
options.setCreatedEntities([]);
options.setProjectEntityRefs((snapshot?.entities || []).filter((e) => e?.operation === "reference"));
options.setWikis(snapshot?.wikis || []);
options.setEntityWikiLinks(snapshot?.entity_wikis || []);
options.setSelectedFeatureId(null);
options.setEntityFormStatus(null);
}, [options]);
@@ -90,6 +101,9 @@ export function useSectionCommands(options: Options) {
draft: options.editor.draft,
changes: geometryChanges,
pendingEntities: options.pendingEntityCreates,
projectEntityRefs: options.projectEntityRefs,
wikis: options.wikis,
entityWikiLinks: options.entityWikiLinks,
previousSnapshot: options.lastSectionSnapshot,
hasPersistedFeature: options.editor.hasPersistedFeature,
});
@@ -233,6 +247,7 @@ export function useSectionCommands(options: Options) {
if (snapshot?.editor_feature_collection) {
options.setInitialData(snapshot.editor_feature_collection);
}
options.setWikis(snapshot?.wikis || []);
options.setSectionCommits(await fetchSectionCommits(options.activeSection.id));
options.setEntityFormStatus("Đã restore commit.");
} catch (err) {

View File

@@ -1,5 +1,6 @@
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 { DEFAULT_ENTITY_TYPE_ID } from "@/uhm/lib/entityTypeOptions";
import type {
@@ -12,6 +13,8 @@ import type {
export function useEntitySessionState() {
// Entities đã persisted từ backend (dùng cho search/binding).
const [persistedEntities, setPersistedEntities] = useState<Entity[]>([]);
// Entities được "pin" vào project dưới dạng reference (không cần chọn geometry).
const [projectEntityRefs, setProjectEntityRefs] = useState<EntitySnapshot[]>([]);
// Entities tạo mới trong phiên nhưng chưa commit lên backend.
const [pendingEntityCreates, setPendingEntityCreates] = useState<PendingEntityCreate[]>([]);
// Tóm tắt entities đã tạo (để hiển thị nhanh ở sidebar).
@@ -50,6 +53,8 @@ export function useEntitySessionState() {
return {
persistedEntities,
setPersistedEntities,
projectEntityRefs,
setProjectEntityRefs,
pendingEntityCreates,
setPendingEntityCreates,
createdEntities,

View 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 [wikis, setWikis] = useState<WikiSnapshot[]>([]);
const [entityWikiLinks, setEntityWikiLinks] = useState<EntityWikiLinkSnapshot[]>([]);
return { wikis, setWikis, entityWikiLinks, setEntityWikiLinks };
}

View File

@@ -4,6 +4,8 @@ import type { PendingEntityCreate } from "@/uhm/lib/editor/session/sessionTypes"
import type { EntitySnapshot } from "@/uhm/types/entities";
import type { Feature, FeatureCollection, GeometrySnapshot, LinkScopeSnapshot } 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";
export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
@@ -26,6 +28,9 @@ export function buildEditorSnapshot(options: {
draft: FeatureCollection;
changes: Change[];
pendingEntities: PendingEntityCreate[];
projectEntityRefs: EntitySnapshot[];
wikis: WikiSnapshot[];
entityWikiLinks: EntityWikiLinkSnapshot[];
previousSnapshot: EditorSnapshot | null;
hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean;
}): EditorSnapshot {
@@ -55,13 +60,10 @@ export function buildEditorSnapshot(options: {
const pendingEntityIds = new Set(options.pendingEntities.map((entity) => entity.id));
const entityRows = new globalThis.Map<string, EntitySnapshot>();
for (const item of options.previousSnapshot?.entities || []) {
const id = typeof item.id === "string" || typeof item.id === "number" ? String(item.id) : "";
if (id) entityRows.set(id, { ...item });
}
for (const entity of options.pendingEntities) {
entityRows.set(entity.id, {
id: entity.id,
source: "inline",
operation: "create",
name: entity.name,
slug: entity.slug,
@@ -72,11 +74,40 @@ export function buildEditorSnapshot(options: {
});
}
for (const ref of options.projectEntityRefs || []) {
const id = typeof ref?.id === "string" || typeof ref?.id === "number" ? String(ref.id) : "";
if (!id || entityRows.has(id)) continue;
const cloned = JSON.parse(JSON.stringify(ref)) as EntitySnapshot;
entityRows.set(id, {
...cloned,
id,
source: cloned.source || "ref",
ref: cloned.ref || { id },
operation: "reference",
is_deleted: cloned.is_deleted ?? 0,
});
}
// Entities referenced by wiki links should be present as "reference" too.
for (const link of options.entityWikiLinks || []) {
const id = typeof link?.entity_id === "string" ? link.entity_id : "";
if (!id || entityRows.has(id)) continue;
entityRows.set(id, {
id,
source: "ref",
ref: { id },
operation: "reference",
is_deleted: 0,
});
}
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",
ref: { id: entityId },
operation: "reference",
name: feature.properties.entity_names?.[0] || feature.properties.entity_name || entityId,
slug: null,
@@ -95,17 +126,19 @@ export function buildEditorSnapshot(options: {
const changedFromPreviousSnapshot = previousFeature
? JSON.stringify(previousFeature) !== JSON.stringify(feature)
: false;
const operation: GeometrySnapshot["operation"] = previousOperation === "create"
const operation: GeometrySnapshot["operation"] =
previousOperation === "create"
? "create"
: !previousFeature && (options.previousSnapshot || !options.hasPersistedFeature(feature.properties.id))
? "create"
: changedIds.has(id) || changedFromPreviousSnapshot
? "update"
: "reference";
: undefined;
const bbox = getFeatureBBox(feature);
return {
id,
operation,
source: "inline",
type: feature.properties.type || getDefaultTypeIdForFeature(feature),
draw_geometry: feature.geometry,
binding: normalizeFeatureBindingIds(feature),
@@ -134,11 +167,63 @@ export function buildEditorSnapshot(options: {
const linkScopes: LinkScopeSnapshot[] = options.draft.features
.map((feature) => ({
geometry_id: String(feature.properties.id),
operation: "replace" as const,
operation: "reference" as const,
entity_ids: normalizeFeatureEntityIds(feature),
}))
.filter((scope) => scope.entity_ids.length > 0);
const previousWikis = new globalThis.Map<string, WikiSnapshot>();
for (const item of options.previousSnapshot?.wikis || []) {
if (!item || typeof item !== "object") continue;
const id = typeof (item as any).id === "string" ? String((item as any).id) : "";
if (id) 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 wikis: WikiSnapshot[] = (options.wikis || [])
.filter((w) => w && typeof w.id === "string" && w.id.trim().length > 0)
.map((w) => {
const prev = previousWikis.get(w.id) || null;
const cloned = JSON.parse(JSON.stringify(w)) as WikiSnapshot;
cloned.source = cloned.source || "inline";
// Ref wiki: always mark as reference (used for linking, not changed here).
if (cloned.source === "ref") {
cloned.ref = cloned.ref || { id: cloned.id };
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: mark update only if changed; otherwise omit operation.
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 as any).title, doc: (prev as any).doc };
const nextComparable = { title: (cloned as any).title, doc: (cloned as any).doc };
return JSON.stringify(prevComparable) !== JSON.stringify(nextComparable);
} catch {
return true;
}
})();
cloned.operation = changed ? "update" : undefined;
return cloned;
});
return {
schema_version: 1,
section: {
@@ -153,6 +238,8 @@ export function buildEditorSnapshot(options: {
}),
geometries,
link_scopes: linkScopes,
wikis,
entity_wikis: JSON.parse(JSON.stringify(options.entityWikiLinks || [])) as EntityWikiLinkSnapshot[],
};
}

View File

@@ -4,6 +4,7 @@ import { useBackgroundSessionState } from "@/uhm/lib/editor/session/useBackgroun
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 {
@@ -37,6 +38,7 @@ export function useEditorSessionState(options: Options) {
fallbackTimelineRange: options.fallbackTimelineRange,
});
const background = useBackgroundSessionState();
const wiki = useWikiSessionState();
return {
mode,
@@ -47,5 +49,6 @@ export function useEditorSessionState(options: Options) {
...entity,
...timeline,
...background,
...wiki,
};
}

View File

@@ -15,11 +15,19 @@ export type Entity = {
geometry_count?: number;
};
export type EntitySnapshotOperation = "create" | "update" | "delete" | "reference" | "replace";
export type EntitySnapshotOperation = "create" | "update" | "delete" | "reference";
export type EntitySnapshot = {
id: string;
operation: EntitySnapshotOperation;
// Where this entity's data comes from.
// - inline: data is embedded in snapshot_json
// - ref: data should be fetched externally by ref.id (DB/global)
source?: "inline" | "ref";
ref?: { id: string };
// Delta semantics for this commit:
// - create/update/delete: this commit modifies the entity record
// - reference: this entity is referenced/linked (e.g., geometry<->entity, entity<->wiki) but not modified
operation?: EntitySnapshotOperation;
name?: string;
slug?: string | null;
description?: string | null;

View File

@@ -35,11 +35,13 @@ export type FeatureCollection = {
features: Feature[];
};
export type GeometrySnapshotOperation = "create" | "update" | "delete" | "reference" | "replace";
export type GeometrySnapshotOperation = "create" | "update" | "delete" | "reference";
export type GeometrySnapshot = {
id: string;
operation: GeometrySnapshotOperation;
source?: "inline" | "ref";
ref?: { id: string };
operation?: GeometrySnapshotOperation;
type?: string | null;
draw_geometry?: Geometry;
geometry?: Geometry;
@@ -59,7 +61,8 @@ export type GeometrySnapshot = {
export type LinkScopeSnapshot = {
geometry_id: string;
operation: "replace" | "reference";
// Link deltas should be represented as "reference" operations (no replace in the current flow).
operation: "reference";
entity_ids: string[];
base_links_hash?: string;
};

View File

@@ -1,5 +1,13 @@
import type { EntitySnapshot } from "@/uhm/types/entities";
import type { FeatureCollection, GeometrySnapshot, LinkScopeSnapshot } from "@/uhm/types/geo";
import type { WikiSnapshot } from "@/uhm/types/wiki";
export type EntityWikiLinkSnapshot = {
entity_id: string;
wiki_id: string;
operation?: "reference" | "delete";
is_deleted?: number;
};
// API mới (BackEndGo) dùng Projects/Commits/Submissions.
// Giữ tên type "Section" để tránh thay đổi lan rộng trong FE hiện tại.
@@ -62,6 +70,8 @@ export type EditorSnapshot = {
entities?: EntitySnapshot[];
geometries?: GeometrySnapshot[];
link_scopes?: LinkScopeSnapshot[];
wikis?: WikiSnapshot[];
entity_wikis?: EntityWikiLinkSnapshot[];
};
export type EditorLoadResponse = {

16
src/uhm/types/wiki.ts Normal file
View File

@@ -0,0 +1,16 @@
export type WikiDoc = unknown;
export type WikiSnapshotOperation = "create" | "update" | "delete" | "reference";
export type WikiSnapshot = {
id: string;
source?: "inline" | "ref";
ref?: { id: string };
// Optional for backwards-compat with older commits. New commits should include it.
operation?: WikiSnapshotOperation;
title: string;
doc: WikiDoc;
updated_at?: string;
// Optional, used when representing a delete operation row.
is_deleted?: number;
};