From 12c351c68a1fdd029fac5815d003513cb86198a2 Mon Sep 17 00:00:00 2001 From: taDuc Date: Sat, 2 May 2026 21:13:29 +0700 Subject: [PATCH] pre view wiki --- commit_snapshot.md | 378 +++++++++ package-lock.json | 732 +++++++++++++++++- package.json | 3 + src/app/editor/[id]/page.tsx | 177 +++-- src/app/user/projects/[id]/page.tsx | 3 + src/app/user/projects/page.tsx | 9 +- src/app/user/wikieditor/page.tsx | 554 +++++++++++++ src/uhm/api/config.ts | 1 + src/uhm/api/wikis.ts | 30 + .../components/EntityWikiBindingsPanel.tsx | 166 ++++ src/uhm/components/Map.tsx | 157 +++- src/uhm/components/ProjectEntityRefsPanel.tsx | 181 +++++ src/uhm/components/WikiSidebarPanel.tsx | 587 ++++++++++++++ src/uhm/lib/backgroundLayers.ts | 1 + .../lib/editor/section/useSectionCommands.ts | 17 +- .../editor/session/useEntitySessionState.ts | 5 + .../lib/editor/session/useWikiSessionState.ts | 9 + src/uhm/lib/editor/snapshot/editorSnapshot.ts | 109 ++- src/uhm/lib/useEditorSessionState.ts | 3 + src/uhm/types/entities.ts | 12 +- src/uhm/types/geo.ts | 9 +- src/uhm/types/sections.ts | 10 + src/uhm/types/wiki.ts | 16 + 23 files changed, 3052 insertions(+), 117 deletions(-) create mode 100644 commit_snapshot.md create mode 100644 src/app/user/wikieditor/page.tsx create mode 100644 src/uhm/api/wikis.ts create mode 100644 src/uhm/components/EntityWikiBindingsPanel.tsx create mode 100644 src/uhm/components/ProjectEntityRefsPanel.tsx create mode 100644 src/uhm/components/WikiSidebarPanel.tsx create mode 100644 src/uhm/lib/editor/session/useWikiSessionState.ts create mode 100644 src/uhm/types/wiki.ts diff --git a/commit_snapshot.md b/commit_snapshot.md new file mode 100644 index 0000000..e997c35 --- /dev/null +++ b/commit_snapshot.md @@ -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). diff --git a/package-lock.json b/package-lock.json index ff183d9..645c1f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 950118c..4eb290e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index bf861d8..c01f488 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -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(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(); + 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,29 +747,34 @@ export default function Page() { createdGeometries={createdGeometries} /> -
- {isBackgroundVisibilityReady ? ( - + {isBackgroundVisibilityReady ? ( + + ) : ( +
+ )} + - ) : ( -
- )} - -
+
+ ) : ( + // Wiki-only mode: avoid mounting Map/Timeline (WebGL + geometry fetching) to reduce lag. +
+ )} +
+ + + + {!wikiOnly ? ( + + ) : null} +
} />
diff --git a/src/app/user/projects/[id]/page.tsx b/src/app/user/projects/[id]/page.tsx index 5c2bc7d..4728c2e 100644 --- a/src/app/user/projects/[id]/page.tsx +++ b/src/app/user/projects/[id]/page.tsx @@ -311,6 +311,9 @@ export default function ProjectDetailsPage() { +
diff --git a/src/app/user/projects/page.tsx b/src/app/user/projects/page.tsx index 2f4553e..184c7fc 100644 --- a/src/app/user/projects/page.tsx +++ b/src/app/user/projects/page.tsx @@ -217,7 +217,7 @@ export default function ProjectsPage() { -
+
+
{project.members && project.members.length > 0 ? ( diff --git a/src/app/user/wikieditor/page.tsx b/src/app/user/wikieditor/page.tsx new file mode 100644 index 0000000..419b9f4 --- /dev/null +++ b/src/app/user/wikieditor/page.tsx @@ -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(); + + 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 = {el}; + else if (m.type === "italic") el = {el}; + else if (m.type === "link") { + const href = String(m.attrs?.href || "#"); + el = ( + + {el} + + ); + } + } + + return {el}; +} + +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 ( +

+ {content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))} +

+ ); + } + + 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 ( +
+
+ {content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))} +
+
+ ); + } + + if (type === "bulletList") { + return ( +
    + {content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))} +
