This commit is contained in:
378
commit_snapshot.md
Normal file
378
commit_snapshot.md
Normal file
@@ -0,0 +1,378 @@
|
||||
# Commit Snapshot (`commits.snapshot_json`) - Cấu Trúc Hiện Tại
|
||||
|
||||
Tài liệu này mô tả **commit snapshot** đang được lưu trong `BackEndGo.commits.snapshot_json` (JSONB) và được `FrontEndAdmin` tạo ra khi bấm **Commit** trong `/editor`.
|
||||
|
||||
Mục tiêu: nhìn vào đây là hiểu commit snapshot gồm những phần nào, ý nghĩa ra sao, và `source`/`operation` có vai trò gì.
|
||||
|
||||
Nguồn tham chiếu trong code:
|
||||
|
||||
- Type snapshot: `FrontEndAdmin/src/uhm/types/sections.ts` (`EditorSnapshot`)
|
||||
- Build snapshot khi commit: `FrontEndAdmin/src/uhm/lib/editor/snapshot/editorSnapshot.ts` (`buildEditorSnapshot`)
|
||||
|
||||
## 1) Schema tổng quan (v1)
|
||||
|
||||
Hiện tại snapshot được ghi với `schema_version: 1`.
|
||||
|
||||
```ts
|
||||
export type CommitSnapshotV1 = {
|
||||
schema_version: 1;
|
||||
|
||||
// Project/section đang được edit (FE vẫn giữ tên "section" cho compatibility)
|
||||
section: { id: string; title: string };
|
||||
|
||||
// GeoJSON draft để render map + làm nguồn dựng geometries/link_scopes
|
||||
editor_feature_collection?: FeatureCollection;
|
||||
|
||||
// Operation-based rows
|
||||
entities?: EntitySnapshot[];
|
||||
geometries?: GeometrySnapshot[];
|
||||
link_scopes?: LinkScopeSnapshot[];
|
||||
|
||||
// Wiki list (tiptap JSON hoặc reference)
|
||||
wikis?: WikiSnapshot[];
|
||||
|
||||
// Join table inside snapshot: links between entities and wikis (project-level)
|
||||
entity_wikis?: EntityWikiLinkSnapshot[];
|
||||
};
|
||||
```
|
||||
|
||||
## 2) `operation` có những giá trị nào?
|
||||
|
||||
Trong commit snapshot hiện tại có 4 nơi dùng `operation`:
|
||||
|
||||
1. `entities[].operation`:
|
||||
|
||||
- `create` | `update` | `delete` | `reference`
|
||||
|
||||
2. `geometries[].operation`:
|
||||
|
||||
- `create` | `update` | `delete` | `reference`
|
||||
|
||||
3. `link_scopes[].operation`:
|
||||
|
||||
- `reference`
|
||||
|
||||
4. `wikis[].operation`:
|
||||
|
||||
- `create` | `update` | `delete` | `reference`
|
||||
|
||||
Ghi chú về semantics:
|
||||
|
||||
- `create/update/delete`: bản ghi bị thay đổi trong commit này
|
||||
- `reference`: bản ghi được đưa vào snapshot để làm đầu mối **nối (link)** (vd: geometry↔entity, entity↔wiki), không phải “không đổi”
|
||||
|
||||
Ngoài ra snapshot có `entity_wikis[]` để nối entity <-> wiki.
|
||||
|
||||
## 3) Sơ đồ trực quan (Mermaid)
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class CommitSnapshotV1 {
|
||||
+number schema_version
|
||||
+SectionRef section
|
||||
+FeatureCollection editor_feature_collection?
|
||||
+EntitySnapshot[] entities?
|
||||
+GeometrySnapshot[] geometries?
|
||||
+LinkScopeSnapshot[] link_scopes?
|
||||
+WikiSnapshot[] wikis?
|
||||
+EntityWikiLinkSnapshot[] entity_wikis?
|
||||
}
|
||||
|
||||
class SectionRef {
|
||||
+string id
|
||||
+string title
|
||||
}
|
||||
|
||||
class FeatureCollection {
|
||||
+string type // "FeatureCollection"
|
||||
+Feature[] features
|
||||
}
|
||||
|
||||
class Feature {
|
||||
+string type // "Feature"
|
||||
+FeatureProperties properties
|
||||
+Geometry geometry
|
||||
}
|
||||
|
||||
class FeatureProperties {
|
||||
+string|number id
|
||||
+string type?
|
||||
+number time_start?
|
||||
+number time_end?
|
||||
+string[] binding?
|
||||
+string entity_id?
|
||||
+string[] entity_ids?
|
||||
+string entity_name?
|
||||
+string[] entity_names?
|
||||
+string entity_type_id?
|
||||
}
|
||||
|
||||
class EntitySnapshot {
|
||||
+string id
|
||||
+string source? // inline|ref
|
||||
+Ref ref?
|
||||
+string operation? // create|update|delete|reference
|
||||
+string name?
|
||||
+string slug?
|
||||
+string description?
|
||||
+string type_id?
|
||||
+number status?
|
||||
+number is_deleted?
|
||||
+string base_updated_at?
|
||||
+string base_hash?
|
||||
}
|
||||
|
||||
class GeometrySnapshot {
|
||||
+string id
|
||||
+string source? // inline|ref
|
||||
+Ref ref?
|
||||
+string operation? // create|update|delete|reference
|
||||
+string type?
|
||||
+Geometry draw_geometry?
|
||||
+string[] binding?
|
||||
+number time_start?
|
||||
+number time_end?
|
||||
+BBox bbox?
|
||||
+number is_deleted?
|
||||
+string base_updated_at?
|
||||
+string base_hash?
|
||||
}
|
||||
|
||||
class BBox {
|
||||
+number min_lng
|
||||
+number min_lat
|
||||
+number max_lng
|
||||
+number max_lat
|
||||
}
|
||||
|
||||
class LinkScopeSnapshot {
|
||||
+string geometry_id
|
||||
+string operation // reference
|
||||
+string[] entity_ids
|
||||
+string base_links_hash?
|
||||
}
|
||||
|
||||
class WikiSnapshot {
|
||||
+string id
|
||||
+string source? // inline|ref
|
||||
+Ref ref?
|
||||
+string operation? // create|update|delete|reference
|
||||
+string title
|
||||
+any doc
|
||||
+string updated_at?
|
||||
+number is_deleted?
|
||||
}
|
||||
|
||||
class EntityWikiLinkSnapshot {
|
||||
+string entity_id
|
||||
+string wiki_id
|
||||
+string operation? // reference|delete
|
||||
+number is_deleted?
|
||||
}
|
||||
|
||||
class Ref {
|
||||
+string id
|
||||
}
|
||||
|
||||
CommitSnapshotV1 --> SectionRef
|
||||
CommitSnapshotV1 --> FeatureCollection
|
||||
FeatureCollection --> Feature
|
||||
Feature --> FeatureProperties
|
||||
CommitSnapshotV1 --> EntitySnapshot
|
||||
CommitSnapshotV1 --> GeometrySnapshot
|
||||
CommitSnapshotV1 --> LinkScopeSnapshot
|
||||
CommitSnapshotV1 --> WikiSnapshot
|
||||
CommitSnapshotV1 --> EntityWikiLinkSnapshot
|
||||
```
|
||||
|
||||
## 4) Ý nghĩa từng phần
|
||||
|
||||
### 4.1 `section`
|
||||
|
||||
Chỉ là “ref” tối thiểu để biết commit này thuộc project nào:
|
||||
|
||||
- `section.id` = `project_id`
|
||||
- `section.title` = title tại thời điểm commit (phục vụ UI)
|
||||
|
||||
### 4.2 `editor_feature_collection`
|
||||
|
||||
GeoJSON `FeatureCollection` là nguồn để:
|
||||
|
||||
- render map trong editor
|
||||
- build `geometries[]` + `link_scopes[]` khi commit
|
||||
|
||||
Trong thực tế, nó là “bản đồ draft state” của commit.
|
||||
|
||||
### 4.3 `entities[]`
|
||||
|
||||
`entities[]` là tập các entity rows kèm `source`/`operation`. Trong `buildEditorSnapshot` hiện tại, nó được dựng từ:
|
||||
|
||||
1. `pending entities` tạo trong editor:
|
||||
- `source: "inline"`, `operation: "create"`
|
||||
2. `projectEntityRefs` (entity được user “pin” vào project từ thanh search):
|
||||
- `source: "ref"`, `ref: {id}`, `operation: "reference"`
|
||||
3. Các entity IDs đang được gắn vào geometries trong `editor_feature_collection` (nếu chưa có trong list):
|
||||
- `source: "ref"`, `ref: {id}`, `operation: "reference"`
|
||||
4. Các entity IDs xuất hiện trong `entity_wikis[]`:
|
||||
- `source: "ref"`, `ref: {id}`, `operation: "reference"`
|
||||
|
||||
=> Nghĩa là: `entities[]` trong commit snapshot hiện tại hoạt động như một “danh sách entity liên quan tới project”, không nhất thiết phải gắn vào một geometry cụ thể.
|
||||
|
||||
### 4.4 `geometries[]`
|
||||
|
||||
Mỗi `Feature` trong `editor_feature_collection.features[]` sẽ sinh ra một `GeometrySnapshot` row:
|
||||
|
||||
- `id`: `String(feature.properties.id)`
|
||||
- `draw_geometry`: lấy từ `feature.geometry`
|
||||
- `type`: `feature.properties.type || getDefaultTypeIdForFeature(feature)`
|
||||
- `binding`: normalize từ `feature.properties.binding`
|
||||
- `time_start/time_end`
|
||||
- `bbox`: tính từ geometry
|
||||
- `is_deleted: 0`
|
||||
|
||||
`operation` được suy ra dựa vào `changes` + so sánh với snapshot trước:
|
||||
|
||||
- `create`: feature mới
|
||||
- `update`: feature thay đổi
|
||||
- (không có `operation`): feature không đổi (không delta trong commit)
|
||||
- `delete`: feature bị xoá khỏi draft (FE sẽ thêm 1 row `{ id, operation:"delete", is_deleted:1 }`)
|
||||
|
||||
### 4.5 `link_scopes[]`
|
||||
|
||||
FE build link scopes từ GeoJSON features:
|
||||
|
||||
- `geometry_id = String(feature.properties.id)`
|
||||
- `operation = "reference"`
|
||||
- `entity_ids` lấy từ `feature.properties.entity_ids` hoặc `entity_id`
|
||||
|
||||
Chỉ add scope nếu `entity_ids.length > 0`.
|
||||
|
||||
### 4.6 `wikis[]`
|
||||
|
||||
`wikis[]` là danh sách wiki của project tại thời điểm commit.
|
||||
|
||||
Type hiện tại:
|
||||
|
||||
```ts
|
||||
export type WikiSnapshot = {
|
||||
id: string;
|
||||
source?: "inline" | "ref";
|
||||
ref?: { id: string };
|
||||
operation?: "create" | "update" | "delete" | "reference";
|
||||
title: string;
|
||||
doc: unknown; // tiptap JSON doc (inline) hoặc null (reference)
|
||||
updated_at?: string;
|
||||
is_deleted?: number;
|
||||
};
|
||||
```
|
||||
|
||||
Quy ước FE đang dùng:
|
||||
|
||||
- Wiki tạo mới trong editor: `operation: "create"`, `doc` là tiptap JSON.
|
||||
- Wiki sửa: `operation: "update"`, `doc` là tiptap JSON.
|
||||
- Wiki không đổi so với snapshot trước: thường **không có** `operation` (không delta).
|
||||
- Wiki add từ thanh search (wiki đã tồn tại trong DB): `source:"ref"`, `ref:{id}`, `operation:"reference"`, **`doc` có thể là `null`**.
|
||||
|
||||
Ghi chú quan trọng:
|
||||
|
||||
- Hiện tại FE **chưa generate “delete rows” cho wikis** (khác với geometries). Khi bạn remove một wiki khỏi list thì snapshot mới sẽ đơn giản là không còn wiki đó nữa.
|
||||
|
||||
### 4.7 `entity_wikis[]` (bảng nối Entity ↔ Wiki)
|
||||
|
||||
`entity_wikis[]` là bảng nối trong snapshot để thể hiện “wiki nào thuộc entity nào” ở mức project/commit.
|
||||
|
||||
```ts
|
||||
export type EntityWikiLinkSnapshot = {
|
||||
entity_id: string;
|
||||
wiki_id: string;
|
||||
operation?: "reference" | "delete";
|
||||
is_deleted?: number;
|
||||
};
|
||||
```
|
||||
|
||||
FE hiện dùng panel “Entity ↔ Wiki” để toggle link:
|
||||
|
||||
- Tick checkbox => `{ operation:"reference", is_deleted:0 }`
|
||||
- Untick checkbox => `{ operation:"delete", is_deleted:1 }`
|
||||
|
||||
## 5) Ví dụ JSON (rút gọn)
|
||||
|
||||
Ví dụ dưới đây thể hiện:
|
||||
|
||||
- 1 geometry gắn entity `e_1`
|
||||
- 1 entity ref “pin” vào project (`e_2`) dù chưa gắn geometry
|
||||
- 1 wiki inline và 1 wiki reference (search từ DB)
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"section": { "id": "019d...project", "title": "Project A" },
|
||||
"editor_feature_collection": {
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"id": "g_1",
|
||||
"type": "city",
|
||||
"time_start": 1200,
|
||||
"time_end": 1300,
|
||||
"entity_ids": ["e_1"],
|
||||
"entity_names": ["Ha Noi"]
|
||||
},
|
||||
"geometry": { "type": "Point", "coordinates": [105.8, 21.0] }
|
||||
}
|
||||
]
|
||||
},
|
||||
"entities": [
|
||||
{ "id": "e_2", "operation": "reference", "name": "Pinned Entity", "is_deleted": 0 },
|
||||
{ "id": "e_1", "operation": "reference", "name": "Ha Noi", "type_id": "city", "status": 1, "is_deleted": 0 }
|
||||
],
|
||||
"geometries": [
|
||||
{
|
||||
"id": "g_1",
|
||||
"operation": "update",
|
||||
"type": "city",
|
||||
"draw_geometry": { "type": "Point", "coordinates": [105.8, 21.0] },
|
||||
"binding": [],
|
||||
"time_start": 1200,
|
||||
"time_end": 1300,
|
||||
"bbox": { "min_lng": 105.8, "min_lat": 21.0, "max_lng": 105.8, "max_lat": 21.0 },
|
||||
"is_deleted": 0
|
||||
}
|
||||
],
|
||||
"link_scopes": [{ "geometry_id": "g_1", "operation": "reference", "entity_ids": ["e_1"] }],
|
||||
"wikis": [
|
||||
{
|
||||
"id": "w_inline_1",
|
||||
"source": "inline",
|
||||
"operation": "create",
|
||||
"title": "Overview",
|
||||
"doc": { "type": "doc", "content": [{ "type": "paragraph" }] }
|
||||
},
|
||||
{
|
||||
"id": "019d...wiki_from_db",
|
||||
"source": "ref",
|
||||
"ref": { "id": "019d...wiki_from_db" },
|
||||
"operation": "reference",
|
||||
"title": "Existing Wiki (DB)",
|
||||
"doc": null
|
||||
}
|
||||
],
|
||||
"entity_wikis": [
|
||||
{ "entity_id": "e_1", "wiki_id": "w_inline_1", "operation": "reference", "is_deleted": 0 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 6) Các điểm cần chốt khi muốn đi xa hơn với “ref”
|
||||
|
||||
Để `source:"ref"` thực sự “lấy từ bên ngoài snapshot”, cần thống nhất:
|
||||
|
||||
1. Wiki DB format:
|
||||
- BackEndGo `wikis.content` hiện là `TEXT`, trong khi editor wiki dùng TipTap JSON (`doc`).
|
||||
- Nếu muốn `ref` load content khi cần, phải chốt format lưu trữ (JSON string / HTML / Markdown).
|
||||
|
||||
2. Semantics `operation:"reference"`:
|
||||
- `reference` được dùng theo nghĩa “đầu mối để nối (link)” và thường đi kèm `source:"ref"` (ref tới DB/global).
|
||||
- Các bản ghi inline không thay đổi nên **không có `operation`** (không delta).
|
||||
732
package-lock.json
generated
732
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import Map from "@/uhm/components/Map";
|
||||
import Editor from "@/uhm/components/Editor";
|
||||
import BackgroundLayersPanel from "@/uhm/components/BackgroundLayersPanel";
|
||||
import TimelineBar from "@/uhm/components/TimelineBar";
|
||||
import SelectedGeometryPanel from "@/uhm/components/SelectedGeometryPanel";
|
||||
import WikiSidebarPanel from "@/uhm/components/WikiSidebarPanel";
|
||||
import ProjectEntityRefsPanel from "@/uhm/components/ProjectEntityRefsPanel";
|
||||
import EntityWikiBindingsPanel from "@/uhm/components/EntityWikiBindingsPanel";
|
||||
import { Entity, fetchEntities, searchEntitiesByName } from "@/uhm/api/entities";
|
||||
import { ApiError } from "@/uhm/api/http";
|
||||
import { fetchCurrentUser } from "@/uhm/api/auth";
|
||||
@@ -63,8 +66,11 @@ const DEFAULT_EDITOR_USER_ID = "local-editor";
|
||||
export default function Page() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const projectId = String(params.id || "");
|
||||
const openedProjectIdRef = useRef<string | null>(null);
|
||||
const autoOpenWiki = searchParams.get("only") === "wiki";
|
||||
const wikiOnly = autoOpenWiki;
|
||||
|
||||
const {
|
||||
mode,
|
||||
@@ -99,6 +105,8 @@ export default function Page() {
|
||||
setLastSectionSnapshot,
|
||||
persistedEntities,
|
||||
setPersistedEntities,
|
||||
projectEntityRefs,
|
||||
setProjectEntityRefs,
|
||||
pendingEntityCreates,
|
||||
setPendingEntityCreates,
|
||||
createdEntities,
|
||||
@@ -137,6 +145,10 @@ export default function Page() {
|
||||
setBackgroundVisibility,
|
||||
isBackgroundVisibilityReady,
|
||||
setIsBackgroundVisibilityReady,
|
||||
wikis,
|
||||
setWikis,
|
||||
entityWikiLinks,
|
||||
setEntityWikiLinks,
|
||||
} = useEditorSessionState({
|
||||
emptyFeatureCollection: EMPTY_FEATURE_COLLECTION,
|
||||
defaultEditorUserId: DEFAULT_EDITOR_USER_ID,
|
||||
@@ -154,6 +166,20 @@ export default function Page() {
|
||||
() => mergeEntitiesWithPending(persistedEntities, pendingEntityCreates),
|
||||
[persistedEntities, pendingEntityCreates]
|
||||
);
|
||||
|
||||
const projectEntityChoices = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const ref of projectEntityRefs) ids.add(String(ref.id));
|
||||
for (const feature of editor.draft.features) {
|
||||
for (const id of normalizeFeatureEntityIds(feature)) ids.add(id);
|
||||
}
|
||||
const rows = Array.from(ids).map((id) => {
|
||||
const found = entities.find((e) => e.id === id) || null;
|
||||
return { id, name: found?.name || id };
|
||||
});
|
||||
rows.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return rows;
|
||||
}, [editor.draft.features, entities, projectEntityRefs]);
|
||||
const selectedFeature =
|
||||
selectedFeatureId === null
|
||||
? null
|
||||
@@ -186,6 +212,17 @@ export default function Page() {
|
||||
return rows;
|
||||
}, [editor.changes, entities]);
|
||||
|
||||
const wikiDirty = useMemo(() => {
|
||||
const prev = lastSectionSnapshot?.wikis || [];
|
||||
try {
|
||||
return JSON.stringify(prev) !== JSON.stringify(wikis);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}, [lastSectionSnapshot?.wikis, wikis]);
|
||||
|
||||
const pendingSaveCount = editor.changeCount + pendingEntityCreates.length + (wikiDirty ? 1 : 0);
|
||||
|
||||
const sectionCommands = useSectionCommands({
|
||||
editor,
|
||||
editorUserId,
|
||||
@@ -194,8 +231,11 @@ export default function Page() {
|
||||
sectionState,
|
||||
selectedSectionId,
|
||||
newSectionTitle,
|
||||
pendingSaveCount: editor.changeCount + pendingEntityCreates.length,
|
||||
pendingSaveCount,
|
||||
pendingEntityCreates,
|
||||
projectEntityRefs,
|
||||
wikis,
|
||||
entityWikiLinks,
|
||||
lastSectionSnapshot,
|
||||
commitTitle,
|
||||
commitNote,
|
||||
@@ -206,7 +246,10 @@ export default function Page() {
|
||||
setInitialData,
|
||||
setSectionCommits,
|
||||
setPendingEntityCreates,
|
||||
setProjectEntityRefs,
|
||||
setCreatedEntities,
|
||||
setWikis,
|
||||
setEntityWikiLinks,
|
||||
setEntityFormStatus,
|
||||
setSelectedFeatureId,
|
||||
setEntityStatus,
|
||||
@@ -659,7 +702,6 @@ export default function Page() {
|
||||
}
|
||||
};
|
||||
|
||||
const pendingSaveCount = editor.changeCount + pendingEntityCreates.length;
|
||||
const headCommit = sectionState?.head_commit_id
|
||||
? sectionCommits.find((commit) => commit.id === sectionState.head_commit_id) || null
|
||||
: null;
|
||||
@@ -705,6 +747,7 @@ export default function Page() {
|
||||
createdGeometries={createdGeometries}
|
||||
/>
|
||||
|
||||
{!wikiOnly ? (
|
||||
<div style={{ flex: 1, position: "relative", minHeight: "100vh" }}>
|
||||
{isBackgroundVisibilityReady ? (
|
||||
<Map
|
||||
@@ -728,6 +771,10 @@ export default function Page() {
|
||||
statusText={timelineStatusText}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
// Wiki-only mode: avoid mounting Map/Timeline (WebGL + geometry fetching) to reduce lag.
|
||||
<div style={{ flex: 1, minHeight: "100vh", background: "#0b1220" }} />
|
||||
)}
|
||||
|
||||
<BackgroundLayersPanel
|
||||
visibility={backgroundVisibility}
|
||||
@@ -735,6 +782,16 @@ export default function Page() {
|
||||
onShowAll={handleShowAllBackgroundLayers}
|
||||
onHideAll={handleHideAllBackgroundLayers}
|
||||
topContent={
|
||||
<div style={{ display: "grid", gap: "12px" }}>
|
||||
<WikiSidebarPanel
|
||||
projectId={projectId}
|
||||
wikis={wikis}
|
||||
setWikis={setWikis}
|
||||
autoOpen={autoOpenWiki}
|
||||
/>
|
||||
<ProjectEntityRefsPanel entityRefs={projectEntityRefs} setEntityRefs={setProjectEntityRefs} />
|
||||
<EntityWikiBindingsPanel entities={projectEntityChoices} wikis={wikis} links={entityWikiLinks} setLinks={setEntityWikiLinks} />
|
||||
{!wikiOnly ? (
|
||||
<SelectedGeometryPanel
|
||||
selectedFeature={selectedFeature}
|
||||
selectedFeatureEntitySummary={
|
||||
@@ -769,6 +826,8 @@ export default function Page() {
|
||||
changeCount={editor.changeCount}
|
||||
entityFormStatus={entityFormStatus}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -311,6 +311,9 @@ export default function ProjectDetailsPage() {
|
||||
<Button size="sm" variant="outline" onClick={() => router.push(`/editor/${id}`)}>
|
||||
Mo editor
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => router.push(`/editor/${id}?only=wiki`)}>
|
||||
Editor only wiki
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -217,7 +217,7 @@ export default function ProjectsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center mt-4 md:mt-0 gap-10 w-[240px] justify-end shrink-0">
|
||||
<div className="flex items-center mt-4 md:mt-0 gap-3 w-[340px] justify-end shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -225,6 +225,13 @@ export default function ProjectsPage() {
|
||||
>
|
||||
Editor
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => router.push(`/editor/${project.id}?only=wiki`)}
|
||||
>
|
||||
Editor only wiki
|
||||
</Button>
|
||||
|
||||
<div className="flex -space-x-2 overflow-hidden">
|
||||
{project.members && project.members.length > 0 ? (
|
||||
|
||||
554
src/app/user/wikieditor/page.tsx
Normal file
554
src/app/user/wikieditor/page.tsx
Normal file
@@ -0,0 +1,554 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
||||
import ComponentCard from "@/components/common/ComponentCard";
|
||||
import Button from "@/components/ui/button/Button";
|
||||
import Badge from "@/components/ui/badge/Badge";
|
||||
import Label from "@/components/form/Label";
|
||||
|
||||
import { EditorContent, useEditor, type JSONContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import TiptapLink from "@tiptap/extension-link";
|
||||
|
||||
const STORAGE_KEY = "uhm_wiki_draft_v1";
|
||||
|
||||
type TocItem = {
|
||||
level: number;
|
||||
text: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
type WikiDraft = {
|
||||
schema_version: 1;
|
||||
title: string;
|
||||
doc: JSONContent;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
function slugify(input: string) {
|
||||
return input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
function textFromNode(node: any): string {
|
||||
if (!node) return "";
|
||||
if (node.type === "text") return node.text || "";
|
||||
if (Array.isArray(node.content)) return node.content.map(textFromNode).join("");
|
||||
return "";
|
||||
}
|
||||
|
||||
function buildToc(doc: JSONContent | null): TocItem[] {
|
||||
if (!doc) return [];
|
||||
const out: TocItem[] = [];
|
||||
const seen = new Map<string, number>();
|
||||
|
||||
const walk = (node: any) => {
|
||||
if (!node) return;
|
||||
if (node.type === "heading") {
|
||||
const level = Number(node.attrs?.level || 1);
|
||||
const text = textFromNode(node).trim();
|
||||
if (text) {
|
||||
const base = slugify(text) || "heading";
|
||||
const n = (seen.get(base) || 0) + 1;
|
||||
seen.set(base, n);
|
||||
const slug = n === 1 ? base : `${base}-${n}`;
|
||||
out.push({ level, text, slug });
|
||||
}
|
||||
}
|
||||
if (Array.isArray(node.content)) node.content.forEach(walk);
|
||||
};
|
||||
|
||||
walk(doc);
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderInlineText(node: any, key: string) {
|
||||
if (node.type !== "text") return null;
|
||||
const marks: any[] = Array.isArray(node.marks) ? node.marks : [];
|
||||
let el: React.ReactNode = node.text || "";
|
||||
|
||||
for (const m of marks) {
|
||||
if (m.type === "bold") el = <strong key={`${key}-b`}>{el}</strong>;
|
||||
else if (m.type === "italic") el = <em key={`${key}-i`}>{el}</em>;
|
||||
else if (m.type === "link") {
|
||||
const href = String(m.attrs?.href || "#");
|
||||
el = (
|
||||
<a
|
||||
key={`${key}-a`}
|
||||
href={href}
|
||||
target={m.attrs?.target || "_blank"}
|
||||
rel="noreferrer"
|
||||
className="text-brand-600 dark:text-brand-400 underline underline-offset-2"
|
||||
>
|
||||
{el}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <span key={key}>{el}</span>;
|
||||
}
|
||||
|
||||
function renderDoc(node: any, keyPrefix = "n", toc: TocItem[] = []) : React.ReactNode {
|
||||
if (!node) return null;
|
||||
const type = node.type;
|
||||
const content: any[] = Array.isArray(node.content) ? node.content : [];
|
||||
|
||||
if (type === "doc") {
|
||||
return <>{content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))}</>;
|
||||
}
|
||||
|
||||
if (type === "paragraph") {
|
||||
return (
|
||||
<p key={keyPrefix} className="text-sm leading-6 text-gray-800 dark:text-gray-200">
|
||||
{content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "heading") {
|
||||
const level = Number(node.attrs?.level || 1);
|
||||
const text = textFromNode(node).trim();
|
||||
const slug = toc.find((t) => t.text === text)?.slug || slugify(text);
|
||||
const cls =
|
||||
level === 1
|
||||
? "text-2xl font-bold"
|
||||
: level === 2
|
||||
? "text-xl font-semibold"
|
||||
: "text-lg font-semibold";
|
||||
return (
|
||||
<div key={keyPrefix} className="mt-5">
|
||||
<div id={slug} className={`${cls} text-gray-900 dark:text-gray-100`}>
|
||||
{content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "bulletList") {
|
||||
return (
|
||||
<ul key={keyPrefix} className="list-disc pl-5 text-sm text-gray-800 dark:text-gray-200">
|
||||
{content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "orderedList") {
|
||||
return (
|
||||
<ol key={keyPrefix} className="list-decimal pl-5 text-sm text-gray-800 dark:text-gray-200">
|
||||
{content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "listItem") {
|
||||
return <li key={keyPrefix}>{content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))}</li>;
|
||||
}
|
||||
|
||||
if (type === "blockquote") {
|
||||
return (
|
||||
<blockquote
|
||||
key={keyPrefix}
|
||||
className="border-l-4 border-gray-200 dark:border-gray-800 pl-4 text-sm text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))}
|
||||
</blockquote>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "codeBlock") {
|
||||
const code = content.map(textFromNode).join("");
|
||||
return (
|
||||
<pre
|
||||
key={keyPrefix}
|
||||
className="rounded-xl border border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-[#0d1117] p-4 overflow-auto text-xs"
|
||||
>
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "hardBreak") return <br key={keyPrefix} />;
|
||||
|
||||
if (type === "text") return renderInlineText(node, keyPrefix);
|
||||
|
||||
// fallback: render children
|
||||
return <span key={keyPrefix}>{content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))}</span>;
|
||||
}
|
||||
|
||||
type ViewMode = "edit" | "split" | "preview";
|
||||
|
||||
export default function WikiEditorPage() {
|
||||
const [view, setView] = useState<ViewMode>("split");
|
||||
const [showJson, setShowJson] = useState(false);
|
||||
const [title, setTitle] = useState("Untitled wiki");
|
||||
const [docJson, setDocJson] = useState<JSONContent | null>(null);
|
||||
const [savedAt, setSavedAt] = useState<string | null>(null);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const saveTimerRef = useRef<number | null>(null);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
}),
|
||||
TiptapLink.configure({
|
||||
openOnClick: false,
|
||||
autolink: true,
|
||||
linkOnPaste: true,
|
||||
}),
|
||||
],
|
||||
content: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "Write your wiki content here." }] },
|
||||
{ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Section" }] },
|
||||
{ type: "paragraph", content: [{ type: "text", text: "Use H1/H2/H3 and the TOC will follow." }] },
|
||||
],
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
setDocJson(editor.getJSON());
|
||||
setIsDirty(true);
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
// Keep editor styling independent from whatever global typography the app uses.
|
||||
class:
|
||||
"tiptap-editor focus:outline-none min-h-[360px] px-4 py-3",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Load draft
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw) as WikiDraft;
|
||||
if (parsed && typeof parsed === "object" && parsed.schema_version === 1 && parsed.doc) {
|
||||
setTitle(parsed.title || "Untitled wiki");
|
||||
editor.commands.setContent(parsed.doc as JSONContent);
|
||||
setDocJson(parsed.doc as JSONContent);
|
||||
setSavedAt(parsed.updated_at || "loaded");
|
||||
setIsDirty(false);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [editor]);
|
||||
|
||||
const toc = useMemo(() => buildToc(docJson), [docJson]);
|
||||
|
||||
const doSaveDraft = () => {
|
||||
if (!editor) return;
|
||||
const payload: WikiDraft = {
|
||||
schema_version: 1,
|
||||
title: title.trim() || "Untitled wiki",
|
||||
doc: editor.getJSON(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
|
||||
setSavedAt(new Date().toLocaleString("vi-VN"));
|
||||
setIsDirty(false);
|
||||
};
|
||||
|
||||
// Debounced autosave
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
if (!isDirty) return;
|
||||
|
||||
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = window.setTimeout(() => {
|
||||
doSaveDraft();
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editor, isDirty, title, docJson]);
|
||||
|
||||
const can = (cmd: () => boolean) => {
|
||||
try {
|
||||
return Boolean(editor && cmd());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const setLink = () => {
|
||||
if (!editor) return;
|
||||
const prev = editor.getAttributes("link")?.href as string | undefined;
|
||||
const href = window.prompt("Link URL", prev || "https://");
|
||||
if (href == null) return;
|
||||
const next = href.trim();
|
||||
if (!next.length) {
|
||||
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().extendMarkRange("link").setLink({ href: next }).run();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto pb-10">
|
||||
<PageBreadcrumb pageTitle="Wiki editor" paths={[{ name: "User", href: "/user" }]} />
|
||||
|
||||
<style jsx global>{`
|
||||
.tiptap-editor {
|
||||
color: inherit;
|
||||
}
|
||||
.tiptap-editor p {
|
||||
margin: 0.5rem 0;
|
||||
line-height: 1.65;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.tiptap-editor h1 {
|
||||
margin: 1rem 0 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.tiptap-editor h2 {
|
||||
margin: 0.9rem 0 0.4rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.tiptap-editor h3 {
|
||||
margin: 0.8rem 0 0.35rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.tiptap-editor ul,
|
||||
.tiptap-editor ol {
|
||||
margin: 0.6rem 0;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
.tiptap-editor li {
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
.tiptap-editor blockquote {
|
||||
margin: 0.75rem 0;
|
||||
padding-left: 0.75rem;
|
||||
border-left: 4px solid rgba(148, 163, 184, 0.55);
|
||||
color: rgba(100, 116, 139, 1);
|
||||
}
|
||||
.dark .tiptap-editor blockquote {
|
||||
border-left-color: rgba(71, 85, 105, 1);
|
||||
color: rgba(148, 163, 184, 1);
|
||||
}
|
||||
.tiptap-editor code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
"Liberation Mono", "Courier New", monospace;
|
||||
font-size: 0.85em;
|
||||
padding: 0.1rem 0.25rem;
|
||||
border-radius: 0.35rem;
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
}
|
||||
.tiptap-editor pre {
|
||||
margin: 0.8rem 0;
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(226, 232, 240, 1);
|
||||
background: rgba(248, 250, 252, 1);
|
||||
overflow: auto;
|
||||
}
|
||||
.dark .tiptap-editor pre {
|
||||
border-color: rgba(30, 41, 59, 1);
|
||||
background: rgba(13, 17, 23, 1);
|
||||
}
|
||||
.tiptap-editor pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
.tiptap-editor a {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.tiptap-editor hr {
|
||||
margin: 1rem 0;
|
||||
border: none;
|
||||
border-top: 1px solid rgba(226, 232, 240, 1);
|
||||
}
|
||||
.dark .tiptap-editor hr {
|
||||
border-top-color: rgba(30, 41, 59, 1);
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
<ComponentCard title="Wiki">
|
||||
<div className="p-4 flex flex-col gap-4">
|
||||
<div>
|
||||
<Label>Title</Label>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => {
|
||||
setTitle(e.target.value);
|
||||
setIsDirty(true);
|
||||
}}
|
||||
className="h-11 w-full rounded-xl border border-gray-200 bg-transparent px-4 py-2.5 text-sm text-gray-800 outline-none focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:text-white/90 dark:focus:border-brand-800"
|
||||
placeholder="Wiki title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge size="sm" variant="light" color="info">
|
||||
TipTap
|
||||
</Badge>
|
||||
{isDirty ? (
|
||||
<Badge size="sm" variant="light" color="warning">
|
||||
Unsaved
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" variant="light" color="success">
|
||||
Saved
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => setShowJson((v) => !v)}>
|
||||
{showJson ? "Hide JSON" : "Show JSON"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Last save: {savedAt || "-"}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-gray-600 dark:text-gray-300 mb-2">TOC</div>
|
||||
{toc.length === 0 ? (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">No headings</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{toc.map((t) => (
|
||||
<Link
|
||||
key={t.slug}
|
||||
href={`#${t.slug}`}
|
||||
className={`text-xs hover:underline text-gray-700 dark:text-gray-300 ${
|
||||
t.level === 1 ? "font-semibold" : t.level === 2 ? "pl-3" : "pl-6"
|
||||
}`}
|
||||
title={t.text}
|
||||
>
|
||||
{t.text}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-1 flex gap-2">
|
||||
<Button size="sm" className="bg-brand-500 hover:bg-brand-600 text-white" onClick={doSaveDraft} disabled={!editor}>
|
||||
Save now
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
setSavedAt(null);
|
||||
setIsDirty(false);
|
||||
}}
|
||||
>
|
||||
Clear draft
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
|
||||
<div className="lg:col-span-3 flex flex-col gap-6">
|
||||
<ComponentCard title="Editor">
|
||||
<div className="p-4">
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
<Button size="sm" variant="outline" onClick={() => setView("edit")}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setView("split")}>
|
||||
Split
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setView("preview")}>
|
||||
Preview
|
||||
</Button>
|
||||
|
||||
<div className="w-px h-7 bg-gray-200 dark:bg-gray-800 mx-1" />
|
||||
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleBold().run()} disabled={!can(() => editor!.can().toggleBold())}>
|
||||
B
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleItalic().run()} disabled={!can(() => editor!.can().toggleItalic())}>
|
||||
I
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleHeading({ level: 1 }).run()}>
|
||||
H1
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()}>
|
||||
H2
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()}>
|
||||
H3
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleBulletList().run()}>
|
||||
Bullets
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleOrderedList().run()}>
|
||||
Numbers
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleBlockquote().run()}>
|
||||
Quote
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleCodeBlock().run()}>
|
||||
Code
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={setLink} disabled={!editor}>
|
||||
Link
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().undo().run()} disabled={!can(() => editor!.can().undo())}>
|
||||
Undo
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().redo().run()} disabled={!can(() => editor!.can().redo())}>
|
||||
Redo
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={view === "split" ? "grid grid-cols-1 lg:grid-cols-2 gap-4" : ""}>
|
||||
{view !== "preview" ? (
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-[#0d1117]">
|
||||
{editor ? <EditorContent editor={editor} /> : <div className="p-4 text-sm text-gray-500">Loading editor...</div>}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{view !== "edit" ? (
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-[#0d1117] p-4">
|
||||
<div className="text-xs font-semibold text-gray-600 dark:text-gray-300 mb-2">
|
||||
Preview
|
||||
</div>
|
||||
{renderDoc(docJson, "p", toc)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
|
||||
{showJson ? (
|
||||
<ComponentCard title="Document JSON">
|
||||
<div className="p-4">
|
||||
<pre className="text-xs whitespace-pre-wrap break-words rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-[#0d1117] p-4 overflow-auto max-h-[520px]">
|
||||
{JSON.stringify({ title: title.trim() || "Untitled wiki", doc: docJson }, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export const API_BASE_URL =
|
||||
export const API_ENDPOINTS = {
|
||||
geometries: `${API_BASE_URL}/geometries`,
|
||||
entities: `${API_BASE_URL}/entities`,
|
||||
wikis: `${API_BASE_URL}/wikis`,
|
||||
// New API uses projects + commits + submissions (JWT-protected).
|
||||
authSignin: `${API_BASE_URL}/auth/signin`,
|
||||
authRefresh: `${API_BASE_URL}/auth/refresh`,
|
||||
|
||||
30
src/uhm/api/wikis.ts
Normal file
30
src/uhm/api/wikis.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { API_ENDPOINTS } from "@/uhm/api/config";
|
||||
import { requestJson } from "@/uhm/api/http";
|
||||
|
||||
export type Wiki = {
|
||||
id: string;
|
||||
title?: string;
|
||||
content?: string;
|
||||
is_deleted?: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export async function searchWikisByTitle(title: string, options?: { limit?: number; cursor?: string; entityId?: string }): Promise<Wiki[]> {
|
||||
const keyword = title.trim();
|
||||
if (!keyword.length) return [];
|
||||
|
||||
const params = new URLSearchParams({ title: keyword });
|
||||
if (options?.limit && Number.isFinite(options.limit)) params.set("limit", String(Math.trunc(options.limit)));
|
||||
if (options?.cursor) params.set("cursor", options.cursor);
|
||||
if (options?.entityId) params.set("entity_id", options.entityId);
|
||||
|
||||
return requestJson<Wiki[]>(`${API_ENDPOINTS.wikis}?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function fetchWikiById(id: string): Promise<Wiki> {
|
||||
const wikiId = String(id || "").trim();
|
||||
if (!wikiId) throw new Error("Missing wiki id");
|
||||
return requestJson<Wiki>(`${API_ENDPOINTS.wikis}/${encodeURIComponent(wikiId)}`);
|
||||
}
|
||||
|
||||
166
src/uhm/components/EntityWikiBindingsPanel.tsx
Normal file
166
src/uhm/components/EntityWikiBindingsPanel.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Entity } from "@/uhm/types/entities";
|
||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
||||
|
||||
type EntityChoice = { id: string; name: string };
|
||||
type WikiChoice = { id: string; title: string; operation?: string };
|
||||
|
||||
type Props = {
|
||||
entities: EntityChoice[];
|
||||
wikis: WikiSnapshot[];
|
||||
links: EntityWikiLinkSnapshot[];
|
||||
setLinks: React.Dispatch<React.SetStateAction<EntityWikiLinkSnapshot[]>>;
|
||||
};
|
||||
|
||||
function wikiTitle(w: WikiSnapshot): string {
|
||||
const t = String(w.title || "").trim();
|
||||
return t.length ? t : "Untitled wiki";
|
||||
}
|
||||
|
||||
export default function EntityWikiBindingsPanel({ entities, wikis, links, setLinks }: Props) {
|
||||
const [activeEntityId, setActiveEntityId] = useState<string>("");
|
||||
|
||||
const wikiChoices: WikiChoice[] = useMemo(
|
||||
() =>
|
||||
(wikis || [])
|
||||
.filter((w) => w && typeof w.id === "string" && w.id.trim().length > 0)
|
||||
.map((w) => ({ id: w.id, title: wikiTitle(w), operation: w.operation })),
|
||||
[wikis]
|
||||
);
|
||||
|
||||
const entityChoices = useMemo(() => {
|
||||
const cleaned = (entities || []).filter((e) => e && typeof e.id === "string" && e.id.trim().length > 0);
|
||||
cleaned.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return cleaned;
|
||||
}, [entities]);
|
||||
|
||||
const activeLinks = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const l of links || []) {
|
||||
if (!l || l.entity_id !== activeEntityId) continue;
|
||||
if (l.is_deleted) continue;
|
||||
set.add(l.wiki_id);
|
||||
}
|
||||
return set;
|
||||
}, [activeEntityId, links]);
|
||||
|
||||
const toggle = (wikiId: string) => {
|
||||
if (!activeEntityId) return;
|
||||
const id = String(wikiId || "").trim();
|
||||
if (!id) return;
|
||||
|
||||
setLinks((prev) => {
|
||||
const next = [...prev];
|
||||
const idx = next.findIndex((l) => l.entity_id === activeEntityId && l.wiki_id === id);
|
||||
if (idx >= 0) {
|
||||
const existing = next[idx];
|
||||
const currentlyOn = !existing.is_deleted;
|
||||
next[idx] = {
|
||||
...existing,
|
||||
operation: currentlyOn ? "delete" : "reference",
|
||||
is_deleted: currentlyOn ? 1 : 0,
|
||||
};
|
||||
return next;
|
||||
}
|
||||
next.push({
|
||||
entity_id: activeEntityId,
|
||||
wiki_id: id,
|
||||
operation: "reference",
|
||||
is_deleted: 0,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px",
|
||||
background: "#0b1220",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #1f2937",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
|
||||
<div style={{ fontWeight: 700, fontSize: "14px" }}>Entity ↔ Wiki</div>
|
||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{links.length}</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "10px", display: "grid", gap: "8px" }}>
|
||||
<div>
|
||||
<div style={{ fontSize: "12px", color: "#94a3b8", marginBottom: "6px" }}>Entity</div>
|
||||
<select
|
||||
value={activeEntityId}
|
||||
onChange={(e) => setActiveEntityId(e.target.value)}
|
||||
style={{
|
||||
width: "100%",
|
||||
border: "1px solid #1f2937",
|
||||
background: "#0b1220",
|
||||
color: "#e5e7eb",
|
||||
borderRadius: "6px",
|
||||
padding: "8px 10px",
|
||||
fontSize: "12px",
|
||||
outline: "none",
|
||||
}}
|
||||
>
|
||||
<option value="">Select entity…</option>
|
||||
{entityChoices.map((e) => (
|
||||
<option key={e.id} value={e.id}>
|
||||
{e.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: "12px", color: "#94a3b8", marginBottom: "6px" }}>Wikis</div>
|
||||
{!wikiChoices.length ? (
|
||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>No wiki in project yet.</div>
|
||||
) : !activeEntityId ? (
|
||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>Pick an entity to bind wikis.</div>
|
||||
) : (
|
||||
<div style={{ display: "grid", gap: "6px" }}>
|
||||
{wikiChoices.slice(0, 12).map((w) => {
|
||||
const checked = activeLinks.has(w.id);
|
||||
const isRefWiki = (wikis.find((x) => x.id === w.id)?.source || "inline") === "ref";
|
||||
return (
|
||||
<label
|
||||
key={w.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #1f2937",
|
||||
cursor: "pointer",
|
||||
background: checked ? "#111827" : "transparent",
|
||||
}}
|
||||
title={w.id}
|
||||
>
|
||||
<input type="checkbox" checked={checked} onChange={() => toggle(w.id)} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ color: "#e5e7eb", fontSize: "12px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{w.title}
|
||||
{isRefWiki ? " (ref)" : ""}
|
||||
</div>
|
||||
<div style={{ color: "#94a3b8", fontSize: "11px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{w.id}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{wikiChoices.length > 12 ? (
|
||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>+{wikiChoices.length - 12} more…</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -267,6 +267,8 @@ export default function Map({
|
||||
maxZoom: MAP_MAX_ZOOM,
|
||||
style: {
|
||||
version: 8,
|
||||
// Needed for symbol/text layers (country labels).
|
||||
glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
|
||||
sources: {
|
||||
base: {
|
||||
type: "vector",
|
||||
@@ -339,6 +341,78 @@ export default function Map({
|
||||
"line-opacity": 0.85,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "country-labels",
|
||||
type: "symbol",
|
||||
source: "base",
|
||||
// New tiles build uses NaturalEarth label points layer name.
|
||||
// If your tile pipeline exposes a different name, adjust here.
|
||||
"source-layer": "ne_10m_admin_0_label_points",
|
||||
minzoom: 0,
|
||||
layout: {
|
||||
"text-field": [
|
||||
"coalesce",
|
||||
["get", "sr_subunit"],
|
||||
["get", "NAME_EN"],
|
||||
["get", "NAME"],
|
||||
["get", "ADMIN"],
|
||||
["get", "name"],
|
||||
"",
|
||||
],
|
||||
"text-size": [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["zoom"],
|
||||
0, 10,
|
||||
3, 11,
|
||||
6, 14,
|
||||
],
|
||||
"text-max-width": 10,
|
||||
"text-allow-overlap": false,
|
||||
"symbol-placement": "point",
|
||||
},
|
||||
paint: {
|
||||
"text-color": "#e2e8f0",
|
||||
"text-halo-color": "#0b1220",
|
||||
"text-halo-width": 1.2,
|
||||
"text-halo-blur": 0.5,
|
||||
},
|
||||
},
|
||||
// Fallback for tile pipelines that expose country labels under a generic "labels" layer.
|
||||
// Hidden/shown together with "country-labels" toggle.
|
||||
{
|
||||
id: "country-labels-alt",
|
||||
type: "symbol",
|
||||
source: "base",
|
||||
"source-layer": "labels",
|
||||
minzoom: 0,
|
||||
layout: {
|
||||
"text-field": [
|
||||
"coalesce",
|
||||
["get", "name"],
|
||||
["get", "title"],
|
||||
["get", "NAME"],
|
||||
"",
|
||||
],
|
||||
"text-size": [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["zoom"],
|
||||
0, 10,
|
||||
3, 11,
|
||||
6, 14,
|
||||
],
|
||||
"text-max-width": 10,
|
||||
"text-allow-overlap": false,
|
||||
"symbol-placement": "point",
|
||||
},
|
||||
paint: {
|
||||
"text-color": "#e2e8f0",
|
||||
"text-halo-color": "#0b1220",
|
||||
"text-halo-width": 1.2,
|
||||
"text-halo-blur": 0.5,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "regions-line",
|
||||
type: "line",
|
||||
@@ -1112,6 +1186,15 @@ function applyBackgroundLayerVisibility(
|
||||
visibility[layer.id] ? "visible" : "none"
|
||||
);
|
||||
}
|
||||
|
||||
// Keep fallback country label layer in sync with the primary toggle.
|
||||
if (map.getLayer("country-labels-alt")) {
|
||||
map.setLayoutProperty(
|
||||
"country-labels-alt",
|
||||
"visibility",
|
||||
visibility["country-labels"] ? "visible" : "none"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function syncRasterBaseVisibility(map: maplibregl.Map, shouldShow: boolean) {
|
||||
|
||||
181
src/uhm/components/ProjectEntityRefsPanel.tsx
Normal file
181
src/uhm/components/ProjectEntityRefsPanel.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { Entity } from "@/uhm/types/entities";
|
||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||
import { searchEntitiesByName } from "@/uhm/api/entities";
|
||||
|
||||
type Props = {
|
||||
entityRefs: EntitySnapshot[];
|
||||
setEntityRefs: React.Dispatch<React.SetStateAction<EntitySnapshot[]>>;
|
||||
};
|
||||
|
||||
export default function ProjectEntityRefsPanel({ entityRefs, setEntityRefs }: Props) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<Entity[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const searchRequestRef = useState(() => ({ id: 0 }))[0];
|
||||
|
||||
const existingIds = useMemo(() => new Set(entityRefs.map((e) => String(e.id))), [entityRefs]);
|
||||
|
||||
useEffect(() => {
|
||||
const keyword = query.trim();
|
||||
if (!keyword.length) {
|
||||
setResults([]);
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
const requestId = ++searchRequestRef.id;
|
||||
const t = window.setTimeout(async () => {
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const rows = await searchEntitiesByName(keyword, { limit: 20 });
|
||||
if (disposed || requestId !== searchRequestRef.id) return;
|
||||
setResults(rows);
|
||||
} catch (err) {
|
||||
if (disposed || requestId !== searchRequestRef.id) return;
|
||||
console.error("Search entities failed", err);
|
||||
setResults([]);
|
||||
} finally {
|
||||
if (disposed || requestId !== searchRequestRef.id) return;
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearTimeout(t);
|
||||
};
|
||||
}, [query, searchRequestRef]);
|
||||
|
||||
const addRef = (e: Entity) => {
|
||||
const id = String(e.id || "").trim();
|
||||
if (!id) return;
|
||||
if (existingIds.has(id)) return;
|
||||
setEntityRefs((prev) => [
|
||||
{
|
||||
id,
|
||||
source: "ref",
|
||||
ref: { id },
|
||||
operation: "reference",
|
||||
name: e.name,
|
||||
description: e.description ?? null,
|
||||
is_deleted: 0,
|
||||
},
|
||||
...prev,
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px",
|
||||
background: "#0b1220",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #1f2937",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
|
||||
<div style={{ fontWeight: 700, fontSize: "14px" }}>Entities</div>
|
||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{entityRefs.length}</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "10px" }}>
|
||||
<div style={{ fontSize: "12px", color: "#94a3b8", marginBottom: "6px" }}>Add existing entity</div>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(ev) => setQuery(ev.target.value)}
|
||||
placeholder="Search by name…"
|
||||
style={{
|
||||
width: "100%",
|
||||
border: "1px solid #1f2937",
|
||||
background: "#0b1220",
|
||||
color: "#e5e7eb",
|
||||
borderRadius: "6px",
|
||||
padding: "8px 10px",
|
||||
fontSize: "12px",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
{isSearching ? (
|
||||
<div style={{ marginTop: "6px", fontSize: "12px", color: "#94a3b8" }}>Searching…</div>
|
||||
) : null}
|
||||
{!isSearching && query.trim().length > 0 ? (
|
||||
<div style={{ marginTop: "6px", display: "grid", gap: "6px" }}>
|
||||
{results.slice(0, 8).map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #1f2937",
|
||||
background: "transparent",
|
||||
opacity: existingIds.has(r.id) ? 0.55 : 1,
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ color: "#e5e7eb", fontSize: "12px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{r.name}
|
||||
</div>
|
||||
<div style={{ color: "#94a3b8", fontSize: "11px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{r.id}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addRef(r)}
|
||||
disabled={existingIds.has(r.id)}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "#111827",
|
||||
color: existingIds.has(r.id) ? "#64748b" : "#93c5fd",
|
||||
cursor: existingIds.has(r.id) ? "not-allowed" : "pointer",
|
||||
borderRadius: "6px",
|
||||
padding: "6px 8px",
|
||||
fontSize: "12px",
|
||||
fontWeight: 700,
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{!results.length ? <div style={{ fontSize: "12px", color: "#94a3b8" }}>No results.</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{entityRefs.length ? (
|
||||
<div style={{ marginTop: "10px", display: "grid", gap: "6px" }}>
|
||||
{entityRefs.slice(0, 8).map((e) => (
|
||||
<div
|
||||
key={e.id}
|
||||
style={{
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #1f2937",
|
||||
background: "transparent",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: "12px", color: "#e5e7eb", fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{e.name || e.id}
|
||||
</div>
|
||||
<div style={{ fontSize: "11px", color: "#94a3b8", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{e.id}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{entityRefs.length > 8 ? <div style={{ fontSize: "12px", color: "#94a3b8" }}>+{entityRefs.length - 8} more…</div> : null}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>No entity ref yet for this project.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
587
src/uhm/components/WikiSidebarPanel.tsx
Normal file
587
src/uhm/components/WikiSidebarPanel.tsx
Normal file
@@ -0,0 +1,587 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { EditorContent, useEditor, type JSONContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import TiptapLink from "@tiptap/extension-link";
|
||||
import { searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
|
||||
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import Button from "@/components/ui/button/Button";
|
||||
import Badge from "@/components/ui/badge/Badge";
|
||||
import Label from "@/components/form/Label";
|
||||
|
||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
wikis: WikiSnapshot[];
|
||||
setWikis: React.Dispatch<React.SetStateAction<WikiSnapshot[]>>;
|
||||
autoOpen?: boolean;
|
||||
};
|
||||
|
||||
function newId() {
|
||||
try {
|
||||
return crypto.randomUUID();
|
||||
} catch {
|
||||
return `wiki_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function clampTitle(title: string) {
|
||||
const t = title.trim();
|
||||
return t.length ? t.slice(0, 120) : "Untitled wiki";
|
||||
}
|
||||
|
||||
export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const activeWiki = useMemo(() => wikis.find((w) => w.id === activeId) || null, [activeId, wikis]);
|
||||
|
||||
const [wikiTitle, setWikiTitle] = useState("");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<Wiki[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const searchRequestRef = useState(() => ({ id: 0 }))[0];
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
}),
|
||||
TiptapLink.configure({
|
||||
openOnClick: false,
|
||||
autolink: true,
|
||||
linkOnPaste: true,
|
||||
}),
|
||||
],
|
||||
content: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "" }] }] },
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "tiptap-editor focus:outline-none min-h-[320px] px-4 py-3",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoOpen) return;
|
||||
// open once on mount
|
||||
setOpen(true);
|
||||
}, [autoOpen]);
|
||||
|
||||
// keep editor content in sync when switching wiki
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
if (!open) return;
|
||||
|
||||
const doc = (activeWiki?.doc || null) as JSONContent | null;
|
||||
editor.commands.setContent(
|
||||
(doc && typeof doc === "object" ? doc : { type: "doc", content: [{ type: "paragraph" }] }) as any
|
||||
);
|
||||
setWikiTitle(activeWiki?.title || "");
|
||||
}, [activeWiki?.doc, activeWiki?.title, editor, open]);
|
||||
|
||||
const ensureActive = () => {
|
||||
if (activeId && wikis.some((w) => w.id === activeId)) return;
|
||||
setActiveId(wikis[0]?.id || null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
ensureActive();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [wikis.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const keyword = searchQuery.trim();
|
||||
if (!keyword.length) {
|
||||
setSearchResults([]);
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
const requestId = ++searchRequestRef.id;
|
||||
const t = window.setTimeout(async () => {
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const rows = await searchWikisByTitle(keyword, { limit: 12 });
|
||||
if (disposed || requestId !== searchRequestRef.id) return;
|
||||
setSearchResults(rows);
|
||||
} catch (err) {
|
||||
if (disposed || requestId !== searchRequestRef.id) return;
|
||||
console.error("Search wikis failed", err);
|
||||
setSearchResults([]);
|
||||
} finally {
|
||||
if (disposed || requestId !== searchRequestRef.id) return;
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearTimeout(t);
|
||||
};
|
||||
}, [searchQuery, searchRequestRef]);
|
||||
|
||||
const addWikiRef = (wiki: Wiki) => {
|
||||
const id = String(wiki.id || "").trim();
|
||||
if (!id) return;
|
||||
if (wikis.some((w) => w.id === id)) {
|
||||
setActiveId(id);
|
||||
return;
|
||||
}
|
||||
const title = (wiki.title || "").trim() || "Untitled wiki";
|
||||
setWikis((prev) => [
|
||||
{
|
||||
id,
|
||||
source: "ref",
|
||||
ref: { id },
|
||||
operation: "reference",
|
||||
title,
|
||||
doc: null,
|
||||
updated_at: wiki.updated_at,
|
||||
},
|
||||
...prev,
|
||||
]);
|
||||
setActiveId(id);
|
||||
};
|
||||
|
||||
const openEditor = () => {
|
||||
if (!wikis.length) {
|
||||
const id = newId();
|
||||
const seed: WikiSnapshot = {
|
||||
id,
|
||||
source: "inline",
|
||||
operation: "create",
|
||||
title: "Untitled wiki",
|
||||
doc: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "" }] }] },
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
setWikis((prev) => [seed, ...prev]);
|
||||
setActiveId(id);
|
||||
}
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const createWiki = () => {
|
||||
const id = newId();
|
||||
const next: WikiSnapshot = {
|
||||
id,
|
||||
source: "inline",
|
||||
operation: "create",
|
||||
title: "Untitled wiki",
|
||||
doc: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "" }] }] },
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
setWikis((prev) => [next, ...prev]);
|
||||
setActiveId(id);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const removeWiki = (id: string) => {
|
||||
setWikis((prev) => prev.filter((w) => w.id !== id));
|
||||
if (activeId === id) setActiveId(null);
|
||||
};
|
||||
|
||||
const saveWiki = () => {
|
||||
if (!editor || !activeId) return;
|
||||
const payload = editor.getJSON();
|
||||
const nextTitle = clampTitle(wikiTitle);
|
||||
setWikis((prev) =>
|
||||
prev.map((w) =>
|
||||
w.id !== activeId
|
||||
? w
|
||||
: {
|
||||
...w,
|
||||
source: w.source || "inline",
|
||||
operation: w.operation === "create" ? "create" : "update",
|
||||
title: nextTitle,
|
||||
doc: payload,
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
)
|
||||
);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const setLink = () => {
|
||||
if (!editor) return;
|
||||
const prev = editor.getAttributes("link")?.href as string | undefined;
|
||||
const href = window.prompt("Link URL", prev || "https://");
|
||||
if (href == null) return;
|
||||
const next = href.trim();
|
||||
if (!next.length) {
|
||||
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().extendMarkRange("link").setLink({ href: next }).run();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px",
|
||||
background: "#0b1220",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #1f2937",
|
||||
}}
|
||||
>
|
||||
<style jsx global>{`
|
||||
.tiptap-editor p {
|
||||
margin: 0.5rem 0;
|
||||
line-height: 1.65;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.tiptap-editor h1 {
|
||||
margin: 1rem 0 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.tiptap-editor h2 {
|
||||
margin: 0.9rem 0 0.4rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.tiptap-editor h3 {
|
||||
margin: 0.8rem 0 0.35rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.tiptap-editor ul,
|
||||
.tiptap-editor ol {
|
||||
margin: 0.6rem 0;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
.tiptap-editor li {
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
.tiptap-editor blockquote {
|
||||
margin: 0.75rem 0;
|
||||
padding-left: 0.75rem;
|
||||
border-left: 4px solid rgba(148, 163, 184, 0.55);
|
||||
color: rgba(100, 116, 139, 1);
|
||||
}
|
||||
.dark .tiptap-editor blockquote {
|
||||
border-left-color: rgba(71, 85, 105, 1);
|
||||
color: rgba(148, 163, 184, 1);
|
||||
}
|
||||
.tiptap-editor code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
"Liberation Mono", "Courier New", monospace;
|
||||
font-size: 0.85em;
|
||||
padding: 0.1rem 0.25rem;
|
||||
border-radius: 0.35rem;
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
}
|
||||
.tiptap-editor pre {
|
||||
margin: 0.8rem 0;
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(226, 232, 240, 1);
|
||||
background: rgba(248, 250, 252, 1);
|
||||
overflow: auto;
|
||||
}
|
||||
.dark .tiptap-editor pre {
|
||||
border-color: rgba(30, 41, 59, 1);
|
||||
background: rgba(13, 17, 23, 1);
|
||||
}
|
||||
.tiptap-editor pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
.tiptap-editor a {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
|
||||
<div style={{ fontWeight: 700, fontSize: "14px" }}>Wiki</div>
|
||||
<Badge size="sm" variant="light" color="info">
|
||||
{wikis.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "8px", marginTop: "10px" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openEditor}
|
||||
style={{
|
||||
flex: 1,
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
padding: "8px",
|
||||
cursor: "pointer",
|
||||
background: "#2563eb",
|
||||
color: "white",
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Open wiki editor
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={createWiki}
|
||||
title="New wiki"
|
||||
style={{
|
||||
width: "42px",
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
padding: "8px",
|
||||
cursor: "pointer",
|
||||
background: "#1f2937",
|
||||
color: "white",
|
||||
fontWeight: 900,
|
||||
}}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "10px" }}>
|
||||
<div style={{ fontSize: "12px", color: "#94a3b8", marginBottom: "6px" }}>Add existing wiki</div>
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search by title…"
|
||||
style={{
|
||||
width: "100%",
|
||||
border: "1px solid #1f2937",
|
||||
background: "#0b1220",
|
||||
color: "#e5e7eb",
|
||||
borderRadius: "6px",
|
||||
padding: "8px 10px",
|
||||
fontSize: "12px",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
{isSearching ? (
|
||||
<div style={{ marginTop: "6px", fontSize: "12px", color: "#94a3b8" }}>Searching…</div>
|
||||
) : null}
|
||||
{!isSearching && searchQuery.trim().length > 0 ? (
|
||||
<div style={{ marginTop: "6px", display: "grid", gap: "6px" }}>
|
||||
{searchResults.slice(0, 8).map((w) => (
|
||||
<div
|
||||
key={w.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #1f2937",
|
||||
background: "transparent",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ color: "#e5e7eb", fontSize: "12px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{(w.title || "").trim() || "Untitled wiki"}
|
||||
</div>
|
||||
<div style={{ color: "#94a3b8", fontSize: "11px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{w.id}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addWikiRef(w)}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "#111827",
|
||||
color: "#93c5fd",
|
||||
cursor: "pointer",
|
||||
borderRadius: "6px",
|
||||
padding: "6px 8px",
|
||||
fontSize: "12px",
|
||||
fontWeight: 700,
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{!searchResults.length ? (
|
||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>No results.</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{wikis.length ? (
|
||||
<div style={{ marginTop: "10px", display: "grid", gap: "8px" }}>
|
||||
{wikis.slice(0, 8).map((w) => (
|
||||
<div
|
||||
key={w.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #1f2937",
|
||||
background: w.id === activeId ? "#111827" : "transparent",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveId(w.id);
|
||||
setOpen(true);
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
textAlign: "left",
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: "#e5e7eb",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
title={w.title}
|
||||
>
|
||||
{w.title}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeWiki(w.id)}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "#111827",
|
||||
color: "#fca5a5",
|
||||
cursor: "pointer",
|
||||
borderRadius: "6px",
|
||||
padding: "6px 8px",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
title="Remove"
|
||||
>
|
||||
Del
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{wikis.length > 8 ? (
|
||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>+{wikis.length - 8} more…</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>
|
||||
No wiki yet for this project.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
isOpen={open}
|
||||
onClose={() => setOpen(false)}
|
||||
showCloseButton={false}
|
||||
// Defensive: even if Modal defaults change, keep wiki popup free of the "X" close button.
|
||||
className="max-w-[1100px] m-4 [&>button]:hidden"
|
||||
>
|
||||
<div className="p-6 bg-white rounded-3xl dark:bg-gray-900">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">Project</div>
|
||||
<div className="text-sm font-mono break-all text-gray-700 dark:text-gray-200">{projectId}</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" className="bg-brand-500 hover:bg-brand-600 text-white" onClick={saveWiki} disabled={!editor || !activeId}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
<div className="lg:col-span-1">
|
||||
<div className="text-xs font-semibold text-gray-700 dark:text-gray-200 mb-2">Wikis</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{wikis.map((w) => (
|
||||
<button
|
||||
key={w.id}
|
||||
type="button"
|
||||
onClick={() => setActiveId(w.id)}
|
||||
className={`text-left rounded-xl border px-3 py-2 text-sm transition ${
|
||||
w.id === activeId
|
||||
? "border-brand-500 bg-brand-50 dark:bg-brand-500/10"
|
||||
: "border-gray-200 dark:border-gray-800 bg-white dark:bg-[#0d1117]"
|
||||
}`}
|
||||
title={w.title}
|
||||
>
|
||||
<div className="font-medium truncate">{w.title}</div>
|
||||
<div className="text-[11px] text-gray-500 dark:text-gray-400 truncate">{w.id}</div>
|
||||
</button>
|
||||
))}
|
||||
<Button size="sm" variant="outline" onClick={createWiki}>
|
||||
+ New wiki
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-3">
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<div>
|
||||
<Label>Title</Label>
|
||||
<input
|
||||
value={wikiTitle}
|
||||
onChange={(e) => setWikiTitle(e.target.value)}
|
||||
className="h-11 w-full rounded-xl border border-gray-200 bg-transparent px-4 py-2.5 text-sm text-gray-800 outline-none focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:text-white/90 dark:focus:border-brand-800"
|
||||
placeholder="Wiki title"
|
||||
disabled={!activeId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleBold().run()} disabled={!editor}>
|
||||
B
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleItalic().run()} disabled={!editor}>
|
||||
I
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleHeading({ level: 1 }).run()} disabled={!editor}>
|
||||
H1
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()} disabled={!editor}>
|
||||
H2
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()} disabled={!editor}>
|
||||
H3
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleBulletList().run()} disabled={!editor}>
|
||||
Bullets
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleOrderedList().run()} disabled={!editor}>
|
||||
Numbers
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleBlockquote().run()} disabled={!editor}>
|
||||
Quote
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleCodeBlock().run()} disabled={!editor}>
|
||||
Code
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={setLink} disabled={!editor}>
|
||||
Link
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-[#0d1117]">
|
||||
{editor ? <EditorContent editor={editor} /> : <div className="p-4 text-sm text-gray-500">Loading editor...</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
Stored in snapshot_json on commit. This page does not write to DB yet.
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -14,7 +14,9 @@ import { buildEditorSnapshot, normalizeEditorSnapshot } from "@/uhm/lib/editor/s
|
||||
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
|
||||
import type { CreatedEntitySummary, PendingEntityCreate } from "@/uhm/lib/editor/session/sessionTypes";
|
||||
import type { Feature, FeatureCollection, FeatureId } from "@/uhm/types/geo";
|
||||
import type { EditorSnapshot, Section, SectionCommit, SectionState } from "@/uhm/types/sections";
|
||||
import type { EditorSnapshot, Section, SectionCommit, SectionState, EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
|
||||
type EditorDraftApi = {
|
||||
draft: FeatureCollection;
|
||||
@@ -33,6 +35,9 @@ type Options = {
|
||||
newSectionTitle: string;
|
||||
pendingSaveCount: number;
|
||||
pendingEntityCreates: PendingEntityCreate[];
|
||||
projectEntityRefs: EntitySnapshot[];
|
||||
wikis: WikiSnapshot[];
|
||||
entityWikiLinks: EntityWikiLinkSnapshot[];
|
||||
lastSectionSnapshot: EditorSnapshot | null;
|
||||
commitTitle: string;
|
||||
commitNote: string;
|
||||
@@ -43,7 +48,10 @@ type Options = {
|
||||
setInitialData: Dispatch<SetStateAction<FeatureCollection>>;
|
||||
setSectionCommits: Dispatch<SetStateAction<SectionCommit[]>>;
|
||||
setPendingEntityCreates: Dispatch<SetStateAction<PendingEntityCreate[]>>;
|
||||
setProjectEntityRefs: Dispatch<SetStateAction<EntitySnapshot[]>>;
|
||||
setCreatedEntities: Dispatch<SetStateAction<CreatedEntitySummary[]>>;
|
||||
setWikis: Dispatch<SetStateAction<WikiSnapshot[]>>;
|
||||
setEntityWikiLinks: Dispatch<SetStateAction<EntityWikiLinkSnapshot[]>>;
|
||||
setSelectedFeatureId: Dispatch<SetStateAction<FeatureId | null>>;
|
||||
setEntityFormStatus: Dispatch<SetStateAction<string | null>>;
|
||||
setEntityStatus: Dispatch<SetStateAction<string | null>>;
|
||||
@@ -71,6 +79,9 @@ export function useSectionCommands(options: Options) {
|
||||
options.setSectionCommits(commits);
|
||||
options.setPendingEntityCreates([]);
|
||||
options.setCreatedEntities([]);
|
||||
options.setProjectEntityRefs((snapshot?.entities || []).filter((e) => e?.operation === "reference"));
|
||||
options.setWikis(snapshot?.wikis || []);
|
||||
options.setEntityWikiLinks(snapshot?.entity_wikis || []);
|
||||
options.setSelectedFeatureId(null);
|
||||
options.setEntityFormStatus(null);
|
||||
}, [options]);
|
||||
@@ -90,6 +101,9 @@ export function useSectionCommands(options: Options) {
|
||||
draft: options.editor.draft,
|
||||
changes: geometryChanges,
|
||||
pendingEntities: options.pendingEntityCreates,
|
||||
projectEntityRefs: options.projectEntityRefs,
|
||||
wikis: options.wikis,
|
||||
entityWikiLinks: options.entityWikiLinks,
|
||||
previousSnapshot: options.lastSectionSnapshot,
|
||||
hasPersistedFeature: options.editor.hasPersistedFeature,
|
||||
});
|
||||
@@ -233,6 +247,7 @@ export function useSectionCommands(options: Options) {
|
||||
if (snapshot?.editor_feature_collection) {
|
||||
options.setInitialData(snapshot.editor_feature_collection);
|
||||
}
|
||||
options.setWikis(snapshot?.wikis || []);
|
||||
options.setSectionCommits(await fetchSectionCommits(options.activeSection.id));
|
||||
options.setEntityFormStatus("Đã restore commit.");
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import type { Entity } from "@/uhm/types/entities";
|
||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||
import type { FeatureId } from "@/uhm/types/geo";
|
||||
import { DEFAULT_ENTITY_TYPE_ID } from "@/uhm/lib/entityTypeOptions";
|
||||
import type {
|
||||
@@ -12,6 +13,8 @@ import type {
|
||||
export function useEntitySessionState() {
|
||||
// Entities đã persisted từ backend (dùng cho search/binding).
|
||||
const [persistedEntities, setPersistedEntities] = useState<Entity[]>([]);
|
||||
// Entities được "pin" vào project dưới dạng reference (không cần chọn geometry).
|
||||
const [projectEntityRefs, setProjectEntityRefs] = useState<EntitySnapshot[]>([]);
|
||||
// Entities tạo mới trong phiên nhưng chưa commit lên backend.
|
||||
const [pendingEntityCreates, setPendingEntityCreates] = useState<PendingEntityCreate[]>([]);
|
||||
// Tóm tắt entities đã tạo (để hiển thị nhanh ở sidebar).
|
||||
@@ -50,6 +53,8 @@ export function useEntitySessionState() {
|
||||
return {
|
||||
persistedEntities,
|
||||
setPersistedEntities,
|
||||
projectEntityRefs,
|
||||
setProjectEntityRefs,
|
||||
pendingEntityCreates,
|
||||
setPendingEntityCreates,
|
||||
createdEntities,
|
||||
|
||||
9
src/uhm/lib/editor/session/useWikiSessionState.ts
Normal file
9
src/uhm/lib/editor/session/useWikiSessionState.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
||||
|
||||
export function useWikiSessionState() {
|
||||
const [wikis, setWikis] = useState<WikiSnapshot[]>([]);
|
||||
const [entityWikiLinks, setEntityWikiLinks] = useState<EntityWikiLinkSnapshot[]>([]);
|
||||
return { wikis, setWikis, entityWikiLinks, setEntityWikiLinks };
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import type { PendingEntityCreate } from "@/uhm/lib/editor/session/sessionTypes"
|
||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||
import type { Feature, FeatureCollection, GeometrySnapshot, LinkScopeSnapshot } from "@/uhm/types/geo";
|
||||
import type { EditorSnapshot, Section } from "@/uhm/types/sections";
|
||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
||||
|
||||
export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
@@ -26,6 +28,9 @@ export function buildEditorSnapshot(options: {
|
||||
draft: FeatureCollection;
|
||||
changes: Change[];
|
||||
pendingEntities: PendingEntityCreate[];
|
||||
projectEntityRefs: EntitySnapshot[];
|
||||
wikis: WikiSnapshot[];
|
||||
entityWikiLinks: EntityWikiLinkSnapshot[];
|
||||
previousSnapshot: EditorSnapshot | null;
|
||||
hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean;
|
||||
}): EditorSnapshot {
|
||||
@@ -55,13 +60,10 @@ export function buildEditorSnapshot(options: {
|
||||
|
||||
const pendingEntityIds = new Set(options.pendingEntities.map((entity) => entity.id));
|
||||
const entityRows = new globalThis.Map<string, EntitySnapshot>();
|
||||
for (const item of options.previousSnapshot?.entities || []) {
|
||||
const id = typeof item.id === "string" || typeof item.id === "number" ? String(item.id) : "";
|
||||
if (id) entityRows.set(id, { ...item });
|
||||
}
|
||||
for (const entity of options.pendingEntities) {
|
||||
entityRows.set(entity.id, {
|
||||
id: entity.id,
|
||||
source: "inline",
|
||||
operation: "create",
|
||||
name: entity.name,
|
||||
slug: entity.slug,
|
||||
@@ -72,11 +74,40 @@ export function buildEditorSnapshot(options: {
|
||||
});
|
||||
}
|
||||
|
||||
for (const ref of options.projectEntityRefs || []) {
|
||||
const id = typeof ref?.id === "string" || typeof ref?.id === "number" ? String(ref.id) : "";
|
||||
if (!id || entityRows.has(id)) continue;
|
||||
const cloned = JSON.parse(JSON.stringify(ref)) as EntitySnapshot;
|
||||
entityRows.set(id, {
|
||||
...cloned,
|
||||
id,
|
||||
source: cloned.source || "ref",
|
||||
ref: cloned.ref || { id },
|
||||
operation: "reference",
|
||||
is_deleted: cloned.is_deleted ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Entities referenced by wiki links should be present as "reference" too.
|
||||
for (const link of options.entityWikiLinks || []) {
|
||||
const id = typeof link?.entity_id === "string" ? link.entity_id : "";
|
||||
if (!id || entityRows.has(id)) continue;
|
||||
entityRows.set(id, {
|
||||
id,
|
||||
source: "ref",
|
||||
ref: { id },
|
||||
operation: "reference",
|
||||
is_deleted: 0,
|
||||
});
|
||||
}
|
||||
|
||||
for (const feature of options.draft.features) {
|
||||
for (const entityId of normalizeFeatureEntityIds(feature)) {
|
||||
if (entityRows.has(entityId)) continue;
|
||||
entityRows.set(entityId, {
|
||||
id: entityId,
|
||||
source: "ref",
|
||||
ref: { id: entityId },
|
||||
operation: "reference",
|
||||
name: feature.properties.entity_names?.[0] || feature.properties.entity_name || entityId,
|
||||
slug: null,
|
||||
@@ -95,17 +126,19 @@ export function buildEditorSnapshot(options: {
|
||||
const changedFromPreviousSnapshot = previousFeature
|
||||
? JSON.stringify(previousFeature) !== JSON.stringify(feature)
|
||||
: false;
|
||||
const operation: GeometrySnapshot["operation"] = previousOperation === "create"
|
||||
const operation: GeometrySnapshot["operation"] =
|
||||
previousOperation === "create"
|
||||
? "create"
|
||||
: !previousFeature && (options.previousSnapshot || !options.hasPersistedFeature(feature.properties.id))
|
||||
? "create"
|
||||
: changedIds.has(id) || changedFromPreviousSnapshot
|
||||
? "update"
|
||||
: "reference";
|
||||
: undefined;
|
||||
const bbox = getFeatureBBox(feature);
|
||||
return {
|
||||
id,
|
||||
operation,
|
||||
source: "inline",
|
||||
type: feature.properties.type || getDefaultTypeIdForFeature(feature),
|
||||
draw_geometry: feature.geometry,
|
||||
binding: normalizeFeatureBindingIds(feature),
|
||||
@@ -134,11 +167,63 @@ export function buildEditorSnapshot(options: {
|
||||
const linkScopes: LinkScopeSnapshot[] = options.draft.features
|
||||
.map((feature) => ({
|
||||
geometry_id: String(feature.properties.id),
|
||||
operation: "replace" as const,
|
||||
operation: "reference" as const,
|
||||
entity_ids: normalizeFeatureEntityIds(feature),
|
||||
}))
|
||||
.filter((scope) => scope.entity_ids.length > 0);
|
||||
|
||||
const previousWikis = new globalThis.Map<string, WikiSnapshot>();
|
||||
for (const item of options.previousSnapshot?.wikis || []) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
const id = typeof (item as any).id === "string" ? String((item as any).id) : "";
|
||||
if (id) previousWikis.set(id, item as WikiSnapshot);
|
||||
}
|
||||
|
||||
// Wikis in snapshot_json are treated as current state (not a delta-table like geometries[]).
|
||||
// Operation semantics:
|
||||
// - create/update/delete: this commit changes the wiki itself
|
||||
// - reference: this wiki is a ref used for linking (entity<->wiki), not a modification
|
||||
const wikis: WikiSnapshot[] = (options.wikis || [])
|
||||
.filter((w) => w && typeof w.id === "string" && w.id.trim().length > 0)
|
||||
.map((w) => {
|
||||
const prev = previousWikis.get(w.id) || null;
|
||||
const cloned = JSON.parse(JSON.stringify(w)) as WikiSnapshot;
|
||||
|
||||
cloned.source = cloned.source || "inline";
|
||||
|
||||
// Ref wiki: always mark as reference (used for linking, not changed here).
|
||||
if (cloned.source === "ref") {
|
||||
cloned.ref = cloned.ref || { id: cloned.id };
|
||||
cloned.operation = "reference";
|
||||
return cloned;
|
||||
}
|
||||
|
||||
// Inline wiki: if explicitly marked create/update/delete by UI, keep it.
|
||||
if (cloned.operation === "create" || cloned.operation === "update" || cloned.operation === "delete") {
|
||||
return cloned;
|
||||
}
|
||||
|
||||
// Inline wiki with no explicit operation: mark update only if changed; otherwise omit operation.
|
||||
if (!prev) {
|
||||
// New wiki that somehow has no op set: treat as create.
|
||||
cloned.operation = "create";
|
||||
return cloned;
|
||||
}
|
||||
|
||||
const changed = (() => {
|
||||
try {
|
||||
const prevComparable = { title: (prev as any).title, doc: (prev as any).doc };
|
||||
const nextComparable = { title: (cloned as any).title, doc: (cloned as any).doc };
|
||||
return JSON.stringify(prevComparable) !== JSON.stringify(nextComparable);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
})();
|
||||
|
||||
cloned.operation = changed ? "update" : undefined;
|
||||
return cloned;
|
||||
});
|
||||
|
||||
return {
|
||||
schema_version: 1,
|
||||
section: {
|
||||
@@ -153,6 +238,8 @@ export function buildEditorSnapshot(options: {
|
||||
}),
|
||||
geometries,
|
||||
link_scopes: linkScopes,
|
||||
wikis,
|
||||
entity_wikis: JSON.parse(JSON.stringify(options.entityWikiLinks || [])) as EntityWikiLinkSnapshot[],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||
import type { FeatureCollection, GeometrySnapshot, LinkScopeSnapshot } from "@/uhm/types/geo";
|
||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
|
||||
export type EntityWikiLinkSnapshot = {
|
||||
entity_id: string;
|
||||
wiki_id: string;
|
||||
operation?: "reference" | "delete";
|
||||
is_deleted?: number;
|
||||
};
|
||||
|
||||
// API mới (BackEndGo) dùng Projects/Commits/Submissions.
|
||||
// Giữ tên type "Section" để tránh thay đổi lan rộng trong FE hiện tại.
|
||||
@@ -62,6 +70,8 @@ export type EditorSnapshot = {
|
||||
entities?: EntitySnapshot[];
|
||||
geometries?: GeometrySnapshot[];
|
||||
link_scopes?: LinkScopeSnapshot[];
|
||||
wikis?: WikiSnapshot[];
|
||||
entity_wikis?: EntityWikiLinkSnapshot[];
|
||||
};
|
||||
|
||||
export type EditorLoadResponse = {
|
||||
|
||||
16
src/uhm/types/wiki.ts
Normal file
16
src/uhm/types/wiki.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type WikiDoc = unknown;
|
||||
|
||||
export type WikiSnapshotOperation = "create" | "update" | "delete" | "reference";
|
||||
|
||||
export type WikiSnapshot = {
|
||||
id: string;
|
||||
source?: "inline" | "ref";
|
||||
ref?: { id: string };
|
||||
// Optional for backwards-compat with older commits. New commits should include it.
|
||||
operation?: WikiSnapshotOperation;
|
||||
title: string;
|
||||
doc: WikiDoc;
|
||||
updated_at?: string;
|
||||
// Optional, used when representing a delete operation row.
|
||||
is_deleted?: number;
|
||||
};
|
||||
Reference in New Issue
Block a user