pre view wiki
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",
|
"@reduxjs/toolkit": "^2.11.2",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tailwindcss/postcss": "^4.1.17",
|
"@tailwindcss/postcss": "^4.1.17",
|
||||||
|
"@tiptap/extension-link": "^2.26.1",
|
||||||
|
"@tiptap/react": "^2.26.1",
|
||||||
|
"@tiptap/starter-kit": "^2.26.1",
|
||||||
"apexcharts": "^4.7.0",
|
"apexcharts": "^4.7.0",
|
||||||
"autoprefixer": "^10.4.22",
|
"autoprefixer": "^10.4.22",
|
||||||
"axios": "^1.14.0",
|
"axios": "^1.14.0",
|
||||||
@@ -2893,6 +2896,16 @@
|
|||||||
"node": ">=12.4.0"
|
"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": {
|
"node_modules/@react-dnd/asap": {
|
||||||
"version": "5.0.2",
|
"version": "5.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz",
|
"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": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||||
@@ -3602,6 +3621,410 @@
|
|||||||
"tailwindcss": "4.2.2"
|
"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": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.1",
|
"version": "0.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||||
@@ -3645,6 +4068,28 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.19.37",
|
"version": "20.19.37",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
|
||||||
@@ -4350,7 +4795,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "Python-2.0"
|
"license": "Python-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/aria-query": {
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -5547,7 +5997,6 @@
|
|||||||
"version": "4.5.0",
|
"version": "4.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.12"
|
"node": ">=0.12"
|
||||||
@@ -5753,7 +6202,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
@@ -7658,6 +8106,21 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"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"
|
"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": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -7798,6 +8278,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "CC0-1.0"
|
"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": {
|
"node_modules/merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
@@ -8198,6 +8684,12 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/own-keys": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
|
||||||
@@ -8436,6 +8928,204 @@
|
|||||||
"react-is": "^16.13.1"
|
"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": {
|
"node_modules/protocol-buffers-schema": {
|
||||||
"version": "3.6.1",
|
"version": "3.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
|
||||||
@@ -8461,6 +9151,15 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@@ -8867,6 +9566,12 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/run-parallel": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||||
@@ -9576,6 +10281,15 @@
|
|||||||
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
|
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"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"
|
"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": {
|
"node_modules/unbox-primitive": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
||||||
@@ -9928,6 +10648,12 @@
|
|||||||
"uuid": "dist/bin/uuid"
|
"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": {
|
"node_modules/warning": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/warning/-/warning-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/warning/-/warning-3.0.0.tgz",
|
||||||
|
|||||||
@@ -20,6 +20,9 @@
|
|||||||
"@reduxjs/toolkit": "^2.11.2",
|
"@reduxjs/toolkit": "^2.11.2",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tailwindcss/postcss": "^4.1.17",
|
"@tailwindcss/postcss": "^4.1.17",
|
||||||
|
"@tiptap/extension-link": "^2.26.1",
|
||||||
|
"@tiptap/react": "^2.26.1",
|
||||||
|
"@tiptap/starter-kit": "^2.26.1",
|
||||||
"apexcharts": "^4.7.0",
|
"apexcharts": "^4.7.0",
|
||||||
"autoprefixer": "^10.4.22",
|
"autoprefixer": "^10.4.22",
|
||||||
"axios": "^1.14.0",
|
"axios": "^1.14.0",
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
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 Map from "@/uhm/components/Map";
|
||||||
import Editor from "@/uhm/components/Editor";
|
import Editor from "@/uhm/components/Editor";
|
||||||
import BackgroundLayersPanel from "@/uhm/components/BackgroundLayersPanel";
|
import BackgroundLayersPanel from "@/uhm/components/BackgroundLayersPanel";
|
||||||
import TimelineBar from "@/uhm/components/TimelineBar";
|
import TimelineBar from "@/uhm/components/TimelineBar";
|
||||||
import SelectedGeometryPanel from "@/uhm/components/SelectedGeometryPanel";
|
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 { Entity, fetchEntities, searchEntitiesByName } from "@/uhm/api/entities";
|
||||||
import { ApiError } from "@/uhm/api/http";
|
import { ApiError } from "@/uhm/api/http";
|
||||||
import { fetchCurrentUser } from "@/uhm/api/auth";
|
import { fetchCurrentUser } from "@/uhm/api/auth";
|
||||||
@@ -63,8 +66,11 @@ const DEFAULT_EDITOR_USER_ID = "local-editor";
|
|||||||
export default function Page() {
|
export default function Page() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const projectId = String(params.id || "");
|
const projectId = String(params.id || "");
|
||||||
const openedProjectIdRef = useRef<string | null>(null);
|
const openedProjectIdRef = useRef<string | null>(null);
|
||||||
|
const autoOpenWiki = searchParams.get("only") === "wiki";
|
||||||
|
const wikiOnly = autoOpenWiki;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mode,
|
mode,
|
||||||
@@ -99,6 +105,8 @@ export default function Page() {
|
|||||||
setLastSectionSnapshot,
|
setLastSectionSnapshot,
|
||||||
persistedEntities,
|
persistedEntities,
|
||||||
setPersistedEntities,
|
setPersistedEntities,
|
||||||
|
projectEntityRefs,
|
||||||
|
setProjectEntityRefs,
|
||||||
pendingEntityCreates,
|
pendingEntityCreates,
|
||||||
setPendingEntityCreates,
|
setPendingEntityCreates,
|
||||||
createdEntities,
|
createdEntities,
|
||||||
@@ -137,6 +145,10 @@ export default function Page() {
|
|||||||
setBackgroundVisibility,
|
setBackgroundVisibility,
|
||||||
isBackgroundVisibilityReady,
|
isBackgroundVisibilityReady,
|
||||||
setIsBackgroundVisibilityReady,
|
setIsBackgroundVisibilityReady,
|
||||||
|
wikis,
|
||||||
|
setWikis,
|
||||||
|
entityWikiLinks,
|
||||||
|
setEntityWikiLinks,
|
||||||
} = useEditorSessionState({
|
} = useEditorSessionState({
|
||||||
emptyFeatureCollection: EMPTY_FEATURE_COLLECTION,
|
emptyFeatureCollection: EMPTY_FEATURE_COLLECTION,
|
||||||
defaultEditorUserId: DEFAULT_EDITOR_USER_ID,
|
defaultEditorUserId: DEFAULT_EDITOR_USER_ID,
|
||||||
@@ -154,6 +166,20 @@ export default function Page() {
|
|||||||
() => mergeEntitiesWithPending(persistedEntities, pendingEntityCreates),
|
() => mergeEntitiesWithPending(persistedEntities, pendingEntityCreates),
|
||||||
[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 =
|
const selectedFeature =
|
||||||
selectedFeatureId === null
|
selectedFeatureId === null
|
||||||
? null
|
? null
|
||||||
@@ -186,6 +212,17 @@ export default function Page() {
|
|||||||
return rows;
|
return rows;
|
||||||
}, [editor.changes, entities]);
|
}, [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({
|
const sectionCommands = useSectionCommands({
|
||||||
editor,
|
editor,
|
||||||
editorUserId,
|
editorUserId,
|
||||||
@@ -194,8 +231,11 @@ export default function Page() {
|
|||||||
sectionState,
|
sectionState,
|
||||||
selectedSectionId,
|
selectedSectionId,
|
||||||
newSectionTitle,
|
newSectionTitle,
|
||||||
pendingSaveCount: editor.changeCount + pendingEntityCreates.length,
|
pendingSaveCount,
|
||||||
pendingEntityCreates,
|
pendingEntityCreates,
|
||||||
|
projectEntityRefs,
|
||||||
|
wikis,
|
||||||
|
entityWikiLinks,
|
||||||
lastSectionSnapshot,
|
lastSectionSnapshot,
|
||||||
commitTitle,
|
commitTitle,
|
||||||
commitNote,
|
commitNote,
|
||||||
@@ -206,7 +246,10 @@ export default function Page() {
|
|||||||
setInitialData,
|
setInitialData,
|
||||||
setSectionCommits,
|
setSectionCommits,
|
||||||
setPendingEntityCreates,
|
setPendingEntityCreates,
|
||||||
|
setProjectEntityRefs,
|
||||||
setCreatedEntities,
|
setCreatedEntities,
|
||||||
|
setWikis,
|
||||||
|
setEntityWikiLinks,
|
||||||
setEntityFormStatus,
|
setEntityFormStatus,
|
||||||
setSelectedFeatureId,
|
setSelectedFeatureId,
|
||||||
setEntityStatus,
|
setEntityStatus,
|
||||||
@@ -659,7 +702,6 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const pendingSaveCount = editor.changeCount + pendingEntityCreates.length;
|
|
||||||
const headCommit = sectionState?.head_commit_id
|
const headCommit = sectionState?.head_commit_id
|
||||||
? sectionCommits.find((commit) => commit.id === sectionState.head_commit_id) || null
|
? sectionCommits.find((commit) => commit.id === sectionState.head_commit_id) || null
|
||||||
: null;
|
: null;
|
||||||
@@ -705,6 +747,7 @@ export default function Page() {
|
|||||||
createdGeometries={createdGeometries}
|
createdGeometries={createdGeometries}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{!wikiOnly ? (
|
||||||
<div style={{ flex: 1, position: "relative", minHeight: "100vh" }}>
|
<div style={{ flex: 1, position: "relative", minHeight: "100vh" }}>
|
||||||
{isBackgroundVisibilityReady ? (
|
{isBackgroundVisibilityReady ? (
|
||||||
<Map
|
<Map
|
||||||
@@ -728,6 +771,10 @@ export default function Page() {
|
|||||||
statusText={timelineStatusText}
|
statusText={timelineStatusText}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
// Wiki-only mode: avoid mounting Map/Timeline (WebGL + geometry fetching) to reduce lag.
|
||||||
|
<div style={{ flex: 1, minHeight: "100vh", background: "#0b1220" }} />
|
||||||
|
)}
|
||||||
|
|
||||||
<BackgroundLayersPanel
|
<BackgroundLayersPanel
|
||||||
visibility={backgroundVisibility}
|
visibility={backgroundVisibility}
|
||||||
@@ -735,6 +782,16 @@ export default function Page() {
|
|||||||
onShowAll={handleShowAllBackgroundLayers}
|
onShowAll={handleShowAllBackgroundLayers}
|
||||||
onHideAll={handleHideAllBackgroundLayers}
|
onHideAll={handleHideAllBackgroundLayers}
|
||||||
topContent={
|
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
|
<SelectedGeometryPanel
|
||||||
selectedFeature={selectedFeature}
|
selectedFeature={selectedFeature}
|
||||||
selectedFeatureEntitySummary={
|
selectedFeatureEntitySummary={
|
||||||
@@ -769,6 +826,8 @@ export default function Page() {
|
|||||||
changeCount={editor.changeCount}
|
changeCount={editor.changeCount}
|
||||||
entityFormStatus={entityFormStatus}
|
entityFormStatus={entityFormStatus}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -311,6 +311,9 @@ export default function ProjectDetailsPage() {
|
|||||||
<Button size="sm" variant="outline" onClick={() => router.push(`/editor/${id}`)}>
|
<Button size="sm" variant="outline" onClick={() => router.push(`/editor/${id}`)}>
|
||||||
Mo editor
|
Mo editor
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => router.push(`/editor/${id}?only=wiki`)}>
|
||||||
|
Editor only wiki
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ export default function ProjectsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -225,6 +225,13 @@ export default function ProjectsPage() {
|
|||||||
>
|
>
|
||||||
Editor
|
Editor
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push(`/editor/${project.id}?only=wiki`)}
|
||||||
|
>
|
||||||
|
Editor only wiki
|
||||||
|
</Button>
|
||||||
|
|
||||||
<div className="flex -space-x-2 overflow-hidden">
|
<div className="flex -space-x-2 overflow-hidden">
|
||||||
{project.members && project.members.length > 0 ? (
|
{project.members && project.members.length > 0 ? (
|
||||||
|
|||||||
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 = {
|
export const API_ENDPOINTS = {
|
||||||
geometries: `${API_BASE_URL}/geometries`,
|
geometries: `${API_BASE_URL}/geometries`,
|
||||||
entities: `${API_BASE_URL}/entities`,
|
entities: `${API_BASE_URL}/entities`,
|
||||||
|
wikis: `${API_BASE_URL}/wikis`,
|
||||||
// New API uses projects + commits + submissions (JWT-protected).
|
// New API uses projects + commits + submissions (JWT-protected).
|
||||||
authSignin: `${API_BASE_URL}/auth/signin`,
|
authSignin: `${API_BASE_URL}/auth/signin`,
|
||||||
authRefresh: `${API_BASE_URL}/auth/refresh`,
|
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,
|
maxZoom: MAP_MAX_ZOOM,
|
||||||
style: {
|
style: {
|
||||||
version: 8,
|
version: 8,
|
||||||
|
// Needed for symbol/text layers (country labels).
|
||||||
|
glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
|
||||||
sources: {
|
sources: {
|
||||||
base: {
|
base: {
|
||||||
type: "vector",
|
type: "vector",
|
||||||
@@ -339,6 +341,78 @@ export default function Map({
|
|||||||
"line-opacity": 0.85,
|
"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",
|
id: "regions-line",
|
||||||
type: "line",
|
type: "line",
|
||||||
@@ -1112,6 +1186,15 @@ function applyBackgroundLayerVisibility(
|
|||||||
visibility[layer.id] ? "visible" : "none"
|
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) {
|
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: "land", label: "Land" },
|
||||||
{ id: "bg-countries-fill", label: "Countries" },
|
{ id: "bg-countries-fill", label: "Countries" },
|
||||||
{ id: "bg-country-borders-line", label: "Country Borders" },
|
{ id: "bg-country-borders-line", label: "Country Borders" },
|
||||||
|
{ id: "country-labels", label: "Country Labels" },
|
||||||
{ id: "regions-line", label: "Regions" },
|
{ id: "regions-line", label: "Regions" },
|
||||||
{ id: "lakes-fill", label: "Lakes" },
|
{ id: "lakes-fill", label: "Lakes" },
|
||||||
{ id: "rivers-line", label: "Rivers" },
|
{ 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 { Change } from "@/uhm/lib/editor/draft/editorTypes";
|
||||||
import type { CreatedEntitySummary, PendingEntityCreate } from "@/uhm/lib/editor/session/sessionTypes";
|
import type { CreatedEntitySummary, PendingEntityCreate } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
import type { Feature, FeatureCollection, FeatureId } from "@/uhm/types/geo";
|
import type { Feature, FeatureCollection, FeatureId } from "@/uhm/types/geo";
|
||||||
import type { EditorSnapshot, Section, SectionCommit, SectionState } 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 = {
|
type EditorDraftApi = {
|
||||||
draft: FeatureCollection;
|
draft: FeatureCollection;
|
||||||
@@ -33,6 +35,9 @@ type Options = {
|
|||||||
newSectionTitle: string;
|
newSectionTitle: string;
|
||||||
pendingSaveCount: number;
|
pendingSaveCount: number;
|
||||||
pendingEntityCreates: PendingEntityCreate[];
|
pendingEntityCreates: PendingEntityCreate[];
|
||||||
|
projectEntityRefs: EntitySnapshot[];
|
||||||
|
wikis: WikiSnapshot[];
|
||||||
|
entityWikiLinks: EntityWikiLinkSnapshot[];
|
||||||
lastSectionSnapshot: EditorSnapshot | null;
|
lastSectionSnapshot: EditorSnapshot | null;
|
||||||
commitTitle: string;
|
commitTitle: string;
|
||||||
commitNote: string;
|
commitNote: string;
|
||||||
@@ -43,7 +48,10 @@ type Options = {
|
|||||||
setInitialData: Dispatch<SetStateAction<FeatureCollection>>;
|
setInitialData: Dispatch<SetStateAction<FeatureCollection>>;
|
||||||
setSectionCommits: Dispatch<SetStateAction<SectionCommit[]>>;
|
setSectionCommits: Dispatch<SetStateAction<SectionCommit[]>>;
|
||||||
setPendingEntityCreates: Dispatch<SetStateAction<PendingEntityCreate[]>>;
|
setPendingEntityCreates: Dispatch<SetStateAction<PendingEntityCreate[]>>;
|
||||||
|
setProjectEntityRefs: Dispatch<SetStateAction<EntitySnapshot[]>>;
|
||||||
setCreatedEntities: Dispatch<SetStateAction<CreatedEntitySummary[]>>;
|
setCreatedEntities: Dispatch<SetStateAction<CreatedEntitySummary[]>>;
|
||||||
|
setWikis: Dispatch<SetStateAction<WikiSnapshot[]>>;
|
||||||
|
setEntityWikiLinks: Dispatch<SetStateAction<EntityWikiLinkSnapshot[]>>;
|
||||||
setSelectedFeatureId: Dispatch<SetStateAction<FeatureId | null>>;
|
setSelectedFeatureId: Dispatch<SetStateAction<FeatureId | null>>;
|
||||||
setEntityFormStatus: Dispatch<SetStateAction<string | null>>;
|
setEntityFormStatus: Dispatch<SetStateAction<string | null>>;
|
||||||
setEntityStatus: Dispatch<SetStateAction<string | null>>;
|
setEntityStatus: Dispatch<SetStateAction<string | null>>;
|
||||||
@@ -71,6 +79,9 @@ export function useSectionCommands(options: Options) {
|
|||||||
options.setSectionCommits(commits);
|
options.setSectionCommits(commits);
|
||||||
options.setPendingEntityCreates([]);
|
options.setPendingEntityCreates([]);
|
||||||
options.setCreatedEntities([]);
|
options.setCreatedEntities([]);
|
||||||
|
options.setProjectEntityRefs((snapshot?.entities || []).filter((e) => e?.operation === "reference"));
|
||||||
|
options.setWikis(snapshot?.wikis || []);
|
||||||
|
options.setEntityWikiLinks(snapshot?.entity_wikis || []);
|
||||||
options.setSelectedFeatureId(null);
|
options.setSelectedFeatureId(null);
|
||||||
options.setEntityFormStatus(null);
|
options.setEntityFormStatus(null);
|
||||||
}, [options]);
|
}, [options]);
|
||||||
@@ -90,6 +101,9 @@ export function useSectionCommands(options: Options) {
|
|||||||
draft: options.editor.draft,
|
draft: options.editor.draft,
|
||||||
changes: geometryChanges,
|
changes: geometryChanges,
|
||||||
pendingEntities: options.pendingEntityCreates,
|
pendingEntities: options.pendingEntityCreates,
|
||||||
|
projectEntityRefs: options.projectEntityRefs,
|
||||||
|
wikis: options.wikis,
|
||||||
|
entityWikiLinks: options.entityWikiLinks,
|
||||||
previousSnapshot: options.lastSectionSnapshot,
|
previousSnapshot: options.lastSectionSnapshot,
|
||||||
hasPersistedFeature: options.editor.hasPersistedFeature,
|
hasPersistedFeature: options.editor.hasPersistedFeature,
|
||||||
});
|
});
|
||||||
@@ -233,6 +247,7 @@ export function useSectionCommands(options: Options) {
|
|||||||
if (snapshot?.editor_feature_collection) {
|
if (snapshot?.editor_feature_collection) {
|
||||||
options.setInitialData(snapshot.editor_feature_collection);
|
options.setInitialData(snapshot.editor_feature_collection);
|
||||||
}
|
}
|
||||||
|
options.setWikis(snapshot?.wikis || []);
|
||||||
options.setSectionCommits(await fetchSectionCommits(options.activeSection.id));
|
options.setSectionCommits(await fetchSectionCommits(options.activeSection.id));
|
||||||
options.setEntityFormStatus("Đã restore commit.");
|
options.setEntityFormStatus("Đã restore commit.");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { Entity } from "@/uhm/types/entities";
|
import type { Entity } from "@/uhm/types/entities";
|
||||||
|
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||||
import type { FeatureId } from "@/uhm/types/geo";
|
import type { FeatureId } from "@/uhm/types/geo";
|
||||||
import { DEFAULT_ENTITY_TYPE_ID } from "@/uhm/lib/entityTypeOptions";
|
import { DEFAULT_ENTITY_TYPE_ID } from "@/uhm/lib/entityTypeOptions";
|
||||||
import type {
|
import type {
|
||||||
@@ -12,6 +13,8 @@ import type {
|
|||||||
export function useEntitySessionState() {
|
export function useEntitySessionState() {
|
||||||
// Entities đã persisted từ backend (dùng cho search/binding).
|
// Entities đã persisted từ backend (dùng cho search/binding).
|
||||||
const [persistedEntities, setPersistedEntities] = useState<Entity[]>([]);
|
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.
|
// Entities tạo mới trong phiên nhưng chưa commit lên backend.
|
||||||
const [pendingEntityCreates, setPendingEntityCreates] = useState<PendingEntityCreate[]>([]);
|
const [pendingEntityCreates, setPendingEntityCreates] = useState<PendingEntityCreate[]>([]);
|
||||||
// Tóm tắt entities đã tạo (để hiển thị nhanh ở sidebar).
|
// Tóm tắt entities đã tạo (để hiển thị nhanh ở sidebar).
|
||||||
@@ -50,6 +53,8 @@ export function useEntitySessionState() {
|
|||||||
return {
|
return {
|
||||||
persistedEntities,
|
persistedEntities,
|
||||||
setPersistedEntities,
|
setPersistedEntities,
|
||||||
|
projectEntityRefs,
|
||||||
|
setProjectEntityRefs,
|
||||||
pendingEntityCreates,
|
pendingEntityCreates,
|
||||||
setPendingEntityCreates,
|
setPendingEntityCreates,
|
||||||
createdEntities,
|
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 { EntitySnapshot } from "@/uhm/types/entities";
|
||||||
import type { Feature, FeatureCollection, GeometrySnapshot, LinkScopeSnapshot } from "@/uhm/types/geo";
|
import type { Feature, FeatureCollection, GeometrySnapshot, LinkScopeSnapshot } from "@/uhm/types/geo";
|
||||||
import type { EditorSnapshot, Section } from "@/uhm/types/sections";
|
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 {
|
export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
||||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||||
@@ -26,6 +28,9 @@ export function buildEditorSnapshot(options: {
|
|||||||
draft: FeatureCollection;
|
draft: FeatureCollection;
|
||||||
changes: Change[];
|
changes: Change[];
|
||||||
pendingEntities: PendingEntityCreate[];
|
pendingEntities: PendingEntityCreate[];
|
||||||
|
projectEntityRefs: EntitySnapshot[];
|
||||||
|
wikis: WikiSnapshot[];
|
||||||
|
entityWikiLinks: EntityWikiLinkSnapshot[];
|
||||||
previousSnapshot: EditorSnapshot | null;
|
previousSnapshot: EditorSnapshot | null;
|
||||||
hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean;
|
hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean;
|
||||||
}): EditorSnapshot {
|
}): EditorSnapshot {
|
||||||
@@ -55,13 +60,10 @@ export function buildEditorSnapshot(options: {
|
|||||||
|
|
||||||
const pendingEntityIds = new Set(options.pendingEntities.map((entity) => entity.id));
|
const pendingEntityIds = new Set(options.pendingEntities.map((entity) => entity.id));
|
||||||
const entityRows = new globalThis.Map<string, EntitySnapshot>();
|
const entityRows = new globalThis.Map<string, EntitySnapshot>();
|
||||||
for (const 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) {
|
for (const entity of options.pendingEntities) {
|
||||||
entityRows.set(entity.id, {
|
entityRows.set(entity.id, {
|
||||||
id: entity.id,
|
id: entity.id,
|
||||||
|
source: "inline",
|
||||||
operation: "create",
|
operation: "create",
|
||||||
name: entity.name,
|
name: entity.name,
|
||||||
slug: entity.slug,
|
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 feature of options.draft.features) {
|
||||||
for (const entityId of normalizeFeatureEntityIds(feature)) {
|
for (const entityId of normalizeFeatureEntityIds(feature)) {
|
||||||
if (entityRows.has(entityId)) continue;
|
if (entityRows.has(entityId)) continue;
|
||||||
entityRows.set(entityId, {
|
entityRows.set(entityId, {
|
||||||
id: entityId,
|
id: entityId,
|
||||||
|
source: "ref",
|
||||||
|
ref: { id: entityId },
|
||||||
operation: "reference",
|
operation: "reference",
|
||||||
name: feature.properties.entity_names?.[0] || feature.properties.entity_name || entityId,
|
name: feature.properties.entity_names?.[0] || feature.properties.entity_name || entityId,
|
||||||
slug: null,
|
slug: null,
|
||||||
@@ -95,17 +126,19 @@ export function buildEditorSnapshot(options: {
|
|||||||
const changedFromPreviousSnapshot = previousFeature
|
const changedFromPreviousSnapshot = previousFeature
|
||||||
? JSON.stringify(previousFeature) !== JSON.stringify(feature)
|
? JSON.stringify(previousFeature) !== JSON.stringify(feature)
|
||||||
: false;
|
: false;
|
||||||
const operation: GeometrySnapshot["operation"] = previousOperation === "create"
|
const operation: GeometrySnapshot["operation"] =
|
||||||
|
previousOperation === "create"
|
||||||
? "create"
|
? "create"
|
||||||
: !previousFeature && (options.previousSnapshot || !options.hasPersistedFeature(feature.properties.id))
|
: !previousFeature && (options.previousSnapshot || !options.hasPersistedFeature(feature.properties.id))
|
||||||
? "create"
|
? "create"
|
||||||
: changedIds.has(id) || changedFromPreviousSnapshot
|
: changedIds.has(id) || changedFromPreviousSnapshot
|
||||||
? "update"
|
? "update"
|
||||||
: "reference";
|
: undefined;
|
||||||
const bbox = getFeatureBBox(feature);
|
const bbox = getFeatureBBox(feature);
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
operation,
|
operation,
|
||||||
|
source: "inline",
|
||||||
type: feature.properties.type || getDefaultTypeIdForFeature(feature),
|
type: feature.properties.type || getDefaultTypeIdForFeature(feature),
|
||||||
draw_geometry: feature.geometry,
|
draw_geometry: feature.geometry,
|
||||||
binding: normalizeFeatureBindingIds(feature),
|
binding: normalizeFeatureBindingIds(feature),
|
||||||
@@ -134,11 +167,63 @@ export function buildEditorSnapshot(options: {
|
|||||||
const linkScopes: LinkScopeSnapshot[] = options.draft.features
|
const linkScopes: LinkScopeSnapshot[] = options.draft.features
|
||||||
.map((feature) => ({
|
.map((feature) => ({
|
||||||
geometry_id: String(feature.properties.id),
|
geometry_id: String(feature.properties.id),
|
||||||
operation: "replace" as const,
|
operation: "reference" as const,
|
||||||
entity_ids: normalizeFeatureEntityIds(feature),
|
entity_ids: normalizeFeatureEntityIds(feature),
|
||||||
}))
|
}))
|
||||||
.filter((scope) => scope.entity_ids.length > 0);
|
.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 {
|
return {
|
||||||
schema_version: 1,
|
schema_version: 1,
|
||||||
section: {
|
section: {
|
||||||
@@ -153,6 +238,8 @@ export function buildEditorSnapshot(options: {
|
|||||||
}),
|
}),
|
||||||
geometries,
|
geometries,
|
||||||
link_scopes: linkScopes,
|
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 { useEntitySessionState } from "@/uhm/lib/editor/session/useEntitySessionState";
|
||||||
import { useSectionSessionState } from "@/uhm/lib/editor/session/useSectionSessionState";
|
import { useSectionSessionState } from "@/uhm/lib/editor/session/useSectionSessionState";
|
||||||
import { useTimelineState } from "@/uhm/lib/editor/session/useTimelineState";
|
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";
|
import type { EditorMode, TimelineRange } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
@@ -37,6 +38,7 @@ export function useEditorSessionState(options: Options) {
|
|||||||
fallbackTimelineRange: options.fallbackTimelineRange,
|
fallbackTimelineRange: options.fallbackTimelineRange,
|
||||||
});
|
});
|
||||||
const background = useBackgroundSessionState();
|
const background = useBackgroundSessionState();
|
||||||
|
const wiki = useWikiSessionState();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mode,
|
mode,
|
||||||
@@ -47,5 +49,6 @@ export function useEditorSessionState(options: Options) {
|
|||||||
...entity,
|
...entity,
|
||||||
...timeline,
|
...timeline,
|
||||||
...background,
|
...background,
|
||||||
|
...wiki,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,19 @@ export type Entity = {
|
|||||||
geometry_count?: number;
|
geometry_count?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EntitySnapshotOperation = "create" | "update" | "delete" | "reference" | "replace";
|
export type EntitySnapshotOperation = "create" | "update" | "delete" | "reference";
|
||||||
|
|
||||||
export type EntitySnapshot = {
|
export type EntitySnapshot = {
|
||||||
id: string;
|
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;
|
name?: string;
|
||||||
slug?: string | null;
|
slug?: string | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
|||||||
@@ -35,11 +35,13 @@ export type FeatureCollection = {
|
|||||||
features: Feature[];
|
features: Feature[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GeometrySnapshotOperation = "create" | "update" | "delete" | "reference" | "replace";
|
export type GeometrySnapshotOperation = "create" | "update" | "delete" | "reference";
|
||||||
|
|
||||||
export type GeometrySnapshot = {
|
export type GeometrySnapshot = {
|
||||||
id: string;
|
id: string;
|
||||||
operation: GeometrySnapshotOperation;
|
source?: "inline" | "ref";
|
||||||
|
ref?: { id: string };
|
||||||
|
operation?: GeometrySnapshotOperation;
|
||||||
type?: string | null;
|
type?: string | null;
|
||||||
draw_geometry?: Geometry;
|
draw_geometry?: Geometry;
|
||||||
geometry?: Geometry;
|
geometry?: Geometry;
|
||||||
@@ -59,7 +61,8 @@ export type GeometrySnapshot = {
|
|||||||
|
|
||||||
export type LinkScopeSnapshot = {
|
export type LinkScopeSnapshot = {
|
||||||
geometry_id: string;
|
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[];
|
entity_ids: string[];
|
||||||
base_links_hash?: string;
|
base_links_hash?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||||
import type { FeatureCollection, GeometrySnapshot, LinkScopeSnapshot } from "@/uhm/types/geo";
|
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.
|
// 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.
|
// 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[];
|
entities?: EntitySnapshot[];
|
||||||
geometries?: GeometrySnapshot[];
|
geometries?: GeometrySnapshot[];
|
||||||
link_scopes?: LinkScopeSnapshot[];
|
link_scopes?: LinkScopeSnapshot[];
|
||||||
|
wikis?: WikiSnapshot[];
|
||||||
|
entity_wikis?: EntityWikiLinkSnapshot[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EditorLoadResponse = {
|
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