+ ); + } + + if (type === "orderedList") { + return ( +
    + {content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))} +
+ ); + } + + if (type === "listItem") { + return
  • {content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))}
  • ; + } + + if (type === "blockquote") { + return ( +
    + {content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))} +
    + ); + } + + if (type === "codeBlock") { + const code = content.map(textFromNode).join(""); + return ( +
    +        {code}
    +      
    + ); + } + + if (type === "hardBreak") return
    ; + + if (type === "text") return renderInlineText(node, keyPrefix); + + // fallback: render children + return {content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))}; +} + +type ViewMode = "edit" | "split" | "preview"; + +export default function WikiEditorPage() { + const [view, setView] = useState("split"); + const [showJson, setShowJson] = useState(false); + const [title, setTitle] = useState("Untitled wiki"); + const [docJson, setDocJson] = useState(null); + const [savedAt, setSavedAt] = useState(null); + const [isDirty, setIsDirty] = useState(false); + const saveTimerRef = useRef(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 ( +
    + + + + +
    + +
    +
    + + { + 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" + /> +
    + +
    +
    + + TipTap + + {isDirty ? ( + + Unsaved + + ) : ( + + Saved + + )} +
    + +
    + +
    + Last save: {savedAt || "-"} +
    + +
    +
    TOC
    + {toc.length === 0 ? ( +
    No headings
    + ) : ( +
    + {toc.map((t) => ( + + {t.text} + + ))} +
    + )} +
    + +
    + + +
    +
    +
    + +
    + +
    +
    + + + + +
    + + + + + + + + + + + + + +
    + +
    + {view !== "preview" ? ( +
    + {editor ? :
    Loading editor...
    } +
    + ) : null} + + {view !== "edit" ? ( +
    +
    + Preview +
    + {renderDoc(docJson, "p", toc)} +
    + ) : null} +
    +
    + + + {showJson ? ( + +
    +
    +                  {JSON.stringify({ title: title.trim() || "Untitled wiki", doc: docJson }, null, 2)}
    +                
    +
    +
    + ) : null} +
    +
    +
    + ); +} diff --git a/src/uhm/api/config.ts b/src/uhm/api/config.ts index 33021a2..06e9a29 100644 --- a/src/uhm/api/config.ts +++ b/src/uhm/api/config.ts @@ -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`, diff --git a/src/uhm/api/wikis.ts b/src/uhm/api/wikis.ts new file mode 100644 index 0000000..6e436c7 --- /dev/null +++ b/src/uhm/api/wikis.ts @@ -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 { + 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(`${API_ENDPOINTS.wikis}?${params.toString()}`); +} + +export async function fetchWikiById(id: string): Promise { + const wikiId = String(id || "").trim(); + if (!wikiId) throw new Error("Missing wiki id"); + return requestJson(`${API_ENDPOINTS.wikis}/${encodeURIComponent(wikiId)}`); +} + diff --git a/src/uhm/components/EntityWikiBindingsPanel.tsx b/src/uhm/components/EntityWikiBindingsPanel.tsx new file mode 100644 index 0000000..df82ba3 --- /dev/null +++ b/src/uhm/components/EntityWikiBindingsPanel.tsx @@ -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>; +}; + +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(""); + + 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(); + 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 ( +
    +
    +
    Entity ↔ Wiki
    +
    {links.length}
    +
    + +
    +
    +
    Entity
    + +
    + +
    +
    Wikis
    + {!wikiChoices.length ? ( +
    No wiki in project yet.
    + ) : !activeEntityId ? ( +
    Pick an entity to bind wikis.
    + ) : ( +
    + {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 ( + + ); + })} + {wikiChoices.length > 12 ? ( +
    +{wikiChoices.length - 12} more…
    + ) : null} +
    + )} +
    +
    +
    + ); +} diff --git a/src/uhm/components/Map.tsx b/src/uhm/components/Map.tsx index 0c1b496..abc0cba 100644 --- a/src/uhm/components/Map.tsx +++ b/src/uhm/components/Map.tsx @@ -260,22 +260,24 @@ export default function Map({ const container = containerRef.current; if (!container) return; - const map = new maplibregl.Map({ - container, - attributionControl: false, - minZoom: MAP_MIN_ZOOM, - maxZoom: MAP_MAX_ZOOM, - style: { - version: 8, - sources: { - base: { - type: "vector", - tiles: [getVectorTileTemplateUrl()], - minzoom: 0, - maxzoom: 6, + const map = new maplibregl.Map({ + container, + attributionControl: false, + minZoom: MAP_MIN_ZOOM, + 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", + tiles: [getVectorTileTemplateUrl()], + minzoom: 0, + maxzoom: 6, + }, }, - }, - layers: [ + layers: [ { id: "background", type: "background", @@ -321,29 +323,101 @@ export default function Map({ "fill-opacity": 0.38, }, }, - { - id: "bg-country-borders-line", - type: "line", - source: "base", - "source-layer": "country_borders", - paint: { - "line-color": "#cbd5e1", - "line-width": [ - "interpolate", - ["linear"], - ["zoom"], - 0, 0.2, - 4, 0.5, - 6, 1.1, - ], - "line-opacity": 0.85, + { + id: "bg-country-borders-line", + type: "line", + source: "base", + "source-layer": "country_borders", + paint: { + "line-color": "#cbd5e1", + "line-width": [ + "interpolate", + ["linear"], + ["zoom"], + 0, 0.2, + 4, 0.5, + 6, 1.1, + ], + "line-opacity": 0.85, + }, }, - }, - { - id: "regions-line", - type: "line", - source: "base", - "source-layer": "regions", + { + 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", + source: "base", + "source-layer": "regions", paint: { "line-color": "#475569", "line-width": [ @@ -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) { diff --git a/src/uhm/components/ProjectEntityRefsPanel.tsx b/src/uhm/components/ProjectEntityRefsPanel.tsx new file mode 100644 index 0000000..75d8007 --- /dev/null +++ b/src/uhm/components/ProjectEntityRefsPanel.tsx @@ -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>; +}; + +export default function ProjectEntityRefsPanel({ entityRefs, setEntityRefs }: Props) { + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + 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 ( +
    +
    +
    Entities
    +
    {entityRefs.length}
    +
    + +
    +
    Add existing entity
    + 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 ? ( +
    Searching…
    + ) : null} + {!isSearching && query.trim().length > 0 ? ( +
    + {results.slice(0, 8).map((r) => ( +
    +
    +
    + {r.name} +
    +
    + {r.id} +
    +
    + +
    + ))} + {!results.length ?
    No results.
    : null} +
    + ) : null} +
    + + {entityRefs.length ? ( +
    + {entityRefs.slice(0, 8).map((e) => ( +
    +
    + {e.name || e.id} +
    +
    + {e.id} +
    +
    + ))} + {entityRefs.length > 8 ?
    +{entityRefs.length - 8} more…
    : null} +
    + ) : ( +
    No entity ref yet for this project.
    + )} +
    + ); +} diff --git a/src/uhm/components/WikiSidebarPanel.tsx b/src/uhm/components/WikiSidebarPanel.tsx new file mode 100644 index 0000000..6c20228 --- /dev/null +++ b/src/uhm/components/WikiSidebarPanel.tsx @@ -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>; + 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(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([]); + 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 ( +
    + + +
    +
    Wiki
    + + {wikis.length} + +
    + +
    + + +
    + +
    +
    Add existing wiki
    + 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 ? ( +
    Searching…
    + ) : null} + {!isSearching && searchQuery.trim().length > 0 ? ( +
    + {searchResults.slice(0, 8).map((w) => ( +
    +
    +
    + {(w.title || "").trim() || "Untitled wiki"} +
    +
    + {w.id} +
    +
    + +
    + ))} + {!searchResults.length ? ( +
    No results.
    + ) : null} +
    + ) : null} +
    + + {wikis.length ? ( +
    + {wikis.slice(0, 8).map((w) => ( +
    + + +
    + ))} + {wikis.length > 8 ? ( +
    +{wikis.length - 8} more…
    + ) : null} +
    + ) : ( +
    + No wiki yet for this project. +
    + )} + + 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" + > +
    +
    +
    +
    Project
    +
    {projectId}
    +
    +
    + + +
    +
    + +
    +
    +
    Wikis
    +
    + {wikis.map((w) => ( + + ))} + +
    +
    + +
    +
    +
    + + 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} + /> +
    + +
    + + + + + + + + + + +
    + +
    + {editor ? :
    Loading editor...
    } +
    +
    +
    +
    + +
    + Stored in snapshot_json on commit. This page does not write to DB yet. +
    +
    +
    +
    + ); +} diff --git a/src/uhm/lib/backgroundLayers.ts b/src/uhm/lib/backgroundLayers.ts index 817ef63..5391a92 100644 --- a/src/uhm/lib/backgroundLayers.ts +++ b/src/uhm/lib/backgroundLayers.ts @@ -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" }, diff --git a/src/uhm/lib/editor/section/useSectionCommands.ts b/src/uhm/lib/editor/section/useSectionCommands.ts index d6e6ee0..82d44cd 100644 --- a/src/uhm/lib/editor/section/useSectionCommands.ts +++ b/src/uhm/lib/editor/section/useSectionCommands.ts @@ -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>; setSectionCommits: Dispatch>; setPendingEntityCreates: Dispatch>; + setProjectEntityRefs: Dispatch>; setCreatedEntities: Dispatch>; + setWikis: Dispatch>; + setEntityWikiLinks: Dispatch>; setSelectedFeatureId: Dispatch>; setEntityFormStatus: Dispatch>; setEntityStatus: Dispatch>; @@ -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) { diff --git a/src/uhm/lib/editor/session/useEntitySessionState.ts b/src/uhm/lib/editor/session/useEntitySessionState.ts index cf96bdf..d86ded7 100644 --- a/src/uhm/lib/editor/session/useEntitySessionState.ts +++ b/src/uhm/lib/editor/session/useEntitySessionState.ts @@ -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([]); + // Entities được "pin" vào project dưới dạng reference (không cần chọn geometry). + const [projectEntityRefs, setProjectEntityRefs] = useState([]); // Entities tạo mới trong phiên nhưng chưa commit lên backend. const [pendingEntityCreates, setPendingEntityCreates] = useState([]); // 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, diff --git a/src/uhm/lib/editor/session/useWikiSessionState.ts b/src/uhm/lib/editor/session/useWikiSessionState.ts new file mode 100644 index 0000000..64f9844 --- /dev/null +++ b/src/uhm/lib/editor/session/useWikiSessionState.ts @@ -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([]); + const [entityWikiLinks, setEntityWikiLinks] = useState([]); + return { wikis, setWikis, entityWikiLinks, setEntityWikiLinks }; +} diff --git a/src/uhm/lib/editor/snapshot/editorSnapshot.ts b/src/uhm/lib/editor/snapshot/editorSnapshot.ts index 08505e9..7e5f7bf 100644 --- a/src/uhm/lib/editor/snapshot/editorSnapshot.ts +++ b/src/uhm/lib/editor/snapshot/editorSnapshot.ts @@ -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(); - 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" - ? "create" - : !previousFeature && (options.previousSnapshot || !options.hasPersistedFeature(feature.properties.id)) + const operation: GeometrySnapshot["operation"] = + previousOperation === "create" ? "create" - : changedIds.has(id) || changedFromPreviousSnapshot - ? "update" - : "reference"; + : !previousFeature && (options.previousSnapshot || !options.hasPersistedFeature(feature.properties.id)) + ? "create" + : changedIds.has(id) || changedFromPreviousSnapshot + ? "update" + : 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(); + 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[], }; } diff --git a/src/uhm/lib/useEditorSessionState.ts b/src/uhm/lib/useEditorSessionState.ts index d95d002..28e0177 100644 --- a/src/uhm/lib/useEditorSessionState.ts +++ b/src/uhm/lib/useEditorSessionState.ts @@ -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, }; } diff --git a/src/uhm/types/entities.ts b/src/uhm/types/entities.ts index 07c6afe..8f6c51a 100644 --- a/src/uhm/types/entities.ts +++ b/src/uhm/types/entities.ts @@ -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; diff --git a/src/uhm/types/geo.ts b/src/uhm/types/geo.ts index 3df3b50..456b9f5 100644 --- a/src/uhm/types/geo.ts +++ b/src/uhm/types/geo.ts @@ -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; }; diff --git a/src/uhm/types/sections.ts b/src/uhm/types/sections.ts index 5868c86..a45b0b4 100644 --- a/src/uhm/types/sections.ts +++ b/src/uhm/types/sections.ts @@ -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 = { diff --git a/src/uhm/types/wiki.ts b/src/uhm/types/wiki.ts new file mode 100644 index 0000000..48266bd --- /dev/null +++ b/src/uhm/types/wiki.ts @@ -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; +};