Compare commits
54 Commits
2e80e45eab
...
staging
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d774440a9 | |||
| b54fdb987e | |||
| f904f91a9c | |||
| 3c71249926 | |||
| 8fc9456a6a | |||
| c92aaafc33 | |||
| f1d6f22f80 | |||
| 14a06af343 | |||
| 41e43d4974 | |||
| 33a866b659 | |||
| 08120ef987 | |||
| e725b52590 | |||
| cb3e720644 | |||
| 94c58e1d42 | |||
| 72d7073c40 | |||
| 51f432f0fe | |||
| 7b1f7538ab | |||
| d40c3831cb | |||
| 8f6d848d55 | |||
| 8f911abe35 | |||
| 16fce9da7a | |||
| 6076f098fa | |||
| eecedec560 | |||
| 1b321da6aa | |||
| 1baba25303 | |||
| ac8b0404dd | |||
| fe7696b72d | |||
| f2f5295218 | |||
| 91d9d20447 | |||
| 9be308b65c | |||
| c371d70993 | |||
| b14f11574b | |||
| 31297c8b59 | |||
| 78824ed07a | |||
| 655454d83a | |||
| 6757eb2086 | |||
| cf880f573f | |||
| 3ec9c51085 | |||
| f5fa38ec5a | |||
| add1728916 | |||
| a9b8c4ab8b | |||
| c945a56a33 | |||
| 6f163c3730 | |||
| ce4bc4f2a5 | |||
| 8b1df73797 | |||
| a29a3a2049 | |||
| f555909b09 | |||
| 34a5c3d041 | |||
| fca188f0be | |||
| 12c351c68a | |||
| a74047fd09 | |||
| 41af501b51 | |||
| 65806d197f | |||
| 7ff68659ad |
@@ -0,0 +1,312 @@
|
||||
# Editor (/editor) - Local Store & Snapshot Conversion
|
||||
|
||||
Tài liệu này mô tả chi tiết **các nơi lưu trữ state (store) ở phía FrontEndUser** trong `/editor/[id]`, ý nghĩa từng biến state, state nào là “single source of truth”, state nào chỉ là cache/UI, và cách chuyển đổi qua lại giữa:
|
||||
|
||||
1. **Local session state** (React state trong phiên làm việc)
|
||||
2. **Commit snapshot** (`commits.snapshot_json`)
|
||||
3. **Reload trang** (mất state local, load lại từ commit snapshot)
|
||||
|
||||
Mục tiêu: dễ debug, nhất quán dữ liệu, tránh sai semantics `"reference"`/`"binding"`.
|
||||
|
||||
---
|
||||
|
||||
## 0) 5 Dataset Quan Trọng Nhất (GEO/ENT/WIKI/ENT_WIKI/GEO_ENT)
|
||||
|
||||
Trong `/editor`, 5 nhóm dữ liệu quan trọng nhất tương ứng trực tiếp với snapshot:
|
||||
|
||||
1. **GEO**: `snapshot_json.geometries[]` + `snapshot_json.editor_feature_collection`
|
||||
2. **ENT**: `snapshot_json.entities[]`
|
||||
3. **WIKI**: `snapshot_json.wikis[]`
|
||||
4. **ENT_WIKI** (entity ↔ wiki): `snapshot_json.entity_wiki[]`
|
||||
5. **GEO_ENT** (geometry ↔ entity): `snapshot_json.geometry_entity[]`
|
||||
|
||||
Điểm quan trọng về “store”:
|
||||
|
||||
- **ENT/WIKI/ENT_WIKI** có store snapshot riêng trong React session:
|
||||
- `snapshotEntities` -> `entities[]`
|
||||
- `snapshotWikis` -> `wikis[]`
|
||||
- `snapshotEntityWikiLinks` -> `entity_wiki[]`
|
||||
|
||||
- **GEO/GEO_ENT không có store snapshot riêng theo kiểu `snapshotGeometries` / `snapshotGeometryEntity`**.
|
||||
- Trong session, GEO sống ở **`editor.draft`** (GeoJSON FeatureCollection).
|
||||
- Khi commit, FE **build ra**:
|
||||
- `geometries[]` từ `editor.draft + editor.changes + baselineSnapshot.geometries`
|
||||
- `geometry_entity[]` từ `editor.draft.features[].properties.entity_ids`
|
||||
|
||||
Vì vậy, nếu bạn “tìm store của geo trong React state” thì bạn sẽ thấy nó nằm ở `useEditorState()` chứ không nằm trong `useEditorSessionState()`.
|
||||
|
||||
---
|
||||
|
||||
## 1) Nguyên tắc chung
|
||||
|
||||
### 1.1 Single source of truth theo lớp
|
||||
|
||||
- **Geometry (map/editor):** `useEditorState(initialData)` là state trung tâm cho `draft/changes/undo`.
|
||||
- **Snapshot stores (phần sẽ đi vào commit snapshot):**
|
||||
- `snapshotEntities` -> `snapshot_json.entities`
|
||||
- `snapshotWikis` -> `snapshot_json.wikis`
|
||||
- `snapshotEntityWikiLinks` -> `snapshot_json.entity_wiki`
|
||||
- **Catalog/cache để tìm kiếm & hiển thị:**
|
||||
- `entityCatalog` là danh sách entity “global” trong RAM (fetch + search merge). Không phải snapshot.
|
||||
|
||||
### 1.2 “reference” vs “binding”
|
||||
|
||||
- `"reference"` (entities/wikis/geometries.operation) nghĩa là **không sửa record** trong commit đó.
|
||||
- `"binding"` (chỉ áp dụng cho `entity_wiki.operation`) nghĩa là **link entity ↔ wiki đang tồn tại** trong snapshot.
|
||||
- `"delete"` nghĩa là xóa record (entities/wikis/geometries) hoặc unlink (entity_wiki).
|
||||
|
||||
Khi **mở 1 phiên editor mới từ commit**, mọi operation local đều bị “reset về baseline”:
|
||||
|
||||
- `entities[].operation` và `wikis[].operation` trong session -> `"reference"`
|
||||
- `entity_wiki[].operation` trong session -> `"binding"` (nếu link còn active)
|
||||
|
||||
---
|
||||
|
||||
## 2) Local state: danh sách đầy đủ và ý nghĩa
|
||||
|
||||
Các state này được tạo từ `useEditorSessionState()` và `useEditorState()` trong:
|
||||
|
||||
- `FrontEndUser/src/app/editor/[id]/page.tsx`
|
||||
- `FrontEndUser/src/uhm/lib/useEditorSessionState.ts`
|
||||
- `FrontEndUser/src/uhm/lib/useEditorState.ts`
|
||||
|
||||
### 2.1 Geometry editor state (core)
|
||||
|
||||
Nguồn: `const editor = useEditorState(initialData)`
|
||||
|
||||
- `initialData: FeatureCollection`
|
||||
- Là **baseline** của session hiện tại để render Map ban đầu.
|
||||
- Được set khi:
|
||||
- mở project (load snapshot head),
|
||||
- restore FE-only từ 1 commit,
|
||||
- hoặc import/replace dữ liệu session.
|
||||
|
||||
- `editor.draft: FeatureCollection`
|
||||
- **Single source of truth** cho geometry đang hiển thị + chỉnh sửa.
|
||||
- Map render trực tiếp từ `draft` (hoặc bản “visibleDraft” đã filter theo timeline/binding).
|
||||
- Đây chính là **store runtime của GEO** trong session.
|
||||
|
||||
- `editor.changes: Map<id, Change>`
|
||||
- Diff giữa `draft` và baseline map nội bộ (initialMapRef).
|
||||
- Dùng để tính `pendingSaveCount` và để build snapshot geometries/update/delete.
|
||||
|
||||
- `editor.undoStack`
|
||||
- Danh sách thao tác gần nhất (create/update/properties/delete).
|
||||
|
||||
- `editor.changeCount`
|
||||
- Số lượng changes (để chặn commit khi không đổi gì).
|
||||
|
||||
- `editor.hasPersistedFeature(id)`
|
||||
- `true` nếu feature đã tồn tại trong baseline map nội bộ.
|
||||
- Dùng cho timeline filter: feature mới tạo trong session vẫn luôn visible.
|
||||
|
||||
### 2.2 Snapshot stores (persisted on commit)
|
||||
|
||||
Các state này là “source of truth” cho những phần non-geometry trong commit snapshot.
|
||||
|
||||
#### a) `snapshotEntities: EntitySnapshot[]`
|
||||
|
||||
- Dùng để build `snapshot_json.entities`.
|
||||
- Bao gồm:
|
||||
- entity “pin” vào project (`source:"ref"`, `operation:"reference"`),
|
||||
- entity tạo mới local (`source:"inline"`, `operation:"create"`),
|
||||
- entity bị xóa (nếu có) (`operation:"delete"`).
|
||||
|
||||
Lưu ý quan trọng:
|
||||
|
||||
- `snapshotEntities` là nơi “giữ entity” **qua các commit**, kể cả entity tạo mới chưa bind geometry.
|
||||
- `buildEditorSnapshot()` có logic carry-forward inline entity từ `previousSnapshot` để tránh mất entity sau commit/reload.
|
||||
|
||||
#### b) `snapshotWikis: WikiSnapshot[]`
|
||||
|
||||
- Dùng để build `snapshot_json.wikis`.
|
||||
- Wiki hiện lưu `doc` là **string (HTML)** (Quill) hoặc `null` với ref wiki.
|
||||
- Tiptap JSON cũ: được normalize sang HTML để hiển thị.
|
||||
|
||||
#### c) `snapshotEntityWikiLinks: EntityWikiLinkSnapshot[]`
|
||||
|
||||
- Dùng để build `snapshot_json.entity_wiki`.
|
||||
- `operation`:
|
||||
- `"binding"`: link đang tồn tại
|
||||
- `"delete"`: unlink trong snapshot
|
||||
- (compat) `"reference"` từ snapshot cũ được normalize thành `"binding"` khi load.
|
||||
|
||||
### 2.3 Catalog/cache state (không persist)
|
||||
|
||||
#### `entityCatalog: Entity[]`
|
||||
|
||||
Đây là **RAM cache** để:
|
||||
|
||||
- hiển thị tên/description/status của entity,
|
||||
- merge kết quả fetch + search,
|
||||
- giảm tình trạng UI “cùng 1 entity nhưng 2 object khác nhau”.
|
||||
|
||||
Không ghi thẳng vào snapshot. Snapshot vẫn lấy từ `snapshotEntities`.
|
||||
|
||||
Trong page, danh sách `entities` dùng cho UI được merge:
|
||||
|
||||
`entities = mergeEntitySearchResults(entityCatalog, snapshotEntitiesAsEntities)`
|
||||
|
||||
Nghĩa là: snapshot entities (local) luôn được ưu tiên hiển thị trong UI.
|
||||
|
||||
### 2.4 UI-only state (không persist)
|
||||
|
||||
Các state sau chỉ phục vụ UX, mất khi reload:
|
||||
|
||||
- `mode` (idle/select/add-*)
|
||||
- `selectedFeatureId`
|
||||
- `selectedGeometryEntityIds` (list bind tạm thời cho UI, map patch sẽ sync vào feature properties)
|
||||
- `geometryMetaForm`
|
||||
- `entityForm` (tạo entity mới)
|
||||
- `entityFormStatus` (toast/status 3s)
|
||||
- `searchKind`, `searchQuery`
|
||||
- `entitySearchResults`, `wikiSearchResults`, `geoSearchResults`
|
||||
- `timelineDraftYear`, `timelineFilterEnabled`
|
||||
- panel widths (`leftPanelWidth`, `rightPanelWidth`)
|
||||
|
||||
### 2.5 LocalStorage (trên browser)
|
||||
|
||||
Hiện tại chỉ có **1 thứ** persist sang LocalStorage:
|
||||
|
||||
- `backgroundVisibility` (ẩn/hiện layer nền)
|
||||
|
||||
Các snapshot stores (`snapshotEntities`, `snapshotWikis`, `snapshotEntityWikiLinks`, `draft`) **không** lưu LocalStorage; chúng được persist qua commit snapshot (backend).
|
||||
|
||||
---
|
||||
|
||||
## 3) Chuyển đổi giữa local session ↔ snapshot
|
||||
|
||||
### 3.1 Load snapshot -> mở session
|
||||
|
||||
Luồng: `openSectionEditor()` -> `normalizeEditorSnapshot()` -> `toEditorSessionSnapshot()`
|
||||
|
||||
Khi mở session mới:
|
||||
|
||||
1. `baselineSnapshot = toEditorSessionSnapshot(snapshot)`
|
||||
2. `initialData = baselineSnapshot.editor_feature_collection || EMPTY_FEATURE_COLLECTION`
|
||||
3. `snapshotEntities = baselineSnapshot.entities || []`
|
||||
4. `snapshotWikis = baselineSnapshot.wikis || []`
|
||||
5. `snapshotEntityWikiLinks = baselineSnapshot.entity_wiki || []`
|
||||
|
||||
Riêng về GEO/GEO_ENT khi load:
|
||||
|
||||
- `baselineSnapshot.editor_feature_collection` là dữ liệu map gốc đưa vào `initialData`.
|
||||
- `normalizeEditorSnapshot()` sẽ **rehydrate** `feature.properties.entity_ids/entity_id` từ `snapshot.geometry_entity[]` (hoặc legacy `link_scopes`) để UI bind entity hoạt động.
|
||||
- Lưu ý: đây là rehydrate phục vụ editor UX, **không phải** dữ liệu persist chính thức trên `feature.properties` trong snapshot.
|
||||
|
||||
Điểm mấu chốt: **toEditorSessionSnapshot() reset operation** để snapshot trở thành “baseline state”:
|
||||
|
||||
- entities/wikis -> `"reference"`
|
||||
- entity_wiki active -> `"binding"`
|
||||
|
||||
### 3.2 Commit session -> snapshot_json
|
||||
|
||||
Luồng: `commitSection()` -> `buildEditorSnapshot({ draft, changes, snapshotEntities, snapshotWikis, snapshotEntityWikiLinks, previousSnapshot: baselineSnapshot })`
|
||||
|
||||
`buildEditorSnapshot()` sẽ tạo:
|
||||
|
||||
- `editor_feature_collection` (draft đã strip các field denormalized)
|
||||
- `geometries[]` (create/update/delete dựa trên changes + previousSnapshot)
|
||||
- `geometry_entity[]` (join table từ feature.properties.entity_ids)
|
||||
- `entities[]` (từ snapshotEntities + carry-forward inline + ensure entities referenced by joins)
|
||||
- `wikis[]` (từ snapshotWikis, tương tự)
|
||||
- `entity_wiki[]` (từ snapshotEntityWikiLinks, đã dedupe/sort)
|
||||
|
||||
Sau khi commit thành công:
|
||||
|
||||
- `baselineSnapshot` cập nhật = `toEditorSessionSnapshot(snapshot)` của commit mới
|
||||
- snapshot stores cập nhật theo baseline mới (operation reset về `"reference"/"binding"`)
|
||||
|
||||
### 3.3 Reload trang -> mất local state
|
||||
|
||||
Khi reload:
|
||||
|
||||
- Toàn bộ React state reset
|
||||
- App sẽ load lại snapshot từ backend (head commit)
|
||||
- Các thứ bạn “tạo/sửa” chỉ còn lại nếu đã nằm trong commit snapshot
|
||||
|
||||
Vì vậy:
|
||||
|
||||
- Entity/Wiki/Link/Geometry muốn “không mất” phải đi qua **Commit**.
|
||||
- Các state UI (selected geo, search results, form đang nhập) sẽ mất.
|
||||
|
||||
---
|
||||
|
||||
## 4) GEO Search (`/geometries/entity`) và tác động lên local store
|
||||
|
||||
Search GEO gọi:
|
||||
|
||||
`GET /geometries/entity?name=<keyword>&limit=<n>`
|
||||
|
||||
Khi bấm **Import** một geometry từ kết quả search:
|
||||
|
||||
1. Tắt `timelineFilterEnabled` để geometry luôn nhìn thấy (không bị filter theo năm).
|
||||
2. Add entity tương ứng vào:
|
||||
- `snapshotEntities` (source:"ref", operation:"reference")
|
||||
- `entityCatalog` (để UI có name/description)
|
||||
3. Nếu geometry chưa có trong `editor.draft`:
|
||||
- tạo `Feature` mới với `id = geometry.id`
|
||||
- set `properties.type` từ `geo_type` (map qua `geoTypeCodeToTypeKey`)
|
||||
- set `time_start/time_end/binding`
|
||||
- set denormalized `entity_id/entity_ids/entity_name/entity_names` để UI/joins hoạt động
|
||||
4. `editor.createFeature(feature)` và auto select feature đó.
|
||||
|
||||
Lưu ý: Import geo tạo ra “create change” trong editor session, nên sẽ đi vào commit snapshot.
|
||||
|
||||
---
|
||||
|
||||
## 4.1 Nhìn nhanh “5 dataset nằm ở đâu” trong session
|
||||
|
||||
- GEO:
|
||||
- Runtime store: `editor.draft.features[]`
|
||||
- Persisted on commit: `snapshot_json.geometries[]` (build khi commit)
|
||||
|
||||
- ENT:
|
||||
- Runtime store (snapshot): `snapshotEntities`
|
||||
- Persisted on commit: `snapshot_json.entities[]`
|
||||
|
||||
- WIKI:
|
||||
- Runtime store (snapshot): `snapshotWikis`
|
||||
- Persisted on commit: `snapshot_json.wikis[]`
|
||||
|
||||
- ENT_WIKI:
|
||||
- Runtime store (snapshot): `snapshotEntityWikiLinks`
|
||||
- Persisted on commit: `snapshot_json.entity_wiki[]`
|
||||
|
||||
- GEO_ENT:
|
||||
- Runtime store: denormalized tạm thời trên `editor.draft.features[].properties.entity_ids` (để UI chạy)
|
||||
- Persisted on commit: `snapshot_json.geometry_entity[]` (build khi commit)
|
||||
|
||||
---
|
||||
|
||||
## 5) Checklist khi debug “mất dữ liệu”
|
||||
|
||||
1. Dữ liệu có nằm trong `snapshotEntities/snapshotWikis/snapshotEntityWikiLinks/editor.draft` không?
|
||||
2. Có bấm **Commit** chưa?
|
||||
3. `pendingSaveCount` có > 0 không (Commit button có enable không)?
|
||||
4. Khi reload, snapshot head commit load lên có chứa các rows đó không?
|
||||
5. Nếu entity tạo mới bị mất:
|
||||
- kiểm tra commit snapshot có `entities[].source:"inline"` không
|
||||
- nếu có mà reload vẫn mất, kiểm tra `normalizeEditorSnapshot()` có parse đúng không
|
||||
|
||||
---
|
||||
|
||||
## 6) File/entrypoints liên quan
|
||||
|
||||
- Session stores:
|
||||
- `FrontEndUser/src/uhm/lib/useEditorSessionState.ts`
|
||||
- `FrontEndUser/src/uhm/lib/editor/session/useEntitySessionState.ts`
|
||||
- `FrontEndUser/src/uhm/lib/editor/session/useWikiSessionState.ts`
|
||||
- `FrontEndUser/src/uhm/lib/editor/session/useSectionSessionState.ts`
|
||||
|
||||
- Geometry editor core:
|
||||
- `FrontEndUser/src/uhm/lib/useEditorState.ts`
|
||||
|
||||
- Snapshot normalization + build snapshot:
|
||||
- `FrontEndUser/src/uhm/lib/editor/snapshot/editorSnapshot.ts`
|
||||
|
||||
- Open/commit/restore commands:
|
||||
- `FrontEndUser/src/uhm/lib/editor/section/useSectionCommands.ts`
|
||||
|
||||
- Page wiring / UI state:
|
||||
- `FrontEndUser/src/app/editor/[id]/page.tsx`
|
||||
@@ -1,200 +1 @@
|
||||
# TailAdmin Next.js - Free Next.js Tailwind Admin Dashboard
|
||||
|
||||
TailAdmin is a free and open-source admin dashboard template built on **Next.js and Tailwind CSS** providing developers with everything they need to create a feature-rich and data-driven: back-end, dashboard, or admin panel solution for any sort of web project.
|
||||
|
||||

|
||||
|
||||
With TailAdmin Next.js, you get access to all the necessary dashboard UI components, elements, and pages required to build a high-quality and complete dashboard or admin panel. Whether you're building a dashboard or admin panel for a complex web application or a simple website.
|
||||
|
||||
TailAdmin utilizes the powerful features of **Next.js 16** and common features of Next.js such as server-side rendering (SSR), static site generation (SSG), and seamless API route integration. Combined with the advancements of **React 19** and the robustness of **TypeScript**, TailAdmin is the perfect solution to help get your project up and running quickly.
|
||||
|
||||
## Overview
|
||||
|
||||
TailAdmin provides essential UI components and layouts for building feature-rich, data-driven admin dashboards and control panels. It's built on:
|
||||
|
||||
* Next.js 16.x
|
||||
* React 19
|
||||
* TypeScript
|
||||
* Tailwind CSS V4
|
||||
|
||||
### Quick Links
|
||||
|
||||
* [✨ Visit Website](https://tailadmin.com)
|
||||
* [📄 Documentation](https://tailadmin.com/docs)
|
||||
* [⬇️ Download](https://tailadmin.com/download)
|
||||
* [🖌️ Figma Design File (Community Edition)](https://www.figma.com/community/file/1463141366275764364)
|
||||
* [⚡ Get PRO Version](https://tailadmin.com/pricing)
|
||||
|
||||
### Demos
|
||||
|
||||
* [Free Version](https://nextjs-free-demo.tailadmin.com)
|
||||
* [Pro Version](https://nextjs-demo.tailadmin.com)
|
||||
|
||||
### Other Versions
|
||||
|
||||
- [Next.js Version](https://github.com/TailAdmin/free-nextjs-admin-dashboard)
|
||||
- [React.js Version](https://github.com/TailAdmin/free-react-tailwind-admin-dashboard)
|
||||
- [Vue.js Version](https://github.com/TailAdmin/vue-tailwind-admin-dashboard)
|
||||
- [Angular Version](https://github.com/TailAdmin/free-angular-tailwind-dashboard)
|
||||
- [Laravel Version](https://github.com/TailAdmin/tailadmin-laravel)
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
To get started with TailAdmin, ensure you have the following prerequisites installed and set up:
|
||||
|
||||
* Node.js 18.x or later (recommended to use Node.js 20.x or later)
|
||||
|
||||
### Cloning the Repository
|
||||
|
||||
Clone the repository using the following command:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/TailAdmin/free-nextjs-admin-dashboard.git
|
||||
```
|
||||
|
||||
> Windows Users: place the repository near the root of your drive if you face issues while cloning.
|
||||
|
||||
1. Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
# or
|
||||
yarn install
|
||||
```
|
||||
|
||||
> Use `--legacy-peer-deps` flag if you face peer-dependency error during installation.
|
||||
|
||||
2. Start the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
TailAdmin is a pre-designed starting point for building a web-based dashboard using Next.js and Tailwind CSS. The template includes:
|
||||
|
||||
* Sophisticated and accessible sidebar
|
||||
* Data visualization components
|
||||
* Profile management and custom 404 page
|
||||
* Tables and Charts(Line and Bar)
|
||||
* Authentication forms and input elements
|
||||
* Alerts, Dropdowns, Modals, Buttons and more
|
||||
* Can't forget Dark Mode 🕶️
|
||||
|
||||
All components are built with React and styled using Tailwind CSS for easy customization.
|
||||
|
||||
## Feature Comparison
|
||||
|
||||
### Free Version
|
||||
|
||||
* 1 Unique Dashboard
|
||||
* 30+ dashboard components
|
||||
* 50+ UI elements
|
||||
* Basic Figma design files
|
||||
* Community support
|
||||
|
||||
### Pro Version
|
||||
|
||||
* 7 Unique Dashboards: Analytics, Ecommerce, Marketing, CRM, SaaS, Stocks, Logistics (more coming soon)
|
||||
* 500+ dashboard components and UI elements
|
||||
* Complete Figma design file
|
||||
* Email support
|
||||
|
||||
To learn more about pro version features and pricing, visit our [pricing page](https://tailadmin.com/pricing).
|
||||
|
||||
## Changelog
|
||||
|
||||
### Version 2.2.3 - [March 15, 2026]
|
||||
|
||||
* update ESLint configuration and dependencies; upgrade Next.js to version 16.1.6
|
||||
|
||||
### Version 2.2.2 - [December 30, 2025]
|
||||
|
||||
* Fixed date picker positioning and functionality in Statistics Chart.
|
||||
|
||||
|
||||
### Version 2.1.0 - [November 15, 2025]
|
||||
|
||||
* Updated to Next.js 16.x
|
||||
* Fixed all reported minor bugs
|
||||
|
||||
### Version 2.0.2 - [March 25, 2025]
|
||||
|
||||
* Upgraded to Next.js 16.x for [CVE-2025-29927](https://nextjs.org/blog/cve-2025-29927) concerns
|
||||
* Included overrides vectormap for packages to prevent peer dependency errors during installation.
|
||||
* Migrated from react-flatpickr to flatpickr package for React 19 support
|
||||
|
||||
### Version 2.0.1 - [February 27, 2025]
|
||||
|
||||
#### Update Overview
|
||||
|
||||
* Upgraded to Tailwind CSS v4 for better performance and efficiency.
|
||||
* Updated class usage to match the latest syntax and features.
|
||||
* Replaced deprecated class and optimized styles.
|
||||
|
||||
#### Next Steps
|
||||
|
||||
* Run npm install or yarn install to update dependencies.
|
||||
* Check for any style changes or compatibility issues.
|
||||
* Refer to the Tailwind CSS v4 [Migration Guide](https://tailwindcss.com/docs/upgrade-guide) on this release. if needed.
|
||||
* This update keeps the project up to date with the latest Tailwind improvements. 🚀
|
||||
|
||||
### v2.0.0 (February 2025)
|
||||
|
||||
A major update focused on Next.js 16 implementation and comprehensive redesign.
|
||||
|
||||
#### Major Improvements
|
||||
|
||||
* Complete redesign using Next.js 16 App Router and React Server Components
|
||||
* Enhanced user interface with Next.js-optimized components
|
||||
* Improved responsiveness and accessibility
|
||||
* New features including collapsible sidebar, chat screens, and calendar
|
||||
* Redesigned authentication using Next.js App Router and server actions
|
||||
* Updated data visualization using ApexCharts for React
|
||||
|
||||
#### Breaking Changes
|
||||
|
||||
* Migrated from Next.js 14 to Next.js 16
|
||||
* Chart components now use ApexCharts for React
|
||||
* Authentication flow updated to use Server Actions and middleware
|
||||
|
||||
[Read more](https://tailadmin.com/docs/update-logs/nextjs) on this release.
|
||||
|
||||
### v1.3.4 (July 01, 2024)
|
||||
|
||||
* Fixed JSvectormap rendering issues
|
||||
|
||||
### v1.3.3 (June 20, 2024)
|
||||
|
||||
* Fixed build error related to Loader component
|
||||
|
||||
### v1.3.2 (June 19, 2024)
|
||||
|
||||
* Added ClickOutside component for dropdown menus
|
||||
* Refactored sidebar components
|
||||
* Updated Jsvectormap package
|
||||
|
||||
### v1.3.1 (Feb 12, 2024)
|
||||
|
||||
* Fixed layout naming consistency
|
||||
* Updated styles
|
||||
|
||||
### v1.3.0 (Feb 05, 2024)
|
||||
|
||||
* Upgraded to Next.js 14
|
||||
* Added Flatpickr integration
|
||||
* Improved form elements
|
||||
* Enhanced multiselect functionality
|
||||
* Added default layout component
|
||||
|
||||
## License
|
||||
|
||||
TailAdmin Next.js Free Version is released under the MIT License.
|
||||
|
||||
## Support
|
||||
If you find this project helpful, please consider giving it a star on GitHub. Your support helps us continue developing and maintaining this template.
|
||||
xamluoccampuchia
|
||||
@@ -58,4 +58,13 @@ export const API = {
|
||||
GET_COMMITS: (id: number | string) => `${API_URL_ROOT}/projects/${id}/commits`,
|
||||
RESTORE_COMMIT: (id: number | string) => `${API_URL_ROOT}/projects/${id}/commits/restore`,
|
||||
},
|
||||
}
|
||||
Submission: {
|
||||
SEARCH: `${API_URL_ROOT}/submissions`,
|
||||
GET_BY_ID: (id: number | string) => `${API_URL_ROOT}/submissions/${id}`,
|
||||
UPDATE_STATUS: (id: number | string) => `${API_URL_ROOT}/submissions/${id}/status`,
|
||||
DELETE: (id: number | string) => `${API_URL_ROOT}/submissions/${id}`,
|
||||
},
|
||||
Chatbot:{
|
||||
CHAT: `${API_URL_ROOT}/chatbot/chat`,
|
||||
}
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1,248 @@
|
||||
# Commit Snapshot (`commits.snapshot_json`) - Chuẩn Hiện Tại (FrontEndUser / UHM)
|
||||
|
||||
Tài liệu này mô tả **snapshot_json** mà `FrontEndUser` (module UHM editor) tạo ra khi bấm **Commit** trong `/editor/[id]`, và gửi lên endpoint `POST /projects/{id}/commits`.
|
||||
|
||||
Nguồn tham chiếu trong code (FrontEndUser):
|
||||
|
||||
- Types:
|
||||
- `src/uhm/types/sections.ts` (`EditorSnapshot`, `EntityWikiLinkSnapshot`)
|
||||
- `src/uhm/types/geo.ts` (`FeatureCollection`, `GeometrySnapshot`, `GeometryEntitySnapshot`)
|
||||
- `src/uhm/types/entities.ts` (`EntitySnapshot`)
|
||||
- `src/uhm/types/wiki.ts` (`WikiSnapshot`)
|
||||
- Build/normalize snapshot:
|
||||
- `src/uhm/lib/editor/snapshot/editorSnapshot.ts` (`buildEditorSnapshot`, `normalizeEditorSnapshot`)
|
||||
|
||||
## 1) Root Shape
|
||||
|
||||
FE hiện tại không dùng `schema_version`. `snapshot_json` là một object có các phần sau:
|
||||
|
||||
```ts
|
||||
export type EditorSnapshot = {
|
||||
editor_feature_collection?: FeatureCollection;
|
||||
entities?: EntitySnapshot[];
|
||||
geometries?: GeometrySnapshot[];
|
||||
geometry_entity?: GeometryEntitySnapshot[];
|
||||
wikis?: WikiSnapshot[];
|
||||
entity_wiki?: EntityWikiLinkSnapshot[];
|
||||
};
|
||||
```
|
||||
|
||||
Lưu ý:
|
||||
|
||||
- FE có thể **đọc** cả `entity_wiki` và legacy alias `entity_wikis` khi load snapshot (normalize), nhưng khi commit FE ghi `entity_wiki`.
|
||||
- `editor_feature_collection` là nguồn để render editor/map. Các join table (`geometry_entity`, `entity_wiki`) là nguồn quan hệ.
|
||||
|
||||
## 2) Types (TypeScript) - Đúng Theo FE Hiện Tại
|
||||
|
||||
### 2.1 GeoJSON (editor_feature_collection)
|
||||
|
||||
```ts
|
||||
export type Geometry =
|
||||
| { type: "Point"; coordinates: [number, number] }
|
||||
| { type: "MultiPoint"; coordinates: [number, number][] }
|
||||
| { type: "LineString"; coordinates: [number, number][] }
|
||||
| { type: "MultiLineString"; coordinates: [number, number][][] }
|
||||
| { type: "Polygon"; coordinates: [number, number][][] }
|
||||
| { type: "MultiPolygon"; coordinates: [number, number][][][] };
|
||||
|
||||
export type FeatureId = string | number;
|
||||
|
||||
export type FeatureProperties = {
|
||||
id: FeatureId;
|
||||
type?: string | null;
|
||||
geometry_preset?: string | null;
|
||||
time_start?: number | null;
|
||||
time_end?: number | null;
|
||||
binding?: string[];
|
||||
|
||||
// UI-only / legacy fields (FE sẽ strip khi persist snapshot):
|
||||
entity_id?: string | null;
|
||||
entity_ids?: string[];
|
||||
entity_name?: string | null;
|
||||
entity_names?: string[];
|
||||
entity_type_id?: string | null;
|
||||
};
|
||||
|
||||
export type Feature = {
|
||||
type: "Feature";
|
||||
properties: FeatureProperties;
|
||||
geometry: Geometry;
|
||||
};
|
||||
|
||||
export type FeatureCollection = {
|
||||
type: "FeatureCollection";
|
||||
features: Feature[];
|
||||
};
|
||||
```
|
||||
|
||||
### 2.2 Snapshot rows
|
||||
|
||||
```ts
|
||||
export type SnapshotSource = "inline" | "ref";
|
||||
|
||||
export type EntitySnapshotOperation = "create" | "update" | "delete" | "reference";
|
||||
export type GeometrySnapshotOperation = "create" | "update" | "delete" | "reference";
|
||||
export type WikiSnapshotOperation = "create" | "update" | "delete" | "reference";
|
||||
|
||||
export type EntitySnapshot = {
|
||||
id: string;
|
||||
source: SnapshotSource;
|
||||
operation?: EntitySnapshotOperation;
|
||||
name?: string;
|
||||
slug?: string | null;
|
||||
description?: string | null;
|
||||
status?: number | null;
|
||||
base_updated_at?: string;
|
||||
base_hash?: string;
|
||||
};
|
||||
|
||||
export type GeometrySnapshot = {
|
||||
id: string;
|
||||
source: SnapshotSource;
|
||||
operation?: GeometrySnapshotOperation;
|
||||
type?: string | null;
|
||||
draw_geometry?: Geometry;
|
||||
geometry?: Geometry; // legacy
|
||||
binding?: string[];
|
||||
time_start?: number | null;
|
||||
time_end?: number | null;
|
||||
bbox?: {
|
||||
min_lng: number;
|
||||
min_lat: number;
|
||||
max_lng: number;
|
||||
max_lat: number;
|
||||
} | null;
|
||||
base_updated_at?: string;
|
||||
base_hash?: string;
|
||||
};
|
||||
|
||||
// FE stores wiki doc as a string (commonly HTML; in some flows it may be a JSON-stringified editor payload).
|
||||
export type WikiDoc = string | null;
|
||||
|
||||
export type WikiSnapshot = {
|
||||
id: string;
|
||||
source: SnapshotSource;
|
||||
operation?: WikiSnapshotOperation;
|
||||
title: string;
|
||||
slug?: string | null;
|
||||
doc: WikiDoc;
|
||||
updated_at?: string;
|
||||
};
|
||||
```
|
||||
|
||||
### 2.3 Join tables
|
||||
|
||||
```ts
|
||||
export type GeometryEntitySnapshot = {
|
||||
geometry_id: string;
|
||||
entity_id: string;
|
||||
base_links_hash?: string;
|
||||
};
|
||||
|
||||
export type EntityWikiLinkSnapshot = {
|
||||
entity_id: string;
|
||||
wiki_id: string;
|
||||
operation?: "reference" | "binding" | "delete";
|
||||
};
|
||||
```
|
||||
|
||||
## 3) Quy Ước FE Khi Build Snapshot (buildEditorSnapshot)
|
||||
|
||||
### 3.1 Feature.properties entity fields bị strip
|
||||
|
||||
Khi persist snapshot, FE chủ động xoá các field denormalize trên feature properties:
|
||||
`entity_id`, `entity_ids`, `entity_name`, `entity_names`, `entity_type_id`.
|
||||
|
||||
Quan hệ geometry ↔ entity chỉ nằm ở `geometry_entity[]`.
|
||||
|
||||
### 3.2 entities[]
|
||||
|
||||
FE cố gắng đảm bảo mọi entity có `name` không rỗng (fallback sang `id`) và có `source`.
|
||||
|
||||
`operation` được dùng như "delta" trong commit:
|
||||
|
||||
- `"create"|"update"|"delete"`: thay đổi record entity
|
||||
- `"reference"`: đưa entity vào context snapshot (pin/link) nhưng commit không sửa record entity
|
||||
|
||||
### 3.3 geometries[]
|
||||
|
||||
FE sinh 1 `GeometrySnapshot` cho mỗi feature đang tồn tại trong `editor_feature_collection.features[]`:
|
||||
|
||||
- `id = String(feature.properties.id)`
|
||||
- `source:"inline"`
|
||||
- `draw_geometry = feature.geometry`
|
||||
- `binding`, `time_start`, `time_end`, `bbox` (nếu tính được)
|
||||
- `type`: FE hiện gửi **string code** (geo_type smallint) dưới dạng string
|
||||
- `operation`:
|
||||
- `"create"` nếu geometry mới
|
||||
- `"update"` nếu geometry thay đổi
|
||||
- `undefined` nếu geometry không đổi
|
||||
|
||||
Nếu feature bị xoá khỏi draft, FE thêm 1 row:
|
||||
|
||||
```json
|
||||
{ "id": "…", "source": "ref", "operation": "delete" }
|
||||
```
|
||||
|
||||
### 3.4 geometry_entity[]
|
||||
|
||||
`geometry_entity` là danh sách quan hệ many-to-many geometry ↔ entity. Mỗi row là một cặp:
|
||||
|
||||
```ts
|
||||
{ geometry_id: string; entity_id: string }
|
||||
```
|
||||
|
||||
### 3.5 wikis[]
|
||||
|
||||
- Wiki `source:"ref"` (được add từ search): FE set `operation:"reference"` và `doc:null`.
|
||||
- Wiki `source:"inline"` (được tạo/sửa trong editor):
|
||||
- nếu UI set explicit `create|update|delete` thì giữ nguyên
|
||||
- nếu không có operation:
|
||||
- wiki mới: FE coi là `"create"`
|
||||
- wiki cũ không đổi: FE gán `"reference"`
|
||||
- wiki cũ có đổi nội dung: FE gán `"update"`
|
||||
|
||||
### 3.6 entity_wiki[]
|
||||
|
||||
Type trong FE cho UI state cho phép `"binding"` và `"delete"`.
|
||||
|
||||
Khi build snapshot để commit, FE map link “đang bật” về `"reference"` để tương thích với backend (một số backend chỉ chấp nhận `"reference"|"delete"`).
|
||||
|
||||
## 4) Ví Dụ snapshot_json (rút gọn)
|
||||
|
||||
```json
|
||||
{
|
||||
"editor_feature_collection": {
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": { "id": "019e…", "type": "country", "time_start": 1000, "time_end": 1500 },
|
||||
"geometry": { "type": "Polygon", "coordinates": [[[100, 10], [101, 10], [101, 11], [100, 10]]] }
|
||||
}
|
||||
]
|
||||
},
|
||||
"entities": [
|
||||
{ "id": "019e…", "source": "inline", "operation": "reference", "name": "ent1", "description": null, "status": 1 }
|
||||
],
|
||||
"geometries": [
|
||||
{ "id": "019e…", "source": "inline", "operation": "update", "type": "9", "draw_geometry": { "type": "Polygon", "coordinates": [] }, "binding": [], "time_start": 1000, "time_end": 1500, "bbox": null }
|
||||
],
|
||||
"geometry_entity": [
|
||||
{ "geometry_id": "019e…", "entity_id": "019e…" }
|
||||
],
|
||||
"wikis": [
|
||||
{ "id": "019e…", "source": "ref", "operation": "reference", "title": "Existing wiki", "doc": null, "updated_at": "2026-05-08T00:00:00.000Z" }
|
||||
],
|
||||
"entity_wiki": [
|
||||
{ "entity_id": "019e…", "wiki_id": "019e…", "operation": "reference" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 5) Compat Notes (khi load snapshot cũ)
|
||||
|
||||
FE normalize khi load snapshot:
|
||||
|
||||
- Nếu thấy `entity_wikis` (plural) sẽ đọc như `entity_wiki`.
|
||||
- Nếu join link có `operation:"reference"` thì FE coi như link active (UI biểu diễn như “binding”).
|
||||
+10
-1
@@ -1,4 +1,12 @@
|
||||
import type { NextConfig } from "next";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
// Turbopack uses its "root" directory for module resolution. In a repo that
|
||||
// contains multiple projects (without a package.json at the repo root),
|
||||
// Turbopack can accidentally pick the repo root and then fail to resolve
|
||||
// dependencies like `tailwindcss`. Force Turbopack root to this app directory.
|
||||
const turbopackRoot = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
@@ -28,6 +36,7 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
|
||||
turbopack: {
|
||||
root: turbopackRoot,
|
||||
rules: {
|
||||
'*.svg': {
|
||||
loaders: ['@svgr/webpack'],
|
||||
@@ -37,4 +46,4 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
export default nextConfig;
|
||||
|
||||
Generated
+1224
-32
File diff suppressed because it is too large
Load Diff
+9
-1
@@ -3,7 +3,7 @@
|
||||
"version": "2.2.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "node ./scripts/dev.mjs",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint ."
|
||||
@@ -20,11 +20,17 @@
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@tiptap/extension-link": "^2.26.1",
|
||||
"@tiptap/react": "^2.26.1",
|
||||
"@tiptap/starter-kit": "^2.26.1",
|
||||
"apexcharts": "^4.7.0",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"axios": "^1.14.0",
|
||||
"flatpickr": "^4.6.13",
|
||||
"maplibre-gl": "^5.20.2",
|
||||
"next": "^16.1.6",
|
||||
"polylabel": "^2.0.1",
|
||||
"quill-blot-formatter": "^1.0.5",
|
||||
"react": "^19.2.0",
|
||||
"react-apexcharts": "^1.8.0",
|
||||
"react-dnd": "^16.0.1",
|
||||
@@ -37,12 +43,14 @@
|
||||
"sweetalert2": "^11.26.24",
|
||||
"swiper": "^11.2.10",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"uuid": "^13.0.0",
|
||||
"yet-another-react-lightbox": "^3.30.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@types/node": "^20.19.25",
|
||||
"@types/polylabel": "^1.1.3",
|
||||
"@types/react": "^19.2.1",
|
||||
"@types/react-dom": "^19.2.1",
|
||||
"@types/react-transition-group": "^4.4.12",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 161 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,26 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
// WebStorm sometimes runs npm scripts with a different working directory (repo root),
|
||||
// which breaks module resolution for PostCSS/Tailwind. Force cwd to this package.
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const pkgRoot = path.resolve(here, "..");
|
||||
|
||||
// Ensure this process (and any child tools that read process.cwd()) runs from package root,
|
||||
// even if the caller started in a different working directory (e.g. IDE run configs).
|
||||
process.chdir(pkgRoot);
|
||||
|
||||
const nextBin = path.join(pkgRoot, "node_modules", "next", "dist", "bin", "next");
|
||||
|
||||
// Forward any args passed after `--` from npm, e.g. `npm run dev -- --port 3005`.
|
||||
const extraArgs = process.argv.slice(2);
|
||||
const child = spawn(process.execPath, [nextBin, "dev", ...extraArgs], {
|
||||
cwd: pkgRoot,
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
child.on("exit", (code) => {
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
@@ -42,7 +42,7 @@ export default function ResetPasswordForm() {
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
await apiCreateOTP(email);
|
||||
await apiCreateOTP(email, 1);
|
||||
toast.success("Mã OTP đã được gửi đến email của bạn!");
|
||||
setStep(2);
|
||||
} catch (error) {
|
||||
@@ -69,7 +69,7 @@ export default function ResetPasswordForm() {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const verifyRes = await apiVerifyOTP(email, otp);
|
||||
const verifyRes = await apiVerifyOTP(email, otp, 1);
|
||||
const tokenId = verifyRes?.data?.token_id;
|
||||
|
||||
if (!tokenId) {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { Entity } from "@/uhm/types/entities";
|
||||
import type { Feature, FeatureProperties } from "@/uhm/types/geo";
|
||||
import { ApiError } from "@/uhm/api/http";
|
||||
import { buildFeatureEntityPatch } from "@/uhm/lib/editor/entity/entityBinding";
|
||||
import { buildGeometryMetadataPatch } from "@/uhm/lib/editor/geometry/geometryMetadata";
|
||||
import { uniqueEntityIds } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
||||
import type { GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionTypes";
|
||||
|
||||
type EditorDraftApi = {
|
||||
patchFeatureProperties: (id: FeatureProperties["id"], patch: Partial<FeatureProperties>) => void;
|
||||
patchFeaturePropertiesBatch: (
|
||||
patches: Array<{ id: FeatureProperties["id"]; patch: Partial<FeatureProperties> }>,
|
||||
label?: string
|
||||
) => void;
|
||||
};
|
||||
|
||||
type Options = {
|
||||
editor: EditorDraftApi;
|
||||
selectedFeatures: Feature[];
|
||||
geometryMetaForm: GeometryMetaFormState;
|
||||
setGeometryMetaForm: Dispatch<SetStateAction<GeometryMetaFormState>>;
|
||||
selectedGeometryEntityIds: string[];
|
||||
setSelectedGeometryEntityIds: Dispatch<SetStateAction<string[]>>;
|
||||
entities: Entity[];
|
||||
setIsEntitySubmitting: Dispatch<SetStateAction<boolean>>;
|
||||
setEntityFormStatus: Dispatch<SetStateAction<string | null>>;
|
||||
};
|
||||
|
||||
export function useFeatureCommands(options: Options) {
|
||||
const {
|
||||
editor,
|
||||
selectedFeatures,
|
||||
geometryMetaForm,
|
||||
setGeometryMetaForm,
|
||||
selectedGeometryEntityIds,
|
||||
setSelectedGeometryEntityIds,
|
||||
entities,
|
||||
setIsEntitySubmitting,
|
||||
setEntityFormStatus,
|
||||
} = options;
|
||||
|
||||
const applyGeometryMetadata = useCallback(async (): Promise<{ ok: boolean; error?: string }> => {
|
||||
if (!selectedFeatures || selectedFeatures.length === 0) {
|
||||
const msg = "Hãy chọn ít nhất một geometry trước.";
|
||||
setEntityFormStatus(msg);
|
||||
return { ok: false, error: msg };
|
||||
}
|
||||
|
||||
if (!geometryMetaForm.time_start.trim() || !geometryMetaForm.time_end.trim()) {
|
||||
const msg = "time_start và time_end là bắt buộc.";
|
||||
setEntityFormStatus(msg);
|
||||
return { ok: false, error: msg };
|
||||
}
|
||||
|
||||
let metadata;
|
||||
try {
|
||||
metadata = buildGeometryMetadataPatch(geometryMetaForm);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Thời gian không hợp lệ.";
|
||||
setEntityFormStatus(msg);
|
||||
return { ok: false, error: msg };
|
||||
}
|
||||
|
||||
setIsEntitySubmitting(true);
|
||||
setEntityFormStatus(null);
|
||||
try {
|
||||
editor.patchFeaturePropertiesBatch(
|
||||
selectedFeatures.map((feature) => ({
|
||||
id: feature.properties.id,
|
||||
patch: metadata.patch,
|
||||
})),
|
||||
"Cập nhật thuộc tính GEO"
|
||||
);
|
||||
setGeometryMetaForm(metadata.formState);
|
||||
setEntityFormStatus("Đã cập nhật thuộc tính GEO. Commit khi sẵn sàng.");
|
||||
return { ok: true };
|
||||
} finally {
|
||||
setIsEntitySubmitting(false);
|
||||
}
|
||||
}, [
|
||||
editor,
|
||||
geometryMetaForm,
|
||||
selectedFeatures,
|
||||
setEntityFormStatus,
|
||||
setGeometryMetaForm,
|
||||
setIsEntitySubmitting,
|
||||
]);
|
||||
|
||||
const applyEntitiesToSelectedGeometry = useCallback(async () => {
|
||||
if (!selectedFeatures || selectedFeatures.length === 0) {
|
||||
setEntityFormStatus("Hãy chọn ít nhất một geometry trước.");
|
||||
return;
|
||||
}
|
||||
|
||||
const entityIds = uniqueEntityIds(selectedGeometryEntityIds);
|
||||
setIsEntitySubmitting(true);
|
||||
setEntityFormStatus(null);
|
||||
try {
|
||||
editor.patchFeaturePropertiesBatch(
|
||||
selectedFeatures.map((feature) => ({
|
||||
id: feature.properties.id,
|
||||
patch: buildFeatureEntityPatch(feature, entityIds, entities),
|
||||
})),
|
||||
"Cập nhật entity cho GEO"
|
||||
);
|
||||
setSelectedGeometryEntityIds(entityIds);
|
||||
setEntityFormStatus("Đã cập nhật danh sách entity. Commit khi sẵn sàng.");
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setEntityFormStatus(`Lưu thất bại: ${err.body}`);
|
||||
} else {
|
||||
setEntityFormStatus("Lưu thất bại.");
|
||||
}
|
||||
} finally {
|
||||
setIsEntitySubmitting(false);
|
||||
}
|
||||
}, [
|
||||
editor,
|
||||
entities,
|
||||
selectedFeatures,
|
||||
selectedGeometryEntityIds,
|
||||
setEntityFormStatus,
|
||||
setIsEntitySubmitting,
|
||||
setSelectedGeometryEntityIds,
|
||||
]);
|
||||
|
||||
return {
|
||||
applyGeometryMetadata,
|
||||
applyEntitiesToSelectedGeometry,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function EditorIndexPage() {
|
||||
// Editor must be opened from a specific project (see /user/projects).
|
||||
redirect("/user/projects");
|
||||
}
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ export default function RootLayout({
|
||||
<html lang="en">
|
||||
<body className={`${inter.className} dark:bg-gray-900`}>
|
||||
<StoreProvider>
|
||||
<ThemeProvider>
|
||||
<ThemeProvider>
|
||||
<SidebarProvider>{children} <Toaster closeButton richColors position="top-right" /> </SidebarProvider>
|
||||
</ThemeProvider>
|
||||
</StoreProvider>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-white/40 backdrop-blur-sm dark:bg-black/40">
|
||||
<div className="w-12 h-12 border-4 border-blue-200 border-t-blue-600 dark:border-blue-900 dark:border-t-blue-500 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+788
-6
@@ -1,9 +1,791 @@
|
||||
import AdminLayout from "./user/layout";
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import Map, { type MapHoverPayload } from "@/uhm/components/Map";
|
||||
import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar";
|
||||
import TimelineBar from "@/uhm/components/ui/TimelineBar";
|
||||
import { fetchEntities, type Entity } from "@/uhm/api/entities";
|
||||
import { fetchGeometriesByBBox } from "@/uhm/api/geometries";
|
||||
import { ApiError } from "@/uhm/api/http";
|
||||
import { fetchWikiBySlug, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
|
||||
import {
|
||||
BACKGROUND_LAYER_OPTIONS,
|
||||
type BackgroundLayerId,
|
||||
type BackgroundLayerVisibility,
|
||||
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
||||
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
||||
} from "@/uhm/lib/map/styles/backgroundLayers";
|
||||
import {
|
||||
loadBackgroundLayerVisibilityFromStorage,
|
||||
persistBackgroundLayerVisibility,
|
||||
} from "@/uhm/lib/editor/background/backgroundVisibilityStorage";
|
||||
import { EMPTY_FEATURE_COLLECTION, WORLD_BBOX } from "@/uhm/lib/map/geo/constants";
|
||||
import { GEO_TYPE_KEYS } from "@/uhm/lib/map/geo/geoTypeMap";
|
||||
import { clampYearToFixedRange, TIMELINE_DEBOUNCE_MS } from "@/uhm/lib/utils/timeline";
|
||||
import type { FeatureCollection } from "@/uhm/types/geo";
|
||||
|
||||
const CURRENT_YEAR = new Date().getUTCFullYear();
|
||||
const ENTITY_PAGE_LIMIT = 100;
|
||||
const WIKI_PAGE_LIMIT = 100;
|
||||
const RELATION_CONCURRENCY = 6;
|
||||
|
||||
type RelationIndex = {
|
||||
entitiesById: Record<string, Entity>;
|
||||
entityGeometriesById: Record<string, FeatureCollection>;
|
||||
entityWikisById: Record<string, Wiki[]>;
|
||||
geometryEntityIds: Record<string, string[]>;
|
||||
wikiEntityIdsBySlug: Record<string, string[]>;
|
||||
wikiBySlug: Record<string, Wiki>;
|
||||
};
|
||||
|
||||
type LinkEntityPopupState = {
|
||||
slug: string;
|
||||
entities: Entity[];
|
||||
top: number;
|
||||
left: number;
|
||||
};
|
||||
|
||||
const EMPTY_RELATIONS: RelationIndex = {
|
||||
entitiesById: {},
|
||||
entityGeometriesById: {},
|
||||
entityWikisById: {},
|
||||
geometryEntityIds: {},
|
||||
wikiEntityIdsBySlug: {},
|
||||
wikiBySlug: {},
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className=''>Page</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
const [data, setData] = useState<FeatureCollection>(EMPTY_FEATURE_COLLECTION);
|
||||
const [selectedFeatureIds, setSelectedFeatureIds] = useState<(string | number)[]>([]);
|
||||
const [timelineYear, setTimelineYear] = useState<number>(() => clampYearToFixedRange(CURRENT_YEAR));
|
||||
const [timelineDraftYear, setTimelineDraftYear] = useState<number>(() => clampYearToFixedRange(CURRENT_YEAR));
|
||||
const [timeRange, setTimeRange] = useState<number>(0);
|
||||
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
|
||||
const [timelineStatus, setTimelineStatus] = useState<string | null>(null);
|
||||
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
|
||||
() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })
|
||||
);
|
||||
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
|
||||
const [geometryVisibility, setGeometryVisibility] = useState<Record<string, boolean>>(() => {
|
||||
const init: Record<string, boolean> = {};
|
||||
for (const key of GEO_TYPE_KEYS) init[key] = true;
|
||||
return init;
|
||||
});
|
||||
const [relations, setRelations] = useState<RelationIndex>(EMPTY_RELATIONS);
|
||||
const [isRelationsLoading, setIsRelationsLoading] = useState(false);
|
||||
const [relationsStatus, setRelationsStatus] = useState<string | null>(null);
|
||||
const [relationsProgress, setRelationsProgress] = useState<{ completed: number; total: number }>({
|
||||
completed: 0,
|
||||
total: 0,
|
||||
});
|
||||
const [hoverAnchor, setHoverAnchor] = useState<MapHoverPayload | null>(null);
|
||||
const [activeEntityId, setActiveEntityId] = useState<string | null>(null);
|
||||
const [activeWikiSlug, setActiveWikiSlug] = useState<string | null>(null);
|
||||
const [wikiCache, setWikiCache] = useState<Record<string, Wiki>>({});
|
||||
const [isActiveWikiLoading, setIsActiveWikiLoading] = useState(false);
|
||||
const [activeWikiError, setActiveWikiError] = useState<string | null>(null);
|
||||
const [linkEntityPopup, setLinkEntityPopup] = useState<LinkEntityPopupState | null>(null);
|
||||
const [entityFocusToken, setEntityFocusToken] = useState(0);
|
||||
|
||||
const timelineFetchRequestRef = useRef(0);
|
||||
const hoverHideTimerRef = useRef<number | null>(null);
|
||||
const hoverPopupHoveredRef = useRef(false);
|
||||
const linkEntityPopupRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const selectedFeature = useMemo(() => {
|
||||
if (!selectedFeatureIds || selectedFeatureIds.length === 0) return null;
|
||||
return (
|
||||
data.features.find((feature) => String(feature.properties.id) === String(selectedFeatureIds[0])) || null
|
||||
);
|
||||
}, [data.features, selectedFeatureIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedFeatureIds || selectedFeatureIds.length === 0) return;
|
||||
const stillExistIds = selectedFeatureIds.filter(id =>
|
||||
data.features.some(feature => String(feature.properties.id) === String(id))
|
||||
);
|
||||
if (stillExistIds.length !== selectedFeatureIds.length) {
|
||||
setSelectedFeatureIds(stillExistIds);
|
||||
}
|
||||
}, [data.features, selectedFeatureIds]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
if (timelineDraftYear !== timelineYear) setTimelineYear(timelineDraftYear);
|
||||
}, TIMELINE_DEBOUNCE_MS);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [timelineDraftYear, timelineYear]);
|
||||
|
||||
useEffect(() => {
|
||||
setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage());
|
||||
setIsBackgroundVisibilityReady(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
const requestId = ++timelineFetchRequestRef.current;
|
||||
|
||||
async function loadByTimeline() {
|
||||
setIsTimelineLoading(true);
|
||||
setTimelineStatus(null);
|
||||
try {
|
||||
const next = await fetchGeometriesByBBox({ ...WORLD_BBOX, time: timelineYear, timeRange });
|
||||
if (disposed || requestId !== timelineFetchRequestRef.current) return;
|
||||
setData(next);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
console.error("Load timeline data failed", err.body);
|
||||
} else {
|
||||
console.error("Load timeline data failed", err);
|
||||
}
|
||||
if (!disposed && requestId === timelineFetchRequestRef.current) {
|
||||
setTimelineStatus("Không tải được geometry tại mốc thời gian đã chọn.");
|
||||
}
|
||||
} finally {
|
||||
if (!disposed && requestId === timelineFetchRequestRef.current) {
|
||||
setIsTimelineLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadByTimeline();
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [timelineYear, timeRange]);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
|
||||
async function loadRelations() {
|
||||
setIsRelationsLoading(true);
|
||||
setRelationsStatus(null);
|
||||
setRelationsProgress({ completed: 0, total: 0 });
|
||||
|
||||
try {
|
||||
const entities = await fetchAllEntities();
|
||||
if (disposed) return;
|
||||
|
||||
const next: RelationIndex = {
|
||||
entitiesById: {},
|
||||
entityGeometriesById: {},
|
||||
entityWikisById: {},
|
||||
geometryEntityIds: {},
|
||||
wikiEntityIdsBySlug: {},
|
||||
wikiBySlug: {},
|
||||
};
|
||||
|
||||
for (const entity of entities) {
|
||||
next.entitiesById[entity.id] = entity;
|
||||
}
|
||||
|
||||
setRelationsProgress({ completed: 0, total: entities.length });
|
||||
|
||||
await mapWithConcurrency(entities, RELATION_CONCURRENCY, async (entity, index) => {
|
||||
const [geometries, wikis] = await Promise.all([
|
||||
fetchGeometriesByBBox({ ...WORLD_BBOX, entity_id: entity.id }),
|
||||
fetchAllWikisForEntity(entity.id),
|
||||
]);
|
||||
if (disposed) return;
|
||||
|
||||
next.entityGeometriesById[entity.id] = geometries;
|
||||
next.entityWikisById[entity.id] = wikis;
|
||||
|
||||
for (const feature of geometries.features) {
|
||||
pushUniqueString(next.geometryEntityIds, String(feature.properties.id), entity.id);
|
||||
}
|
||||
|
||||
for (const wiki of wikis) {
|
||||
const slug = String(wiki.slug || "").trim();
|
||||
if (!slug.length) continue;
|
||||
next.wikiBySlug[slug] = wiki;
|
||||
pushUniqueString(next.wikiEntityIdsBySlug, slug, entity.id);
|
||||
}
|
||||
|
||||
const completed = index + 1;
|
||||
if (completed === entities.length || completed % 5 === 0) {
|
||||
setRelationsProgress({ completed, total: entities.length });
|
||||
}
|
||||
});
|
||||
|
||||
if (disposed) return;
|
||||
|
||||
normalizeRelationArrays(next.geometryEntityIds);
|
||||
normalizeRelationArrays(next.wikiEntityIdsBySlug);
|
||||
|
||||
setRelations(next);
|
||||
setWikiCache((prev) => ({ ...next.wikiBySlug, ...prev }));
|
||||
} catch (err) {
|
||||
console.error("Load relation index failed", err);
|
||||
if (!disposed) {
|
||||
setRelationsStatus("Không tải được liên kết entity/wiki cho bản đồ.");
|
||||
}
|
||||
} finally {
|
||||
if (!disposed) {
|
||||
setIsRelationsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadRelations();
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hoverEntityIds = useMemo(() => {
|
||||
if (!hoverAnchor) return [];
|
||||
return relations.geometryEntityIds[String(hoverAnchor.featureId)] || [];
|
||||
}, [hoverAnchor, relations.geometryEntityIds]);
|
||||
|
||||
const hoverEntities = useMemo(() => {
|
||||
return hoverEntityIds
|
||||
.map((entityId) => relations.entitiesById[entityId] || null)
|
||||
.filter((entity): entity is Entity => Boolean(entity));
|
||||
}, [hoverEntityIds, relations.entitiesById]);
|
||||
|
||||
const activeEntity = activeEntityId ? relations.entitiesById[activeEntityId] || null : null;
|
||||
const activeEntityGeometries = activeEntityId
|
||||
? relations.entityGeometriesById[activeEntityId] || EMPTY_FEATURE_COLLECTION
|
||||
: EMPTY_FEATURE_COLLECTION;
|
||||
|
||||
const activeWiki = useMemo(() => {
|
||||
if (!activeWikiSlug) return null;
|
||||
return wikiCache[activeWikiSlug] || relations.wikiBySlug[activeWikiSlug] || null;
|
||||
}, [activeWikiSlug, relations.wikiBySlug, wikiCache]);
|
||||
|
||||
const updateBackgroundVisibility = (updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility) => {
|
||||
setBackgroundVisibility((prev) => {
|
||||
const next = updater(prev);
|
||||
persistBackgroundLayerVisibility(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleBackgroundLayer = (id: BackgroundLayerId) => {
|
||||
updateBackgroundVisibility((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||
};
|
||||
|
||||
const handleShowAllBackgroundLayers = () => {
|
||||
updateBackgroundVisibility(() => ({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }));
|
||||
};
|
||||
|
||||
const handleHideAllBackgroundLayers = () => {
|
||||
updateBackgroundVisibility(() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }));
|
||||
};
|
||||
|
||||
const handleTimelineYearChange = (nextYear: number) => {
|
||||
setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear)));
|
||||
};
|
||||
|
||||
const handleTimeRangeChange = (nextRange: number) => {
|
||||
const safe = Number.isFinite(nextRange) ? Math.trunc(nextRange) : 0;
|
||||
setTimeRange(Math.max(0, Math.min(30, safe)));
|
||||
};
|
||||
|
||||
const clearHoverHideTimer = useCallback(() => {
|
||||
if (hoverHideTimerRef.current !== null) {
|
||||
window.clearTimeout(hoverHideTimerRef.current);
|
||||
hoverHideTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const selectEntity = useCallback((
|
||||
entityId: string,
|
||||
options?: {
|
||||
sourceFeatureId?: string | number | null;
|
||||
preferredWikiSlug?: string | null;
|
||||
focusMap?: boolean;
|
||||
selectGeometry?: boolean;
|
||||
}
|
||||
) => {
|
||||
const entity = relations.entitiesById[entityId] || null;
|
||||
if (!entity) return;
|
||||
|
||||
const linkedWikis = relations.entityWikisById[entityId] || [];
|
||||
const preferredWikiSlug = String(options?.preferredWikiSlug || "").trim();
|
||||
const nextWikiSlug =
|
||||
(preferredWikiSlug && linkedWikis.some((wiki) => String(wiki.slug || "").trim() === preferredWikiSlug)
|
||||
? preferredWikiSlug
|
||||
: "") ||
|
||||
linkedWikis.map((wiki) => String(wiki.slug || "").trim()).find((slug) => slug.length > 0) ||
|
||||
null;
|
||||
|
||||
setActiveEntityId(entityId);
|
||||
setActiveWikiSlug(nextWikiSlug);
|
||||
setActiveWikiError(null);
|
||||
setLinkEntityPopup(null);
|
||||
if (options?.focusMap !== false) {
|
||||
setEntityFocusToken((prev) => prev + 1);
|
||||
}
|
||||
if (options?.selectGeometry && options?.sourceFeatureId != null) {
|
||||
setSelectedFeatureIds([options.sourceFeatureId]);
|
||||
}
|
||||
}, [relations.entitiesById, relations.entityWikisById]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedFeatureIds || selectedFeatureIds.length === 0) return;
|
||||
// For UI simplicity in viewer, just link to the first selected geometry
|
||||
const linkedEntityIds = relations.geometryEntityIds[String(selectedFeatureIds[0])] || [];
|
||||
if (linkedEntityIds.length !== 1) return;
|
||||
|
||||
const onlyEntityId = linkedEntityIds[0];
|
||||
if (activeEntityId === onlyEntityId) return;
|
||||
|
||||
selectEntity(onlyEntityId, {
|
||||
sourceFeatureId: selectedFeatureIds[0],
|
||||
focusMap: false,
|
||||
selectGeometry: false,
|
||||
});
|
||||
}, [activeEntityId, relations.geometryEntityIds, selectEntity, selectedFeatureIds]);
|
||||
|
||||
const handleMapHoverChange = useCallback((payload: MapHoverPayload | null) => {
|
||||
clearHoverHideTimer();
|
||||
|
||||
if (payload) {
|
||||
setHoverAnchor(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hoverPopupHoveredRef.current) return;
|
||||
hoverHideTimerRef.current = window.setTimeout(() => {
|
||||
setHoverAnchor(null);
|
||||
}, 120);
|
||||
}, [clearHoverHideTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hoverHideTimerRef.current !== null) {
|
||||
window.clearTimeout(hoverHideTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!linkEntityPopup) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setLinkEntityPopup(null);
|
||||
};
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node | null;
|
||||
if (target && linkEntityPopupRef.current?.contains(target)) return;
|
||||
setLinkEntityPopup(null);
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("pointerdown", handlePointerDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("pointerdown", handlePointerDown);
|
||||
};
|
||||
}, [linkEntityPopup]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeWikiSlug) {
|
||||
setIsActiveWikiLoading(false);
|
||||
setActiveWikiError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = wikiCache[activeWikiSlug] || relations.wikiBySlug[activeWikiSlug] || null;
|
||||
if (cached?.content) {
|
||||
setIsActiveWikiLoading(false);
|
||||
setActiveWikiError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
(async () => {
|
||||
setIsActiveWikiLoading(true);
|
||||
setActiveWikiError(null);
|
||||
try {
|
||||
const row = await fetchWikiBySlug(activeWikiSlug);
|
||||
if (disposed) return;
|
||||
if (row) {
|
||||
setWikiCache((prev) => ({ ...prev, [activeWikiSlug]: row }));
|
||||
} else {
|
||||
setActiveWikiError("Không tìm thấy wiki cho entity đã chọn.");
|
||||
}
|
||||
} catch (err) {
|
||||
if (disposed) return;
|
||||
setActiveWikiError(err instanceof Error ? err.message : "Không tải được wiki.");
|
||||
} finally {
|
||||
if (!disposed) setIsActiveWikiLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [activeWikiSlug, relations.wikiBySlug, wikiCache]);
|
||||
|
||||
const handleWikiLinkRequest = useCallback(async ({ slug, rect }: { slug: string; rect: DOMRect }) => {
|
||||
const linkedEntityIds = relations.wikiEntityIdsBySlug[slug] || [];
|
||||
const linkedEntities = linkedEntityIds
|
||||
.map((entityId) => relations.entitiesById[entityId] || null)
|
||||
.filter((entity): entity is Entity => Boolean(entity));
|
||||
|
||||
if (linkedEntities.length === 1) {
|
||||
selectEntity(linkedEntities[0].id, { preferredWikiSlug: slug });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!wikiCache[slug] && !relations.wikiBySlug[slug]) {
|
||||
try {
|
||||
const row = await fetchWikiBySlug(slug);
|
||||
if (row) setWikiCache((prev) => ({ ...prev, [slug]: row }));
|
||||
} catch (err) {
|
||||
console.error("Load wiki by slug failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
if (!linkedEntities.length) return;
|
||||
|
||||
const popupWidth = 240;
|
||||
const popupHeight = Math.min(240, linkedEntities.length * 44 + 20);
|
||||
const { top, left } = computeFixedPopupPosition(rect, popupWidth, popupHeight);
|
||||
|
||||
setLinkEntityPopup({
|
||||
slug,
|
||||
entities: linkedEntities,
|
||||
top,
|
||||
left,
|
||||
});
|
||||
}, [relations.entitiesById, relations.wikiBySlug, relations.wikiEntityIdsBySlug, selectEntity, wikiCache]);
|
||||
|
||||
const helperText = isRelationsLoading
|
||||
? `Đang index entity/wiki ${relationsProgress.completed}/${relationsProgress.total || "?"}`
|
||||
: relationsStatus || `Features: ${data.features.length}`;
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen overflow-hidden bg-gray-950 text-gray-100">
|
||||
<div className="relative min-h-screen">
|
||||
{isBackgroundVisibilityReady ? (
|
||||
<Map
|
||||
mode="select"
|
||||
draft={data}
|
||||
selectedFeatureIds={selectedFeatureIds}
|
||||
onSelectFeatureIds={setSelectedFeatureIds}
|
||||
backgroundVisibility={backgroundVisibility}
|
||||
geometryVisibility={geometryVisibility}
|
||||
allowGeometryEditing={false}
|
||||
respectBindingFilter={true}
|
||||
onHoverFeatureChange={handleMapHoverChange}
|
||||
highlightFeatures={activeEntityGeometries}
|
||||
focusFeatureCollection={activeEntityGeometries}
|
||||
focusRequestKey={entityFocusToken}
|
||||
focusPadding={activeEntityId ? { top: 84, right: 500, bottom: 116, left: 84 } : { top: 84, right: 84, bottom: 116, left: 84 }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-screen w-full bg-[#0b1220]" />
|
||||
)}
|
||||
|
||||
<TimelineBar
|
||||
year={timelineDraftYear}
|
||||
onYearChange={handleTimelineYearChange}
|
||||
timeRange={timeRange}
|
||||
onTimeRangeChange={handleTimeRangeChange}
|
||||
isLoading={isTimelineLoading}
|
||||
disabled={false}
|
||||
statusText={timelineStatus}
|
||||
/>
|
||||
|
||||
<div className="absolute left-4 top-4 z-20 w-[280px] max-w-[calc(100vw-2rem)] overflow-hidden rounded-xl border border-white/10 bg-slate-950/92 shadow-xl backdrop-blur">
|
||||
<div className="border-b border-white/10 px-4 py-3">
|
||||
<div className="text-sm font-semibold text-white">Map Layers</div>
|
||||
<div className="mt-1 text-xs text-slate-400">{helperText}</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 px-4 py-4">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between text-[11px] uppercase tracking-[0.08em] text-slate-500">
|
||||
<span>Background</span>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={handleShowAllBackgroundLayers} className="text-slate-300 transition hover:text-white">
|
||||
All
|
||||
</button>
|
||||
<button type="button" onClick={handleHideAllBackgroundLayers} className="text-slate-300 transition hover:text-white">
|
||||
Off
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{BACKGROUND_LAYER_OPTIONS.map((layer) => {
|
||||
const active = Boolean(backgroundVisibility[layer.id]);
|
||||
return (
|
||||
<button
|
||||
key={layer.id}
|
||||
type="button"
|
||||
onClick={() => handleToggleBackgroundLayer(layer.id)}
|
||||
className={`rounded-md border px-2.5 py-1 text-xs transition ${active
|
||||
? "border-sky-400/40 bg-sky-500/10 text-sky-200"
|
||||
: "border-white/10 bg-white/[0.03] text-slate-400 hover:text-slate-200"
|
||||
}`}
|
||||
>
|
||||
{layer.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 text-[11px] uppercase tracking-[0.08em] text-slate-500">
|
||||
Geometry
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{GEO_TYPE_KEYS.map((typeKey) => {
|
||||
const active = geometryVisibility[typeKey] !== false;
|
||||
return (
|
||||
<button
|
||||
key={typeKey}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setGeometryVisibility((prev) => ({
|
||||
...prev,
|
||||
[typeKey]: prev[typeKey] === false,
|
||||
}));
|
||||
}}
|
||||
className={`rounded-md border px-2.5 py-1 text-xs capitalize transition ${active
|
||||
? "border-emerald-400/40 bg-emerald-500/10 text-emerald-200"
|
||||
: "border-white/10 bg-white/[0.03] text-slate-400 hover:text-slate-200"
|
||||
}`}
|
||||
>
|
||||
{typeKey.replaceAll("_", " ")}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hoverAnchor && hoverEntities.length > 0 ? (
|
||||
<div
|
||||
className="absolute z-30 w-[320px] max-w-[calc(100vw-2rem)]"
|
||||
style={{
|
||||
left: clampNumber(hoverAnchor.point.x + 18, 16, typeof window !== "undefined" ? window.innerWidth - 340 : hoverAnchor.point.x + 18),
|
||||
top: clampNumber(hoverAnchor.point.y - 8, 16, typeof window !== "undefined" ? window.innerHeight - 280 : hoverAnchor.point.y - 8),
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
hoverPopupHoveredRef.current = true;
|
||||
clearHoverHideTimer();
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
hoverPopupHoveredRef.current = false;
|
||||
setHoverAnchor(null);
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden rounded-xl border border-white/10 bg-slate-950/95 shadow-xl backdrop-blur">
|
||||
{hoverEntities.length > 1 ? (
|
||||
<div className="border-b border-white/10 px-4 py-3">
|
||||
<div className="text-sm font-semibold text-white">Related Entities</div>
|
||||
<div className="mt-1 text-xs text-slate-400">
|
||||
Geometry #{String(hoverAnchor.featureId)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="max-h-[252px] overflow-y-auto">
|
||||
<div className="grid gap-2 p-3">
|
||||
{hoverEntities.map((entity) => (
|
||||
<button
|
||||
key={entity.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
selectEntity(entity.id, {
|
||||
sourceFeatureId: hoverAnchor.featureId,
|
||||
focusMap: true,
|
||||
selectGeometry: true,
|
||||
});
|
||||
setHoverAnchor(null);
|
||||
}}
|
||||
className="w-full rounded-lg border border-white/10 bg-white/[0.03] px-3 py-3 text-left transition hover:border-sky-400/40 hover:bg-sky-500/10"
|
||||
>
|
||||
<div className="truncate text-sm font-semibold text-white">
|
||||
{entity.name}
|
||||
</div>
|
||||
<div
|
||||
className="mt-1 text-xs leading-5 text-slate-400"
|
||||
style={{
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{entity.description?.trim() || "Không có mô tả."}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeEntity ? (
|
||||
<aside className="absolute bottom-4 right-4 top-4 z-20 w-[420px] max-w-[calc(100vw-2rem)]">
|
||||
<PublicWikiSidebar
|
||||
entity={activeEntity}
|
||||
wiki={activeWiki}
|
||||
isLoading={isActiveWikiLoading}
|
||||
error={activeWikiError}
|
||||
onClose={() => {
|
||||
setActiveEntityId(null);
|
||||
setActiveWikiSlug(null);
|
||||
setActiveWikiError(null);
|
||||
setLinkEntityPopup(null);
|
||||
}}
|
||||
onWikiLinkRequest={handleWikiLinkRequest}
|
||||
/>
|
||||
</aside>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{linkEntityPopup ? (
|
||||
<div
|
||||
ref={linkEntityPopupRef}
|
||||
className="fixed z-[60] w-[240px] overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-800 dark:bg-gray-950"
|
||||
style={{ top: linkEntityPopup.top, left: linkEntityPopup.left }}
|
||||
>
|
||||
<div className="border-b border-gray-200 px-3 py-2 dark:border-gray-800">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
Related Entities
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
/wiki/{linkEntityPopup.slug}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[220px] overflow-y-auto p-2">
|
||||
<div className="grid gap-1">
|
||||
{linkEntityPopup.entities.map((entity) => (
|
||||
<button
|
||||
key={entity.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
selectEntity(entity.id, { preferredWikiSlug: linkEntityPopup.slug });
|
||||
setLinkEntityPopup(null);
|
||||
}}
|
||||
className="rounded-lg px-3 py-2 text-left text-sm text-gray-700 transition hover:bg-gray-50 hover:text-gray-900 dark:text-gray-200 dark:hover:bg-white/[0.04] dark:hover:text-white"
|
||||
>
|
||||
{entity.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchAllEntities(): Promise<Entity[]> {
|
||||
const items: Entity[] = [];
|
||||
const seen = new Set<string>();
|
||||
let cursor: string | undefined;
|
||||
|
||||
while (true) {
|
||||
const page = await fetchEntities({ q: "", limit: ENTITY_PAGE_LIMIT, cursor });
|
||||
if (!page.length) break;
|
||||
|
||||
for (const entity of page) {
|
||||
if (!entity?.id || seen.has(entity.id)) continue;
|
||||
seen.add(entity.id);
|
||||
items.push(entity);
|
||||
}
|
||||
|
||||
if (page.length < ENTITY_PAGE_LIMIT) break;
|
||||
const nextCursor = page[page.length - 1]?.id;
|
||||
if (!nextCursor || nextCursor === cursor) break;
|
||||
cursor = nextCursor;
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
async function fetchAllWikisForEntity(entityId: string): Promise<Wiki[]> {
|
||||
const items: Wiki[] = [];
|
||||
const seen = new Set<string>();
|
||||
let cursor: string | undefined;
|
||||
|
||||
while (true) {
|
||||
const page = await searchWikisByTitle("", {
|
||||
entityId,
|
||||
limit: WIKI_PAGE_LIMIT,
|
||||
cursor,
|
||||
});
|
||||
if (!page.length) break;
|
||||
|
||||
for (const wiki of page) {
|
||||
if (!wiki?.id || seen.has(wiki.id)) continue;
|
||||
seen.add(wiki.id);
|
||||
items.push(wiki);
|
||||
}
|
||||
|
||||
if (page.length < WIKI_PAGE_LIMIT) break;
|
||||
const nextCursor = page[page.length - 1]?.id;
|
||||
if (!nextCursor || nextCursor === cursor) break;
|
||||
cursor = nextCursor;
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
async function mapWithConcurrency<T>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
worker: (item: T, index: number) => Promise<void>
|
||||
): Promise<void> {
|
||||
const runnerCount = Math.max(1, Math.min(concurrency, items.length));
|
||||
let nextIndex = 0;
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: runnerCount }, async () => {
|
||||
while (true) {
|
||||
const current = nextIndex++;
|
||||
if (current >= items.length) return;
|
||||
await worker(items[current], current);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function pushUniqueString(target: Record<string, string[]>, key: string, value: string) {
|
||||
if (!target[key]) {
|
||||
target[key] = [value];
|
||||
return;
|
||||
}
|
||||
if (!target[key].includes(value)) {
|
||||
target[key].push(value);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRelationArrays(target: Record<string, string[]>) {
|
||||
for (const key of Object.keys(target)) {
|
||||
target[key] = Array.from(new Set(target[key]));
|
||||
}
|
||||
}
|
||||
|
||||
function clampNumber(value: number, min: number, max: number): number {
|
||||
if (!Number.isFinite(value)) return min;
|
||||
if (value < min) return min;
|
||||
if (value > max) return max;
|
||||
return value;
|
||||
}
|
||||
|
||||
function computeFixedPopupPosition(rect: DOMRect, width: number, height: number) {
|
||||
const margin = 12;
|
||||
const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 1440;
|
||||
const viewportHeight = typeof window !== "undefined" ? window.innerHeight : 900;
|
||||
const preferredLeft = rect.right + margin;
|
||||
const maxLeft = Math.max(margin, viewportWidth - width - margin);
|
||||
const left = Math.min(preferredLeft, maxLeft);
|
||||
|
||||
const preferredTop = rect.top;
|
||||
const maxTop = Math.max(margin, viewportHeight - height - margin);
|
||||
const top = Math.max(margin, Math.min(preferredTop, maxTop));
|
||||
|
||||
return { top, left };
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { apiGetCurrentUser } from "@/service/auth";
|
||||
|
||||
export default function GetUser() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await apiGetCurrentUser();
|
||||
// console.log("Current User from useEffect:", result);
|
||||
setUser(result);
|
||||
} catch (err) {
|
||||
// console.error("Lỗi 401 hoặc lỗi kết nối:", err);
|
||||
// setError(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUser();
|
||||
}, []);
|
||||
|
||||
if (loading) return <div>Đang tải thông tin...</div>;
|
||||
if (error) return <div>Bạn chưa đăng nhập (Lỗi 401)</div>;
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<h1>Thông tin người dùng hiện tại:</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function LandingPage() {
|
||||
const features = [
|
||||
{
|
||||
title: "Bản đồ dòng thời gian",
|
||||
desc: "Giao diện bản đồ tự động thay đổi biên giới, địa danh và sự kiện tương ứng với mốc thời gian được lựa chọn.",
|
||||
icon: "🗺️",
|
||||
},
|
||||
{
|
||||
title: "Tương tác thực tế",
|
||||
desc: "Hiển thị chi tiết bối cảnh, nhân vật và số liệu khi người dùng thao tác vào các điểm neo sự kiện trên bản đồ.",
|
||||
icon: "📍",
|
||||
},
|
||||
{
|
||||
title: "Trợ lý ảo & Công cụ học",
|
||||
desc: "Tích hợp AI giải đáp thắc mắc lịch sử, kết hợp hệ thống giao bài tập và làm Quiz trực tuyến cho học đường.",
|
||||
icon: "🤖",
|
||||
},
|
||||
];
|
||||
|
||||
const team = [
|
||||
{
|
||||
name: "Trần Anh Đức",
|
||||
role: "Project Manager",
|
||||
desc: "Fan cứng anh Lại Ngứa Chân",
|
||||
avatar: "/images/teamdev/tad.jpeg",
|
||||
},
|
||||
{
|
||||
name: "Đỗ Duy Khánh",
|
||||
role: "Backend Developer",
|
||||
desc: "Kì nhân dị sỹ",
|
||||
avatar: "/images/teamdev/ddk2.jpeg",
|
||||
},
|
||||
{
|
||||
name: "Ngô Cung Đức Anh",
|
||||
role: "Frontend Developer",
|
||||
desc: "Cũng đẹp trai nhưng cao m7 thôi",
|
||||
avatar: "/images/teamdev/ncda.jpeg",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
// Sử dụng tông màu Vàng cổ (Parchment) và Xanh rêu (Dark Slate Green)
|
||||
<div className="relative min-h-screen w-full text-[#2D3A3A] font-sans selection:bg-[#A88B4C] selection:text-white overflow-x-hidden">
|
||||
{/* --- BACKGROUND IMAGE --- */}
|
||||
<div className="fixed inset-0 -z-20 pointer-events-none">
|
||||
<Image
|
||||
src="/images/map.jpeg"
|
||||
alt="World Map Background"
|
||||
fill
|
||||
className="object-cover object-center opacity-40"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
{/* Lớp overlay mờ để làm dịu background */}
|
||||
<div className="fixed inset-0 bg-gradient-to-b from-[#FDFBF7]/80 via-[#FDFBF7]/70 to-[#FDFBF7]/90 -z-10 pointer-events-none"></div>
|
||||
|
||||
{/* --- HEADER NAVBAR --- */}
|
||||
<header className="fixed top-0 w-full px-6 py-4 flex justify-between items-center backdrop-blur-sm bg-[#FDFBF7]/70 z-50 border-b border-[#A88B4C]/20">
|
||||
<div className="text-xl font-bold tracking-widest text-[#2D3A3A] uppercase">
|
||||
<span className="text-[#A88B4C]">Geo</span>History
|
||||
</div>
|
||||
<nav className="flex gap-4 items-center">
|
||||
<Link
|
||||
href="/auth/signin"
|
||||
className="text-sm font-semibold text-[#2D3A3A] hover:text-[#A88B4C] transition-colors"
|
||||
>
|
||||
Đăng nhập
|
||||
</Link>
|
||||
<Link
|
||||
href="/user"
|
||||
className="text-sm font-semibold px-4 py-2 bg-[#2D3A3A] text-[#FDFBF7] rounded-lg hover:bg-[#1a2323] transition-colors"
|
||||
>
|
||||
Vào Hệ thống
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main className="max-w-6xl mx-auto px-6 pt-32 pb-24 flex flex-col gap-32 w-full relative">
|
||||
{/* --- PHẦN 1: GIỚI THIỆU TỔNG QUAN --- */}
|
||||
<section className="min-h-[70vh] flex flex-col justify-center relative">
|
||||
<h1 className="text-5xl md:text-7xl font-black leading-tight tracking-tight mb-6">
|
||||
Bách khoa toàn thư <br />
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-[#A88B4C] to-[#806835]">
|
||||
Bản đồ số Lịch sử
|
||||
</span>
|
||||
</h1>
|
||||
<div className="max-w-3xl space-y-6 text-lg md:text-xl text-[#4A5555] leading-relaxed">
|
||||
<p>
|
||||
Hệ thống thông tin địa lý (GIS) tiên phong trong việc trực quan
|
||||
hóa dữ liệu lịch sử. Nền tảng của chúng tôi cho phép hiển thị động
|
||||
các thông tin như biên giới quốc gia, diễn biến trận chiến và sự
|
||||
kiện theo đúng tiến trình thời gian.
|
||||
</p>
|
||||
<p>
|
||||
Đây là không gian tập trung tri thức được tinh lọc, nơi các chuyên
|
||||
gia, nhà sử học và giáo viên đóng góp dữ liệu tọa độ, vector, được
|
||||
hệ thống kiểm duyệt chặt chẽ trước khi xuất bản.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-10 flex gap-4">
|
||||
<Link
|
||||
href="#mission"
|
||||
className="px-8 py-4 bg-[#A88B4C] text-white font-bold rounded-xl shadow-lg shadow-[#A88B4C]/20 hover:bg-[#8e743c]"
|
||||
>
|
||||
Khám phá sứ mệnh
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* --- PHẦN 2: SỨ MỆNH & CHỨC NĂNG --- */}
|
||||
<section id="mission" className="scroll-mt-24 relative">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-center">
|
||||
{/* Sứ mệnh */}
|
||||
<div>
|
||||
<div className="inline-block px-3 py-1 bg-[#A88B4C]/10 text-[#A88B4C] font-bold text-sm tracking-widest uppercase rounded-full mb-4 border border-[#A88B4C]/20">
|
||||
Sứ mệnh của chúng tôi
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-6">
|
||||
Định hình lại cách học Lịch sử
|
||||
</h2>
|
||||
<div className="space-y-6 text-[#4A5555]">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-[#2D3A3A] mb-2 flex items-center gap-2">
|
||||
<span className="w-8 h-8 rounded-full bg-[#A88B4C]/20 flex items-center justify-center text-[#A88B4C]">
|
||||
1
|
||||
</span>
|
||||
Giải quyết rào cản giáo dục
|
||||
</h3>
|
||||
<p>
|
||||
Khắc phục sự nhàm chán và khó tiếp cận của phương pháp học
|
||||
lịch sử truyền thống bằng cách biến dữ liệu chữ viết thành
|
||||
hình ảnh không gian, thời gian trực quan.
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full h-px bg-[#A88B4C]/20"></div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-[#2D3A3A] mb-2 flex items-center gap-2">
|
||||
<span className="w-8 h-8 rounded-full bg-[#A88B4C]/20 flex items-center justify-center text-[#A88B4C]">
|
||||
2
|
||||
</span>
|
||||
Tập trung hóa tri thức
|
||||
</h3>
|
||||
<p>
|
||||
Xây dựng một kho dữ liệu lịch sử thống nhất, chuẩn xác, phục
|
||||
vụ đa dạng đối tượng từ chính phủ, chuyên gia nghiên cứu đến
|
||||
học sinh, sinh viên và cộng đồng.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chức năng */}
|
||||
<div>
|
||||
<div className="inline-block px-3 py-1 bg-[#2D3A3A]/10 text-[#2D3A3A] font-bold text-sm tracking-widest uppercase rounded-full mb-4 border border-[#2D3A3A]/20">
|
||||
Tính năng cốt lõi
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-6">
|
||||
Công nghệ hội tụ
|
||||
</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
{features.map((feat, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex gap-4 items-start p-5 hover:bg-[#A88B4C]/10 rounded-2xl group"
|
||||
>
|
||||
<div className="text-3xl bg-transparent w-14 h-14 rounded-xl flex items-center justify-center shrink-0">
|
||||
{feat.icon}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-[#2D3A3A] text-lg mb-1">
|
||||
{feat.title}
|
||||
</h4>
|
||||
<p className="text-sm text-[#4A5555] leading-relaxed">
|
||||
{feat.desc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* --- PHẦN 3: ĐỘI NGŨ PHÁT TRIỂN --- */}
|
||||
<section className="relative">
|
||||
<div className="text-center max-w-2xl mx-auto mb-12">
|
||||
<div className="inline-block px-3 py-1 bg-[#A88B4C]/10 text-[#A88B4C] font-bold text-sm tracking-widest uppercase rounded-full mb-4 border border-[#A88B4C]/20">
|
||||
Về chúng tôi
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4">
|
||||
Đội ngũ phát triển
|
||||
</h2>
|
||||
<p className="text-[#4A5555]">
|
||||
Những con người đam mê lịch sử và công nghệ chung tay xây dựng cỗ
|
||||
máy thời gian kỹ thuật số.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex mx-auto justify-center gap-12 flex-wrap">
|
||||
{team.map((member, i) => (
|
||||
<div key={i} className="p-6 text-center min-w-[264px]">
|
||||
<div className="w-24 aspect-square mx-auto bg-gradient-to-tr from-[#A88B4C]/20 to-[#2D3A3A]/20 rounded-full mb-4 flex items-center justify-center overflow-hidden">
|
||||
<Image
|
||||
src={member.avatar}
|
||||
alt={member.name}
|
||||
width={96}
|
||||
height={96}
|
||||
className="w-full h-full object-cover object-center rounded-full"
|
||||
/>
|
||||
</div>
|
||||
<h3 className="font-bold text-lg text-[#2D3A3A]">
|
||||
{member.name}
|
||||
</h3>
|
||||
<p className="text-xs font-bold text-[#A88B4C] uppercase tracking-wider my-2">
|
||||
{member.role}
|
||||
</p>
|
||||
<p className="text-sm text-[#4A5555]">{member.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* --- PHẦN 4: GÓP Ý & LIÊN HỆ --- */}
|
||||
<section className="relative mt-8 mb-16 w-full">
|
||||
<div className="flex flex-col lg:flex-row justify-between gap-10 border rounded-2xl p-8 bg-[#FDFBF7]/80 backdrop-blur-sm border-[#A88B4C]/20">
|
||||
{/* Box Text */}
|
||||
<div className="lg:w-1/4 text-center lg:text-left flex flex-col justify-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-3">
|
||||
Góp ý cho chúng tôi!
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 leading-relaxed">
|
||||
Đăng ký nhận tin tức mới nhất hoặc để lại ý kiến đóng góp giúp hệ
|
||||
thống hoàn thiện hơn.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Box Form Nhập Liệu (Dòng trên - Dòng dưới) */}
|
||||
<div className="flex-1 w-full max-w-3xl flex flex-col gap-4">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email của bạn..."
|
||||
className="w-full px-5 py-3.5 text-gray-700 bg-gray-50 border border-gray-200 rounded-xl focus:outline-none focus:bg-white focus:border-[#FFDE00] focus:ring-4 focus:ring-[#FFDE00]/20 transition-all text-sm placeholder:text-gray-400"
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Nội dung góp ý của bạn..."
|
||||
rows={3}
|
||||
className="w-full px-5 py-3.5 text-gray-700 bg-gray-50 border border-gray-200 rounded-xl focus:outline-none focus:bg-white focus:border-[#FFDE00] focus:ring-4 focus:ring-[#FFDE00]/20 transition-all text-sm placeholder:text-gray-400 resize-none"
|
||||
></textarea>
|
||||
<div className="flex justify-end">
|
||||
<button className="bg-[#FFDE00] hover:bg-[#F0D100] text-black font-bold uppercase tracking-wide px-8 py-3.5 rounded-xl transition-colors text-sm shadow-sm">
|
||||
Gửi Góp Ý
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Box Socials */}
|
||||
<div className="lg:w-auto flex flex-col items-center lg:items-start pl-0 lg:pl-8 border-t lg:border-t-0 lg:border-l border-gray-100 pt-8 lg:pt-0 justify-center">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-5">Follow us</h3>
|
||||
<div className="flex gap-4">
|
||||
<a
|
||||
href="https://www.youtube.com/@BlackCatStudio-mw2sq"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-12 h-12 rounded-full bg-[#FF0000] flex items-center justify-center text-white hover:opacity-90 hover:-translate-y-1 transition-all shadow-lg group"
|
||||
>
|
||||
<svg className="w-6 h-6 fill-current" viewBox="0 0 24 24">
|
||||
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FOOTER */}
|
||||
<footer className="border-t border-[#A88B4C]/20 bg-[#2D3A3A] text-white py-12 text-center w-full mt-auto rounded-2xl">
|
||||
<div className="max-w-6xl mx-auto px-6 flex flex-col items-center">
|
||||
<div className="text-2xl font-bold tracking-widest uppercase mb-4">
|
||||
<span className="text-[#A88B4C]">Geo</span>History
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm mb-4 max-w-md">
|
||||
Bách khoa toàn thư bản đồ số lịch sử. Kết nối quá khứ, thấu hiểu
|
||||
hiện tại, kiến tạo tương lai.
|
||||
</p>
|
||||
<div className="text-xs text-gray-500">
|
||||
© {new Date().getFullYear()} GeoHistory Project. All rights
|
||||
reserved.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+8
-11
@@ -8,7 +8,7 @@ import { apiGetCurrentUser } from "@/service/auth";
|
||||
import { setUserData } from "@/store/features/userSlice";
|
||||
import React, { useEffect } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { usePathname } from "next/navigation";
|
||||
import ChatbotWidget from "@/components/ui/chat/ChatbotWidget";
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
@@ -17,8 +17,6 @@ export default function AdminLayout({
|
||||
}) {
|
||||
const { isExpanded, isHovered, isMobileOpen } = useSidebar();
|
||||
const dispatch = useDispatch()
|
||||
const pathname = usePathname();
|
||||
const isHomePage = pathname === "/";
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
@@ -34,19 +32,17 @@ export default function AdminLayout({
|
||||
|
||||
|
||||
// Dynamic class for main content margin based on sidebar state
|
||||
const mainContentMargin = isHomePage
|
||||
const mainContentMargin = isMobileOpen
|
||||
? "ml-0"
|
||||
: isMobileOpen
|
||||
? "ml-0"
|
||||
: isExpanded || isHovered
|
||||
? "lg:ml-[290px]"
|
||||
: "lg:ml-[90px]";
|
||||
: isExpanded || isHovered
|
||||
? "lg:ml-[290px]"
|
||||
: "lg:ml-[0px]";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen xl:flex">
|
||||
{/* Sidebar and Backdrop */}
|
||||
{!isHomePage && <AppSidebar />}
|
||||
{!isHomePage && <Backdrop />}
|
||||
<AppSidebar />
|
||||
<Backdrop />
|
||||
{/* Main Content Area */}
|
||||
<div
|
||||
className={`flex-1 transition-all duration-300 ease-in-out ${mainContentMargin}`}
|
||||
@@ -56,6 +52,7 @@ export default function AdminLayout({
|
||||
{/* Page Content */}
|
||||
<div className="p-4 mx-auto max-w-(--breakpoint-2xl) md:p-6">{children}</div>
|
||||
</div>
|
||||
<ChatbotWidget />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { MediaDto } from "@/interface/media"; // Assuming this file will be crea
|
||||
import { apiGetCurrentUserApplications, apiGetCurrentUserMedia } from "@/service/userService";
|
||||
import MediaLibrary from "@/components/user-profile/Media";
|
||||
import { Application } from "@/interface/historian";
|
||||
import Loading from "@/app/loading";
|
||||
|
||||
export default function LibraryPage() {
|
||||
const [mediaData, setMediaData] = useState<MediaDto | null>(null);
|
||||
@@ -31,13 +32,7 @@ export default function LibraryPage() {
|
||||
fetchLibraryContent();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-[50vh] items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (loading) return <Loading />;
|
||||
|
||||
const hasNoData = (!mediaData?.data || mediaData.data.length === 0) && applications.length === 0;
|
||||
|
||||
|
||||
@@ -0,0 +1,625 @@
|
||||
"use client";
|
||||
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Project } from "@/interface/project";
|
||||
import Swal from "sweetalert2";
|
||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
||||
import { apiAddProjectMember, apiChangeProjectOwner, apiDeleteProject, apiGetProjectDetail, apiRemoveProjectMember, apiUpdateProject, apiUpdateProjectMemberRole } from "@/service/projectService";
|
||||
import Loading from "@/app/loading";
|
||||
import Button from "@/components/ui/button/Button";
|
||||
|
||||
type TabType = "overview" | "members" | "settings";
|
||||
|
||||
export default function ProjectDetailsPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const id = params.id as string;
|
||||
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<TabType>("overview");
|
||||
|
||||
const [editForm, setEditForm] = useState({
|
||||
title: "",
|
||||
description: "",
|
||||
status: "PRIVATE" as any,
|
||||
});
|
||||
const [newOwnerId, setNewOwnerId] = useState("");
|
||||
const [newMember, setNewMember] = useState({
|
||||
user_id: "",
|
||||
role: "EDITOR" as any,
|
||||
});
|
||||
|
||||
const fetchProject = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiGetProjectDetail(id);
|
||||
if (res?.status && res.data) {
|
||||
setProject(res.data);
|
||||
setEditForm({
|
||||
title: res.data.title,
|
||||
description: res.data.description,
|
||||
status: res.data.project_status,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Lỗi khi tải dữ liệu dự án");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (id) fetchProject();
|
||||
}, [id]);
|
||||
|
||||
const handleUpdateInfo = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await apiUpdateProject(id, editForm);
|
||||
toast.success("Cập nhật thông tin thành công!");
|
||||
fetchProject();
|
||||
} catch (error) {
|
||||
toast.error("Cập nhật thất bại");
|
||||
}
|
||||
};
|
||||
|
||||
const handleTransferOwnership = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const memberName =
|
||||
project?.members?.find((m) => m.user_id === newOwnerId)?.display_name ||
|
||||
"thành viên này";
|
||||
|
||||
const result = await Swal.fire({
|
||||
title: "Chuyển quyền sở hữu?",
|
||||
html: `Bạn có chắc chắn muốn chuyển dự án này cho <b>${memberName}</b>?<br/>Hành động này <b>không thể hoàn tác</b> và bạn sẽ không còn là chủ sở hữu nữa.`,
|
||||
icon: "error",
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: "#238636",
|
||||
cancelButtonColor: "#30363d",
|
||||
confirmButtonText: "Tôi hiểu, chuyển quyền sở hữu",
|
||||
cancelButtonText: "Hủy bỏ",
|
||||
color: "#333",
|
||||
customClass: {
|
||||
popup: "border border-[#30363d] rounded-xl",
|
||||
},
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
try {
|
||||
const res = await apiChangeProjectOwner(id, {
|
||||
new_owner_id: newOwnerId,
|
||||
});
|
||||
if (res?.status) {
|
||||
toast.success("Đã chuyển quyền sở hữu thành công!");
|
||||
setNewOwnerId("");
|
||||
fetchProject();
|
||||
} else {
|
||||
toast.error(res?.message || "Chuyển quyền thất bại");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Lỗi hệ thống khi chuyển quyền");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddMember = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newMember.user_id) return toast.error("Vui lòng nhập User ID");
|
||||
try {
|
||||
await apiAddProjectMember(id, newMember);
|
||||
toast.success("Thêm thành viên thành công");
|
||||
setNewMember({ user_id: "", role: "EDITOR" });
|
||||
fetchProject();
|
||||
} catch (error) {
|
||||
toast.error("Lỗi thêm thành viên");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateRole = async (userId: string, newRole: string) => {
|
||||
try {
|
||||
const res = await apiUpdateProjectMemberRole(id, userId, {
|
||||
role: newRole as any,
|
||||
});
|
||||
|
||||
if (res?.status) {
|
||||
toast.success("Cập nhật quyền thành công");
|
||||
fetchProject();
|
||||
} else {
|
||||
toast.error(res?.message || "Cập nhật quyền thất bại");
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMessage =
|
||||
error.response?.data?.message || "Cập nhật quyền thất bại";
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (userId: string) => {
|
||||
// 1. Hiển thị hộp thoại xác nhận bằng SweetAlert2
|
||||
const result = await Swal.fire({
|
||||
title: "Xác nhận xóa?",
|
||||
text: "Bạn có chắc chắn muốn xóa thành viên này khỏi dự án?",
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: "#3085d6",
|
||||
cancelButtonColor: "#d33",
|
||||
confirmButtonText: "Đồng ý",
|
||||
cancelButtonText: "Hủy",
|
||||
});
|
||||
|
||||
if (!result.isConfirmed) return;
|
||||
|
||||
try {
|
||||
const res = await apiRemoveProjectMember(id, userId);
|
||||
|
||||
if (res?.status) {
|
||||
toast.success("Đã xóa thành viên");
|
||||
} else {
|
||||
toast.error(res?.message || "Xóa thành viên thất bại");
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMessage =
|
||||
error.response?.data?.message || "Xóa thành viên thất bại";
|
||||
toast.error(errorMessage);
|
||||
|
||||
console.error("Remove Member Error:", error);
|
||||
} finally {
|
||||
fetchProject();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProject = async () => {
|
||||
const result = await Swal.fire({
|
||||
title: "Xác nhận xóa dự án?",
|
||||
text: "Hành động này sẽ xóa vĩnh viễn dự án. Bạn không thể hoàn tác sau khi xác nhận!",
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: "#d33",
|
||||
cancelButtonColor: "#30363d",
|
||||
confirmButtonText: "Tôi hiểu, xóa dự án này",
|
||||
cancelButtonText: "Hủy",
|
||||
color: "#333",
|
||||
customClass: {
|
||||
popup: "border border-[#30363d] rounded-xl",
|
||||
},
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
try {
|
||||
const res = await apiDeleteProject(id);
|
||||
if (res?.status) {
|
||||
toast.success("Đã xóa dự án thành công");
|
||||
router.push("/user/projects");
|
||||
} else {
|
||||
toast.error(res?.message || "Xóa dự án thất bại");
|
||||
}
|
||||
} catch (error : any) {
|
||||
toast.error(error.response?.data?.message || "Xóa dự án thất bại");
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Loading />;
|
||||
|
||||
if (!project)
|
||||
return (
|
||||
<div className="flex justify-center p-20 text-red-500">
|
||||
Không tìm thấy dự án
|
||||
</div>
|
||||
);
|
||||
|
||||
// console.log(project)
|
||||
return (
|
||||
<div className="min-h-screen dark:bg-[#0d1117] text-gray-900 dark:text-[#c9d1d9] font-sans">
|
||||
<PageBreadcrumb
|
||||
pageTitle="Chi tiết dự án"
|
||||
paths={[{ name: "Quản lý dự án", href: "/user/projects" }]}
|
||||
/>
|
||||
<div className="pt-8 border-b border-gray-200 dark:border-[#30363d] bg-gray-50 dark:bg-[#0d1117]">
|
||||
<div className="px-6">
|
||||
<div className="flex items-center gap-2 text-xl mb-6">
|
||||
<div className="w-8 h-8 shrink-0 flex items-center justify-center">
|
||||
{project.user?.avatar_url ? (
|
||||
<div className="relative w-8 h-8 rounded-full overflow-hidden border border-gray-200 dark:border-gray-800">
|
||||
<Image
|
||||
src={project.user.avatar_url}
|
||||
alt="avatar"
|
||||
fill
|
||||
className="object-cover rounded-full"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center border border-gray-300 dark:border-gray-600">
|
||||
<span className="text-[10px] font-bold text-gray-500 dark:text-gray-300 leading-none">
|
||||
{project.user?.display_name?.charAt(0)?.toUpperCase() ||
|
||||
"U"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="font-medium text-blue-600 dark:text-[#58a6ff] hover:underline cursor-pointer">
|
||||
{project.user?.display_name}
|
||||
</span>
|
||||
<span className="text-gray-400">/</span>
|
||||
<strong className="font-semibold text-blue-600 dark:text-[#58a6ff] hover:underline cursor-pointer">
|
||||
{project.title}
|
||||
</strong>
|
||||
<span className="ml-2 px-2.5 py-0.5 text-xs font-medium rounded-full border border-gray-200 dark:border-[#30363d] text-gray-500 dark:text-[#8b949e]">
|
||||
{project.project_status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 pb-3">
|
||||
{[
|
||||
{
|
||||
id: "overview",
|
||||
label: "Overview",
|
||||
icon: "M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z",
|
||||
},
|
||||
{
|
||||
id: "members",
|
||||
label: `Members`,
|
||||
count: project.members?.length || 0,
|
||||
icon: "M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z",
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
label: "Settings",
|
||||
icon: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z",
|
||||
},
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as TabType)}
|
||||
className={`flex items-center gap-2 px-3 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "border-[#f78166] text-gray-900 dark:text-[#c9d1d9]"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-[#8b949e]"
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 opacity-70"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d={tab.icon}
|
||||
/>
|
||||
</svg>
|
||||
{tab.label}
|
||||
{tab.count !== undefined && (
|
||||
<span className="ml-1 px-2 py-0.5 text-xs rounded-full bg-gray-200/50 dark:bg-[#21262d] text-gray-600 dark:text-[#c9d1d9]">
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="flex-1" />
|
||||
<Button size="sm" variant="outline" onClick={() => router.push(`/editor/${id}`)}>
|
||||
Mo editor
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => router.push(`/editor/${id}?only=wiki`)}>
|
||||
Editor only wiki
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-8">
|
||||
{activeTab === "overview" && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<div className="md:col-span-3">
|
||||
<div className="border border-gray-200 dark:border-[#30363d] rounded-xl overflow-hidden ">
|
||||
<div className="bg-gray-50 dark:bg-[#161b22] px-5 py-3 border-b border-gray-200 dark:border-[#30363d] font-semibold text-sm text-gray-800 dark:text-[#c9d1d9]">
|
||||
About
|
||||
</div>
|
||||
<div className="p-6 bg-white dark:bg-[#0d1117] text-[15px] leading-relaxed text-gray-700 dark:text-[#8b949e]">
|
||||
{project.description || (
|
||||
<i className="text-gray-400">
|
||||
Không có mô tả cho dự án này.
|
||||
</i>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-1 space-y-6">
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm mb-4 border-b border-gray-200 dark:border-[#30363d] pb-2 text-gray-800 dark:text-[#c9d1d9]">
|
||||
Owner
|
||||
</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 shrink-0 flex items-center justify-center">
|
||||
{project.user?.avatar_url ? (
|
||||
<div className="relative w-8 h-8 rounded-full overflow-hidden border border-gray-200 dark:border-gray-800">
|
||||
<Image
|
||||
src={project.user.avatar_url}
|
||||
alt="avatar"
|
||||
fill
|
||||
className="object-cover rounded-full"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center border border-gray-300 dark:border-gray-600">
|
||||
<span className="text-[10px] font-bold text-gray-500 dark:text-gray-300 leading-none">
|
||||
{project.user?.display_name
|
||||
?.charAt(0)
|
||||
?.toUpperCase() || "U"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-hidden">
|
||||
<div className="font-semibold text-sm text-gray-800 dark:text-[#c9d1d9] truncate">
|
||||
{project.user?.display_name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-[#8b949e] truncate">
|
||||
{project.user?.email || "No email"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "members" && (
|
||||
<div className="max-w-4xl">
|
||||
<h2 className="text-2xl font-normal mb-6 pb-2 border-b border-gray-200 dark:border-[#30363d]">
|
||||
Manage access
|
||||
</h2>
|
||||
|
||||
<form
|
||||
onSubmit={handleAddMember}
|
||||
className="flex flex-col sm:flex-row gap-3 mb-8 p-5 border border-gray-200 dark:border-[#30363d] rounded-xl bg-gray-50 dark:bg-[#161b22] "
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="User ID..."
|
||||
value={newMember.user_id}
|
||||
onChange={(e) =>
|
||||
setNewMember({ ...newMember, user_id: e.target.value })
|
||||
}
|
||||
className="flex-1 px-3 py-2 text-sm rounded-md border border-gray-300 dark:border-[#30363d] bg-white dark:bg-[#0d1117] outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500 transition-shadow"
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<select
|
||||
value={newMember.role}
|
||||
onChange={(e) =>
|
||||
setNewMember({ ...newMember, role: e.target.value as any })
|
||||
}
|
||||
className="px-3 py-2 text-sm rounded-md border border-gray-300 dark:border-[#30363d] bg-white dark:bg-[#0d1117] outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500 cursor-pointer"
|
||||
>
|
||||
<option value="EDITOR">Editor</option>
|
||||
<option value="VIEWER">Viewer</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-5 py-2 text-sm font-medium text-white bg-[#238636] border border-transparent rounded-md hover:bg-[#2ea043] transition-colors "
|
||||
>
|
||||
Add member
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="border border-gray-200 dark:border-[#30363d] rounded-xl overflow-hidden ">
|
||||
<div className="divide-y divide-gray-200 dark:divide-[#30363d]">
|
||||
{project.members && project.members.length > 0 ? (
|
||||
project.members.map((member) => (
|
||||
<div
|
||||
key={member.user_id}
|
||||
className="flex items-center justify-between p-4 hover:bg-gray-50 dark:hover:bg-[#161b22]/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<img
|
||||
src={
|
||||
member.avatar_url ||
|
||||
"https://github.com/identicons/jasonlong.png"
|
||||
}
|
||||
alt={member.display_name}
|
||||
className="w-10 h-10 rounded-full border border-gray-200 dark:border-gray-700"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-semibold text-sm text-gray-800 dark:text-[#c9d1d9]">
|
||||
{member.display_name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-[#8b949e]">
|
||||
ID: {member.user_id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={member.role}
|
||||
onChange={(e) =>
|
||||
handleUpdateRole(member.user_id, e.target.value)
|
||||
}
|
||||
className="text-sm px-3 py-1.5 rounded-md border border-gray-300 dark:border-[#30363d] bg-white dark:bg-[#21262d] outline-none hover:bg-gray-50 dark:hover:bg-[#30363d] cursor-pointer transition-colors"
|
||||
>
|
||||
<option value="EDITOR">Editor</option>
|
||||
<option value="VIEWER">Viewer</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => handleRemoveMember(member.user_id)}
|
||||
className="text-red-500 hover:text-red-600 p-2 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||
title="Remove member"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="p-8 text-center text-gray-500 dark:text-[#8b949e] text-sm italic">
|
||||
Chưa có thành viên nào.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "settings" && (
|
||||
<div className="max-w-3xl space-y-10">
|
||||
<section>
|
||||
<h2 className="text-2xl font-normal mb-4 pb-2 border-b border-gray-200 dark:border-[#30363d]">
|
||||
General
|
||||
</h2>
|
||||
<form onSubmit={handleUpdateInfo} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-1.5 text-gray-800 dark:text-[#c9d1d9]">
|
||||
Project name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.title}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, title: e.target.value })
|
||||
}
|
||||
className="w-full max-w-md px-3 py-2 text-sm rounded-md border border-gray-300 dark:border-[#30363d] bg-white dark:bg-[#0d1117] outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500 transition-shadow"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-1.5 text-gray-800 dark:text-[#c9d1d9]">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={editForm.description}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, description: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 text-sm rounded-md border border-gray-300 dark:border-[#30363d] bg-white dark:bg-[#0d1117] outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500 transition-shadow"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-1.5 text-gray-800 dark:text-[#c9d1d9]">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
value={editForm.status}
|
||||
onChange={(e) =>
|
||||
setEditForm({
|
||||
...editForm,
|
||||
status: e.target.value as any,
|
||||
})
|
||||
}
|
||||
className="w-48 px-3 py-2 text-sm rounded-md border border-gray-300 dark:border-[#30363d] bg-white dark:bg-[#0d1117] outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500 cursor-pointer"
|
||||
>
|
||||
<option value="PUBLIC">Public</option>
|
||||
<option value="PRIVATE">Private</option>
|
||||
<option value="ARCHIVE">Archive</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-5 py-2 text-sm font-medium rounded-md bg-[#21262d] text-[#c9d1d9] border border-[#30363d] hover:bg-[#30363d] transition-colors "
|
||||
>
|
||||
Update settings
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-normal text-red-500 mb-4 pb-2 border-b border-red-500/30">
|
||||
Danger Zone
|
||||
</h2>
|
||||
<div className="border border-red-500/30 rounded-xl overflow-hidden">
|
||||
<div className="p-5 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="font-semibold text-sm text-gray-800 dark:text-[#c9d1d9]">
|
||||
Transfer ownership
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-[#8b949e] mt-1">
|
||||
Transfer this project to another member in the project.
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={handleTransferOwnership}
|
||||
className="flex gap-2"
|
||||
>
|
||||
<select
|
||||
value={newOwnerId}
|
||||
onChange={(e) => setNewOwnerId(e.target.value)}
|
||||
className="w-full sm:w-56 px-3 py-2 text-sm rounded-md border border-gray-300 dark:border-[#30363d] bg-white dark:bg-[#0d1117] outline-none focus:ring-2 focus:ring-red-500/30 focus:border-red-500 cursor-pointer"
|
||||
required
|
||||
>
|
||||
<option value="" disabled>
|
||||
-- Thành viên --
|
||||
</option>
|
||||
{project.members && project.members.length > 0 ? (
|
||||
project.members.map((member) => (
|
||||
<option key={member.user_id} value={member.user_id}>
|
||||
{member.display_name}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<option value="" disabled>
|
||||
Chưa có thành viên nào
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
!newOwnerId ||
|
||||
!project.members ||
|
||||
project.members.length === 0
|
||||
}
|
||||
className="shrink-0 px-4 py-2 text-sm font-medium text-red-500 bg-transparent border border-red-500/50 rounded-md hover:bg-red-500 hover:text-white dark:hover:bg-red-900/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-red-500 dark:disabled:hover:bg-transparent"
|
||||
>
|
||||
Transfer
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div className="p-5 flex flex-col sm:flex-row sm:items-center justify-between gap-4 hover:bg-red-50/30 dark:hover:bg-red-900/10 transition-colors">
|
||||
<div>
|
||||
<div className="font-semibold text-sm text-gray-800 dark:text-[#c9d1d9]">
|
||||
Delete this project
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-[#8b949e] mt-1">
|
||||
Once you delete a project, there is no going back. Please
|
||||
be certain.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteProject}
|
||||
className="shrink-0 px-4 py-2 text-sm font-medium text-red-500 bg-transparent border border-red-500/50 rounded-md hover:bg-red-500 hover:text-white dark:hover:bg-red-900/30 transition-colors"
|
||||
>
|
||||
Delete project
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+369
-65
@@ -1,26 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
||||
import ComponentCard from "@/components/common/ComponentCard";
|
||||
import { getCurrentProject, apiCreateProject, CreateProjectPayload } from "@/service/projectService";
|
||||
import { toast } from "sonner";
|
||||
import { useModal } from "@/hooks/useModal";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import Button from "@/components/ui/button/Button";
|
||||
import Label from "@/components/form/Label";
|
||||
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import Badge from "@/components/ui/badge/Badge";
|
||||
import { Project } from "@/interface/project";
|
||||
import { CreateProjectPayload, Project } from "@/interface/project";
|
||||
import { apiCreateProject, apiCreateProjectCommit, apiGetProjectCommits, getCurrentProject } from "@/service/projectService";
|
||||
import { normalizeEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
||||
import type { EditorSnapshot } from "@/uhm/types/projects";
|
||||
|
||||
export type ProjectSortColumn = "created_at" | "updated_at" | "title";
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const router = useRouter();
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isExportingProjectId, setIsExportingProjectId] = useState<string | null>(null);
|
||||
|
||||
const [sortBy, setSortBy] = useState<ProjectSortColumn>("updated_at");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
|
||||
|
||||
const { isOpen, openModal, closeModal } = useModal();
|
||||
const [formData, setFormData] = useState<CreateProjectPayload>({ title: "", description: "", project_status: "PRIVATE" });
|
||||
const importJsonInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [importSnapshot, setImportSnapshot] = useState<EditorSnapshot | null>(null);
|
||||
const [importSnapshotName, setImportSnapshotName] = useState<string | null>(null);
|
||||
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
@@ -39,7 +51,7 @@ export default function ProjectsPage() {
|
||||
fetchProjects();
|
||||
}, []);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
@@ -52,11 +64,15 @@ export default function ProjectsPage() {
|
||||
}
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
await apiCreateProject(formData);
|
||||
const created = await apiCreateProject(formData);
|
||||
const projectId = created?.data?.id;
|
||||
toast.success("Tạo dự án mới thành công!");
|
||||
closeModal();
|
||||
setFormData({ title: "", description: "", project_status: "PRIVATE" });
|
||||
fetchProjects(); // Tải lại danh sách sau khi tạo
|
||||
setImportSnapshot(null);
|
||||
setImportSnapshotName(null);
|
||||
fetchProjects();
|
||||
if (projectId) router.push(`/editor/${projectId}`);
|
||||
} catch (error) {
|
||||
console.error("Lỗi tạo dự án:", error);
|
||||
toast.error("Có lỗi xảy ra khi tạo dự án.");
|
||||
@@ -65,6 +81,171 @@ export default function ProjectsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePickImportJson = () => {
|
||||
importJsonInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleImportJsonFile = async (file: File | null) => {
|
||||
if (!file) return;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const raw = JSON.parse(text) as unknown;
|
||||
const normalized = normalizeEditorSnapshot(raw);
|
||||
if (!normalized) {
|
||||
toast.error("JSON snapshot không hợp lệ.");
|
||||
return;
|
||||
}
|
||||
setImportSnapshot(normalized);
|
||||
setImportSnapshotName(file.name);
|
||||
toast.success("Đã nạp JSON snapshot. Bấm 'Tạo với JSON' để khởi tạo dự án.");
|
||||
} catch (err) {
|
||||
console.error("Import JSON failed", err);
|
||||
toast.error("Không đọc được file JSON.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateProjectWithJson = async () => {
|
||||
if (!formData.title.trim()) {
|
||||
toast.warning("Vui lòng nhập tên dự án!");
|
||||
return;
|
||||
}
|
||||
if (!importSnapshot) {
|
||||
toast.warning("Chưa chọn JSON snapshot.");
|
||||
handlePickImportJson();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const created = await apiCreateProject(formData);
|
||||
const projectId = created?.data?.id;
|
||||
if (!projectId) {
|
||||
toast.error("Tạo dự án thất bại: thiếu project id.");
|
||||
return;
|
||||
}
|
||||
await apiCreateProjectCommit(projectId, {
|
||||
edit_summary: "Init project from JSON",
|
||||
snapshot_json: importSnapshot as any,
|
||||
} as any);
|
||||
toast.success("Tạo dự án (kèm JSON) thành công!");
|
||||
closeModal();
|
||||
setFormData({ title: "", description: "", project_status: "PRIVATE" });
|
||||
setImportSnapshot(null);
|
||||
setImportSnapshotName(null);
|
||||
if (importJsonInputRef.current) importJsonInputRef.current.value = "";
|
||||
fetchProjects();
|
||||
router.push(`/editor/${projectId}`);
|
||||
} catch (error) {
|
||||
console.error("Lỗi tạo dự án với JSON:", error);
|
||||
toast.error("Có lỗi xảy ra khi tạo dự án với JSON.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportHeadSnapshot = async (project: Project) => {
|
||||
const projectId = String(project.id || "").trim();
|
||||
if (!projectId) return;
|
||||
const headCommitId = project.latest_commit_id ? String(project.latest_commit_id) : "";
|
||||
if (!headCommitId) {
|
||||
toast.warning("Dự án chưa có head commit để export.");
|
||||
return;
|
||||
}
|
||||
setIsExportingProjectId(projectId);
|
||||
try {
|
||||
const res: any = await apiGetProjectCommits(projectId);
|
||||
const rawList = res?.data?.items ?? res?.data ?? res?.items ?? [];
|
||||
const commits = Array.isArray(rawList) ? rawList : [];
|
||||
const head = commits.find((c: any) => String(c?.id || "") === headCommitId) || null;
|
||||
const snapshot = head?.snapshot_json ?? null;
|
||||
if (!snapshot) {
|
||||
toast.error("Không tìm thấy snapshot_json của head commit.");
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(snapshot, null, 2)], { type: "application/json;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `project-${projectId}-head-${headCommitId}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("Đã export JSON snapshot.");
|
||||
} catch (err) {
|
||||
console.error("Export snapshot failed", err);
|
||||
toast.error("Export thất bại.");
|
||||
} finally {
|
||||
setIsExportingProjectId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSort = (column: ProjectSortColumn) => {
|
||||
if (sortBy === column) {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortBy(column);
|
||||
setSortOrder("desc");
|
||||
}
|
||||
};
|
||||
|
||||
const sortedProjects = [...projects].sort((a: any, b: any) => {
|
||||
let valA = a[sortBy];
|
||||
let valB = b[sortBy];
|
||||
|
||||
if (!valA) valA = "";
|
||||
if (!valB) valB = "";
|
||||
|
||||
if (valA < valB) return sortOrder === "asc" ? -1 : 1;
|
||||
if (valA > valB) return sortOrder === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const formatDate = (dateString: string | null | undefined) => {
|
||||
if (!dateString) return "-";
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) return "-";
|
||||
return date.toLocaleDateString("vi-VN", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case "PUBLIC":
|
||||
return <Badge size="sm" variant="light" color="success">PUBLIC</Badge>;
|
||||
case "PRIVATE":
|
||||
return <Badge size="sm" variant="light" color="warning">PRIVATE</Badge>;
|
||||
case "ARCHIVE":
|
||||
return <Badge size="sm" variant="light" color="light">ARCHIVE</Badge>;
|
||||
default:
|
||||
return <Badge size="sm" variant="light" color="dark">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const SortButton = ({ column, label }: { column: ProjectSortColumn; label: string }) => {
|
||||
const isActive = sortBy === column;
|
||||
return (
|
||||
<button
|
||||
onClick={() => handleSort(column)}
|
||||
className={`flex items-center gap-1 text-sm font-medium hover:text-blue-500 transition-colors ${
|
||||
isActive ? "text-blue-600 dark:text-blue-400" : "text-gray-500 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{isActive && <span>{sortOrder === "asc" ? "↑" : "↓"}</span>}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const importLabel = useMemo(() => {
|
||||
if (!importSnapshotName) return "Chưa chọn JSON snapshot";
|
||||
return `JSON: ${importSnapshotName}`;
|
||||
}, [importSnapshotName]);
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto pb-10">
|
||||
<PageBreadcrumb pageTitle="Quản lý dự án" />
|
||||
@@ -85,53 +266,138 @@ export default function ProjectsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{projects.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-white/[0.05] dark:bg-white/[0.03]">
|
||||
<div className="max-w-full overflow-x-auto">
|
||||
<div className="min-w-[800px]">
|
||||
<Table>
|
||||
<TableHeader className="border-b border-gray-100 dark:border-white/[0.05]">
|
||||
<TableRow>
|
||||
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">
|
||||
Tên dự án
|
||||
</TableCell>
|
||||
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">
|
||||
Mô tả
|
||||
</TableCell>
|
||||
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-center text-theme-xs dark:text-gray-400">
|
||||
Trạng thái
|
||||
</TableCell>
|
||||
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-center text-theme-xs dark:text-gray-400">
|
||||
Thao tác
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="divide-y divide-gray-100 dark:divide-white/[0.05]">
|
||||
{projects.map((project) => (
|
||||
<TableRow key={project.id} className="hover:bg-gray-50/50 dark:hover:bg-white/[0.01] transition-colors">
|
||||
<TableCell className="px-5 py-4 text-start text-theme-sm font-semibold text-gray-800 dark:text-white/90">
|
||||
{!isLoading && sortedProjects.length > 0 ? (
|
||||
<div className="max-w-full overflow-x-auto">
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-[#0d1117] min-w-[800px]">
|
||||
<div className="flex items-center px-5 py-3 border-b border-gray-200 dark:border-gray-800 bg-gray-50/50 dark:bg-[#161b22]">
|
||||
<div className="flex-1 pr-4">
|
||||
<SortButton column="title" label="Tên dự án" />
|
||||
</div>
|
||||
<div className="w-48 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">Trạng thái</div>
|
||||
<div className="w-48 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">Thành viên</div>
|
||||
<div className="w-32 px-4">
|
||||
<SortButton column="updated_at" label="Cập nhật" />
|
||||
</div>
|
||||
<div className="w-48 px-4 text-sm font-medium text-gray-500 dark:text-gray-400 text-right">Thao tác</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
|
||||
{sortedProjects.map((project: any) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="group flex items-center p-5 hover:bg-gray-50 dark:hover:bg-[#161b22]/50 transition-colors"
|
||||
>
|
||||
<div className="flex-1 pr-4 min-w-0">
|
||||
<div className="items-center gap-3 mb-1.5">
|
||||
<h3
|
||||
onClick={() => router.push(`/user/projects/${project.id}`)}
|
||||
className="font-semibold text-blue-600 dark:text-[#58a6ff] truncate cursor-pointer hover:underline"
|
||||
>
|
||||
{project.title}
|
||||
</TableCell>
|
||||
<TableCell className="px-5 py-4 text-start text-theme-sm text-gray-500 dark:text-gray-400 max-w-[300px]">
|
||||
<p className="truncate">{project.description || "Chưa có mô tả cho dự án này..."}</p>
|
||||
</TableCell>
|
||||
<TableCell className="px-5 py-4 text-center">
|
||||
<Badge size="sm" variant="light" color={project.project_status === "PUBLIC" ? "success" : project.project_status === "PRIVATE" ? "warning" : "light"}>
|
||||
{project.project_status || "N/A"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-5 py-4 text-center">
|
||||
<Link
|
||||
href={`/user/projects/${project.id}`}
|
||||
className="text-brand-500 hover:text-brand-600 font-medium text-theme-sm"
|
||||
>
|
||||
Thao tác
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</h3>
|
||||
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-[#8b949e]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{project.user?.avatar_url ? (
|
||||
<Image src={project.user.avatar_url} alt="avatar" width={16} height={16} className="rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-4 h-4 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<span className="text-[8px] font-bold text-gray-500 dark:text-gray-300">
|
||||
{project.user?.display_name?.charAt(0)?.toUpperCase() || "U"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="truncate max-w-[150px]">{project.user?.display_name || "Unknown"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-48 px-4 shrink-0">
|
||||
{getStatusBadge(project.project_status)}
|
||||
</div>
|
||||
|
||||
<div className="w-48 px-4 shrink-0">
|
||||
<div className="flex -space-x-2 overflow-hidden">
|
||||
{project.members && project.members.length > 0 ? (
|
||||
<>
|
||||
{project.members.slice(0, 4).map((m: any, index: number) =>
|
||||
m.avatar_url ? (
|
||||
<Image key={index} src={m.avatar_url} alt={m.display_name} width={32} height={32} title={m.display_name} className="inline-block w-8 h-8 rounded-full object-cover ring-2 ring-white dark:ring-[#0d1117]" />
|
||||
) : (
|
||||
<div key={index} title={m.display_name} className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 ring-2 ring-white dark:ring-[#0d1117]">
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-gray-300">{m.display_name?.charAt(0)?.toUpperCase() || "U"}</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{project.members.length > 4 && (
|
||||
<div title="Những người khác" className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-800 ring-2 ring-white dark:ring-[#0d1117] z-10">
|
||||
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">+{project.members.length - 4}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-600 italic"></span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-32 px-1 shrink-0 text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatDate(project.updated_at)}
|
||||
</div>
|
||||
|
||||
<div className="w-48 px-4 shrink-0 flex justify-end gap-2">
|
||||
<div className="relative group/btn1 inline-flex">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="!p-0 w-9 h-9 flex items-center justify-center"
|
||||
onClick={() => router.push(`/editor/${project.id}`)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-4 h-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||
</svg>
|
||||
</Button>
|
||||
<span className="absolute -top-8 left-1/2 -translate-x-1/2 scale-0 rounded bg-gray-900 px-2 py-1 text-[11px] font-medium text-white opacity-0 transition-all group-hover/btn1:scale-100 group-hover/btn1:opacity-100 z-50 pointer-events-none whitespace-nowrap shadow-sm dark:bg-gray-700">
|
||||
Editor
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative group/btn2 inline-flex">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="!p-0 w-9 h-9 flex items-center justify-center"
|
||||
disabled={isExportingProjectId === String(project.id)}
|
||||
onClick={() => handleExportHeadSnapshot(project)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-4 h-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m.75 12l3 3m0 0l3-3m-3 3v-6m-1.5-9H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
</svg>
|
||||
</Button>
|
||||
<span className="absolute -top-8 left-1/2 -translate-x-1/2 scale-0 rounded bg-gray-900 px-2 py-1 text-[11px] font-medium text-white opacity-0 transition-all group-hover/btn2:scale-100 group-hover/btn2:opacity-100 z-50 pointer-events-none whitespace-nowrap shadow-sm dark:bg-gray-700">
|
||||
Export JSON
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative group/btn3 inline-flex">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="!p-0 w-9 h-9 flex items-center justify-center"
|
||||
onClick={() => router.push(`/editor/${project.id}?only=wiki`)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-4 h-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
</Button>
|
||||
<span className="absolute -top-8 left-1/2 -translate-x-1/2 scale-0 rounded bg-gray-900 px-2 py-1 text-[11px] font-medium text-white opacity-0 transition-all group-hover/btn3:scale-100 group-hover/btn3:opacity-100 z-50 pointer-events-none whitespace-nowrap shadow-sm dark:bg-gray-700">
|
||||
Wiki Editor
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -145,28 +411,32 @@ export default function ProjectsPage() {
|
||||
)}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Tạo Dự án */}
|
||||
<Modal isOpen={isOpen} onClose={closeModal} className="max-w-[500px] m-4">
|
||||
<div className="p-6 bg-white rounded-3xl dark:bg-gray-900">
|
||||
<h3 className="mb-5 text-xl font-bold text-gray-800 dark:text-white/90">Tạo dự án mới</h3>
|
||||
<form onSubmit={handleCreateProject} className="flex flex-col gap-5">
|
||||
<div>
|
||||
<Label>Tên dự án <span className="text-red-500">*</span></Label>
|
||||
<input
|
||||
type="text"
|
||||
name="title"
|
||||
value={formData.title}
|
||||
onChange={handleChange}
|
||||
placeholder="Nhập tên dự án..."
|
||||
autoFocus
|
||||
<input
|
||||
type="text"
|
||||
name="title"
|
||||
value={formData.title}
|
||||
onChange={handleChange}
|
||||
placeholder="Nhập tên dự án..."
|
||||
autoFocus
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Trạng thái</Label>
|
||||
<select name="status" value={formData.project_status} onChange={(e: any) => handleChange(e)} 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">
|
||||
<select
|
||||
name="project_status"
|
||||
value={formData.project_status}
|
||||
onChange={handleChange}
|
||||
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"
|
||||
>
|
||||
<option value="PRIVATE">Riêng tư (Private)</option>
|
||||
<option value="PUBLIC">Công khai (Public)</option>
|
||||
<option value="ARCHIVE">Lưu trữ (Archive)</option>
|
||||
@@ -174,13 +444,47 @@ export default function ProjectsPage() {
|
||||
</div>
|
||||
<div>
|
||||
<Label>Mô tả dự án</Label>
|
||||
<textarea name="description" value={formData.description} onChange={handleChange} rows={4} className="w-full rounded-xl border border-gray-200 bg-transparent px-4 py-3 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 custom-scrollbar" placeholder="Mô tả ngắn gọn về dự án..."></textarea>
|
||||
<textarea
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows={4}
|
||||
className="w-full rounded-xl border border-gray-200 bg-transparent px-4 py-3 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 custom-scrollbar"
|
||||
placeholder="Mô tả ngắn gọn về dự án..."
|
||||
></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Khởi tạo từ JSON</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" type="button" onClick={handlePickImportJson}>
|
||||
Chọn JSON
|
||||
</Button>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{importLabel}
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref={importJsonInputRef}
|
||||
type="file"
|
||||
accept="application/json"
|
||||
className="hidden"
|
||||
onChange={(e) => handleImportJsonFile(e.target.files?.[0] || null)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 mt-4">
|
||||
<Button size="sm" variant="outline" type="button" onClick={closeModal}>Hủy</Button>
|
||||
<Button size="sm" type="submit" disabled={isSubmitting} className="bg-brand-500 hover:bg-brand-600 text-white">
|
||||
{isSubmitting ? "Đang tạo..." : "Khởi tạo"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
disabled={isSubmitting}
|
||||
className="bg-gray-900 hover:bg-gray-800 text-white"
|
||||
onClick={handleCreateProjectWithJson}
|
||||
>
|
||||
Tạo với JSON
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
const faqData = [
|
||||
{
|
||||
id: 1,
|
||||
question: "1. Phần mềm này tương thích với những hệ điều hành nào?",
|
||||
answer: "Hệ thống tương thích hoàn toàn với Windows 10/11, macOS 12 trở lên. Đối với môi trường máy chủ, chúng tôi hỗ trợ các bản phân phối Linux phổ biến như Ubuntu và Debian."
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
question: "2. Sự khác biệt giữa phiên bản Miễn phí và Trả phí là gì?",
|
||||
answer: "Phiên bản trả phí cung cấp băng thông không giới hạn, hỗ trợ kỹ thuật ưu tiên 24/7, và quyền truy cập sớm vào các tính năng nâng cao. Bản miễn phí sẽ giới hạn một số tính năng xuất dữ liệu."
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
question: "3. Hệ thống hỗ trợ những phương thức kết nối nào?",
|
||||
answer: "Chúng tôi hỗ trợ kết nối qua REST API, WebSocket cho dữ liệu thời gian thực và cung cấp sẵn SDK cho các ngôn ngữ phổ biến như TypeScript, Go."
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
question: "4. Làm thế nào để tôi có thể tích hợp vào dự án Next.js hiện tại?",
|
||||
answer: "Bạn chỉ cần cài đặt package qua npm/yarn, thêm API Key vào file .env và gọi component Provider ở file layout.tsx gốc. Tài liệu chi tiết có sẵn trong mục Developer Docs."
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
question: "5. Dữ liệu của tôi được bảo mật như thế nào?",
|
||||
answer: "Toàn bộ dữ liệu được mã hóa đầu cuối (End-to-End Encryption). Chúng tôi tuân thủ nghiêm ngặt các tiêu chuẩn bảo mật quốc tế và thường xuyên rà soát hệ thống để phòng chống các lỗ hổng bảo mật."
|
||||
}
|
||||
];
|
||||
|
||||
export default function Page() {
|
||||
// Lưu index của câu hỏi đang được mở. Mặc định mở câu đầu tiên (index 0).
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(0);
|
||||
|
||||
const toggleFAQ = (index: number) => {
|
||||
// Nếu click lại vào câu đang mở thì đóng nó, ngược lại thì mở câu mới
|
||||
setOpenIndex(openIndex === index ? null : index);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white text-slate-900 py-16 px-4 sm:px-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-4xl font-bold mb-10">FAQs</h1>
|
||||
|
||||
<div className="border-t border-slate-200">
|
||||
{faqData.map((faq, index) => {
|
||||
const isOpen = openIndex === index;
|
||||
|
||||
return (
|
||||
<div key={faq.id} className="border-b border-slate-200">
|
||||
<button
|
||||
onClick={() => toggleFAQ(index)}
|
||||
className="w-full py-6 flex justify-between items-center text-left focus:outline-none group"
|
||||
>
|
||||
<span className="text-lg font-bold group-hover:text-indigo-600 transition-colors">
|
||||
{faq.question}
|
||||
</span>
|
||||
<span className="text-3xl font-light ml-4 text-indigo-600 shrink-0 leading-none">
|
||||
{isOpen ? '−' : '+'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Phần nội dung có hiệu ứng trượt */}
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-300 ease-in-out ${
|
||||
isOpen ? 'max-h-96 opacity-100 pb-6' : 'max-h-0 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<p className="text-slate-600 text-base leading-relaxed pr-8">
|
||||
{faq.answer}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import "yet-another-react-lightbox/styles.css";
|
||||
import "yet-another-react-lightbox/plugins/captions.css";
|
||||
import { createHistorianCV } from "@/service/historianService";
|
||||
import { toast } from "sonner";
|
||||
import { newId } from "@/uhm/lib/utils/id";
|
||||
import Swal from "sweetalert2";
|
||||
import { PresignedUrlResponse } from "@/interface/media";
|
||||
|
||||
@@ -94,7 +95,7 @@ export default function RoleUpgrade() {
|
||||
const presigned = await getPresignedUrl(file);
|
||||
|
||||
return {
|
||||
id: Math.random().toString(36).substring(7),
|
||||
id: newId(),
|
||||
file: file,
|
||||
previewUrl: isImage ? URL.createObjectURL(file) : "",
|
||||
name: file.name,
|
||||
|
||||
@@ -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: "Project" }] },
|
||||
{ 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import WikiBySlugClient from "./wiki-by-slug-client";
|
||||
|
||||
export default async function WikiBySlugPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }> | { slug: string };
|
||||
}) {
|
||||
const resolved = await params;
|
||||
return <WikiBySlugClient slug={resolved.slug} />;
|
||||
}
|
||||
@@ -0,0 +1,822 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import "react-quill-new/dist/quill.snow.css";
|
||||
|
||||
import { ApiError } from "@/uhm/api/http";
|
||||
import { fetchWikiBySlug, getContentByVersionWikiId, type Wiki } from "@/uhm/api/wikis";
|
||||
|
||||
type TocItem = {
|
||||
id: string;
|
||||
level: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function tiptapJsonToPlainText(node: unknown): string {
|
||||
if (node == null) return "";
|
||||
if (typeof node === "string") return node;
|
||||
if (Array.isArray(node)) return node.map(tiptapJsonToPlainText).join("");
|
||||
|
||||
if (isRecord(node)) {
|
||||
if (node.type === "text" && typeof node.text === "string") return node.text;
|
||||
if (node.type === "hardBreak") return "\n";
|
||||
if ("content" in node) return tiptapJsonToPlainText(node.content);
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function escapeHtml(input: string): string {
|
||||
return input
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll("\"", """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function normalizeWikiContentToHtml(raw: string | null | undefined): string {
|
||||
const value = String(raw || "").trim();
|
||||
if (!value.length) return "";
|
||||
|
||||
// New format: HTML string.
|
||||
if (value[0] === "<") return value;
|
||||
|
||||
// Legacy format: Tiptap JSON string.
|
||||
if (value[0] === "{") {
|
||||
try {
|
||||
const json: unknown = JSON.parse(value);
|
||||
const text = tiptapJsonToPlainText(json).trim();
|
||||
if (!text.length) return "";
|
||||
return `<p>${escapeHtml(text).replace(/\n/g, "<br/>")}</p>`;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown plaintext: treat as plain text.
|
||||
return `<p>${escapeHtml(value).replace(/\n/g, "<br/>")}</p>`;
|
||||
}
|
||||
|
||||
function slugifyHeading(raw: string): string {
|
||||
const input = String(raw || "").trim();
|
||||
if (!input.length) return "";
|
||||
return input
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+/, "")
|
||||
.replace(/-+$/, "")
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
function isExternalHref(href: string): boolean {
|
||||
const h = href.trim().toLowerCase();
|
||||
return (
|
||||
h.startsWith("http://") ||
|
||||
h.startsWith("https://") ||
|
||||
h.startsWith("mailto:") ||
|
||||
h.startsWith("tel:") ||
|
||||
h.startsWith("sms:")
|
||||
);
|
||||
}
|
||||
|
||||
function rewriteHtmlAndBuildToc(inputHtml: string, wikiBaseUrl: string): { html: string; toc: TocItem[] } {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(inputHtml, "text/html");
|
||||
|
||||
// Basic hardening: do not render scripts in user content.
|
||||
for (const el of Array.from(doc.querySelectorAll("script"))) el.remove();
|
||||
|
||||
// Rewrite internal wiki links: Quill stores slug as <a href="other-wiki-slug">...</a>
|
||||
for (const a of Array.from(doc.querySelectorAll("a[href]"))) {
|
||||
const href = String(a.getAttribute("href") || "").trim();
|
||||
if (!href.length) continue;
|
||||
if (href === "__missing__") continue;
|
||||
if (href.startsWith("#")) continue;
|
||||
if (href.startsWith("/")) continue;
|
||||
if (isExternalHref(href)) continue;
|
||||
|
||||
const match = href.match(/^([^?#]+)([?#].*)?$/);
|
||||
const slugPart = String(match?.[1] || "").replace(/^\/+/, "").trim();
|
||||
const suffix = String(match?.[2] || "");
|
||||
|
||||
const normalizedSlug = slugPart;
|
||||
if (!normalizedSlug.length) continue;
|
||||
|
||||
a.setAttribute("href", `${wikiBaseUrl}${encodeURIComponent(normalizedSlug)}${suffix}`);
|
||||
a.setAttribute("target", "_self");
|
||||
}
|
||||
|
||||
// Build TOC from headings and ensure they have stable IDs.
|
||||
const toc: TocItem[] = [];
|
||||
const seen = new Map<string, number>();
|
||||
const headings = Array.from(doc.body.querySelectorAll("h1,h2,h3,h4,h5,h6"));
|
||||
for (const h of headings) {
|
||||
const text = String(h.textContent || "").trim();
|
||||
if (!text.length) continue;
|
||||
|
||||
const level = Number(String(h.tagName || "").replace(/^H/i, "")) || 1;
|
||||
const existingId = String(h.getAttribute("id") || "").trim();
|
||||
if (existingId) {
|
||||
toc.push({ id: existingId, level, text });
|
||||
continue;
|
||||
}
|
||||
|
||||
const base = slugifyHeading(text) || "heading";
|
||||
const n = (seen.get(base) || 0) + 1;
|
||||
seen.set(base, n);
|
||||
const id = n === 1 ? base : `${base}-${n}`;
|
||||
|
||||
h.setAttribute("id", id);
|
||||
toc.push({ id, level, text });
|
||||
}
|
||||
|
||||
return { html: doc.body.innerHTML, toc };
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null, options?: Intl.DateTimeFormatOptions): string {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw) return "-";
|
||||
const d = new Date(raw);
|
||||
if (Number.isNaN(d.getTime())) return raw;
|
||||
return d.toLocaleString(
|
||||
"vi-VN",
|
||||
options || {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default function WikiBySlugClient({ slug }: { slug: string }) {
|
||||
const [wiki, setWiki] = useState<Wiki | null>(null);
|
||||
const [status, setStatus] = useState<"idle" | "loading" | "error" | "ready">("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [viewMode, setViewMode] = useState<"read" | "history" | "compare">("read");
|
||||
const [selectedVersionsForCompare, setSelectedVersionsForCompare] = useState<Set<string>>(new Set());
|
||||
const [comparisonData, setComparisonData] = useState<{ id: string; content: string; createdAt: string; title: string }[]>([]);
|
||||
const [isComparing, setIsComparing] = useState(false);
|
||||
|
||||
const [renderHtml, setRenderHtml] = useState<string>("");
|
||||
const [toc, setToc] = useState<TocItem[]>([]);
|
||||
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
|
||||
const [linkPreview, setLinkPreview] = useState<{
|
||||
slug: string;
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
visible: boolean;
|
||||
} | null>(null);
|
||||
const [linkPreviewData, setLinkPreviewData] = useState<{
|
||||
slug: string;
|
||||
title: string;
|
||||
quote: string | null;
|
||||
status: "idle" | "loading" | "ready" | "error";
|
||||
} | null>(null);
|
||||
|
||||
const normalizedSlug = useMemo(() => String(slug || "").trim(), [slug]);
|
||||
const contentRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const hidePreviewTimerRef = useRef<number | null>(null);
|
||||
const previewCacheRef = useRef<Map<string, { title: string; quote: string | null }>>(new Map());
|
||||
|
||||
const allVersions = useMemo(() => {
|
||||
if (!wiki) return [];
|
||||
const current = {
|
||||
id: wiki.id,
|
||||
created_at: wiki.updated_at,
|
||||
content: wiki.content,
|
||||
isCurrent: true,
|
||||
};
|
||||
const history = (wiki.content_sample || []).map(s => ({ ...s, isCurrent: false }));
|
||||
const uniqueHistory = history.filter(h => h.id !== current.id);
|
||||
const combined = [current, ...uniqueHistory];
|
||||
return combined
|
||||
.filter(v => v.id && v.created_at)
|
||||
.sort((a, b) => new Date(b.created_at!).getTime() - new Date(a.created_at!).getTime());
|
||||
}, [wiki]);
|
||||
// Load wiki data by slug.
|
||||
useEffect(() => {
|
||||
const value = String(normalizedSlug || "").trim();
|
||||
if (!value.length) {
|
||||
setWiki(null);
|
||||
setStatus("error");
|
||||
setError("Missing wiki slug.");
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
(async () => {
|
||||
setStatus("loading");
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetchWikiBySlug(value);
|
||||
let versionContent = res?.content;
|
||||
try {
|
||||
if (res?.content_sample?.[0]?.id) {
|
||||
const contentResp = await getContentByVersionWikiId(res.content_sample[0].id);
|
||||
if (contentResp?.data?.content) {
|
||||
versionContent = contentResp.data.content;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch version content:", err);
|
||||
}
|
||||
|
||||
if (disposed) return;
|
||||
if (!res) {
|
||||
setWiki(null);
|
||||
setStatus("ready");
|
||||
setRenderHtml("");
|
||||
setToc([]);
|
||||
return;
|
||||
}
|
||||
setWiki({ ...res, content: versionContent });
|
||||
setStatus("ready");
|
||||
} catch (err) {
|
||||
if (disposed) return;
|
||||
const msg =
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: "Failed to load wiki.";
|
||||
setStatus("error");
|
||||
setError(msg);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [normalizedSlug]);
|
||||
|
||||
// Transform content: normalize -> rewrite internal links -> inject heading ids + toc.
|
||||
useEffect(() => {
|
||||
if (!wiki) {
|
||||
setRenderHtml("");
|
||||
setToc([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const raw =
|
||||
(wiki.content ?? (wiki as unknown as { doc?: string | null }).doc ?? "") || "";
|
||||
const html = normalizeWikiContentToHtml(raw);
|
||||
|
||||
try {
|
||||
const base = `${window.location.origin}/wiki/`;
|
||||
const processed = rewriteHtmlAndBuildToc(html, base);
|
||||
setRenderHtml(processed.html);
|
||||
setToc(processed.toc);
|
||||
setActiveHeadingId(processed.toc[0]?.id ?? null);
|
||||
} catch (err) {
|
||||
console.error("Failed to process wiki HTML", err);
|
||||
setRenderHtml(html);
|
||||
setToc([]);
|
||||
}
|
||||
}, [wiki]);
|
||||
|
||||
// Track active heading for TOC highlight.
|
||||
useEffect(() => {
|
||||
if (!toc.length) return;
|
||||
const root = contentRootRef.current;
|
||||
if (!root) return;
|
||||
|
||||
const headings = toc
|
||||
.map((t) => root.querySelector<HTMLElement>(`#${CSS.escape(t.id)}`))
|
||||
.filter((el): el is HTMLElement => Boolean(el));
|
||||
if (!headings.length) return;
|
||||
|
||||
const obs = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((e) => e.isIntersecting)
|
||||
.sort((a, b) => (a.boundingClientRect.top ?? 0) - (b.boundingClientRect.top ?? 0));
|
||||
const top = visible[0]?.target as HTMLElement | undefined;
|
||||
const id = top?.id || null;
|
||||
if (id) setActiveHeadingId(id);
|
||||
},
|
||||
{ root: null, rootMargin: "-20% 0px -70% 0px", threshold: [0, 1] }
|
||||
);
|
||||
|
||||
for (const h of headings) obs.observe(h);
|
||||
return () => obs.disconnect();
|
||||
}, [toc]);
|
||||
|
||||
// Hover preview for internal wiki links (title + first blockquote).
|
||||
useEffect(() => {
|
||||
const root = contentRootRef.current;
|
||||
if (!root) return;
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const clearHideTimer = () => {
|
||||
if (hidePreviewTimerRef.current != null) {
|
||||
window.clearTimeout(hidePreviewTimerRef.current);
|
||||
hidePreviewTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const hideSoon = () => {
|
||||
clearHideTimer();
|
||||
hidePreviewTimerRef.current = window.setTimeout(() => {
|
||||
setLinkPreview((prev) => (prev ? { ...prev, visible: false } : prev));
|
||||
}, 140);
|
||||
};
|
||||
|
||||
const resolveInternalWikiSlug = (href: string): string | null => {
|
||||
const h = href.trim();
|
||||
if (!h.length) return null;
|
||||
if (h === "__missing__") return null;
|
||||
if (h.startsWith("#")) return null;
|
||||
|
||||
const stripQueryHash = (s: string) => {
|
||||
const m = s.match(/^([^?#]+)([?#].*)?$/);
|
||||
return String(m?.[1] || "");
|
||||
};
|
||||
|
||||
if (h.startsWith("/wiki/")) {
|
||||
const path = stripQueryHash(h);
|
||||
const slugPart = path.slice("/wiki/".length).trim();
|
||||
return slugPart ? decodeURIComponent(slugPart) : null;
|
||||
}
|
||||
|
||||
const originPrefix = window.location.origin + "/wiki/";
|
||||
if (h.startsWith(originPrefix)) {
|
||||
const rest = stripQueryHash(h.slice(originPrefix.length));
|
||||
const slugPart = rest.trim();
|
||||
return slugPart ? decodeURIComponent(slugPart) : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const fetchPreview = async (targetSlug: string) => {
|
||||
const key = targetSlug.trim();
|
||||
if (!key.length) return;
|
||||
|
||||
const cached = previewCacheRef.current.get(key);
|
||||
if (cached) {
|
||||
setLinkPreviewData({ slug: key, title: cached.title, quote: cached.quote, status: "ready" });
|
||||
return;
|
||||
}
|
||||
|
||||
setLinkPreviewData((prev) => ({ slug: key, title: prev?.title || key, quote: null, status: "loading" }));
|
||||
try {
|
||||
const row = await fetchWikiBySlug(key);
|
||||
if (!row) {
|
||||
setLinkPreviewData({ slug: key, title: key, quote: null, status: "error" });
|
||||
return;
|
||||
}
|
||||
|
||||
const html = normalizeWikiContentToHtml(row.content ?? "");
|
||||
let quote: string | null = null;
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
const bq = doc.body.querySelector("blockquote");
|
||||
const txt = String(bq?.textContent || "").trim();
|
||||
quote = txt.length ? txt : null;
|
||||
} catch {
|
||||
quote = null;
|
||||
}
|
||||
|
||||
const title = String(row.title || "").trim() || key;
|
||||
previewCacheRef.current.set(key, { title, quote });
|
||||
setLinkPreviewData({ slug: key, title, quote, status: "ready" });
|
||||
} catch {
|
||||
setLinkPreviewData({ slug: key, title: key, quote: null, status: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
const showForAnchor = (a: HTMLAnchorElement) => {
|
||||
const href = String(a.getAttribute("href") || "").trim();
|
||||
const targetSlug = resolveInternalWikiSlug(href);
|
||||
if (!targetSlug) return;
|
||||
|
||||
// Avoid previews on touch devices.
|
||||
if (window.matchMedia && window.matchMedia("(hover: none)").matches) return;
|
||||
|
||||
const rect = a.getBoundingClientRect();
|
||||
const width = 420;
|
||||
const height = 320;
|
||||
const margin = 12;
|
||||
|
||||
const preferredLeft = rect.right + margin;
|
||||
const maxLeft = Math.max(margin, window.innerWidth - width - margin);
|
||||
const left = Math.min(preferredLeft, maxLeft);
|
||||
|
||||
const preferredTop = rect.top;
|
||||
const maxTop = Math.max(margin, window.innerHeight - height - margin);
|
||||
const top = Math.max(margin, Math.min(preferredTop, maxTop));
|
||||
|
||||
clearHideTimer();
|
||||
setLinkPreview({ slug: targetSlug, top, left, width, height, visible: true });
|
||||
void fetchPreview(targetSlug);
|
||||
};
|
||||
|
||||
const onMouseOver = (evt: MouseEvent) => {
|
||||
const target = evt.target as HTMLElement | null;
|
||||
const a = target?.closest?.("a") as HTMLAnchorElement | null;
|
||||
if (!a) return;
|
||||
showForAnchor(a);
|
||||
};
|
||||
|
||||
const onMouseOut = (evt: MouseEvent) => {
|
||||
const target = evt.target as HTMLElement | null;
|
||||
const related = evt.relatedTarget as HTMLElement | null;
|
||||
const fromA = target?.closest?.("a");
|
||||
if (!fromA) return;
|
||||
if (related && related.closest?.(".uhm-wiki-link-preview")) return;
|
||||
hideSoon();
|
||||
};
|
||||
|
||||
const onKeyDown = (evt: KeyboardEvent) => {
|
||||
if (evt.key === "Escape") {
|
||||
clearHideTimer();
|
||||
setLinkPreview((prev) => (prev ? { ...prev, visible: false } : prev));
|
||||
}
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
setLinkPreview((prev) => (prev ? { ...prev, visible: false } : prev));
|
||||
};
|
||||
|
||||
root.addEventListener("mouseover", onMouseOver);
|
||||
root.addEventListener("mouseout", onMouseOut);
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
root.removeEventListener("mouseover", onMouseOver);
|
||||
root.removeEventListener("mouseout", onMouseOut);
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
clearHideTimer();
|
||||
};
|
||||
}, [renderHtml]);
|
||||
|
||||
const handleToggleVersionForCompare = (versionId: string) => {
|
||||
setSelectedVersionsForCompare(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(versionId)) {
|
||||
next.delete(versionId);
|
||||
} else {
|
||||
if (next.size >= 3) {
|
||||
return prev; // Do not allow selecting more than 3
|
||||
}
|
||||
next.add(versionId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleCompareVersions = async () => {
|
||||
if (selectedVersionsForCompare.size < 1) {
|
||||
alert("Vui lòng chọn ít nhất 1 phiên bản để so sánh.");
|
||||
return;
|
||||
}
|
||||
setIsComparing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const versionsToFetch = Array.from(selectedVersionsForCompare);
|
||||
const promises = versionsToFetch.map(async (versionId) => {
|
||||
const sample = allVersions.find(s => s.id === versionId);
|
||||
const versionInfo = {
|
||||
id: versionId,
|
||||
createdAt: sample?.created_at || 'Unknown date',
|
||||
title: `Phiên bản lúc ${formatDate(sample?.created_at)}`
|
||||
};
|
||||
if (sample?.isCurrent) {
|
||||
return { ...versionInfo, content: sample.content || '' };
|
||||
}
|
||||
const contentResp = await getContentByVersionWikiId(versionId);
|
||||
return { ...versionInfo, content: contentResp?.data?.content || "" };
|
||||
});
|
||||
const results = await Promise.all(promises);
|
||||
const processedResults = results.map(r => {
|
||||
const { html } = rewriteHtmlAndBuildToc(normalizeWikiContentToHtml(r.content), `${window.location.origin}/wiki/`);
|
||||
return { ...r, content: html };
|
||||
});
|
||||
setComparisonData(processedResults.sort((a,b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()));
|
||||
setViewMode("compare");
|
||||
} catch (err) {
|
||||
const msg = err instanceof ApiError ? err.message : err instanceof Error ? err.message : "Lỗi khi tải phiên bản để so sánh.";
|
||||
setError(msg);
|
||||
setViewMode("read");
|
||||
} finally {
|
||||
setIsComparing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f8f9fa] text-[#202122] font-sans">
|
||||
<header className="bg-white border-b border-gray-300 px-6 py-2 flex justify-between items-center">
|
||||
<div className="text-lg font-bold">GeoHistory Wiki</div>
|
||||
<Link href="/" className="text-sm text-blue-600 hover:underline">Trang chủ</Link>
|
||||
</header>
|
||||
|
||||
<div className={viewMode === 'compare' ? '' : 'mx-auto max-w-7xl px-4 sm:px-6 py-6'}>
|
||||
{status === "loading" && <div className="text-center p-10">Đang tải...</div>}
|
||||
{status === "error" && <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative">{error}</div>}
|
||||
{status === "ready" && !wiki && <div className="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded relative">Không tìm thấy wiki với slug: <strong>{normalizedSlug}</strong></div>}
|
||||
|
||||
{status === "ready" && wiki && (
|
||||
<>
|
||||
<div className={viewMode === 'compare' ? 'mx-auto max-w-7xl px-4 sm:px-6 py-6' : ''}>
|
||||
<h1 className="text-3xl pb-2 mb-1">
|
||||
{wiki.title?.trim() || normalizedSlug}
|
||||
</h1>
|
||||
{viewMode === 'compare' && (
|
||||
<div className="mt-4 p-3 border border-gray-300 bg-white rounded-sm text-xs space-y-1">
|
||||
<div><span className="font-semibold">Slug:</span> {normalizedSlug || "-"}</div>
|
||||
<div><span className="font-semibold">ID:</span> {wiki.id || "-"}</div>
|
||||
<div><span className="font-semibold">Dự án:</span> {wiki.project_id || "-"}</div>
|
||||
<div><span className="font-semibold">Tạo lúc:</span> {formatDate(wiki.created_at)}</div>
|
||||
<div><span className="font-semibold">Cập nhật:</span> {formatDate(wiki.updated_at)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`grid grid-cols-1 ${viewMode === 'compare' ? '' : 'lg:grid-cols-[minmax(0,1fr)_auto] gap-8 items-start'}`}>
|
||||
<main className={`min-w-0 bg-white ${viewMode === 'compare' ? 'border-y border-gray-300' : 'border border-gray-300 rounded-sm'}`}>
|
||||
<div className={`flex border-b border-gray-300 text-sm ${viewMode === 'compare' ? 'mx-auto max-w-7xl px-4 sm:px-6' : ''}`}>
|
||||
<button onClick={() => setViewMode('read')} className={`px-4 py-2 ${viewMode === 'read' ? 'border-b-2 border-blue-600 text-blue-700' : 'text-gray-600'}`}>Bài viết</button>
|
||||
<button onClick={() => setViewMode('history')} className={`px-4 py-2 ${viewMode === 'history' || viewMode === 'compare' ? 'border-b-2 border-blue-600 text-blue-700' : 'text-gray-600'}`}>Xem lịch sử</button>
|
||||
</div>
|
||||
|
||||
{viewMode === 'read' && (
|
||||
<div ref={contentRootRef} className="uhm-wiki-view ql-editor wiki-article" dangerouslySetInnerHTML={{ __html: renderHtml }} />
|
||||
)}
|
||||
|
||||
{viewMode === 'history' && (
|
||||
<div className="p-4">
|
||||
<h2 className="text-xl mb-4 font-normal">Lịch sử phiên bản của "{wiki.title}"</h2>
|
||||
<div className="flex gap-4 items-center mb-4">
|
||||
<button onClick={handleCompareVersions} disabled={isComparing || selectedVersionsForCompare.size === 0} className="px-4 py-2 text-sm bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-gray-300">
|
||||
{isComparing ? 'Đang tải...' : `So sánh ${selectedVersionsForCompare.size} phiên bản đã chọn`}
|
||||
</button>
|
||||
</div>
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<th className="p-2 w-16 text-center">So sánh</th>
|
||||
<th className="p-2">Ngày cập nhật</th>
|
||||
<th className="p-2">Ghi chú</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{allVersions.map((v) => {
|
||||
const isChecked = selectedVersionsForCompare.has(v.id!);
|
||||
const isDisabled = !isChecked && selectedVersionsForCompare.size >= 3;
|
||||
return (
|
||||
<tr key={v.id} className={`border-t ${isDisabled ? "opacity-50" : ""}`}>
|
||||
<td className="p-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={() => handleToggleVersionForCompare(v.id!)}
|
||||
checked={isChecked}
|
||||
disabled={isDisabled}
|
||||
className="h-4 w-4 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</td>
|
||||
<td className="p-2 text-blue-600">{formatDate(v.created_at)}</td>
|
||||
<td className="p-2">{v.isCurrent && <span className="font-bold">(Phiên bản hiện tại)</span>}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'compare' && (
|
||||
<div className="p-4">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6">
|
||||
<h2 className="text-xl mb-4 font-normal">So sánh các phiên bản</h2>
|
||||
</div>
|
||||
<div className={`grid grid-cols-1 md:grid-cols-2 gap-4 ${comparisonData.length >= 3 ? 'xl:grid-cols-3' : ''} mx-auto px-4 sm:px-6`}>
|
||||
{comparisonData.map(version => (
|
||||
<div key={version.id} className="border rounded-lg overflow-hidden bg-white">
|
||||
<h3 className="p-2 border-b font-semibold bg-gray-50 text-sm">{version.title}</h3>
|
||||
<div className="uhm-wiki-view ql-editor wiki-article h-[70vh] overflow-auto" dangerouslySetInnerHTML={{ __html: version.content }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{viewMode !== 'compare' && (
|
||||
<aside className="hidden lg:block self-start sticky top-6">
|
||||
{viewMode === 'read' && toc.length > 0 && (
|
||||
<div className="border border-gray-300 bg-[#f8f9fa] p-3 rounded-sm text-sm mb-6">
|
||||
<p className="font-bold text-center mb-2">Mục lục</p>
|
||||
<nav>
|
||||
<div className="grid gap-1 w-full overflow-auto">
|
||||
{toc.map((t) => {
|
||||
const pad = Math.max(0, Math.min(5, t.level - 1)) * 12;
|
||||
const isActive = activeHeadingId === t.id;
|
||||
return (
|
||||
<a key={t.id} href={`#${t.id}`} className={`block py-0.5 text-xs leading-5 transition break-words ${isActive ? "font-bold" : "text-blue-600 hover:underline"}`} style={{ paddingLeft: pad }} title={t.text}>
|
||||
<span className="mr-1">{t.level}.</span>{t.text}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border border-gray-300 bg-white rounded-sm text-xs overflow-hidden">
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
<tr className="border-b border-gray-100 last:border-0">
|
||||
<td className="px-2 py-2 font-normal text-gray-500 w-1/5">Slug</td>
|
||||
<td className="px-2 py-2 text-gray-900 break-all">{normalizedSlug || "-"}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-100 last:border-0">
|
||||
<td className="px-2 py-2 font-normal text-gray-500">ID</td>
|
||||
<td className="px-2 py-2 text-gray-900">{wiki.id || "-"}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-100 last:border-0">
|
||||
<td className="px-2 py-2 font-normal text-gray-500">Dự án</td>
|
||||
<td className="px-2 py-2 text-gray-900">{wiki.project_id || "-"}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-100 last:border-0">
|
||||
<td className="px-2 py-2 font-normal text-gray-500">Tạo lúc</td>
|
||||
<td className="px-2 py-2 text-gray-900">{formatDate(wiki.created_at)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="pr-1 pl-2 py-2 font-normal text-gray-500">Cập nhật</td>
|
||||
<td className="px-2 py-2 text-gray-900">{formatDate(wiki.updated_at)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{linkPreview && linkPreview.visible ? (
|
||||
<div
|
||||
className="uhm-wiki-link-preview fixed z-[9999]"
|
||||
style={{
|
||||
top: linkPreview.top,
|
||||
left: linkPreview.left,
|
||||
width: linkPreview.width,
|
||||
height: linkPreview.height,
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (hidePreviewTimerRef.current != null) {
|
||||
window.clearTimeout(hidePreviewTimerRef.current);
|
||||
hidePreviewTimerRef.current = null;
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setLinkPreview((prev) => (prev ? { ...prev, visible: false } : prev));
|
||||
}}
|
||||
>
|
||||
<div className="h-full w-full overflow-hidden rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 shadow-lg">
|
||||
<div className="h-full w-full p-3 grid grid-rows-[auto_1fr] gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] text-gray-500 dark:text-gray-400 break-all">
|
||||
/wiki/{linkPreview.slug}
|
||||
</div>
|
||||
<div className="mt-0.5 text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||
{linkPreviewData?.slug === linkPreview.slug
|
||||
? linkPreviewData.status === "loading"
|
||||
? "Loading..."
|
||||
: linkPreviewData.status === "error"
|
||||
? "Not found"
|
||||
: linkPreviewData.title
|
||||
: "Loading..."}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 overflow-auto">
|
||||
{linkPreviewData?.slug === linkPreview.slug && linkPreviewData.status === "ready" ? (
|
||||
linkPreviewData.quote ? (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 whitespace-pre-wrap break-words">
|
||||
{linkPreviewData.quote}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">No resume.</div>
|
||||
)
|
||||
) : (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">Loading preview...</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<style jsx global>{`
|
||||
.wiki-article {
|
||||
|
||||
line-height: 1.6;
|
||||
font-size: 1em;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
.uhm-wiki-view.ql-editor {
|
||||
height: auto;
|
||||
overflow-y: visible;
|
||||
}
|
||||
.wiki-article p {
|
||||
margin: 0 0 0.75em;
|
||||
}
|
||||
.wiki-article h1,
|
||||
.wiki-article h2,
|
||||
.wiki-article h3,
|
||||
.wiki-article h4,
|
||||
.wiki-article h5,
|
||||
.wiki-article h6 {
|
||||
|
||||
font-weight: normal;
|
||||
margin: 0.8em 0 0.3em;
|
||||
padding-bottom: 0.1em;
|
||||
border-bottom: 1px solid #a2a9b1;
|
||||
scroll-margin-top: 16px;
|
||||
}
|
||||
.wiki-article h1 {
|
||||
font-size: 1.8em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.wiki-article h2 {
|
||||
font-size: 1.5em;
|
||||
line-height: 1.25;
|
||||
margin-top: 1.4em;
|
||||
}
|
||||
.wiki-article h3 {
|
||||
font-size: 1.25em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.wiki-article h4,
|
||||
.wiki-article h5,
|
||||
.wiki-article h6 {
|
||||
font-size: 1.05em;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.wiki-article ul,
|
||||
.wiki-article ol {
|
||||
margin: 0 0 0.75em;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
.wiki-article blockquote {
|
||||
margin: 0 0 0.75em;
|
||||
padding-left: 12px;
|
||||
border-left: 3px solid #a2a9b1;
|
||||
color: #202122;
|
||||
}
|
||||
.wiki-article pre {
|
||||
margin: 0 0 0.75em;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #a2a9b1;
|
||||
border-radius: 10px;
|
||||
background: #f8f9fa;
|
||||
overflow: auto;
|
||||
font-family: monospace;
|
||||
}
|
||||
.wiki-article img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.wiki-article a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.wiki-article a[href]:not([href=""]):not([href="__missing__"]) {
|
||||
color: #3366cc;
|
||||
}
|
||||
.wiki-article a[href]:not([href=""]):not([href="__missing__"]):hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.wiki-article a[href="__missing__"] {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
.wiki-article a:not([href]),
|
||||
.wiki-article a[href=""],
|
||||
.wiki-article a[href="__missing__"] {
|
||||
color: #dc2626;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
export type StoredTokens = {
|
||||
access_token: string;
|
||||
};
|
||||
|
||||
const LS_KEY = "uhm_auth_tokens_v1";
|
||||
|
||||
let cached: StoredTokens | null = null;
|
||||
|
||||
function safeParseTokens(raw: string | null): StoredTokens | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const v = JSON.parse(raw) as Partial<StoredTokens>;
|
||||
if (!v || typeof v !== "object") return null;
|
||||
if (typeof v.access_token !== "string") return null;
|
||||
if (!v.access_token.trim()) return null;
|
||||
return { access_token: v.access_token };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getStoredTokens(): StoredTokens | null {
|
||||
if (cached) return cached;
|
||||
if (typeof window === "undefined") return null;
|
||||
cached = safeParseTokens(window.localStorage.getItem(LS_KEY));
|
||||
return cached;
|
||||
}
|
||||
|
||||
export function setStoredTokens(tokens: StoredTokens | null): void {
|
||||
cached = tokens;
|
||||
if (typeof window === "undefined") return;
|
||||
if (!tokens) {
|
||||
window.localStorage.removeItem(LS_KEY);
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(LS_KEY, JSON.stringify(tokens));
|
||||
}
|
||||
|
||||
export function getAccessToken(): string | null {
|
||||
return getStoredTokens()?.access_token ?? null;
|
||||
}
|
||||
|
||||
export function clearStoredTokens(): void {
|
||||
setStoredTokens(null);
|
||||
}
|
||||
|
||||
// Helper for dealing with CommonResponse where token payload shape is not strictly typed.
|
||||
export function extractTokensFromResponsePayload(payload: any): StoredTokens | null {
|
||||
const data = payload?.data ?? payload;
|
||||
// Common shapes observed in various backends:
|
||||
// - { status: true, data: { access_token, refresh_token } }
|
||||
// - { data: { tokens: { access_token, refresh_token } } }
|
||||
// - { data: { token: <access>, refresh_token } }
|
||||
// - { accessToken, refreshToken }
|
||||
const tokenContainer = data?.tokens ?? data?.token_set ?? data;
|
||||
|
||||
const access =
|
||||
tokenContainer?.access_token ??
|
||||
tokenContainer?.accessToken ??
|
||||
tokenContainer?.token ??
|
||||
tokenContainer?.access ??
|
||||
tokenContainer?.jwt ??
|
||||
null;
|
||||
|
||||
const refresh =
|
||||
tokenContainer?.refresh_token ??
|
||||
tokenContainer?.refreshToken ??
|
||||
tokenContainer?.refresh ??
|
||||
null;
|
||||
if (typeof access === "string" && access.trim()) {
|
||||
return { access_token: access };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -111,10 +111,15 @@ export default function SignInForm() {
|
||||
<div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:gap-5">
|
||||
<button
|
||||
onClick={() => {
|
||||
const redirectUrl = HOME_URL;
|
||||
window.location.href = `${API.Auth.GOOGLE_LOGIN}?redirect=${redirectUrl}`;
|
||||
}}
|
||||
onClick={() => {
|
||||
// Redirect back to the same origin (avoid hard-coded port/env mismatches).
|
||||
const redirectUrl =
|
||||
typeof window !== "undefined" ? window.location.origin : HOME_URL;
|
||||
const googleUrl = `${API.Auth.GOOGLE_LOGIN}?redirect=${encodeURIComponent(
|
||||
redirectUrl
|
||||
)}`;
|
||||
router.push(googleUrl);
|
||||
}}
|
||||
className="inline-flex items-center justify-center gap-3 py-3 text-sm font-normal text-gray-700 transition-colors bg-gray-100 rounded-lg px-7 hover:bg-gray-200 hover:text-gray-800 dark:bg-white/5 dark:text-white/90 dark:hover:bg-white/10"
|
||||
>
|
||||
<svg
|
||||
|
||||
@@ -163,11 +163,16 @@ export default function SignUpForm() {
|
||||
<div className="grid grid-cols-1 gap-3 sm:gap-5">
|
||||
<button
|
||||
onClick={() => {
|
||||
const redirectUrl = HOME_URL;
|
||||
window.location.href = `${API.Auth.GOOGLE_LOGIN}?redirect=${redirectUrl}`;
|
||||
// Redirect back to the same origin (avoid hard-coded port/env mismatches).
|
||||
const redirectUrl =
|
||||
typeof window !== "undefined" ? window.location.origin : HOME_URL;
|
||||
const googleUrl = `${API.Auth.GOOGLE_LOGIN}?redirect=${encodeURIComponent(
|
||||
redirectUrl
|
||||
)}`;
|
||||
router.push(googleUrl);
|
||||
}}
|
||||
className="inline-flex items-center justify-center gap-3 py-3 text-sm font-normal text-gray-700 transition-colors bg-gray-100 rounded-lg px-7 hover:bg-gray-200 hover:text-gray-800 dark:bg-white/5 dark:text-white/90 dark:hover:bg-white/10"
|
||||
>
|
||||
className="inline-flex items-center justify-center gap-3 py-3 text-sm font-normal text-gray-700 transition-colors bg-gray-100 rounded-lg px-7 hover:bg-gray-200 hover:text-gray-800 dark:bg-white/5 dark:text-white/90 dark:hover:bg-white/10"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@fullcalendar/core";
|
||||
import { useModal } from "@/hooks/useModal";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { newId } from "@/uhm/lib/utils/id";
|
||||
|
||||
interface CalendarEvent extends EventInput {
|
||||
extendedProps: {
|
||||
@@ -99,7 +100,7 @@ const Calendar: React.FC = () => {
|
||||
} else {
|
||||
// Add new event
|
||||
const newEvent: CalendarEvent = {
|
||||
id: Date.now().toString(),
|
||||
id: newId(),
|
||||
title: eventTitle,
|
||||
start: eventStartDate,
|
||||
end: eventEndDate,
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
|
||||
interface BreadcrumbProps {
|
||||
pageTitle: string;
|
||||
interface BreadcrumbPath {
|
||||
name: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const PageBreadcrumb: React.FC<BreadcrumbProps> = ({ pageTitle }) => {
|
||||
interface BreadcrumbProps {
|
||||
pageTitle: string;
|
||||
paths?: BreadcrumbPath[];
|
||||
}
|
||||
|
||||
const PageBreadcrumb: React.FC<BreadcrumbProps> = ({ pageTitle, paths }) => {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-6">
|
||||
<h2
|
||||
@@ -18,7 +24,7 @@ const PageBreadcrumb: React.FC<BreadcrumbProps> = ({ pageTitle }) => {
|
||||
<ol className="flex items-center gap-1.5">
|
||||
<li>
|
||||
<Link
|
||||
className="inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-white/90 transition-colors"
|
||||
href="/"
|
||||
>
|
||||
Home
|
||||
@@ -40,6 +46,33 @@ const PageBreadcrumb: React.FC<BreadcrumbProps> = ({ pageTitle }) => {
|
||||
</svg>
|
||||
</Link>
|
||||
</li>
|
||||
{paths &&
|
||||
paths.map((path, index) => (
|
||||
<li key={index} className="flex items-center gap-1.5">
|
||||
<Link
|
||||
className="inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-white/90 transition-colors"
|
||||
href={path.href}
|
||||
>
|
||||
{path.name}
|
||||
<svg
|
||||
className="stroke-current"
|
||||
width="17"
|
||||
height="16"
|
||||
viewBox="0 0 17 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6.0765 12.667L10.2432 8.50033L6.0765 4.33366"
|
||||
stroke=""
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
<li className="text-sm text-gray-800 dark:text-white/90">
|
||||
{pageTitle}
|
||||
</li>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { fullDataUser } from "@/interface/admin";
|
||||
import { UserMetaCardProps } from "@/interface/user";
|
||||
import { apiGetCurrentUser, apiLogout } from "@/service/auth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ListIcon } from "@/icons";
|
||||
import { ListIcon, ShootingStarIcon } from "@/icons";
|
||||
|
||||
export default function UserDropdown() {
|
||||
const router = useRouter();
|
||||
@@ -152,6 +152,19 @@ export default function UserDropdown() {
|
||||
</span>
|
||||
Nhà Sử Học
|
||||
</DropdownItem>
|
||||
</li>
|
||||
<li>
|
||||
<DropdownItem
|
||||
onItemClick={closeDropdown}
|
||||
tag="a"
|
||||
href="/user/role-upgrade"
|
||||
className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg group text-theme-sm hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
|
||||
>
|
||||
<span className="menu-item-icon">
|
||||
<ShootingStarIcon />
|
||||
</span>
|
||||
Về chúng tôi
|
||||
</DropdownItem>
|
||||
</li>
|
||||
{/* <li>
|
||||
<DropdownItem
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import React, { ReactNode } from "react";
|
||||
|
||||
interface ButtonProps {
|
||||
children: ReactNode; // Button text or content
|
||||
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
size?: "sm" | "md"; // Button size
|
||||
variant?: "primary" | "outline"; // Button variant
|
||||
startIcon?: ReactNode; // Icon before the text
|
||||
endIcon?: ReactNode; // Icon after the text
|
||||
onClick?: () => void; // Click handler
|
||||
disabled?: boolean; // Disabled state
|
||||
className?: string; // Disabled state
|
||||
type?: "button" | "submit" | "reset";
|
||||
}
|
||||
title?: string; // Title text
|
||||
};
|
||||
|
||||
const Button: React.FC<ButtonProps> = ({
|
||||
children,
|
||||
@@ -18,10 +14,11 @@ const Button: React.FC<ButtonProps> = ({
|
||||
variant = "primary",
|
||||
startIcon,
|
||||
endIcon,
|
||||
onClick,
|
||||
className = "",
|
||||
disabled = false,
|
||||
type = "button",
|
||||
type = "button",
|
||||
title,
|
||||
...rest
|
||||
}) => {
|
||||
// Size Classes
|
||||
const sizeClasses = {
|
||||
@@ -44,8 +41,10 @@ const Button: React.FC<ButtonProps> = ({
|
||||
} ${variantClasses[variant]} ${
|
||||
disabled ? "cursor-not-allowed opacity-50" : ""
|
||||
}`}
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
type={type}
|
||||
{...rest}
|
||||
>
|
||||
{startIcon && <span className="flex items-center">{startIcon}</span>}
|
||||
{children}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { ChatbotPayload } from "@/interface/chatbot";
|
||||
import { apiChatbot } from "@/service/chatbotService";
|
||||
|
||||
type Message = {
|
||||
id: string;
|
||||
sender: "user" | "bot";
|
||||
text: string;
|
||||
};
|
||||
|
||||
export default function ChatbotWidget({
|
||||
projectId = "",
|
||||
}: {
|
||||
projectId?: string;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{
|
||||
id: "init",
|
||||
sender: "bot",
|
||||
text: "Xin chào! Tôi là trợ lý lịch sử thân thiện. Tôi có thể giúp gì cho bạn?",
|
||||
},
|
||||
]);
|
||||
const [input, setInput] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [messages, isOpen]);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim()) return;
|
||||
|
||||
const userMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
sender: "user",
|
||||
text: input.trim(),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
setInput("");
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const payload: ChatbotPayload = {
|
||||
project_id: projectId,
|
||||
question: userMessage.text,
|
||||
};
|
||||
|
||||
const res = await apiChatbot(payload);
|
||||
|
||||
const botMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
sender: "bot",
|
||||
text: res?.status
|
||||
? res?.data
|
||||
: "Xin lỗi, tôi không thể trả lời lúc này.",
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, botMessage]);
|
||||
} catch (error: any) {
|
||||
const errorMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
sender: "bot",
|
||||
text:
|
||||
error?.response?.data?.message ||
|
||||
"Có lỗi xảy ra khi kết nối. Vui lòng thử lại sau.",
|
||||
};
|
||||
setMessages((prev) => [...prev, errorMessage]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-8 right-8 z-50">
|
||||
{!isOpen && (
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="w-14 h-14 bg-brand-500 hover:bg-brand-600 text-white rounded-full flex items-center justify-center shadow-[0_4px_14px_rgba(0,0,0,0.25)] transition-transform hover:scale-105"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-6 h-6"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Khung Chat */}
|
||||
{isOpen && (
|
||||
<div className="w-[360px] h-[520px] bg-white dark:bg-gray-900 rounded-2xl shadow-2xl flex flex-col border border-gray-200 dark:border-gray-800 overflow-hidden animate-in slide-in-from-bottom-4 duration-300">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 bg-brand-500 text-white flex items-center justify-between shadow-sm z-10">
|
||||
<div className="font-semibold flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23-.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0112 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5"
|
||||
/>
|
||||
</svg>
|
||||
Trợ lý lịch sử.
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-white hover:text-gray-200 transition-colors"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
stroke="currentColor"
|
||||
className="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Nội dung Chat */}
|
||||
<div className="flex-1 p-4 overflow-y-auto flex flex-col gap-3 bg-gray-50 dark:bg-[#0d1117] text-sm">
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`max-w-[85%] rounded-2xl px-4 py-2 shadow-sm ${
|
||||
msg.sender === "user"
|
||||
? "bg-brand-500 text-white self-end rounded-br-sm"
|
||||
: "bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 border border-gray-100 dark:border-gray-700 self-start rounded-bl-sm"
|
||||
}`}
|
||||
>
|
||||
{msg.text}
|
||||
</div>
|
||||
))}
|
||||
{isLoading && (
|
||||
<div className="bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 self-start rounded-2xl rounded-bl-sm px-4 py-3 shadow-sm flex items-center gap-1.5 max-w-[80%]">
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
|
||||
<div
|
||||
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
|
||||
style={{ animationDelay: "0.2s" }}
|
||||
></div>
|
||||
<div
|
||||
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
|
||||
style={{ animationDelay: "0.4s" }}
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Khu vực Nhập Input */}
|
||||
<div className="p-3 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Nhập câu hỏi..."
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isLoading}
|
||||
className="flex-1 bg-gray-100 dark:bg-gray-800 border-transparent focus:border-brand-500 focus:bg-white dark:focus:bg-gray-900 focus:ring-1 focus:ring-brand-500/20 rounded-full px-4 py-2.5 text-sm outline-none transition-all"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || isLoading}
|
||||
className={`p-2.5 rounded-full transition-colors flex shrink-0 items-center justify-center ${
|
||||
!input.trim() || isLoading
|
||||
? "text-gray-400 bg-gray-100 dark:bg-gray-800 cursor-not-allowed"
|
||||
: "bg-brand-500 text-white hover:bg-brand-600"
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="w-5 h-5"
|
||||
>
|
||||
<path d="M3.478 2.405a.75.75 0 00-.926.94l2.432 7.905H13.5a.75.75 0 010 1.5H4.984l-2.432 7.905a.75.75 0 00.926.94 60.519 60.519 0 0018.445-8.986.75.75 0 000-1.218A60.517 60.517 0 003.478 2.405z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -159,6 +159,9 @@ export default function UserMetaCard({ data }: { data: UserMetaCardProps }) {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
ID: {data.data?.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import axios from "axios";
|
||||
import { API } from "../../api";
|
||||
|
||||
const axiosInstance = axios.create({
|
||||
baseURL: "/",
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
axiosInstance.interceptors.response.use(
|
||||
(response) => {
|
||||
if (response.data && response.data.status === false) {
|
||||
return handleRefreshToken(response);
|
||||
}
|
||||
return response;
|
||||
},
|
||||
async (error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
async function handleRefreshToken(originalResponse: any) {
|
||||
try {
|
||||
const refreshRes = await axios.get(API.Auth.REFRESH, { withCredentials: true });
|
||||
|
||||
if (refreshRes.data && refreshRes.data.status !== false) {
|
||||
return axiosInstance(originalResponse.config);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Refresh token failed", err);
|
||||
}
|
||||
return originalResponse;
|
||||
}
|
||||
|
||||
export default axiosInstance;
|
||||
+125
-36
@@ -1,9 +1,22 @@
|
||||
import axios from "axios"
|
||||
import axios, { AxiosResponse } from "axios"
|
||||
import { API_URL_ROOT } from "../../api"
|
||||
import {
|
||||
clearStoredTokens,
|
||||
extractTokensFromResponsePayload,
|
||||
getAccessToken,
|
||||
setStoredTokens,
|
||||
} from "@/auth/tokenStore"
|
||||
|
||||
const baseURL = API_URL_ROOT || "https://history-api.kain.id.vn"
|
||||
|
||||
const api = axios.create({
|
||||
baseURL,
|
||||
// Support both cookie-based auth (httpOnly) and Bearer JWT.
|
||||
withCredentials: true
|
||||
})
|
||||
|
||||
// Dedicated instance for refresh to avoid interceptor loops and handle baseURL correctly.
|
||||
const refreshApi = axios.create({
|
||||
baseURL,
|
||||
withCredentials: true
|
||||
})
|
||||
@@ -19,47 +32,123 @@ const processQueue = (error?: any) => {
|
||||
queue = []
|
||||
}
|
||||
|
||||
api.interceptors.request.use((config: any) => {
|
||||
if (config.skipAuth) return config
|
||||
|
||||
const token = config.authToken || getAccessToken()
|
||||
if (token) {
|
||||
const headers: any = config.headers || {}
|
||||
// If it's a retry after refresh, we MUST update the Authorization header with the fresh token.
|
||||
// Otherwise, we only set it if not already present.
|
||||
const hasAuth = !!(headers.Authorization || headers.authorization || (typeof headers.get === "function" && headers.get("Authorization")))
|
||||
|
||||
if (config._retry || !hasAuth) {
|
||||
if (typeof headers.set === "function") headers.set("Authorization", `Bearer ${token}`)
|
||||
else headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
config.headers = headers
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
function isAuthTokenExpiredMessage(message: string): boolean {
|
||||
const normalized = message.trim().toLowerCase()
|
||||
if (!normalized) return false
|
||||
// Be specific: don't match general "unauthorized" or "access denied" which could be 403.
|
||||
// Match only messages clearly indicating token expiration or invalidity.
|
||||
return (
|
||||
normalized.includes("invalid or expired jwt") ||
|
||||
normalized.includes("jwt expired") ||
|
||||
normalized.includes("token expired") ||
|
||||
normalized.includes("invalid token") ||
|
||||
normalized.includes("expired token") ||
|
||||
normalized.includes("token is invalid") ||
|
||||
normalized.includes("not authenticated")
|
||||
)
|
||||
}
|
||||
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
async (res: AxiosResponse): Promise<AxiosResponse> => {
|
||||
// Opportunistically persist tokens from signin/refresh responses.
|
||||
const tokens = extractTokensFromResponsePayload(res?.data)
|
||||
if (tokens) setStoredTokens(tokens)
|
||||
|
||||
// Handle backends that return 200 OK with status:false + expired token message.
|
||||
const data = res.data
|
||||
const originalRequest = res.config as any
|
||||
const url = String(originalRequest?.url || "")
|
||||
|
||||
if (
|
||||
data &&
|
||||
data.status === false &&
|
||||
isAuthTokenExpiredMessage(data.message || "") &&
|
||||
!originalRequest._retry &&
|
||||
!originalRequest.skipRefresh &&
|
||||
!url.includes("/auth/")
|
||||
) {
|
||||
return performRefreshAndRetry(originalRequest)
|
||||
}
|
||||
|
||||
return res
|
||||
},
|
||||
async (err) => {
|
||||
const originalRequest = err.config
|
||||
const originalRequest = err.config as any
|
||||
|
||||
if (err.response?.status === 401 && !originalRequest._retry) {
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
queue.push({
|
||||
resolve: () => resolve(api(originalRequest)),
|
||||
reject
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
originalRequest._retry = true
|
||||
isRefreshing = true
|
||||
|
||||
try {
|
||||
await axios.post(
|
||||
`${baseURL}/auth/refresh`,
|
||||
{},
|
||||
{ withCredentials: true }
|
||||
)
|
||||
|
||||
processQueue()
|
||||
|
||||
return api(originalRequest)
|
||||
} catch (refreshErr) {
|
||||
processQueue(refreshErr)
|
||||
|
||||
window.location.href = "/signin"
|
||||
|
||||
return Promise.reject(refreshErr)
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
const url = String(originalRequest?.url || "")
|
||||
if (err.response?.status === 401 && !originalRequest._retry && !originalRequest.skipRefresh && !url.includes("/auth/")) {
|
||||
return performRefreshAndRetry(originalRequest)
|
||||
}
|
||||
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
async function performRefreshAndRetry(originalRequest: any): Promise<AxiosResponse> {
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
queue.push({
|
||||
resolve: () => resolve(api(originalRequest)),
|
||||
reject
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
originalRequest._retry = true
|
||||
isRefreshing = true
|
||||
|
||||
try {
|
||||
const tryCookieRefresh = async () => {
|
||||
return refreshApi.post("/auth/refresh", {})
|
||||
}
|
||||
|
||||
let refreshRes: any = await tryCookieRefresh()
|
||||
|
||||
const nextTokens = extractTokensFromResponsePayload(refreshRes?.data)
|
||||
if (nextTokens) setStoredTokens(nextTokens)
|
||||
// Some backends may return only a new access token; keep refresh token.
|
||||
else {
|
||||
const maybeAccess = (refreshRes?.data?.data?.access_token ?? refreshRes?.data?.access_token) as unknown
|
||||
if (typeof maybeAccess === "string" && maybeAccess.trim()) {
|
||||
if (refreshToken) setStoredTokens({ access_token: maybeAccess, refresh_token: refreshToken })
|
||||
}
|
||||
}
|
||||
|
||||
processQueue()
|
||||
return api(originalRequest)
|
||||
} catch (refreshErr: any) {
|
||||
processQueue(refreshErr)
|
||||
// Only force logout when refresh token/session is truly invalid (401).
|
||||
// CRITICAL: We only redirect if it's a 401, which means the HttpOnly cookie is missing or invalid.
|
||||
if (refreshErr?.response?.status === 401) {
|
||||
clearStoredTokens()
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = "/signin"
|
||||
}
|
||||
}
|
||||
return Promise.reject(refreshErr)
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
export default api
|
||||
|
||||
@@ -27,7 +27,7 @@ export const useSidebar = () => {
|
||||
export const SidebarProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isMobileOpen, setIsMobileOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface ChatbotPayload {
|
||||
project_id?: string;
|
||||
question: string;
|
||||
}
|
||||
|
||||
export interface ChatbotResponse {
|
||||
status: boolean;
|
||||
data: string;
|
||||
}
|
||||
@@ -3,7 +3,84 @@ export interface Project {
|
||||
title: string;
|
||||
description: string;
|
||||
project_status: "PRIVATE" | "PUBLIC" | "ARCHIVE";
|
||||
latest_commit_id?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
// You can add other fields like 'members' if they are part of the response
|
||||
}
|
||||
is_deleted?: boolean;
|
||||
user_id?: string;
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
avatar_url: string;
|
||||
};
|
||||
commits?: any[];
|
||||
// Legacy (old BE): submission_ids
|
||||
submission_ids?: any[];
|
||||
// New BE: lightweight submissions list on project response
|
||||
submissions?: Array<{ id: string; status: string }>;
|
||||
members?: ProjectMember[];
|
||||
}
|
||||
export interface ProjectsResponse<T = Project> {
|
||||
status: boolean;
|
||||
message: string;
|
||||
data: T[];
|
||||
pagination: {
|
||||
current_page: number;
|
||||
page_size: number;
|
||||
total_records: number;
|
||||
total_pages: number;
|
||||
};
|
||||
}
|
||||
export interface UpdateProjectPayload {
|
||||
title: string;
|
||||
description: string;
|
||||
status: "PRIVATE" | "PUBLIC" | "ARCHIVE";
|
||||
}
|
||||
export interface ChangeOwnerPayload {
|
||||
new_owner_id: string;
|
||||
}
|
||||
export interface ProjectMemberPayload {
|
||||
user_id?: string;
|
||||
role: "EDITOR" | "VIEWER" | "ADMIN";
|
||||
}
|
||||
export interface ProjectMember {
|
||||
user_id: string;
|
||||
role: string;
|
||||
display_name: string;
|
||||
avatar_url: string;
|
||||
}
|
||||
export interface GetProjectsParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
sort?: "created_at" | "updated_at" | "title";
|
||||
order?: "asc" | "desc";
|
||||
statuses?: string; // comma-separated
|
||||
user_ids?: string; // comma-separated
|
||||
created_from?: string; // ISO date string
|
||||
created_to?: string; // ISO date string
|
||||
}
|
||||
export interface CreateCommitPayload {
|
||||
edit_summary: string;
|
||||
snapshot_json: number[];
|
||||
}
|
||||
export interface RestoreCommitPayload {
|
||||
commit_id: string;
|
||||
}
|
||||
|
||||
export interface CreateProjectPayload
|
||||
{
|
||||
description: string,
|
||||
project_status: "PRIVATE" | "PUBLIC" | "ARCHIVE",
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface AddMemberPayload {
|
||||
role: "PRIVATE" | "PUBLIC" | "ARCHIVE",
|
||||
user_id: string
|
||||
}
|
||||
|
||||
export interface UpdateMemberRolePayload {
|
||||
role: "PRIVATE" | "PUBLIC" | "ARCHIVE",
|
||||
}
|
||||
|
||||
+44
-50
@@ -5,15 +5,12 @@ import UserDropdown from "@/components/header/UserDropdown";
|
||||
import { useSidebar } from "@/context/SidebarContext";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import React, { useState ,useEffect,useRef} from "react";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
|
||||
const AppHeader: React.FC = () => {
|
||||
const [isApplicationMenuOpen, setApplicationMenuOpen] = useState(false);
|
||||
|
||||
const { isMobileOpen, toggleSidebar, toggleMobileSidebar } = useSidebar();
|
||||
const pathname = usePathname();
|
||||
const isHomePage = pathname === "/";
|
||||
|
||||
const handleToggle = () => {
|
||||
if (window.innerWidth >= 1024) {
|
||||
@@ -47,46 +44,44 @@ const AppHeader: React.FC = () => {
|
||||
<header className="sticky top-0 flex w-full bg-white border-gray-200 z-99 dark:border-gray-800 dark:bg-gray-900 lg:border-b">
|
||||
<div className="flex flex-col items-center justify-between grow lg:flex-row lg:px-6">
|
||||
<div className="flex items-center justify-between w-full gap-2 px-3 py-3 border-b border-gray-200 dark:border-gray-800 sm:gap-4 lg:justify-normal lg:border-b-0 lg:px-0 lg:py-4">
|
||||
{!isHomePage && (
|
||||
<button
|
||||
className="items-center justify-center w-10 h-10 text-gray-500 border-gray-200 rounded-lg z-99999 dark:border-gray-800 lg:flex dark:text-gray-400 lg:h-11 lg:w-11 lg:border"
|
||||
onClick={handleToggle}
|
||||
aria-label="Toggle Sidebar"
|
||||
>
|
||||
{isMobileOpen ? (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.21967 7.28131C5.92678 6.98841 5.92678 6.51354 6.21967 6.22065C6.51256 5.92775 6.98744 5.92775 7.28033 6.22065L11.999 10.9393L16.7176 6.22078C17.0105 5.92789 17.4854 5.92788 17.7782 6.22078C18.0711 6.51367 18.0711 6.98855 17.7782 7.28144L13.0597 12L17.7782 16.7186C18.0711 17.0115 18.0711 17.4863 17.7782 17.7792C17.4854 18.0721 17.0105 18.0721 16.7176 17.7792L11.999 13.0607L7.28033 17.7794C6.98744 18.0722 6.51256 18.0722 6.21967 17.7794C5.92678 17.4865 5.92678 17.0116 6.21967 16.7187L10.9384 12L6.21967 7.28131Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
width="16"
|
||||
height="12"
|
||||
viewBox="0 0 16 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M0.583252 1C0.583252 0.585788 0.919038 0.25 1.33325 0.25H14.6666C15.0808 0.25 15.4166 0.585786 15.4166 1C15.4166 1.41421 15.0808 1.75 14.6666 1.75L1.33325 1.75C0.919038 1.75 0.583252 1.41422 0.583252 1ZM0.583252 11C0.583252 10.5858 0.919038 10.25 1.33325 10.25L14.6666 10.25C15.0808 10.25 15.4166 10.5858 15.4166 11C15.4166 11.4142 15.0808 11.75 14.6666 11.75L1.33325 11.75C0.919038 11.75 0.583252 11.4142 0.583252 11ZM1.33325 5.25C0.919038 5.25 0.583252 5.58579 0.583252 6C0.583252 6.41421 0.919038 6.75 1.33325 6.75L7.99992 6.75C8.41413 6.75 8.74992 6.41421 8.74992 6C8.74992 5.58579 8.41413 5.25 7.99992 5.25L1.33325 5.25Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{/* Cross Icon */}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="items-center justify-center w-10 h-10 text-gray-500 border-gray-200 rounded-lg z-99999 dark:border-gray-800 lg:flex dark:text-gray-400 lg:h-11 lg:w-11 lg:border"
|
||||
onClick={handleToggle}
|
||||
aria-label="Toggle Sidebar"
|
||||
>
|
||||
{isMobileOpen ? (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.21967 7.28131C5.92678 6.98841 5.92678 6.51354 6.21967 6.22065C6.51256 5.92775 6.98744 5.92775 7.28033 6.22065L11.999 10.9393L16.7176 6.22078C17.0105 5.92789 17.4854 5.92788 17.7782 6.22078C18.0711 6.51367 18.0711 6.98855 17.7782 7.28144L13.0597 12L17.7782 16.7186C18.0711 17.0115 18.0711 17.4863 17.7782 17.7792C17.4854 18.0721 17.0105 18.0721 16.7176 17.7792L11.999 13.0607L7.28033 17.7794C6.98744 18.0722 6.51256 18.0722 6.21967 17.7794C5.92678 17.4865 5.92678 17.0116 6.21967 16.7187L10.9384 12L6.21967 7.28131Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
width="16"
|
||||
height="12"
|
||||
viewBox="0 0 16 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M0.583252 1C0.583252 0.585788 0.919038 0.25 1.33325 0.25H14.6666C15.0808 0.25 15.4166 0.585786 15.4166 1C15.4166 1.41421 15.0808 1.75 14.6666 1.75L1.33325 1.75C0.919038 1.75 0.583252 1.41422 0.583252 1ZM0.583252 11C0.583252 10.5858 0.919038 10.25 1.33325 10.25L14.6666 10.25C15.0808 10.25 15.4166 10.5858 15.4166 11C15.4166 11.4142 15.0808 11.75 14.6666 11.75L1.33325 11.75C0.919038 11.75 0.583252 11.4142 0.583252 11ZM1.33325 5.25C0.919038 5.25 0.583252 5.58579 0.583252 6C0.583252 6.41421 0.919038 6.75 1.33325 6.75L7.99992 6.75C8.41413 6.75 8.74992 6.41421 8.74992 6C8.74992 5.58579 8.41413 5.25 7.99992 5.25L1.33325 5.25Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{/* Cross Icon */}
|
||||
</button>
|
||||
|
||||
<Link href="/" className="lg:hidden">
|
||||
<Image
|
||||
@@ -161,21 +156,20 @@ const AppHeader: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`${
|
||||
isApplicationMenuOpen ? "flex" : "hidden"
|
||||
} items-center justify-between w-full gap-4 px-5 py-4 lg:flex shadow-theme-md lg:justify-end lg:px-0 lg:shadow-none`}
|
||||
className={`${isApplicationMenuOpen ? "flex" : "hidden"
|
||||
} items-center justify-between w-full gap-4 px-5 py-4 lg:flex shadow-theme-md lg:justify-end lg:px-0 lg:shadow-none`}
|
||||
>
|
||||
<div className="flex items-center gap-2 2xsm:gap-3">
|
||||
{/* <!-- Dark Mode Toggler --> */}
|
||||
{/* <ThemeToggleButton /> */}
|
||||
{/* <!-- Dark Mode Toggler --> */}
|
||||
|
||||
{/* <NotificationDropdown /> */}
|
||||
{/* <NotificationDropdown /> */}
|
||||
{/* <!-- Notification Menu Area --> */}
|
||||
</div>
|
||||
{/* <!-- User Area --> */}
|
||||
<UserDropdown />
|
||||
|
||||
<UserDropdown />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
+122
-60
@@ -15,6 +15,7 @@ import {
|
||||
PageIcon,
|
||||
PieChartIcon,
|
||||
PlugInIcon,
|
||||
ShootingStarIcon,
|
||||
TableIcon,
|
||||
UserCircleIcon,
|
||||
} from "../icons/index";
|
||||
@@ -23,10 +24,10 @@ type NavItem = {
|
||||
name: string;
|
||||
icon: React.ReactNode;
|
||||
path?: string;
|
||||
subItems?: {
|
||||
name: string;
|
||||
path: string;
|
||||
pro?: boolean;
|
||||
subItems?: {
|
||||
name: string;
|
||||
path: string;
|
||||
pro?: boolean;
|
||||
new?: boolean;
|
||||
}[];
|
||||
};
|
||||
@@ -53,36 +54,47 @@ const ALL_NAV_ITEMS: NavItem[] = [
|
||||
name: "Tài Khoản",
|
||||
path: "/user/account",
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
const OTHERS_ITEMS: NavItem[] = [
|
||||
// {
|
||||
// icon: <PieChartIcon />,
|
||||
// name: "Charts",
|
||||
// subItems: [
|
||||
// { name: "Line Chart", path: "/line-chart", pro: false },
|
||||
// { name: "Bar Chart", path: "/bar-chart", pro: false },
|
||||
// ],
|
||||
// },
|
||||
// {
|
||||
// icon: <BoxCubeIcon />,
|
||||
// name: "UI Elements",
|
||||
// subItems: [
|
||||
// { name: "Alerts", path: "/alerts", pro: false },
|
||||
// { name: "Avatar", path: "/avatars", pro: false },
|
||||
// { name: "Badge", path: "/badge", pro: false },
|
||||
// { name: "Buttons", path: "/buttons", pro: false },
|
||||
// { name: "Images", path: "/images", pro: false },
|
||||
// { name: "Videos", path: "/videos", pro: false },
|
||||
// ],
|
||||
// },
|
||||
// {
|
||||
// icon: <PlugInIcon />,
|
||||
// name: "Authentication",
|
||||
// subItems: [
|
||||
// { name: "Sign In", path: "/signin", pro: false },
|
||||
// { name: "Sign Up", path: "/signup", pro: false },
|
||||
// ],
|
||||
// },
|
||||
{
|
||||
icon: <PieChartIcon />,
|
||||
name: "Charts",
|
||||
subItems: [
|
||||
{ name: "Line Chart", path: "/line-chart", pro: false },
|
||||
{ name: "Bar Chart", path: "/bar-chart", pro: false },
|
||||
],
|
||||
icon: <ShootingStarIcon />,
|
||||
name: "Về Chúng Tôi",
|
||||
path: "/user/about-us",
|
||||
},
|
||||
{
|
||||
icon: <BoxCubeIcon />,
|
||||
name: "UI Elements",
|
||||
subItems: [
|
||||
{ name: "Alerts", path: "/alerts", pro: false },
|
||||
{ name: "Avatar", path: "/avatars", pro: false },
|
||||
{ name: "Badge", path: "/badge", pro: false },
|
||||
{ name: "Buttons", path: "/buttons", pro: false },
|
||||
{ name: "Images", path: "/images", pro: false },
|
||||
{ name: "Videos", path: "/videos", pro: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <PlugInIcon />,
|
||||
name: "Authentication",
|
||||
subItems: [
|
||||
{ name: "Sign In", path: "/signin", pro: false },
|
||||
{ name: "Sign Up", path: "/signup", pro: false },
|
||||
],
|
||||
icon: <ShootingStarIcon />,
|
||||
name: "Hỗ trợ",
|
||||
path: "/user/quick-qa",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -90,23 +102,26 @@ const AppSidebar: React.FC = () => {
|
||||
const { isExpanded, isMobileOpen, isHovered, setIsHovered } = useSidebar();
|
||||
const pathname = usePathname();
|
||||
|
||||
|
||||
const [openSubmenu, setOpenSubmenu] = useState<{
|
||||
type: "main" | "others";
|
||||
index: number;
|
||||
} | null>(null);
|
||||
const [subMenuHeight, setSubMenuHeight] = useState<Record<string, number>>({});
|
||||
const [subMenuHeight, setSubMenuHeight] = useState<Record<string, number>>(
|
||||
{},
|
||||
);
|
||||
const subMenuRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
|
||||
const isActive = useCallback((path: string) => path === pathname, [pathname]);
|
||||
|
||||
const handleSubmenuToggle = (index: number, menuType: "main" | "others") => {
|
||||
setOpenSubmenu((prev) =>
|
||||
prev?.type === menuType && prev?.index === index ? null : { type: menuType, index }
|
||||
setOpenSubmenu((prev) =>
|
||||
prev?.type === menuType && prev?.index === index
|
||||
? null
|
||||
: { type: menuType, index },
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
useEffect(() => {
|
||||
let submenuMatched = false;
|
||||
[
|
||||
{ items: ALL_NAV_ITEMS, type: "main" },
|
||||
@@ -149,25 +164,42 @@ const AppSidebar: React.FC = () => {
|
||||
{nav.subItems ? (
|
||||
<button
|
||||
onClick={() => handleSubmenuToggle(index, menuType)}
|
||||
className={`menu-item group uppercase ${
|
||||
openSubmenu?.type === menuType && openSubmenu?.index === index
|
||||
? "menu-item-active" : "menu-item-inactive"
|
||||
} cursor-pointer ${!isExpanded && !isHovered ? "lg:justify-center" : "lg:justify-start"}`}
|
||||
className={`menu-item group uppercase ${openSubmenu?.type === menuType && openSubmenu?.index === index
|
||||
? "menu-item-active"
|
||||
: "menu-item-inactive"
|
||||
} cursor-pointer ${!isExpanded && !isHovered ? "lg:justify-center" : "lg:justify-start"}`}
|
||||
>
|
||||
<span className={openSubmenu?.type === menuType && openSubmenu?.index === index ? "menu-item-icon-active" : "menu-item-icon-inactive"}>
|
||||
<span
|
||||
className={
|
||||
openSubmenu?.type === menuType && openSubmenu?.index === index
|
||||
? "menu-item-icon-active"
|
||||
: "menu-item-icon-inactive"
|
||||
}
|
||||
>
|
||||
{nav.icon}
|
||||
</span>
|
||||
{(isExpanded || isHovered || isMobileOpen) && (
|
||||
<>
|
||||
<span className={`menu-item-text`}>{nav.name}</span>
|
||||
<ChevronDownIcon className={`ml-auto w-5 h-5 transition-transform duration-200 ${openSubmenu?.type === menuType && openSubmenu?.index === index ? "rotate-180 text-brand-500" : ""}`} />
|
||||
<ChevronDownIcon
|
||||
className={`ml-auto w-5 h-5 transition-transform duration-200 ${openSubmenu?.type === menuType && openSubmenu?.index === index ? "rotate-180 text-brand-500" : ""}`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
nav.path && (
|
||||
<Link href={nav.path} className={`menu-item group ${isActive(nav.path) ? "menu-item-active" : "menu-item-inactive"}`}>
|
||||
<span className={isActive(nav.path) ? "menu-item-icon-active" : "menu-item-icon-inactive"}>
|
||||
<Link
|
||||
href={nav.path}
|
||||
className={`menu-item group ${isActive(nav.path) ? "menu-item-active" : "menu-item-inactive"}`}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
isActive(nav.path)
|
||||
? "menu-item-icon-active"
|
||||
: "menu-item-icon-inactive"
|
||||
}
|
||||
>
|
||||
{nav.icon}
|
||||
</span>
|
||||
{(isExpanded || isHovered || isMobileOpen) && (
|
||||
@@ -178,18 +210,40 @@ const AppSidebar: React.FC = () => {
|
||||
)}
|
||||
{nav.subItems && (isExpanded || isHovered || isMobileOpen) && (
|
||||
<div
|
||||
ref={(el) => { subMenuRefs.current[`${menuType}-${index}`] = el; }}
|
||||
ref={(el) => {
|
||||
subMenuRefs.current[`${menuType}-${index}`] = el;
|
||||
}}
|
||||
className="overflow-hidden transition-all duration-300"
|
||||
style={{ height: openSubmenu?.type === menuType && openSubmenu?.index === index ? `${subMenuHeight[`${menuType}-${index}`]}px` : "0px" }}
|
||||
style={{
|
||||
height:
|
||||
openSubmenu?.type === menuType && openSubmenu?.index === index
|
||||
? `${subMenuHeight[`${menuType}-${index}`]}px`
|
||||
: "0px",
|
||||
}}
|
||||
>
|
||||
<ul className="mt-2 space-y-1 ml-9">
|
||||
{nav.subItems.map((subItem) => (
|
||||
<li key={subItem.name}>
|
||||
<Link href={subItem.path} className={`menu-dropdown-item ${isActive(subItem.path) ? "menu-dropdown-item-active" : "menu-dropdown-item-inactive"}`}>
|
||||
<Link
|
||||
href={subItem.path}
|
||||
className={`menu-dropdown-item ${isActive(subItem.path) ? "menu-dropdown-item-active" : "menu-dropdown-item-inactive"}`}
|
||||
>
|
||||
{subItem.name}
|
||||
<span className="flex items-center gap-1 ml-auto">
|
||||
{subItem.new && <span className={`${isActive(subItem.path) ? "menu-dropdown-badge-active" : "menu-dropdown-badge-inactive"} menu-dropdown-badge`}>new</span>}
|
||||
{subItem.pro && <span className={`${isActive(subItem.path) ? "menu-dropdown-badge-active" : "menu-dropdown-badge-inactive"} menu-dropdown-badge`}>pro</span>}
|
||||
{subItem.new && (
|
||||
<span
|
||||
className={`${isActive(subItem.path) ? "menu-dropdown-badge-active" : "menu-dropdown-badge-inactive"} menu-dropdown-badge`}
|
||||
>
|
||||
new
|
||||
</span>
|
||||
)}
|
||||
{subItem.pro && (
|
||||
<span
|
||||
className={`${isActive(subItem.path) ? "menu-dropdown-badge-active" : "menu-dropdown-badge-inactive"} menu-dropdown-badge`}
|
||||
>
|
||||
pro
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -204,19 +258,21 @@ const AppSidebar: React.FC = () => {
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`fixed mt-16 flex flex-col lg:mt-0 top-0 px-5 left-0 bg-white dark:bg-gray-900 dark:border-gray-800 text-gray-900 h-screen transition-all duration-300 ease-in-out z-50 border-r border-gray-200
|
||||
${isExpanded || isMobileOpen ? "w-[290px]" : isHovered ? "w-[290px]" : "w-[90px]"}
|
||||
${isMobileOpen ? "translate-x-0" : "-translate-x-full"} lg:translate-x-0`}
|
||||
className={`fixed mt-16 flex flex-col lg:mt-0 top-0 left-0 bg-white dark:bg-gray-900 dark:border-gray-800 text-gray-900 h-screen transition-all duration-300 ease-in-out z-50 border-r border-gray-200
|
||||
${isExpanded || isMobileOpen || isHovered ? "w-[290px] px-5" : "w-0 px-0 border-none overflow-hidden"}
|
||||
${isMobileOpen ? "translate-x-0" : "-translate-x-full"} lg:translate-x-0`}
|
||||
onMouseEnter={() => !isExpanded && setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className={`py-8 flex ${!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"}`}>
|
||||
<div
|
||||
className={`py-8 flex ${!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"}`}
|
||||
>
|
||||
<Link href="/">
|
||||
<Image
|
||||
src="/images/logo/logo.svg"
|
||||
alt="Logo"
|
||||
width={isExpanded || isHovered || isMobileOpen ? 80 : 32}
|
||||
height={isExpanded || isHovered || isMobileOpen ? 50 : 32}
|
||||
<Image
|
||||
src="/images/logo/logo.svg"
|
||||
alt="Logo"
|
||||
width={isExpanded || isHovered || isMobileOpen ? 80 : 32}
|
||||
height={isExpanded || isHovered || isMobileOpen ? 50 : 32}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -224,17 +280,23 @@ const AppSidebar: React.FC = () => {
|
||||
<nav className="mb-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"}`}>
|
||||
{isExpanded || isHovered || isMobileOpen ? "Menu" : <HorizontaLDots />}
|
||||
<h2
|
||||
className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"}`}
|
||||
>
|
||||
{isExpanded || isHovered || isMobileOpen ? (
|
||||
"Menu"
|
||||
) : (
|
||||
<HorizontaLDots />
|
||||
)}
|
||||
</h2>
|
||||
{renderMenuItems(ALL_NAV_ITEMS, "main")}
|
||||
</div>
|
||||
{/* <div>
|
||||
<div>
|
||||
<h2 className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"}`}>
|
||||
{isExpanded || isHovered || isMobileOpen ? "Others" : <HorizontaLDots />}
|
||||
</h2>
|
||||
{renderMenuItems(OTHERS_ITEMS, "others")}
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -242,4 +304,4 @@ const AppSidebar: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default AppSidebar;
|
||||
export default AppSidebar;
|
||||
|
||||
@@ -14,4 +14,4 @@ const Backdrop: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Backdrop;
|
||||
export default Backdrop;
|
||||
+7
-4
@@ -1,8 +1,8 @@
|
||||
import api from "@/config/config";
|
||||
import { API } from "../../api";
|
||||
import { clearStoredTokens, extractTokensFromResponsePayload, setStoredTokens } from "@/auth/tokenStore";
|
||||
|
||||
export const apiCreateOTP = async (email: string) => {
|
||||
const token_type = 2;
|
||||
export const apiCreateOTP = async (email: string, token_type: number = 2) => {
|
||||
const response = await api.post(API.Auth.CREATEOTP, {
|
||||
email,
|
||||
token_type
|
||||
@@ -10,8 +10,8 @@ export const apiCreateOTP = async (email: string) => {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const apiVerifyOTP = async (email: string, token: string) => {
|
||||
const body = { email, token, token_type: 2 };
|
||||
export const apiVerifyOTP = async (email: string, token: string, token_type: number = 2) => {
|
||||
const body = { email, token, token_type };
|
||||
const response = await api.post(API.Auth.VERIFYOTP, body);
|
||||
return response.data;
|
||||
};
|
||||
@@ -23,11 +23,14 @@ export const apiSignUp = async (payload: any) => {
|
||||
|
||||
export const apiLogout = async () => {
|
||||
const response = await api.post(API.Auth.LOGOUT);
|
||||
clearStoredTokens();
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const apiSignIn = async (payload: any) => {
|
||||
const response = await api.post(API.Auth.SIGNIN, payload);
|
||||
const tokens = extractTokensFromResponsePayload(response?.data);
|
||||
if (tokens) setStoredTokens(tokens);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import api from "@/config/config";
|
||||
import { API } from "../../api";
|
||||
import { ChatbotPayload, ChatbotResponse } from "@/interface/chatbot";
|
||||
|
||||
export const apiChatbot = async (payload: ChatbotPayload): Promise<ChatbotResponse> => {
|
||||
const response = await api.post(API.Chatbot.CHAT,payload);
|
||||
return await response?.data;
|
||||
};
|
||||
@@ -1,49 +1,8 @@
|
||||
|
||||
import api from "@/config/config";
|
||||
import { API } from "../../api";
|
||||
import { Project } from "@/interface/project";
|
||||
import { CommonResponse, CursorPaginatedResponse } from "@/interface/common";
|
||||
|
||||
// ==========================================
|
||||
// TYPES & INTERFACES (Cơ bản theo logic chuẩn)
|
||||
// ==========================================
|
||||
|
||||
export interface CreateProjectPayload {
|
||||
title: string;
|
||||
description?: string;
|
||||
project_status?: "PRIVATE" | "PUBLIC" | "ARCHIVE";
|
||||
}
|
||||
|
||||
export interface UpdateProjectPayload {
|
||||
title?: string;
|
||||
description?: string;
|
||||
project_status?: "PRIVATE" | "PUBLIC" | "ARCHIVE";
|
||||
}
|
||||
|
||||
export interface AddMemberPayload {
|
||||
user_id: string;
|
||||
role: "EDITOR" | "VIEWER";
|
||||
}
|
||||
|
||||
export interface UpdateMemberRolePayload {
|
||||
role: "EDITOR" | "VIEWER";
|
||||
}
|
||||
|
||||
export interface ChangeOwnerPayload {
|
||||
new_owner_id: string;
|
||||
}
|
||||
|
||||
export interface CreateCommitPayload {
|
||||
edit_summary: string;
|
||||
snapshot_json: number[];
|
||||
}
|
||||
|
||||
export interface RestoreCommitPayload {
|
||||
commit_id: string;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 1. NHÓM: QUẢN LÝ DỰ ÁN (PROJECTS)
|
||||
// ==========================================
|
||||
import { Project, GetProjectsParams, UpdateProjectPayload, CreateProjectPayload, AddMemberPayload, UpdateMemberRolePayload, ChangeOwnerPayload, CreateCommitPayload, RestoreCommitPayload } from "@/interface/project";
|
||||
import { CommonResponse, CursorPaginatedResponse, PaginatedResponse } from "@/interface/common";
|
||||
|
||||
export const apiCreateProject = async (payload: CreateProjectPayload): Promise<CommonResponse<Project>> => {
|
||||
const response = await api.post(API.Project.CREATE, payload);
|
||||
@@ -65,6 +24,11 @@ export const apiDeleteProject = async (id: string): Promise<CommonResponse> => {
|
||||
return response?.data;
|
||||
};
|
||||
|
||||
export const getProjects = async (params: GetProjectsParams): Promise<PaginatedResponse<Project>> => {
|
||||
const response = await api.get(API.Project.GET_ALL, { params });
|
||||
return response?.data;
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// 2. NHÓM: QUẢN LÝ THÀNH VIÊN (MEMBERS)
|
||||
// ==========================================
|
||||
@@ -111,4 +75,4 @@ export const apiRestoreProjectCommit = async (id: string, payload: RestoreCommit
|
||||
export const getCurrentProject = async (params?: { cursor_id?: string; limit?: number }): Promise<CursorPaginatedResponse<Project>> => {
|
||||
const response = await api.get(API.Project.GET_CURRENT_PROJECT, { params });
|
||||
return response?.data;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { API_ENDPOINTS } from "@/uhm/api/config";
|
||||
import { jsonRequestInit, requestJson } from "@/uhm/api/http";
|
||||
import { clearStoredTokens, setStoredTokens } from "@/auth/tokenStore";
|
||||
|
||||
export type AuthTokens = {
|
||||
access_token: string;
|
||||
};
|
||||
|
||||
export type CurrentUser = {
|
||||
id: string;
|
||||
email?: string;
|
||||
display_name?: string;
|
||||
avatar_url?: string | null;
|
||||
roles?: string[];
|
||||
};
|
||||
|
||||
export async function signIn(email: string, password: string): Promise<AuthTokens> {
|
||||
const res = await requestJson<AuthTokens>(
|
||||
API_ENDPOINTS.authSignin,
|
||||
jsonRequestInit("POST", { email, password }),
|
||||
{ skipAuth: true }
|
||||
);
|
||||
if (res?.access_token) setStoredTokens(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await requestJson(API_ENDPOINTS.authLogout, { method: "POST" });
|
||||
clearStoredTokens();
|
||||
}
|
||||
|
||||
export async function fetchCurrentUser(): Promise<CurrentUser> {
|
||||
return requestJson<CurrentUser>(API_ENDPOINTS.currentUser);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Production BackEndGo API base URL.
|
||||
// For local development, override with NEXT_PUBLIC_API_BASE_URL (e.g. http://localhost:3344).
|
||||
const FALLBACK_API_BASE_URL = "https://history-api.kain.id.vn";
|
||||
|
||||
export const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL || FALLBACK_API_BASE_URL;
|
||||
|
||||
export const API_ENDPOINTS = {
|
||||
geometries: `${API_BASE_URL}/geometries`,
|
||||
entities: `${API_BASE_URL}/entities`,
|
||||
wikis: `${API_BASE_URL}/wikis`,
|
||||
wikiContent: (id: string) => `${API_BASE_URL}/wikis/content/${id}`,
|
||||
// New API uses projects + commits + submissions (JWT-protected).
|
||||
authSignin: `${API_BASE_URL}/auth/signin`,
|
||||
authRefresh: `${API_BASE_URL}/auth/refresh`,
|
||||
authLogout: `${API_BASE_URL}/auth/logout`,
|
||||
currentUser: `${API_BASE_URL}/users/current`,
|
||||
currentUserProjects: `${API_BASE_URL}/users/current/project`,
|
||||
projects: `${API_BASE_URL}/projects`,
|
||||
submissions: `${API_BASE_URL}/submissions`,
|
||||
vectorTiles: `${API_BASE_URL}/tiles/{z}/{x}/{y}`,
|
||||
rasterTiles: `${API_BASE_URL}/raster-tiles/{z}/{x}/{y}`,
|
||||
vectorTilesMetadata: `${API_BASE_URL}/tiles/metadata`,
|
||||
rasterTilesMetadata: `${API_BASE_URL}/raster-tiles/metadata`,
|
||||
} as const;
|
||||
@@ -0,0 +1,46 @@
|
||||
import { API_ENDPOINTS } from "@/uhm/api/config";
|
||||
import { requestJson } from "@/uhm/api/http";
|
||||
import type { Entity } from "@/uhm/types/entities";
|
||||
|
||||
export type { Entity } from "@/uhm/types/entities";
|
||||
|
||||
export async function fetchEntities(query?: {
|
||||
q?: string;
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
projectId?: string;
|
||||
}): Promise<Entity[]> {
|
||||
const params = new URLSearchParams();
|
||||
// API mới dùng `name` thay vì `q`.
|
||||
if (query && "q" in query) {
|
||||
params.set("name", String(query.q ?? ""));
|
||||
}
|
||||
if (query?.limit && Number.isFinite(query.limit)) {
|
||||
params.set("limit", String(Math.trunc(query.limit)));
|
||||
}
|
||||
if (query?.cursor) {
|
||||
params.set("cursor", query.cursor);
|
||||
}
|
||||
if (query?.projectId) {
|
||||
params.set("project_id", query.projectId);
|
||||
}
|
||||
const suffix = params.toString();
|
||||
const url = suffix ? `${API_ENDPOINTS.entities}?${suffix}` : API_ENDPOINTS.entities;
|
||||
return requestJson<Entity[]>(url);
|
||||
}
|
||||
|
||||
export async function searchEntitiesByName(
|
||||
name: string,
|
||||
options?: { limit?: number }
|
||||
): Promise<Entity[]> {
|
||||
const keyword = name.trim();
|
||||
if (!keyword.length) return [];
|
||||
|
||||
const params = new URLSearchParams({ name: keyword });
|
||||
if (options?.limit && Number.isFinite(options.limit)) {
|
||||
params.set("limit", String(Math.trunc(options.limit)));
|
||||
}
|
||||
|
||||
// API mới không có `/entities/search`, search qua query string.
|
||||
return requestJson<Entity[]>(`${API_ENDPOINTS.entities}?${params.toString()}`);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { API_ENDPOINTS } from "@/uhm/api/config";
|
||||
import { requestJson } from "@/uhm/api/http";
|
||||
import type { GeometriesBBoxQuery } from "@/uhm/types/api";
|
||||
import type { Feature, FeatureCollection, FeatureProperties, Geometry } from "@/uhm/types/geo";
|
||||
import { geoTypeCodeToTypeKey } from "@/uhm/lib/map/geo/geoTypeMap";
|
||||
|
||||
export type { GeometriesBBoxQuery } from "@/uhm/types/api";
|
||||
|
||||
export type EntityGeometrySearchGeo = {
|
||||
id: string;
|
||||
type: string | null;
|
||||
draw_geometry: unknown;
|
||||
binding?: unknown;
|
||||
time_start?: number | null;
|
||||
time_end?: number | null;
|
||||
};
|
||||
|
||||
export type EntityGeometriesSearchItem = {
|
||||
entity_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
geometries: EntityGeometrySearchGeo[];
|
||||
};
|
||||
|
||||
export type SearchGeometriesByEntityNameResponse = {
|
||||
items: EntityGeometriesSearchItem[];
|
||||
next_cursor?: string;
|
||||
};
|
||||
|
||||
type EntityGeometrySearchGeoRow = Omit<EntityGeometrySearchGeo, "type"> & {
|
||||
geo_type: number;
|
||||
};
|
||||
|
||||
type EntityGeometriesSearchItemRow = Omit<EntityGeometriesSearchItem, "geometries"> & {
|
||||
geometries: EntityGeometrySearchGeoRow[];
|
||||
};
|
||||
|
||||
type SearchGeometriesByEntityNameApiResponse = Omit<SearchGeometriesByEntityNameResponse, "items"> & {
|
||||
items: EntityGeometriesSearchItemRow[];
|
||||
};
|
||||
|
||||
function buildBBoxQueryString(params: GeometriesBBoxQuery): string {
|
||||
const query = new URLSearchParams({
|
||||
// API mới dùng snake_case
|
||||
min_lng: String(params.minLng),
|
||||
min_lat: String(params.minLat),
|
||||
max_lng: String(params.maxLng),
|
||||
max_lat: String(params.maxLat),
|
||||
});
|
||||
|
||||
if (params.time !== undefined) {
|
||||
query.set("time", String(params.time));
|
||||
}
|
||||
|
||||
if (params.timeRange !== undefined) {
|
||||
query.set("time_range", String(params.timeRange));
|
||||
}
|
||||
|
||||
if (params.entity_id) {
|
||||
query.set("entity_id", params.entity_id);
|
||||
}
|
||||
|
||||
return query.toString();
|
||||
}
|
||||
|
||||
export async function fetchGeometriesByBBox(params: GeometriesBBoxQuery): Promise<FeatureCollection> {
|
||||
const url = `${API_ENDPOINTS.geometries}?${buildBBoxQueryString(params)}`;
|
||||
// API mới trả về list geometries, FE cần chuyển thành GeoJSON FeatureCollection.
|
||||
const rows = await requestJson<GeometryRow[]>(url);
|
||||
return geometriesToFeatureCollection(rows);
|
||||
}
|
||||
|
||||
export async function searchGeometriesByEntityName(
|
||||
name: string,
|
||||
options?: { cursor?: string; limit?: number }
|
||||
): Promise<SearchGeometriesByEntityNameResponse> {
|
||||
const keyword = name.trim();
|
||||
if (!keyword.length) return { items: [] };
|
||||
|
||||
const params = new URLSearchParams({ name: keyword });
|
||||
if (options?.cursor) params.set("cursor", options.cursor);
|
||||
if (options?.limit && Number.isFinite(options.limit)) {
|
||||
params.set("limit", String(Math.trunc(options.limit)));
|
||||
}
|
||||
|
||||
const response = await requestJson<SearchGeometriesByEntityNameApiResponse>(
|
||||
`${API_ENDPOINTS.geometries}/entity?${params.toString()}`
|
||||
);
|
||||
|
||||
return {
|
||||
...response,
|
||||
items: (response.items || []).map((item) => ({
|
||||
...item,
|
||||
geometries: (item.geometries || []).map((geometry) => ({
|
||||
id: geometry.id,
|
||||
type: geoTypeCodeToTypeKey(geometry.geo_type) || null,
|
||||
draw_geometry: geometry.draw_geometry,
|
||||
binding: geometry.binding,
|
||||
time_start: geometry.time_start ?? null,
|
||||
time_end: geometry.time_end ?? null,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
type GeometryRow = {
|
||||
id: string;
|
||||
geo_type: number;
|
||||
draw_geometry: unknown;
|
||||
binding?: unknown;
|
||||
time_start?: number;
|
||||
time_end?: number;
|
||||
bbox?: {
|
||||
min_lng: number;
|
||||
min_lat: number;
|
||||
max_lng: number;
|
||||
max_lat: number;
|
||||
} | null;
|
||||
};
|
||||
|
||||
function geometriesToFeatureCollection(rows: GeometryRow[]): FeatureCollection {
|
||||
const features: Feature[] = [];
|
||||
|
||||
for (const row of rows || []) {
|
||||
const geometry = normalizeGeometry(row.draw_geometry);
|
||||
if (!geometry) continue;
|
||||
|
||||
const binding = normalizeBinding(row.binding);
|
||||
const typeKey = geoTypeCodeToTypeKey(row.geo_type) || null;
|
||||
|
||||
const properties: FeatureProperties = {
|
||||
id: row.id,
|
||||
type: typeKey,
|
||||
time_start: row.time_start ?? null,
|
||||
time_end: row.time_end ?? null,
|
||||
binding: binding.length ? binding : undefined,
|
||||
};
|
||||
|
||||
features.push({
|
||||
type: "Feature",
|
||||
properties,
|
||||
geometry,
|
||||
});
|
||||
}
|
||||
|
||||
return { type: "FeatureCollection", features };
|
||||
}
|
||||
|
||||
function normalizeGeometry(value: unknown): Geometry | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const g = value as Record<string, unknown>;
|
||||
if (typeof g.type !== "string") return null;
|
||||
if (!("coordinates" in g)) return null;
|
||||
return value as Geometry;
|
||||
}
|
||||
|
||||
function normalizeBinding(value: unknown): string[] {
|
||||
if (!value) return [];
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((v) => String(v)).filter((v) => v.length > 0);
|
||||
}
|
||||
// Some deployments may return binding as an object; ignore it for FE properties.binding.
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { ApiEnvelope } from "@/uhm/types/api";
|
||||
import api from "@/config/config";
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
body: string;
|
||||
errors: unknown[];
|
||||
|
||||
constructor(message: string, status: number, body: string, errors: unknown[] = []) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
this.errors = errors;
|
||||
}
|
||||
}
|
||||
|
||||
type RequestJsonOptions = {
|
||||
skipAuth?: boolean;
|
||||
skipRefresh?: boolean;
|
||||
authToken?: string | null; // Override bearer token (used for refresh).
|
||||
};
|
||||
|
||||
export async function requestJson<T>(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
options?: RequestJsonOptions
|
||||
): Promise<T> {
|
||||
const url = typeof input === "string" ? input : String(input);
|
||||
const method = init?.method || "GET";
|
||||
|
||||
// Convert RequestInit.body to object if it's a JSON string.
|
||||
let data = init?.body;
|
||||
if (typeof data === "string" && data.length > 0) {
|
||||
try {
|
||||
data = JSON.parse(data);
|
||||
} catch {
|
||||
// Keep as string if not JSON.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.request({
|
||||
url,
|
||||
method,
|
||||
data,
|
||||
headers: init?.headers as any,
|
||||
// Custom properties for our axios interceptor.
|
||||
skipAuth: options?.skipAuth,
|
||||
authToken: options?.authToken,
|
||||
skipRefresh: options?.skipRefresh,
|
||||
} as any);
|
||||
|
||||
const payload = response.data;
|
||||
const envelope = isApiEnvelopeLike<T>(payload) ? payload : null;
|
||||
|
||||
if (envelope) {
|
||||
const isError = envelope.status === false || envelope.status === "error";
|
||||
if (isError) {
|
||||
const message = extractErrorMessage(payload, envelope) || "Request failed";
|
||||
throw new ApiError(message, response.status, stringifyPayload(envelope), normalizeErrors(envelope.errors));
|
||||
}
|
||||
return (envelope.data ?? null) as T;
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
|
||||
const status = err.response?.status || 0;
|
||||
const payload = err.response?.data;
|
||||
const envelope = isApiEnvelopeLike<T>(payload) ? payload : null;
|
||||
const message = extractErrorMessage(payload, envelope) || err.message || "Request failed";
|
||||
const body = envelope ? stringifyPayload(envelope) : stringifyPayload(payload);
|
||||
const errors = envelope?.errors ? normalizeErrors(envelope.errors) : [];
|
||||
|
||||
throw new ApiError(message, status, body, errors);
|
||||
}
|
||||
}
|
||||
|
||||
export function jsonRequestInit(method: string, body: unknown): RequestInit {
|
||||
return {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
}
|
||||
|
||||
function isApiEnvelopeLike<T>(value: unknown): value is ApiEnvelope<T> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const source = value as Record<string, unknown>;
|
||||
return "status" in source && ("data" in source || "message" in source || "errors" in source);
|
||||
}
|
||||
|
||||
function normalizeErrors(value: unknown): unknown[] {
|
||||
if (value == null) return [];
|
||||
if (Array.isArray(value)) return value;
|
||||
return [value];
|
||||
}
|
||||
|
||||
function extractErrorMessage(payload: unknown, envelope: ApiEnvelope<unknown> | null): string | null {
|
||||
const msg =
|
||||
(typeof envelope?.message === "string" && envelope.message.trim()) ||
|
||||
(typeof (payload as any)?.message === "string" && String((payload as any).message).trim());
|
||||
if (msg) return msg;
|
||||
const errors = envelope?.errors ?? (payload as any)?.errors;
|
||||
if (typeof errors === "string" && errors.trim()) return errors.trim();
|
||||
if (Array.isArray(errors) && typeof errors[0] === "string") return errors[0];
|
||||
return null;
|
||||
}
|
||||
|
||||
function stringifyPayload(payload: unknown): string {
|
||||
if (typeof payload === "string") return payload;
|
||||
try {
|
||||
return JSON.stringify(payload);
|
||||
} catch {
|
||||
return String(payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { API_BASE_URL, API_ENDPOINTS } from "@/uhm/api/config";
|
||||
import { ApiError, jsonRequestInit, requestJson } from "@/uhm/api/http";
|
||||
import { toApiEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
||||
import type {
|
||||
CreateCommitInput,
|
||||
CreateProjectInput,
|
||||
EditorLoadResponse,
|
||||
RestoreCommitInput,
|
||||
Project,
|
||||
ProjectCommit,
|
||||
ProjectState,
|
||||
ProjectSubmission,
|
||||
} from "@/uhm/types/projects";
|
||||
|
||||
export type {
|
||||
CreateCommitInput,
|
||||
CreateProjectInput,
|
||||
EditorLoadResponse,
|
||||
RestoreCommitInput,
|
||||
Project,
|
||||
ProjectCommit,
|
||||
ProjectState,
|
||||
ProjectSubmission,
|
||||
} from "@/uhm/types/projects";
|
||||
|
||||
// Projects (API cũ) => Projects (API mới)
|
||||
|
||||
export async function fetchProjects(): Promise<Project[]> {
|
||||
// /users/current/project requires JWT.
|
||||
return requestJson<Project[]>(API_ENDPOINTS.currentUserProjects);
|
||||
}
|
||||
|
||||
export async function createProject(input: CreateProjectInput): Promise<Project> {
|
||||
// POST /projects
|
||||
return requestJson<Project>(API_ENDPOINTS.projects, jsonRequestInit("POST", input));
|
||||
}
|
||||
|
||||
export async function openSectionEditor(projectId: string): Promise<EditorLoadResponse> {
|
||||
// API mới không có endpoint "editor". FE tự load:
|
||||
// 1) Project details
|
||||
// 2) Project commits (to get snapshot_json of latest commit)
|
||||
const project = await requestJson<Project>(`${API_ENDPOINTS.projects}/${encodeURIComponent(projectId)}`);
|
||||
|
||||
const pending = (project.submissions || []).find((s) => s?.status === "PENDING") || null;
|
||||
if (pending) {
|
||||
// BE rule: pending submission blocks further editing/submitting until deleted/reviewed.
|
||||
// We surface a typed error so UI can offer "delete to unlock".
|
||||
throw new ApiError(
|
||||
"Project has a pending submission",
|
||||
409,
|
||||
JSON.stringify({ pending_submission_id: pending.id })
|
||||
);
|
||||
}
|
||||
|
||||
const commits = await fetchProjectCommits(projectId);
|
||||
|
||||
const headCommitId = project.latest_commit_id ?? null;
|
||||
const headCommit = headCommitId ? commits.find((c) => c.id === headCommitId) || null : null;
|
||||
const snapshot = headCommit?.snapshot_json ?? null;
|
||||
|
||||
const state: ProjectState = {
|
||||
status: project.project_status || "ACTIVE",
|
||||
head_commit_id: headCommitId,
|
||||
locked_by: project.locked_by ?? null,
|
||||
};
|
||||
|
||||
return {
|
||||
project: project,
|
||||
state,
|
||||
commit: headCommit,
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createProjectCommit(
|
||||
projectId: string,
|
||||
input: CreateCommitInput
|
||||
): Promise<{ commit: ProjectCommit; state: ProjectState }> {
|
||||
// POST /projects/{id}/commits
|
||||
const snapshot = toApiEditorSnapshot(input.snapshot);
|
||||
const commit = await requestJson<ProjectCommit>(
|
||||
`${API_ENDPOINTS.projects}/${encodeURIComponent(projectId)}/commits`,
|
||||
jsonRequestInit("POST", {
|
||||
snapshot_json: snapshot,
|
||||
edit_summary: input.edit_summary,
|
||||
})
|
||||
);
|
||||
|
||||
// Refresh project state (latest_commit_id may have moved).
|
||||
const project = await requestJson<Project>(`${API_ENDPOINTS.projects}/${encodeURIComponent(projectId)}`);
|
||||
const state: ProjectState = {
|
||||
status: project.project_status || "ACTIVE",
|
||||
head_commit_id: project.latest_commit_id ?? null,
|
||||
locked_by: project.locked_by ?? null,
|
||||
};
|
||||
|
||||
return { commit, state };
|
||||
}
|
||||
|
||||
export async function fetchProjectCommits(projectId: string): Promise<ProjectCommit[]> {
|
||||
return requestJson<ProjectCommit[]>(`${API_ENDPOINTS.projects}/${encodeURIComponent(projectId)}/commits`);
|
||||
}
|
||||
|
||||
export async function restoreProjectCommit(
|
||||
projectId: string,
|
||||
input: RestoreCommitInput
|
||||
): Promise<{ commit: ProjectCommit | null; state: ProjectState }> {
|
||||
// POST /projects/{id}/commits/restore
|
||||
await requestJson(
|
||||
`${API_ENDPOINTS.projects}/${encodeURIComponent(projectId)}/commits/restore`,
|
||||
jsonRequestInit("POST", { commit_id: input.commit_id })
|
||||
);
|
||||
|
||||
// Reload commits + project to determine new head commit.
|
||||
const project = await requestJson<Project>(`${API_ENDPOINTS.projects}/${encodeURIComponent(projectId)}`);
|
||||
const commits = await fetchProjectCommits(projectId);
|
||||
const headCommitId = project.latest_commit_id ?? null;
|
||||
const headCommit = headCommitId ? commits.find((c) => c.id === headCommitId) || null : null;
|
||||
|
||||
const state: ProjectState = {
|
||||
status: project.project_status || "ACTIVE",
|
||||
head_commit_id: headCommitId,
|
||||
locked_by: project.locked_by ?? null,
|
||||
};
|
||||
|
||||
return { commit: headCommit, state };
|
||||
}
|
||||
|
||||
export async function submitSection(projectId: string, content: string): Promise<ProjectSubmission> {
|
||||
// Submit latest commit of project
|
||||
const project = await requestJson<Project>(`${API_ENDPOINTS.projects}/${encodeURIComponent(projectId)}`);
|
||||
const commitId = project.latest_commit_id;
|
||||
if (!commitId) {
|
||||
throw new Error("Project has no latest commit to submit");
|
||||
}
|
||||
|
||||
return requestJson<ProjectSubmission>(
|
||||
API_ENDPOINTS.submissions,
|
||||
jsonRequestInit("POST", {
|
||||
project_id: projectId,
|
||||
commit_id: commitId,
|
||||
content: content,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteSubmission(submissionId: string): Promise<unknown> {
|
||||
return requestJson(
|
||||
`${API_ENDPOINTS.submissions}/${encodeURIComponent(submissionId)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
}
|
||||
|
||||
// Convenience for runtime logs/debug: expose effective base.
|
||||
export const EFFECTIVE_API_BASE_URL = API_BASE_URL;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { API_ENDPOINTS } from "@/uhm/api/config";
|
||||
import { requestJson } from "@/uhm/api/http";
|
||||
|
||||
export type TileMetadata = Record<string, string>;
|
||||
|
||||
export function getVectorTileTemplateUrl(): string {
|
||||
return API_ENDPOINTS.vectorTiles;
|
||||
}
|
||||
|
||||
export function getRasterTileTemplateUrl(): string {
|
||||
return API_ENDPOINTS.rasterTiles;
|
||||
}
|
||||
|
||||
export async function fetchVectorTilesMetadata(): Promise<TileMetadata> {
|
||||
return requestJson<TileMetadata>(API_ENDPOINTS.vectorTilesMetadata);
|
||||
}
|
||||
|
||||
export async function fetchRasterTilesMetadata(): Promise<TileMetadata> {
|
||||
return requestJson<TileMetadata>(API_ENDPOINTS.rasterTilesMetadata);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import api from "@/config/config";
|
||||
import { API_ENDPOINTS } from "@/uhm/api/config";
|
||||
import { ApiError, requestJson } from "@/uhm/api/http";
|
||||
|
||||
export type Wiki = {
|
||||
id: string;
|
||||
project_id?: string;
|
||||
title?: string;
|
||||
slug?: string | null;
|
||||
content?: string;
|
||||
is_deleted?: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
content_sample?:{
|
||||
created_at?: string;
|
||||
content?: string;
|
||||
id?: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export async function searchWikisByTitle(title: string, options?: { limit?: number; cursor?: string; entityId?: string }): Promise<Wiki[]> {
|
||||
const keyword = title.trim();
|
||||
const params = new URLSearchParams({ title: keyword });
|
||||
if (options?.limit && Number.isFinite(options.limit)) params.set("limit", String(Math.trunc(options.limit)));
|
||||
if (options?.cursor) params.set("cursor", options.cursor);
|
||||
if (options?.entityId) params.set("entity_id", options.entityId);
|
||||
|
||||
return requestJson<Wiki[]>(`${API_ENDPOINTS.wikis}?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function fetchWikiById(id: string): Promise<Wiki> {
|
||||
const wikiId = String(id || "").trim();
|
||||
if (!wikiId) throw new Error("Missing wiki id");
|
||||
return requestJson<Wiki>(`${API_ENDPOINTS.wikis}/${encodeURIComponent(wikiId)}`);
|
||||
}
|
||||
|
||||
export async function fetchWikiBySlug(slug: string): Promise<Wiki | null> {
|
||||
const value = String(slug || "").trim();
|
||||
if (!value.length) return null;
|
||||
try {
|
||||
return await requestJson<Wiki>(`${API_ENDPOINTS.wikis}/slug/${encodeURIComponent(value)}`);
|
||||
} catch (err) {
|
||||
// Treat "not found" as an empty result for search UX.
|
||||
if (err instanceof ApiError && err.status === 404) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkWikiSlugExists(slug: string): Promise<boolean> {
|
||||
const value = String(slug || "").trim();
|
||||
if (!value.length) return false;
|
||||
|
||||
const params = new URLSearchParams({ slug: value });
|
||||
const url = `${API_ENDPOINTS.wikis}/slug/exists?${params.toString()}`;
|
||||
const payload = await requestJson<unknown>(url);
|
||||
|
||||
if (typeof payload === "boolean") return payload;
|
||||
if (payload && typeof payload === "object") {
|
||||
const source = payload as Record<string, unknown>;
|
||||
if (typeof source.exists === "boolean") return source.exists;
|
||||
if (typeof source.exists === "number") return source.exists !== 0;
|
||||
if (typeof source.is_exists === "boolean") return source.is_exists;
|
||||
if (typeof source.is_exists === "number") return source.is_exists !== 0;
|
||||
}
|
||||
|
||||
// Be conservative: unknown payload shape, treat as "exists" to prevent creating conflicting slugs.
|
||||
return true;
|
||||
}
|
||||
|
||||
export const getContentByVersionWikiId = async (id: string) => {
|
||||
const response = await api.get(API_ENDPOINTS.wikiContent(id));
|
||||
return response?.data;
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { UndoAction } from "@/uhm/lib/editor/state/useEditorState";
|
||||
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
|
||||
|
||||
import { ProjectPanel } from "./editor/ProjectPanel";
|
||||
import { ToolsPanel } from "./editor/ToolsPanel";
|
||||
import { CommitPanel } from "./editor/CommitPanel";
|
||||
import { CommitHistoryPanel } from "./editor/CommitHistoryPanel";
|
||||
import { UndoListPanel } from "./editor/UndoListPanel";
|
||||
import { SubmitModal } from "./editor/SubmitModal";
|
||||
|
||||
type Props = {
|
||||
mode: EditorMode;
|
||||
setMode: (mode: EditorMode) => void;
|
||||
entityStatus?: string | null;
|
||||
onUndo: () => void;
|
||||
onCommit: () => void;
|
||||
onSubmit: (content: string) => void;
|
||||
onRestoreCommit: (commitId: string) => void;
|
||||
isSaving: boolean;
|
||||
isSubmitting: boolean;
|
||||
sectionTitle: string;
|
||||
projectStatus: string;
|
||||
commitTitle: string;
|
||||
onCommitTitleChange: (title: string) => void;
|
||||
commitCount: number;
|
||||
hasHeadCommit: boolean;
|
||||
headCommitId: string | null;
|
||||
latestCommitLabel: string | null;
|
||||
commits: Array<{
|
||||
id: string;
|
||||
created_at?: string;
|
||||
edit_summary: string;
|
||||
user_id: string;
|
||||
}>;
|
||||
changesCount: number;
|
||||
undoStack: UndoAction[];
|
||||
width?: number;
|
||||
};
|
||||
|
||||
export default function Editor({
|
||||
mode,
|
||||
setMode,
|
||||
entityStatus,
|
||||
onUndo,
|
||||
onCommit,
|
||||
onSubmit,
|
||||
onRestoreCommit,
|
||||
isSaving,
|
||||
isSubmitting,
|
||||
sectionTitle,
|
||||
projectStatus,
|
||||
commitTitle,
|
||||
onCommitTitleChange,
|
||||
commitCount,
|
||||
hasHeadCommit,
|
||||
headCommitId,
|
||||
latestCommitLabel,
|
||||
commits,
|
||||
changesCount,
|
||||
undoStack,
|
||||
width = 280,
|
||||
}: Props) {
|
||||
const [isSubmitModalOpen, setIsSubmitModalOpen] = useState(false);
|
||||
const [submitContent, setSubmitContent] = useState("");
|
||||
|
||||
const handleOpenSubmitModal = () => {
|
||||
setSubmitContent("");
|
||||
setIsSubmitModalOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmSubmit = () => {
|
||||
setIsSubmitModalOpen(false);
|
||||
onSubmit(submitContent);
|
||||
};
|
||||
|
||||
const handleCancelSubmit = () => {
|
||||
setIsSubmitModalOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width,
|
||||
height: "100vh",
|
||||
overflowY: "auto",
|
||||
background: "#0b1220",
|
||||
color: "white",
|
||||
padding: "12px 12px 20px",
|
||||
borderRight: "1px solid #1f2937",
|
||||
}}
|
||||
>
|
||||
<div style={{ position: "sticky", top: 0, zIndex: 5, background: "#0b1220", paddingBottom: 10 }}>
|
||||
|
||||
<ProjectPanel
|
||||
sectionTitle={sectionTitle}
|
||||
projectStatus={projectStatus}
|
||||
commitCount={commitCount}
|
||||
latestCommitLabel={latestCommitLabel}
|
||||
/>
|
||||
|
||||
<ToolsPanel
|
||||
mode={mode}
|
||||
setMode={setMode}
|
||||
onUndo={onUndo}
|
||||
/>
|
||||
|
||||
{entityStatus ? (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 10,
|
||||
padding: "10px",
|
||||
background: "#111827",
|
||||
borderRadius: 8,
|
||||
border: "1px solid #7f1d1d",
|
||||
color: "#fecaca",
|
||||
fontSize: 12,
|
||||
overflowWrap: "anywhere",
|
||||
}}
|
||||
>
|
||||
{entityStatus}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<CommitPanel
|
||||
commitTitle={commitTitle}
|
||||
onCommitTitleChange={onCommitTitleChange}
|
||||
isSaving={isSaving}
|
||||
isSubmitting={isSubmitting}
|
||||
changesCount={changesCount}
|
||||
onCommit={onCommit}
|
||||
hasHeadCommit={hasHeadCommit}
|
||||
handleOpenSubmitModal={handleOpenSubmitModal}
|
||||
/>
|
||||
|
||||
<CommitHistoryPanel
|
||||
commits={commits}
|
||||
headCommitId={headCommitId}
|
||||
onRestoreCommit={onRestoreCommit}
|
||||
isSaving={isSaving}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
|
||||
<UndoListPanel undoStack={undoStack} />
|
||||
|
||||
<SubmitModal
|
||||
isSubmitModalOpen={isSubmitModalOpen}
|
||||
submitContent={submitContent}
|
||||
setSubmitContent={setSubmitContent}
|
||||
handleCancelSubmit={handleCancelSubmit}
|
||||
handleConfirmSubmit={handleConfirmSubmit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
"use client";
|
||||
|
||||
import { type CSSProperties, useEffect, useRef } from "react";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
|
||||
import { Feature, FeatureCollection, Geometry } from "@/uhm/lib/editor/state/useEditorState";
|
||||
import { BackgroundLayerVisibility } from "@/uhm/lib/map/styles/backgroundLayers";
|
||||
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
|
||||
|
||||
import { useMapInstance } from "./map/useMapInstance";
|
||||
import { setupMapLayers } from "./map/useMapLayers";
|
||||
import { useMapInteraction } from "./map/useMapInteraction";
|
||||
import { useMapSync } from "./map/useMapSync";
|
||||
|
||||
export type MapHoverPayload = {
|
||||
featureId: string | number;
|
||||
feature: Feature | null;
|
||||
point: { x: number; y: number };
|
||||
lngLat: { lng: number; lat: number };
|
||||
};
|
||||
|
||||
type MapProps = {
|
||||
mode: EditorMode;
|
||||
draft: FeatureCollection;
|
||||
backgroundVisibility: BackgroundLayerVisibility;
|
||||
geometryVisibility?: Record<string, boolean>;
|
||||
selectedFeatureIds: (string | number)[];
|
||||
onSelectFeatureIds: (ids: (string | number)[]) => void;
|
||||
onSetMode?: (mode: EditorMode, featureId?: string | number) => void;
|
||||
labelContextDraft?: FeatureCollection;
|
||||
onCreateFeature?: (feature: FeatureCollection["features"][number]) => void;
|
||||
onDeleteFeature?: (id: string | number) => void;
|
||||
onUpdateFeature?: (id: string | number, geometry: Geometry) => void;
|
||||
allowGeometryEditing?: boolean;
|
||||
respectBindingFilter?: boolean;
|
||||
height?: CSSProperties["height"];
|
||||
fitToDraftBounds?: boolean;
|
||||
fitBoundsKey?: string | number | null;
|
||||
onHoverFeatureChange?: ((payload: MapHoverPayload | null) => void) | undefined;
|
||||
highlightFeatures?: FeatureCollection | null;
|
||||
focusFeatureCollection?: FeatureCollection | null;
|
||||
focusRequestKey?: string | number | null;
|
||||
focusPadding?: number | import("maplibre-gl").PaddingOptions;
|
||||
hideOutside?: boolean;
|
||||
onToggleHideOutside?: () => void;
|
||||
};
|
||||
|
||||
export default function Map({
|
||||
mode,
|
||||
onSetMode,
|
||||
draft,
|
||||
backgroundVisibility,
|
||||
geometryVisibility,
|
||||
selectedFeatureIds,
|
||||
onSelectFeatureIds,
|
||||
labelContextDraft,
|
||||
onCreateFeature,
|
||||
onDeleteFeature,
|
||||
onUpdateFeature,
|
||||
allowGeometryEditing = true,
|
||||
respectBindingFilter = true,
|
||||
height = "100vh",
|
||||
fitToDraftBounds = false,
|
||||
fitBoundsKey = null,
|
||||
onHoverFeatureChange,
|
||||
highlightFeatures = null,
|
||||
focusFeatureCollection = null,
|
||||
focusRequestKey = null,
|
||||
focusPadding,
|
||||
hideOutside = false,
|
||||
onToggleHideOutside,
|
||||
}: MapProps) {
|
||||
const modeRef = useRef<MapProps["mode"]>(mode);
|
||||
const draftRef = useRef<FeatureCollection>(draft);
|
||||
const onSelectFeatureIdsRef = useRef(onSelectFeatureIds);
|
||||
const onSetModeRef = useRef(onSetMode);
|
||||
const onHoverFeatureChangeRef = useRef<MapProps["onHoverFeatureChange"]>(onHoverFeatureChange);
|
||||
const onCreateRef = useRef<MapProps["onCreateFeature"]>(onCreateFeature);
|
||||
const onDeleteRef = useRef<MapProps["onDeleteFeature"]>(onDeleteFeature);
|
||||
const onUpdateRef = useRef<MapProps["onUpdateFeature"]>(onUpdateFeature);
|
||||
|
||||
useEffect(() => { modeRef.current = mode; }, [mode]);
|
||||
useEffect(() => { draftRef.current = draft; }, [draft]);
|
||||
useEffect(() => { onSelectFeatureIdsRef.current = onSelectFeatureIds; }, [onSelectFeatureIds]);
|
||||
useEffect(() => { onSetModeRef.current = onSetMode; }, [onSetMode]);
|
||||
useEffect(() => { onHoverFeatureChangeRef.current = onHoverFeatureChange; }, [onHoverFeatureChange]);
|
||||
useEffect(() => { onCreateRef.current = onCreateFeature; }, [onCreateFeature]);
|
||||
useEffect(() => { onDeleteRef.current = onDeleteFeature; }, [onDeleteFeature]);
|
||||
useEffect(() => { onUpdateRef.current = onUpdateFeature; }, [onUpdateFeature]);
|
||||
|
||||
const {
|
||||
mapRef,
|
||||
containerRef,
|
||||
fatalInitError,
|
||||
zoomLevel,
|
||||
zoomBounds,
|
||||
isGlobeProjection,
|
||||
setIsGlobeProjection,
|
||||
isMapLoaded,
|
||||
geolocationCenteredRef,
|
||||
handleZoomByStep,
|
||||
handleZoomSliderChange,
|
||||
} = useMapInstance();
|
||||
|
||||
const {
|
||||
editingEngineRef,
|
||||
setupMapInteractions,
|
||||
cleanupMapInteractions,
|
||||
} = useMapInteraction({
|
||||
mapRef,
|
||||
mode,
|
||||
modeRef,
|
||||
draftRef,
|
||||
allowGeometryEditing,
|
||||
selectedFeatureIds,
|
||||
onSelectFeatureIdsRef,
|
||||
onSetModeRef,
|
||||
onCreateRef,
|
||||
onDeleteRef,
|
||||
onUpdateRef,
|
||||
onHoverFeatureChangeRef,
|
||||
});
|
||||
|
||||
const {
|
||||
applyDraftToMap,
|
||||
applyHighlightToMap,
|
||||
tryCenterToUserLocation,
|
||||
} = useMapSync({
|
||||
mapRef,
|
||||
draft,
|
||||
labelContextDraft,
|
||||
backgroundVisibility,
|
||||
geometryVisibility,
|
||||
selectedFeatureIds,
|
||||
respectBindingFilter,
|
||||
fitToDraftBounds,
|
||||
fitBoundsKey,
|
||||
highlightFeatures,
|
||||
focusFeatureCollection,
|
||||
focusRequestKey,
|
||||
focusPadding,
|
||||
allowGeometryEditing,
|
||||
editingEngineRef,
|
||||
geolocationCenteredRef,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !isMapLoaded) return;
|
||||
|
||||
setupMapLayers(map, backgroundVisibility, highlightFeatures, applyHighlightToMap);
|
||||
setupMapInteractions(map);
|
||||
applyDraftToMap(draftRef.current);
|
||||
tryCenterToUserLocation();
|
||||
|
||||
return () => {
|
||||
cleanupMapInteractions();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isMapLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (map && isMapLoaded) {
|
||||
// Trigger resize after a short delay to allow layout to settle
|
||||
setTimeout(() => map.resize(), 100);
|
||||
}
|
||||
}, [mode, isMapLoaded]);
|
||||
|
||||
return (
|
||||
<div style={{ width: "100%", height, position: "relative" }}>
|
||||
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
|
||||
|
||||
{fatalInitError ? (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 50,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
padding: "24px",
|
||||
background: "rgba(2, 6, 23, 0.78)",
|
||||
color: "#e2e8f0",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "680px",
|
||||
border: "1px solid rgba(148, 163, 184, 0.3)",
|
||||
borderRadius: "12px",
|
||||
background: "rgba(15, 23, 42, 0.92)",
|
||||
padding: "14px 16px",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 800, marginBottom: "6px" }}>
|
||||
Map khong khoi tao duoc
|
||||
</div>
|
||||
<div style={{ color: "#cbd5e1", fontSize: "13px" }}>
|
||||
{fatalInitError}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "10px",
|
||||
left: "16px",
|
||||
right: "16px",
|
||||
zIndex: 12,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "650px",
|
||||
margin: "0 auto",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
background: "rgba(15, 23, 42, 0.88)",
|
||||
border: "1px solid rgba(148, 163, 184, 0.38)",
|
||||
borderRadius: "999px",
|
||||
padding: "8px 12px",
|
||||
color: "#e2e8f0",
|
||||
backdropFilter: "blur(3px)",
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
>
|
||||
{mode === "replay" && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSetMode?.("select")}
|
||||
style={{
|
||||
...zoomButtonStyle,
|
||||
width: "auto",
|
||||
padding: "0 12px",
|
||||
fontSize: "12px",
|
||||
fontWeight: 700,
|
||||
background: "#7f1d1d",
|
||||
color: "white",
|
||||
border: "1px solid #991b1b",
|
||||
borderRadius: "999px",
|
||||
cursor: "pointer",
|
||||
marginRight: "4px",
|
||||
}}
|
||||
>
|
||||
Thoát Replay Edit
|
||||
</button>
|
||||
|
||||
<div
|
||||
onClick={onToggleHideOutside}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
cursor: "pointer",
|
||||
marginRight: "8px",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "12px", fontWeight: 700, color: hideOutside ? "#fb7185" : "#94a3b8" }}>
|
||||
Hide Outside
|
||||
</span>
|
||||
<div
|
||||
style={{
|
||||
width: "32px",
|
||||
height: "18px",
|
||||
borderRadius: "10px",
|
||||
background: hideOutside ? "#e11d48" : "#334155",
|
||||
position: "relative",
|
||||
transition: "background 0.2s",
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "2px",
|
||||
left: hideOutside ? "16px" : "2px",
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
borderRadius: "50%",
|
||||
background: "white",
|
||||
transition: "left 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
boxShadow: "0 1px 2px rgba(0,0,0,0.3)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ width: "1px", height: "20px", background: "rgba(148, 163, 184, 0.3)", marginRight: "4px" }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<label
|
||||
title={
|
||||
isGlobeProjection
|
||||
? "Dang o che do hinh cau (globe)"
|
||||
: "Dang o che do trai phang (flat)"
|
||||
}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
padding: "0 6px",
|
||||
userSelect: "none",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isGlobeProjection}
|
||||
onChange={(e) => setIsGlobeProjection(e.target.checked)}
|
||||
aria-label="Toggle globe projection"
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "42px",
|
||||
height: "22px",
|
||||
borderRadius: "999px",
|
||||
border: "1px solid rgba(148, 163, 184, 0.45)",
|
||||
background: isGlobeProjection
|
||||
? "rgba(56, 189, 248, 0.30)"
|
||||
: "rgba(148, 163, 184, 0.18)",
|
||||
boxShadow: "inset 0 0 0 1px rgba(15, 23, 42, 0.35)",
|
||||
transition: "background 160ms ease",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "2px",
|
||||
left: isGlobeProjection ? "22px" : "2px",
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
borderRadius: "999px",
|
||||
background: isGlobeProjection ? "#38bdf8" : "#e2e8f0",
|
||||
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.35)",
|
||||
transition: "left 160ms ease, background 160ms ease",
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: isGlobeProjection ? "#7dd3fc" : "#cbd5e1",
|
||||
fontWeight: 700,
|
||||
minWidth: "40px",
|
||||
}}
|
||||
>
|
||||
{isGlobeProjection ? "Globe" : "Flat"}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleZoomByStep(-0.8)}
|
||||
style={zoomButtonStyle}
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min={zoomBounds.min}
|
||||
max={zoomBounds.max}
|
||||
step={0.1}
|
||||
value={zoomLevel}
|
||||
onChange={(event) => handleZoomSliderChange(Number(event.target.value))}
|
||||
style={{
|
||||
flex: 1,
|
||||
accentColor: "#38bdf8",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
aria-label="Map zoom"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleZoomByStep(0.8)}
|
||||
style={zoomButtonStyle}
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
|
||||
<div
|
||||
style={{
|
||||
minWidth: "56px",
|
||||
textAlign: "right",
|
||||
fontSize: "12px",
|
||||
color: "#cbd5e1",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{zoomLevel.toFixed(1)}x
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const zoomButtonStyle: React.CSSProperties = {
|
||||
width: "28px",
|
||||
height: "28px",
|
||||
borderRadius: "999px",
|
||||
border: "1px solid #334155",
|
||||
background: "#1e293b",
|
||||
color: "#f8fafc",
|
||||
fontSize: "18px",
|
||||
lineHeight: "1",
|
||||
cursor: "pointer",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import {
|
||||
BACKGROUND_LAYER_OPTIONS,
|
||||
BackgroundLayerId,
|
||||
BackgroundLayerVisibility,
|
||||
} from "@/uhm/lib/map/styles/backgroundLayers";
|
||||
import { GEO_TYPE_KEYS } from "@/uhm/lib/map/geo/geoTypeMap";
|
||||
|
||||
type Props = {
|
||||
visibility: BackgroundLayerVisibility;
|
||||
onToggleLayer: (id: BackgroundLayerId) => void;
|
||||
onShowAll: () => void;
|
||||
onHideAll: () => void;
|
||||
geometryVisibility?: Record<string, boolean>;
|
||||
onToggleGeometryType?: (typeKey: string) => void;
|
||||
topContent?: ReactNode;
|
||||
width?: number;
|
||||
};
|
||||
|
||||
export default function BackgroundLayersPanel({
|
||||
visibility,
|
||||
onToggleLayer,
|
||||
onShowAll,
|
||||
onHideAll,
|
||||
geometryVisibility,
|
||||
onToggleGeometryType,
|
||||
topContent,
|
||||
width = 240,
|
||||
}: Props) {
|
||||
return (
|
||||
<aside
|
||||
style={{
|
||||
width,
|
||||
background: "#111827",
|
||||
color: "#e5e7eb",
|
||||
borderLeft: "1px solid #1f2937",
|
||||
padding: "12px",
|
||||
height: "100vh",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{topContent ? <div style={{ marginBottom: "12px" }}>{topContent}</div> : null}
|
||||
|
||||
<h3 style={{ margin: 0, marginBottom: "10px" }}>Map Layers</h3>
|
||||
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "10px 12px" }}>
|
||||
{BACKGROUND_LAYER_OPTIONS.map((layer) => {
|
||||
const on = Boolean(visibility[layer.id]);
|
||||
return (
|
||||
<button
|
||||
key={layer.id}
|
||||
type="button"
|
||||
onClick={() => onToggleLayer(layer.id)}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
cursor: "pointer",
|
||||
color: on ? "#22c55e" : "#e5e7eb",
|
||||
textDecorationLine: on ? "none" : "line-through",
|
||||
textDecorationThickness: on ? undefined : "2px",
|
||||
textDecorationColor: on ? undefined : "rgba(148, 163, 184, 0.7)",
|
||||
fontSize: 13,
|
||||
fontWeight: 750,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
title={on ? "On" : "Off"}
|
||||
>
|
||||
{layer.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{geometryVisibility && onToggleGeometryType ? (
|
||||
<>
|
||||
<div style={{ height: 1, background: "#1f2937", margin: "12px 0" }} />
|
||||
<div style={{ margin: 0, marginBottom: 10, fontWeight: 800, fontSize: 13, color: "#e5e7eb" }}>
|
||||
Geometries
|
||||
</div>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "10px 12px" }}>
|
||||
{GEO_TYPE_KEYS.map((typeKey) => {
|
||||
const on = geometryVisibility[typeKey] !== false;
|
||||
return (
|
||||
<button
|
||||
key={typeKey}
|
||||
type="button"
|
||||
onClick={() => onToggleGeometryType(typeKey)}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
cursor: "pointer",
|
||||
color: on ? "#22c55e" : "#e5e7eb",
|
||||
textDecorationLine: on ? "none" : "line-through",
|
||||
textDecorationThickness: on ? undefined : "2px",
|
||||
textDecorationColor: on ? undefined : "rgba(148, 163, 184, 0.7)",
|
||||
fontSize: 13,
|
||||
fontWeight: 750,
|
||||
whiteSpace: "nowrap",
|
||||
textTransform: "capitalize",
|
||||
}}
|
||||
title={on ? "On" : "Off"}
|
||||
>
|
||||
{typeKey.replaceAll("_", " ")}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Panel } from "./Panel";
|
||||
|
||||
type Commit = {
|
||||
id: string;
|
||||
created_at?: string;
|
||||
edit_summary: string;
|
||||
user_id: string;
|
||||
};
|
||||
|
||||
type CommitHistoryPanelProps = {
|
||||
commits: Commit[];
|
||||
headCommitId: string | null;
|
||||
onRestoreCommit: (commitId: string) => void;
|
||||
isSaving: boolean;
|
||||
isSubmitting: boolean;
|
||||
};
|
||||
|
||||
export function CommitHistoryPanel({
|
||||
commits,
|
||||
headCommitId,
|
||||
onRestoreCommit,
|
||||
isSaving,
|
||||
isSubmitting,
|
||||
}: CommitHistoryPanelProps) {
|
||||
const formatCommitTitle = (commit: Commit) =>
|
||||
commit.edit_summary?.trim() || `Commit ${commit.id.slice(0, 8)}`;
|
||||
|
||||
return (
|
||||
<Panel title="Commit History" badge={String(commits.length)} defaultOpen={false}>
|
||||
{commits.length === 0 ? (
|
||||
<div style={{ color: "#64748b", fontSize: 12 }}>Chưa có commit</div>
|
||||
) : (
|
||||
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: 12 }}>
|
||||
{commits.slice(0, 8).map((commit) => {
|
||||
const isHead = Boolean(headCommitId && commit.id === headCommitId);
|
||||
return (
|
||||
<li
|
||||
key={commit.id}
|
||||
style={{
|
||||
padding: "8px 0",
|
||||
borderBottom: "1px solid #1f2937",
|
||||
color: "#e2e8f0",
|
||||
display: "flex",
|
||||
flexDirection: "row"
|
||||
}}
|
||||
>
|
||||
<div style={{flex:1}}>
|
||||
<div
|
||||
title={formatCommitTitle(commit)}
|
||||
style={{
|
||||
fontWeight: 750,
|
||||
color: "#f8fafc",
|
||||
overflowWrap: "anywhere",
|
||||
}}
|
||||
>
|
||||
{formatCommitTitle(commit)}
|
||||
</div>
|
||||
<div style={{ marginTop: 3, color: "#94a3b8" }}>
|
||||
{commit.created_at ? new Date(commit.created_at).toLocaleString() : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
style={{
|
||||
marginTop: 6,
|
||||
padding: "6px 8px",
|
||||
borderRadius: 6,
|
||||
border: "1px solid #334155",
|
||||
background: isHead ? "#0b1220" : "#334155",
|
||||
color: "white",
|
||||
cursor: isSaving || isSubmitting || isHead ? "not-allowed" : "pointer",
|
||||
opacity: isHead ? 0.65 : 1,
|
||||
fontWeight: 800,
|
||||
fontSize: 12,
|
||||
}}
|
||||
onClick={() => onRestoreCommit(commit.id)}
|
||||
disabled={isSaving || isSubmitting || isHead}
|
||||
title={isHead ? "Đang là head commit" : "Restore snapshot từ commit này (FE-only)"}
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Panel } from "./Panel";
|
||||
|
||||
type CommitPanelProps = {
|
||||
commitTitle: string;
|
||||
onCommitTitleChange: (title: string) => void;
|
||||
isSaving: boolean;
|
||||
isSubmitting: boolean;
|
||||
changesCount: number;
|
||||
onCommit: () => void;
|
||||
hasHeadCommit: boolean;
|
||||
handleOpenSubmitModal: () => void;
|
||||
};
|
||||
|
||||
export function CommitPanel({
|
||||
commitTitle,
|
||||
onCommitTitleChange,
|
||||
isSaving,
|
||||
isSubmitting,
|
||||
changesCount,
|
||||
onCommit,
|
||||
hasHeadCommit,
|
||||
handleOpenSubmitModal,
|
||||
}: CommitPanelProps) {
|
||||
const primaryButtonStyle = {
|
||||
width: "100%",
|
||||
padding: "8px 10px",
|
||||
borderRadius: 6,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontWeight: 850,
|
||||
fontSize: 12,
|
||||
} as const;
|
||||
|
||||
const textInputStyle = {
|
||||
width: "100%",
|
||||
marginTop: 0,
|
||||
padding: "8px 10px",
|
||||
borderRadius: 6,
|
||||
border: "1px solid #334155",
|
||||
background: "#0b1220",
|
||||
color: "white",
|
||||
boxSizing: "border-box",
|
||||
fontSize: 13,
|
||||
outline: "none",
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<Panel title="Commit" defaultOpen>
|
||||
<input
|
||||
value={commitTitle}
|
||||
onChange={(event) => onCommitTitleChange(event.target.value)}
|
||||
placeholder="Edit Summary (Commit Title)"
|
||||
disabled={isSaving || isSubmitting}
|
||||
style={textInputStyle}
|
||||
/>
|
||||
<button
|
||||
style={{
|
||||
...primaryButtonStyle,
|
||||
marginTop: 8,
|
||||
background: isSaving || isSubmitting || changesCount <= 0 ? "#475569" : "#0f766e",
|
||||
cursor: isSaving || isSubmitting || changesCount <= 0 ? "not-allowed" : "pointer",
|
||||
opacity: changesCount <= 0 ? 0.75 : 1,
|
||||
}}
|
||||
onClick={onCommit}
|
||||
disabled={isSaving || isSubmitting || changesCount <= 0}
|
||||
title={changesCount <= 0 ? "Khong co thay doi de commit" : undefined}
|
||||
>
|
||||
Commit ({changesCount})
|
||||
</button>
|
||||
<button
|
||||
style={{
|
||||
...primaryButtonStyle,
|
||||
marginTop: 8,
|
||||
background: isSubmitting || !hasHeadCommit ? "#475569" : "#16a34a",
|
||||
cursor: isSubmitting || !hasHeadCommit ? "not-allowed" : "pointer",
|
||||
opacity: !hasHeadCommit ? 0.6 : 1,
|
||||
}}
|
||||
onClick={handleOpenSubmitModal}
|
||||
disabled={isSubmitting || !hasHeadCommit}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
||||
import NewBadge from "@/uhm/components/editor/NewBadge";
|
||||
|
||||
type EntityChoice = { id: string; name: string; isNew?: boolean };
|
||||
type WikiChoice = { id: string; title: string; isNew?: boolean };
|
||||
type BindingRow = {
|
||||
entityId: string;
|
||||
entityName: string;
|
||||
entityIsNew: boolean;
|
||||
wikiId: string;
|
||||
wikiTitle: string;
|
||||
wikiIsNew: boolean;
|
||||
linkIsNew: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
entities: EntityChoice[];
|
||||
wikis: WikiSnapshot[];
|
||||
links: EntityWikiLinkSnapshot[];
|
||||
setLinks: React.Dispatch<React.SetStateAction<EntityWikiLinkSnapshot[]>>;
|
||||
};
|
||||
|
||||
function wikiTitle(w: WikiSnapshot): string {
|
||||
const t = String(w.title || "").trim();
|
||||
return t.length ? t : "Untitled wiki";
|
||||
}
|
||||
|
||||
export default function EntityWikiBindingsPanel({ entities, wikis, links, setLinks }: Props) {
|
||||
const [activeEntityId, setActiveEntityId] = useState<string>("");
|
||||
const [activeWikiId, setActiveWikiId] = useState<string>("");
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const wikiChoices: WikiChoice[] = useMemo(
|
||||
() =>
|
||||
(wikis || [])
|
||||
.filter((w) => w && typeof w.id === "string" && w.id.trim().length > 0)
|
||||
.map((w) => ({
|
||||
id: w.id,
|
||||
title: wikiTitle(w),
|
||||
isNew: w.source === "inline" && w.operation === "create",
|
||||
})),
|
||||
[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.operation === "delete") continue;
|
||||
set.add(l.wiki_id);
|
||||
}
|
||||
return set;
|
||||
}, [activeEntityId, links]);
|
||||
|
||||
const activeBindingRows = useMemo<BindingRow[]>(() => {
|
||||
const byKey = new Map<string, EntityWikiLinkSnapshot>();
|
||||
for (const link of links || []) {
|
||||
const entityId = String(link?.entity_id || "").trim();
|
||||
const wikiId = String(link?.wiki_id || "").trim();
|
||||
if (!entityId || !wikiId) continue;
|
||||
if (link.operation === "delete") continue;
|
||||
byKey.set(`${entityId}::${wikiId}`, link);
|
||||
}
|
||||
|
||||
const rows = Array.from(byKey.values()).map((link) => {
|
||||
const entityId = String(link.entity_id);
|
||||
const wikiId = String(link.wiki_id);
|
||||
const entity = entityChoices.find((item) => item.id === entityId) || null;
|
||||
const wiki = wikiChoices.find((item) => item.id === wikiId) || null;
|
||||
return {
|
||||
entityId,
|
||||
entityName: entity?.name || entityId,
|
||||
entityIsNew: Boolean(entity?.isNew),
|
||||
wikiId,
|
||||
wikiTitle: wiki?.title || wikiId,
|
||||
wikiIsNew: Boolean(wiki?.isNew),
|
||||
linkIsNew: link.operation === "binding",
|
||||
};
|
||||
});
|
||||
|
||||
rows.sort((a, b) => {
|
||||
if (a.linkIsNew !== b.linkIsNew) return a.linkIsNew ? -1 : 1;
|
||||
const entityCompare = a.entityName.localeCompare(b.entityName);
|
||||
if (entityCompare !== 0) return entityCompare;
|
||||
return a.wikiTitle.localeCompare(b.wikiTitle);
|
||||
});
|
||||
return rows;
|
||||
}, [entityChoices, links, wikiChoices]);
|
||||
|
||||
const toggle = (wikiId: string) => {
|
||||
if (!activeEntityId) return;
|
||||
const id = String(wikiId || "").trim();
|
||||
if (!id) return;
|
||||
|
||||
setLinks((prev) => {
|
||||
const idx = prev.findIndex((l) => l.entity_id === activeEntityId && l.wiki_id === id);
|
||||
// If link exists (reference/binding), unlink by removing the row entirely.
|
||||
if (idx >= 0 && prev[idx]?.operation !== "delete") {
|
||||
return prev.filter((_, i) => i !== idx);
|
||||
}
|
||||
// If link doesn't exist, add as a new binding (create for relation).
|
||||
return [
|
||||
...prev.filter((l) => !(l.entity_id === activeEntityId && l.wiki_id === id)),
|
||||
{ entity_id: activeEntityId, wiki_id: id, operation: "binding" },
|
||||
];
|
||||
});
|
||||
};
|
||||
|
||||
const activeWikiLinked = activeEntityId && activeWikiId ? activeLinks.has(activeWikiId) : false;
|
||||
const activeWikiChoice = activeWikiId ? wikiChoices.find((w) => w.id === activeWikiId) || null : null;
|
||||
const activeEntityChoice = activeEntityId ? entityChoices.find((e) => e.id === activeEntityId) || null : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px",
|
||||
background: "#0b1220",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #1f2937",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
|
||||
<div style={{ fontWeight: 700, fontSize: "14px" }}>Entity ↔ Wiki</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{activeBindingRows.length}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 6,
|
||||
border: "1px solid #334155",
|
||||
background: "#0b1220",
|
||||
color: "#e2e8f0",
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
title={collapsed ? "Mo panel" : "Thu gon panel"}
|
||||
aria-label={collapsed ? "Mo panel Entity Wiki" : "Thu gon panel Entity Wiki"}
|
||||
>
|
||||
{collapsed ? <PlusIcon /> : <MinusIcon />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{collapsed ? null : (
|
||||
<div style={{ marginTop: "10px", display: "grid", gap: "8px" }}>
|
||||
<div>
|
||||
<div style={{ fontSize: "12px", color: "#94a3b8", marginBottom: "6px" }}>Entity</div>
|
||||
<select
|
||||
value={activeEntityId}
|
||||
onChange={(e) => setActiveEntityId(e.target.value)}
|
||||
style={{
|
||||
width: "100%",
|
||||
border: "1px solid #1f2937",
|
||||
background: "#0b1220",
|
||||
color: "#e5e7eb",
|
||||
borderRadius: "6px",
|
||||
padding: "8px 10px",
|
||||
fontSize: "12px",
|
||||
outline: "none",
|
||||
}}
|
||||
>
|
||||
<option value="">Select entity…</option>
|
||||
{entityChoices.map((e) => (
|
||||
<option key={e.id} value={e.id}>
|
||||
{e.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{activeEntityId ? (
|
||||
<ActiveSelectionLabel
|
||||
label={activeEntityChoice?.name || activeEntityId}
|
||||
id={activeEntityId}
|
||||
isNew={Boolean(activeEntityChoice?.isNew)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: "12px", color: "#94a3b8", marginBottom: "6px" }}>Wikis</div>
|
||||
<div style={{ display: "grid", gap: "8px" }}>
|
||||
<select
|
||||
value={activeWikiId}
|
||||
onChange={(e) => setActiveWikiId(e.target.value)}
|
||||
disabled={wikiChoices.length === 0}
|
||||
style={{
|
||||
width: "100%",
|
||||
border: "1px solid #1f2937",
|
||||
background: "#0b1220",
|
||||
color: "#e5e7eb",
|
||||
borderRadius: "6px",
|
||||
padding: "8px 10px",
|
||||
fontSize: "12px",
|
||||
outline: "none",
|
||||
opacity: wikiChoices.length === 0 ? 0.7 : 1,
|
||||
cursor: wikiChoices.length === 0 ? "not-allowed" : "pointer",
|
||||
}}
|
||||
>
|
||||
<option value="">
|
||||
{wikiChoices.length === 0 ? "No wikis available" : "Select wiki…"}
|
||||
</option>
|
||||
{wikiChoices.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{activeWikiChoice ? (
|
||||
<ActiveSelectionLabel
|
||||
label={activeWikiChoice.title}
|
||||
id={activeWikiChoice.id}
|
||||
isNew={Boolean(activeWikiChoice.isNew)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{wikiChoices.length === 0 ? (
|
||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>No wiki in project yet.</div>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!activeEntityId || !activeWikiId}
|
||||
onClick={() => toggle(activeWikiId)}
|
||||
style={{
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
padding: "8px 10px",
|
||||
cursor: !activeEntityId || !activeWikiId ? "not-allowed" : "pointer",
|
||||
background: activeWikiLinked ? "#334155" : "#16a34a",
|
||||
color: "white",
|
||||
fontWeight: 800,
|
||||
fontSize: 12,
|
||||
opacity: !activeEntityId || !activeWikiId ? 0.65 : 1,
|
||||
}}
|
||||
>
|
||||
{activeWikiLinked ? "Unlink wiki" : "Link wiki"}
|
||||
</button>
|
||||
|
||||
{activeWikiChoice ? (
|
||||
<div style={{ fontSize: 12, color: "#94a3b8", overflowWrap: "anywhere" }}>
|
||||
{activeWikiChoice.id}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!activeEntityId ? (
|
||||
<div style={{ fontSize: 12, color: "#94a3b8" }}>Pick an entity to see/link wikis.</div>
|
||||
) : activeLinks.size ? (
|
||||
<div style={{ display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}>
|
||||
<div style={{ fontSize: 12, color: "#94a3b8" }}>Linked wikis ({activeLinks.size})</div>
|
||||
{Array.from(activeLinks).map((id) => {
|
||||
const w = wikiChoices.find((x) => x.id === id) || null;
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
style={{
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #1f2937",
|
||||
background: "#111827",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 8,
|
||||
}}
|
||||
title={id}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
|
||||
<span
|
||||
style={{
|
||||
color: "#e5e7eb",
|
||||
fontSize: 12,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{w?.title || "Untitled wiki"}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ color: "#94a3b8", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{id}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(id)}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "#0b1220",
|
||||
color: "#fecaca",
|
||||
cursor: "pointer",
|
||||
borderRadius: 6,
|
||||
padding: "6px 8px",
|
||||
fontSize: 12,
|
||||
fontWeight: 800,
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
Unlink
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 12, color: "#94a3b8" }}>No wiki linked yet.</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
borderTop: "1px solid #1f2937",
|
||||
paddingTop: 8,
|
||||
display: "grid",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, color: "#94a3b8" }}>
|
||||
All bindings ({activeBindingRows.length})
|
||||
</div>
|
||||
{activeBindingRows.length ? (
|
||||
<div style={{ display: "grid", gap: 6, maxHeight: 260, overflowY: "auto", paddingRight: 4 }}>
|
||||
{activeBindingRows.map((row) => (
|
||||
<div
|
||||
key={`${row.entityId}::${row.wikiId}`}
|
||||
style={{
|
||||
padding: 8,
|
||||
borderRadius: 6,
|
||||
border: row.linkIsNew ? "1px solid rgba(45, 212, 191, 0.55)" : "1px solid #1f2937",
|
||||
background: row.linkIsNew ? "rgba(20, 184, 166, 0.12)" : "#111827",
|
||||
display: "grid",
|
||||
gap: 5,
|
||||
}}
|
||||
title={`${row.entityId} ↔ ${row.wikiId}`}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
|
||||
<span
|
||||
style={{
|
||||
color: "#e5e7eb",
|
||||
fontSize: 12,
|
||||
fontWeight: 800,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{row.entityName}
|
||||
</span>
|
||||
{row.entityIsNew ? <NewBadge title="Entity mới trong phiên này" /> : null}
|
||||
{row.linkIsNew ? <NewBadge title="Binding mới trong phiên này" /> : null}
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
|
||||
<span style={{ color: "#93c5fd", fontSize: 11, flex: "0 0 auto" }}>Wiki</span>
|
||||
<span
|
||||
style={{
|
||||
color: "#cbd5e1",
|
||||
fontSize: 12,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{row.wikiTitle}
|
||||
</span>
|
||||
{row.wikiIsNew ? <NewBadge title="Wiki mới trong phiên này" /> : null}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
color: "#64748b",
|
||||
fontSize: 11,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{row.entityId} ↔ {row.wikiId}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 12, color: "#94a3b8" }}>No entity-wiki binding yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveSelectionLabel({
|
||||
label,
|
||||
id,
|
||||
isNew,
|
||||
}: {
|
||||
label: string;
|
||||
id: string;
|
||||
isNew?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ marginTop: 6, display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
|
||||
<span style={{ color: "#cbd5e1", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{ color: "#64748b", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{id}
|
||||
</span>
|
||||
{isNew ? <NewBadge /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlusIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M12 5v14M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MinusIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, type CSSProperties, type KeyboardEvent } from "react";
|
||||
import NewBadge from "@/uhm/components/editor/NewBadge";
|
||||
|
||||
type GeometryChoice = {
|
||||
id: string;
|
||||
label?: string;
|
||||
isNew?: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
geometries: GeometryChoice[];
|
||||
selectedGeometryId: string | null;
|
||||
selectedGeometryBindingIds: string[];
|
||||
onToggleBindGeometryForSelectedGeometry?: (geometryId: string, nextChecked: boolean) => void;
|
||||
onFocusGeometry?: (geometryId: string) => void;
|
||||
statusText?: string | null;
|
||||
bindingFilterEnabled: boolean;
|
||||
onBindingFilterEnabledChange: (next: boolean) => void;
|
||||
};
|
||||
|
||||
export default function GeometryBindingPanel({
|
||||
geometries,
|
||||
selectedGeometryId,
|
||||
selectedGeometryBindingIds,
|
||||
onToggleBindGeometryForSelectedGeometry,
|
||||
onFocusGeometry,
|
||||
statusText,
|
||||
bindingFilterEnabled,
|
||||
onBindingFilterEnabledChange,
|
||||
}: Props) {
|
||||
const canBindToggle =
|
||||
Boolean(selectedGeometryId) && typeof onToggleBindGeometryForSelectedGeometry === "function";
|
||||
const canFocusGeometry = typeof onFocusGeometry === "function";
|
||||
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const cleaned = (geometries || [])
|
||||
.filter((g) => g && typeof g.id === "string" && g.id.trim().length > 0)
|
||||
.map((g) => ({ id: g.id.trim(), label: (g.label || "").trim(), isNew: Boolean(g.isNew) }));
|
||||
cleaned.sort((a, b) => a.id.localeCompare(b.id));
|
||||
return cleaned;
|
||||
}, [geometries]);
|
||||
|
||||
const bindingSet = useMemo(() => new Set(selectedGeometryBindingIds || []), [selectedGeometryBindingIds]);
|
||||
const selectedGeometry = useMemo(() => {
|
||||
if (!selectedGeometryId) return null;
|
||||
return rows.find((g) => g.id === selectedGeometryId) || null;
|
||||
}, [rows, selectedGeometryId]);
|
||||
const visibleRows = useMemo(() => {
|
||||
return rows
|
||||
.filter((g) => g.id !== selectedGeometryId)
|
||||
.sort((a, b) => {
|
||||
const aBound = bindingSet.has(a.id);
|
||||
const bBound = bindingSet.has(b.id);
|
||||
if (aBound !== bBound) return aBound ? -1 : 1;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
}, [bindingSet, rows, selectedGeometryId]);
|
||||
|
||||
const handleFocusKeyDown = (event: KeyboardEvent<HTMLDivElement>, geometryId: string) => {
|
||||
if (!canFocusGeometry) return;
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
onFocusGeometry?.(geometryId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px",
|
||||
background: "#0b1220",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #1f2937",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: "14px", whiteSpace: "nowrap" }}>Geometry Binding</div>
|
||||
<label
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
cursor: "pointer",
|
||||
userSelect: "none",
|
||||
}}
|
||||
title={bindingFilterEnabled ? "Đang ẩn geo theo binding" : "Đang hiển thị tất cả geo"}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bindingFilterEnabled}
|
||||
onChange={(e) => onBindingFilterEnabledChange(e.target.checked)}
|
||||
style={{ width: 14, height: 14 }}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: "#94a3b8", whiteSpace: "nowrap" }}>Filter</span>
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{rows.length}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 6,
|
||||
border: "1px solid #334155",
|
||||
background: "#0b1220",
|
||||
color: "#e2e8f0",
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
title={collapsed ? "Mở panel" : "Thu gọn panel"}
|
||||
aria-label={collapsed ? "Mở panel Geometry Binding" : "Thu gọn panel Geometry Binding"}
|
||||
>
|
||||
{collapsed ? <PlusIcon /> : <MinusIcon />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{collapsed ? null : selectedGeometry ? (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 10,
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid rgba(59, 130, 246, 0.45)",
|
||||
background: "rgba(37, 99, 235, 0.12)",
|
||||
cursor: canFocusGeometry ? "pointer" : "default",
|
||||
}}
|
||||
title={selectedGeometry.id}
|
||||
role={canFocusGeometry ? "button" : undefined}
|
||||
tabIndex={canFocusGeometry ? 0 : undefined}
|
||||
onClick={() => onFocusGeometry?.(selectedGeometry.id)}
|
||||
onKeyDown={(event) => handleFocusKeyDown(event, selectedGeometry.id)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: "#93c5fd",
|
||||
fontWeight: 900,
|
||||
textTransform: "uppercase",
|
||||
lineHeight: 1,
|
||||
marginBottom: 5,
|
||||
}}
|
||||
>
|
||||
Selected
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "#e5e7eb",
|
||||
fontWeight: 700,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{selectedGeometry.label || selectedGeometry.id}
|
||||
</span>
|
||||
{selectedGeometry.isNew ? <NewBadge /> : null}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 3,
|
||||
fontSize: "11px",
|
||||
color: "#94a3b8",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{selectedGeometry.id}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{collapsed ? null : rows.length ? (
|
||||
<div style={{ marginTop: "10px", display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}>
|
||||
{visibleRows
|
||||
.map((g) => {
|
||||
const isBound = bindingSet.has(g.id);
|
||||
return (
|
||||
<div
|
||||
key={g.id}
|
||||
style={{
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
border: isBound ? "1px solid rgba(20, 184, 166, 0.65)" : "1px solid #1f2937",
|
||||
background: isBound ? "rgba(20, 184, 166, 0.12)" : "transparent",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
cursor: canFocusGeometry ? "pointer" : "default",
|
||||
opacity: canBindToggle ? 1 : 0.75,
|
||||
}}
|
||||
title={g.id}
|
||||
role={canFocusGeometry ? "button" : undefined}
|
||||
tabIndex={canFocusGeometry ? 0 : undefined}
|
||||
onClick={() => onFocusGeometry?.(g.id)}
|
||||
onKeyDown={(event) => handleFocusKeyDown(event, g.id)}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "#e5e7eb",
|
||||
fontWeight: 700,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{g.label || g.id}
|
||||
</span>
|
||||
{isBound ? <span style={boundBadgeStyle}>bound</span> : null}
|
||||
{g.isNew ? <NewBadge /> : null}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
color: "#94a3b8",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{g.id}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canBindToggle ? (
|
||||
<button
|
||||
type="button"
|
||||
title={isBound ? "Unbind from selected geometry" : "Bind to selected geometry"}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleBindGeometryForSelectedGeometry!(g.id, !isBound);
|
||||
}}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 6,
|
||||
border: "1px solid #334155",
|
||||
background: "#0b1220",
|
||||
cursor: "pointer",
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
aria-label={
|
||||
isBound
|
||||
? `Unbind geometry ${g.id} from selected geometry`
|
||||
: `Bind geometry ${g.id} to selected geometry`
|
||||
}
|
||||
>
|
||||
{isBound ? <UnlockIcon /> : <LockIcon />}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>
|
||||
No geometry yet for this project.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{collapsed ? null : statusText ? (
|
||||
<div style={{ marginTop: 10, fontSize: 12, color: "#93c5fd" }}>
|
||||
{statusText}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const boundBadgeStyle: CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flex: "0 0 auto",
|
||||
height: 17,
|
||||
padding: "0 6px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid rgba(45, 212, 191, 0.5)",
|
||||
background: "rgba(20, 184, 166, 0.18)",
|
||||
color: "#99f6e4",
|
||||
fontSize: 10,
|
||||
fontWeight: 900,
|
||||
lineHeight: 1,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0,
|
||||
};
|
||||
|
||||
function LockIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M7 10V8a5 5 0 0 1 10 0v2"
|
||||
stroke="#cbd5e1"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<rect
|
||||
x="6"
|
||||
y="10"
|
||||
width="12"
|
||||
height="10"
|
||||
rx="2"
|
||||
stroke="#cbd5e1"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function UnlockIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M17 10V8a5 5 0 0 0-9.5-2"
|
||||
stroke="#a7f3d0"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<rect
|
||||
x="6"
|
||||
y="10"
|
||||
width="12"
|
||||
height="10"
|
||||
rx="2"
|
||||
stroke="#a7f3d0"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PlusIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M12 5v14M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MinusIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
|
||||
|
||||
export function ModeHint({ mode }: { mode: EditorMode }) {
|
||||
if (mode === "add-line" || mode === "add-path") {
|
||||
return (
|
||||
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
|
||||
Click để thêm điểm, Enter để hoàn tất, Esc để hủy.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (mode === "add-circle") {
|
||||
return (
|
||||
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
|
||||
Giữ chuột trái kéo để mở bán kính, thả chuột để hoàn tất.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (mode === "add-point") {
|
||||
return (
|
||||
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
|
||||
Chọn 1 điểm trên bản đồ để đặt địa điểm.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (mode === "select") {
|
||||
return (
|
||||
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
|
||||
Chọn 1 hình, đường, điểm trên bản đồ để xem chi tiết.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (mode === "draw") {
|
||||
return (
|
||||
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
|
||||
Chọn các điểm trên bản đồ để vẽ hình, ENTER để kết thúc, ESC để hủy.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (mode === "replay") {
|
||||
return (
|
||||
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
|
||||
Đang trong chế độ trình diễn diễn biến kịch bản.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
type Props = {
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export default function NewBadge({ title = "Created in this session and not committed yet" }: Props) {
|
||||
return (
|
||||
<span style={badgeStyle} title={title}>
|
||||
new
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const badgeStyle: CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flex: "0 0 auto",
|
||||
height: 17,
|
||||
padding: "0 6px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid rgba(45, 212, 191, 0.55)",
|
||||
background: "rgba(20, 184, 166, 0.16)",
|
||||
color: "#5eead4",
|
||||
fontSize: 10,
|
||||
fontWeight: 900,
|
||||
lineHeight: 1,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0,
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type PanelProps = {
|
||||
title: string;
|
||||
badge?: string | null;
|
||||
defaultOpen?: boolean;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function Panel({
|
||||
title,
|
||||
badge,
|
||||
defaultOpen,
|
||||
children,
|
||||
}: PanelProps) {
|
||||
return (
|
||||
<details
|
||||
open={Boolean(defaultOpen)}
|
||||
style={{
|
||||
marginTop: 10,
|
||||
padding: 10,
|
||||
background: "#111827",
|
||||
borderRadius: 8,
|
||||
border: "1px solid #1f2937",
|
||||
}}
|
||||
>
|
||||
<summary
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
listStyle: "none",
|
||||
fontWeight: 900,
|
||||
fontSize: 13,
|
||||
color: "white",
|
||||
userSelect: "none",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<span>{title}</span>
|
||||
{badge ? (
|
||||
<span
|
||||
style={{
|
||||
padding: "2px 8px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid #334155",
|
||||
background: "#0b1220",
|
||||
color: "#cbd5e1",
|
||||
fontSize: 12,
|
||||
fontWeight: 850,
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</summary>
|
||||
<div style={{ marginTop: 10 }}>{children}</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, type CSSProperties } from "react";
|
||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||
import type { EntityFormState } from "@/uhm/lib/editor/session/sessionTypes";
|
||||
import NewBadge from "@/uhm/components/editor/NewBadge";
|
||||
|
||||
type Props = {
|
||||
entityRefs: EntitySnapshot[];
|
||||
entityForm: EntityFormState;
|
||||
onEntityFormChange: (key: keyof EntityFormState, value: string) => void;
|
||||
isEntitySubmitting: boolean;
|
||||
onCreateEntityOnly: () => void;
|
||||
onUpdateEntity?: (entityId: string, payload: { name: string; description: string | null }) => void;
|
||||
entityFormStatus: string | null;
|
||||
selectedGeometryEntityIds?: string[];
|
||||
hasSelectedGeometry?: boolean;
|
||||
onToggleBindEntityForSelectedGeometry?: (entityId: string, nextChecked: boolean) => void;
|
||||
};
|
||||
|
||||
export default function ProjectEntityRefsPanel({
|
||||
entityRefs,
|
||||
entityForm,
|
||||
onEntityFormChange,
|
||||
isEntitySubmitting,
|
||||
onCreateEntityOnly,
|
||||
onUpdateEntity,
|
||||
entityFormStatus,
|
||||
selectedGeometryEntityIds,
|
||||
hasSelectedGeometry,
|
||||
onToggleBindEntityForSelectedGeometry,
|
||||
}: Props) {
|
||||
const canBindToggle =
|
||||
Boolean(hasSelectedGeometry) &&
|
||||
Array.isArray(selectedGeometryEntityIds) &&
|
||||
typeof onToggleBindEntityForSelectedGeometry === "function";
|
||||
|
||||
const canEditEntity = typeof onUpdateEntity === "function";
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [activeEntityId, setActiveEntityId] = useState<string | null>(null);
|
||||
const selectedEntityIdSet = useMemo(
|
||||
() => new Set((selectedGeometryEntityIds || []).map(String)),
|
||||
[selectedGeometryEntityIds]
|
||||
);
|
||||
const sortedEntityRefs = useMemo(() => {
|
||||
const rows = [...(entityRefs || [])];
|
||||
rows.sort((a, b) => {
|
||||
const aBound = selectedEntityIdSet.has(String(a.id));
|
||||
const bBound = selectedEntityIdSet.has(String(b.id));
|
||||
if (aBound !== bBound) return aBound ? -1 : 1;
|
||||
const aLabel = String(a.name || a.id || "");
|
||||
const bLabel = String(b.name || b.id || "");
|
||||
return aLabel.localeCompare(bLabel);
|
||||
});
|
||||
return rows;
|
||||
}, [entityRefs, selectedEntityIdSet]);
|
||||
|
||||
const activeEntity = useMemo(
|
||||
() => (activeEntityId ? entityRefs.find((e) => String(e.id) === String(activeEntityId)) || null : null),
|
||||
[activeEntityId, entityRefs]
|
||||
);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editDescription, setEditDescription] = useState("");
|
||||
|
||||
const openEntityEditor = (entity: EntitySnapshot) => {
|
||||
setActiveEntityId(String(entity.id));
|
||||
setEditName(typeof entity.name === "string" ? entity.name : "");
|
||||
setEditDescription(entity.description == null ? "" : String(entity.description));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px",
|
||||
background: "#0b1220",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #1f2937",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
|
||||
<div style={{ fontWeight: 700, fontSize: "14px" }}>Entities</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{entityRefs.length}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
title={collapsed ? "Mo panel" : "Thu gon panel"}
|
||||
aria-label={collapsed ? "Mo panel Entities" : "Thu gon panel Entities"}
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 6,
|
||||
border: "1px solid #334155",
|
||||
background: "#0b1220",
|
||||
color: "#e2e8f0",
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
{collapsed ? <PlusIcon /> : <MinusIcon />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{collapsed ? null : sortedEntityRefs.length ? (
|
||||
<div style={{ marginTop: "10px", display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}>
|
||||
{sortedEntityRefs.map((e) => {
|
||||
const entityId = String(e.id);
|
||||
const isBoundToSelectedGeometry = selectedEntityIdSet.has(entityId);
|
||||
const isActive = activeEntityId === entityId;
|
||||
return (
|
||||
<div
|
||||
key={e.id}
|
||||
style={{
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
border: isActive
|
||||
? "1px solid #2563eb"
|
||||
: isBoundToSelectedGeometry
|
||||
? "1px solid rgba(20, 184, 166, 0.65)"
|
||||
: "1px solid #1f2937",
|
||||
background: isBoundToSelectedGeometry ? "rgba(20, 184, 166, 0.12)" : "transparent",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEntityEditor(e)}
|
||||
title="Chon de sua"
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
textAlign: "left",
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
padding: 0,
|
||||
cursor: canEditEntity ? "pointer" : "default",
|
||||
}}
|
||||
disabled={!canEditEntity}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
|
||||
<span style={{ fontSize: "12px", color: "#e5e7eb", fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{e.name || e.id}
|
||||
</span>
|
||||
{isBoundToSelectedGeometry ? <span style={boundBadgeStyle}>bound</span> : null}
|
||||
{isNewEntityRef(e) ? <NewBadge /> : null}
|
||||
</div>
|
||||
<div style={{ fontSize: "11px", color: "#94a3b8", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{e.id}
|
||||
</div>
|
||||
</button>
|
||||
{canBindToggle ? (
|
||||
<button
|
||||
type="button"
|
||||
title={isBoundToSelectedGeometry ? "Unbind from selected geometry" : "Bind to selected geometry"}
|
||||
onClick={() =>
|
||||
onToggleBindEntityForSelectedGeometry!(
|
||||
entityId,
|
||||
!isBoundToSelectedGeometry
|
||||
)
|
||||
}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 6,
|
||||
border: "1px solid #334155",
|
||||
background: "#0b1220",
|
||||
cursor: "pointer",
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
aria-label={
|
||||
isBoundToSelectedGeometry
|
||||
? `Unbind entity ${entityId} from selected geometry`
|
||||
: `Bind entity ${entityId} to selected geometry`
|
||||
}
|
||||
>
|
||||
{isBoundToSelectedGeometry ? (
|
||||
<UnlockIcon />
|
||||
) : (
|
||||
<LockIcon />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>No entity ref yet for this project.</div>
|
||||
)}
|
||||
|
||||
{collapsed ? null : canEditEntity && activeEntity ? (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "10px",
|
||||
display: "grid",
|
||||
gap: "8px",
|
||||
border: "1px solid #0f766e",
|
||||
borderRadius: "8px",
|
||||
padding: "8px",
|
||||
background: "#0f172a",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
|
||||
<span style={{ color: "#a7f3d0", fontWeight: 700, fontSize: "12px" }}>
|
||||
Sua entity
|
||||
</span>
|
||||
{isNewEntityRef(activeEntity) ? <NewBadge /> : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveEntityId(null)}
|
||||
title="Dong"
|
||||
aria-label="Dong sua entity"
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 6,
|
||||
border: "1px solid #334155",
|
||||
background: "#0b1220",
|
||||
color: "#e2e8f0",
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 11, color: "#94a3b8", overflowWrap: "anywhere" }}>
|
||||
{String(activeEntity.id)}
|
||||
</div>
|
||||
<input
|
||||
value={editName}
|
||||
onChange={(event) => setEditName(event.target.value)}
|
||||
placeholder="Ten entity"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
<input
|
||||
value={editDescription}
|
||||
onChange={(event) => setEditDescription(event.target.value)}
|
||||
placeholder="Description"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdateEntity!(String(activeEntity.id), { name: editName, description: editDescription.trim().length ? editDescription : null })}
|
||||
disabled={isEntitySubmitting}
|
||||
style={{
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
padding: "7px 8px",
|
||||
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
|
||||
background: "#0f766e",
|
||||
color: "#ffffff",
|
||||
opacity: isEntitySubmitting ? 0.7 : 1,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Luu entity
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{collapsed ? null : (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
marginTop: "10px",
|
||||
display: "grid",
|
||||
gap: "8px",
|
||||
border: "1px solid #1e3a8a",
|
||||
borderRadius: "8px",
|
||||
padding: "8px",
|
||||
background: "#0f172a",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
||||
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
|
||||
Tạo entity mới
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCreateOpen((v) => !v)}
|
||||
disabled={isEntitySubmitting}
|
||||
title={isCreateOpen ? "Dong" : "Mo"}
|
||||
aria-label={isCreateOpen ? "Dong tao entity" : "Mo tao entity"}
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 6,
|
||||
border: "1px solid #334155",
|
||||
background: "#0b1220",
|
||||
color: "#e2e8f0",
|
||||
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
|
||||
opacity: isEntitySubmitting ? 0.6 : 1,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
{isCreateOpen ? <CloseIcon /> : <PlusIcon />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isCreateOpen ? (
|
||||
<>
|
||||
<input
|
||||
value={entityForm.name}
|
||||
onChange={(event) => onEntityFormChange("name", event.target.value)}
|
||||
placeholder="Tên entity mới"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
<input
|
||||
value={entityForm.description}
|
||||
onChange={(event) => onEntityFormChange("description", event.target.value)}
|
||||
placeholder="Description"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreateEntityOnly}
|
||||
disabled={isEntitySubmitting}
|
||||
style={{
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
padding: "7px 8px",
|
||||
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
|
||||
background: "#2563eb",
|
||||
color: "#ffffff",
|
||||
opacity: isEntitySubmitting ? 0.7 : 1,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Tạo entity mới
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{entityFormStatus ? (
|
||||
<div style={{ color: "#93c5fd", fontSize: "12px", marginTop: "8px" }}>
|
||||
{entityFormStatus}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isNewEntityRef(entity: EntitySnapshot | null | undefined): boolean {
|
||||
return entity?.source === "inline" && entity?.operation === "create";
|
||||
}
|
||||
|
||||
const entityInputStyle: CSSProperties = {
|
||||
width: "100%",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #334155",
|
||||
background: "#111827",
|
||||
color: "#f8fafc",
|
||||
padding: "6px 8px",
|
||||
fontSize: "13px",
|
||||
};
|
||||
|
||||
const boundBadgeStyle: CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flex: "0 0 auto",
|
||||
height: 17,
|
||||
padding: "0 6px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid rgba(45, 212, 191, 0.5)",
|
||||
background: "rgba(20, 184, 166, 0.18)",
|
||||
color: "#99f6e4",
|
||||
fontSize: 10,
|
||||
fontWeight: 900,
|
||||
lineHeight: 1,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0,
|
||||
};
|
||||
|
||||
function LockIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M7 10V8a5 5 0 0 1 10 0v2"
|
||||
stroke="#cbd5e1"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<rect
|
||||
x="6"
|
||||
y="10"
|
||||
width="12"
|
||||
height="10"
|
||||
rx="2"
|
||||
stroke="#cbd5e1"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function UnlockIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M17 10V8a5 5 0 0 0-9.5-2"
|
||||
stroke="#a7f3d0"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<rect
|
||||
x="6"
|
||||
y="10"
|
||||
width="12"
|
||||
height="10"
|
||||
rx="2"
|
||||
stroke="#a7f3d0"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PlusIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M12 5v14M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MinusIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CloseIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6L6 18" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Panel } from "./Panel";
|
||||
|
||||
type ProjectPanelProps = {
|
||||
sectionTitle: string;
|
||||
projectStatus: string;
|
||||
commitCount: number;
|
||||
latestCommitLabel: string | null;
|
||||
};
|
||||
|
||||
export function ProjectPanel({
|
||||
sectionTitle,
|
||||
projectStatus,
|
||||
commitCount,
|
||||
latestCommitLabel,
|
||||
}: ProjectPanelProps) {
|
||||
return (
|
||||
<Panel title="Project" defaultOpen>
|
||||
<div style={{ fontSize: 12, color: "#cbd5e1", lineHeight: 1.4 }}>
|
||||
<div style={{ color: "white", fontWeight: 850, overflowWrap: "anywhere" }}>{sectionTitle}</div>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
Status: <span style={{ color: "#e2e8f0" }}>{projectStatus}</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
Commits: <span style={{ color: "#e2e8f0" }}>{commitCount}</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
{latestCommitLabel ? (
|
||||
<span style={{ color: "#e2e8f0" }}>{latestCommitLabel}</span>
|
||||
) : (
|
||||
<span style={{ color: "#94a3b8" }}>Chưa có head commit</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
"use client";
|
||||
|
||||
import { type CSSProperties, useMemo, useState } from "react";
|
||||
import { Feature } from "@/uhm/lib/editor/state/useEditorState";
|
||||
import {
|
||||
GeometryPreset,
|
||||
GeometryTypeGroupId,
|
||||
GeometryTypeOption,
|
||||
findGeometryTypeOption,
|
||||
groupGeometryTypeOptions,
|
||||
} from "@/uhm/lib/map/geo/geometryTypeOptions";
|
||||
import { normalizeGeoTypeKey } from "@/uhm/lib/map/geo/geoTypeMap";
|
||||
import type { GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionTypes";
|
||||
|
||||
type Props = {
|
||||
selectedFeatures: Feature[];
|
||||
entityTypeOptions: GeometryTypeOption[];
|
||||
geometryMetaForm: GeometryMetaFormState;
|
||||
onGeometryMetaFormChange: (key: keyof GeometryMetaFormState, value: string) => void;
|
||||
isEntitySubmitting: boolean;
|
||||
onApplyGeometryMetadata: () => Promise<{ ok: boolean; error?: string }>;
|
||||
changeCount: number;
|
||||
};
|
||||
|
||||
export default function SelectedGeometryPanel({
|
||||
selectedFeatures,
|
||||
entityTypeOptions,
|
||||
geometryMetaForm,
|
||||
onGeometryMetaFormChange,
|
||||
isEntitySubmitting,
|
||||
onApplyGeometryMetadata,
|
||||
changeCount,
|
||||
}: Props) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [geoApplyFeedback, setGeoApplyFeedback] = useState<
|
||||
| {
|
||||
kind: "ok" | "error";
|
||||
text: string;
|
||||
signature: string;
|
||||
}
|
||||
| null
|
||||
>(null);
|
||||
|
||||
const geoMetaSignature = useMemo(() => {
|
||||
return [
|
||||
geometryMetaForm.type_key,
|
||||
geometryMetaForm.time_start,
|
||||
geometryMetaForm.time_end,
|
||||
geometryMetaForm.binding,
|
||||
].join("|");
|
||||
}, [
|
||||
geometryMetaForm.binding,
|
||||
geometryMetaForm.time_end,
|
||||
geometryMetaForm.time_start,
|
||||
geometryMetaForm.type_key,
|
||||
]);
|
||||
|
||||
const handleApplyGeoMeta = async () => {
|
||||
setGeoApplyFeedback(null);
|
||||
const result = await onApplyGeometryMetadata();
|
||||
if (result.ok) {
|
||||
setGeoApplyFeedback({ kind: "ok", text: "đã apply thành công", signature: geoMetaSignature });
|
||||
} else if (result.error) {
|
||||
setGeoApplyFeedback({ kind: "error", text: result.error, signature: geoMetaSignature });
|
||||
}
|
||||
};
|
||||
|
||||
const visibleGeoApplyFeedback =
|
||||
geoApplyFeedback && geoApplyFeedback.signature === geoMetaSignature ? geoApplyFeedback : null;
|
||||
|
||||
if (!selectedFeatures || selectedFeatures.length === 0) return null;
|
||||
const representativeFeature = selectedFeatures[0];
|
||||
|
||||
const groupedGeometryTypeOptions = groupGeometryTypeOptions(entityTypeOptions);
|
||||
const featureGeometryPreset = resolveFeatureGeometryPreset(representativeFeature);
|
||||
const allowedGroupIds = getAllowedGroupIdsForPreset(featureGeometryPreset);
|
||||
const groupedGeoTypeOptions = groupedGeometryTypeOptions.filter((group) =>
|
||||
allowedGroupIds.includes(group.id)
|
||||
);
|
||||
const selectedTypeOption = findGeometryTypeOption(geometryMetaForm.type_key);
|
||||
const hasCurrentVisibleTypeOption = groupedGeoTypeOptions.some((group) =>
|
||||
group.options.some((option) => option.value === geometryMetaForm.type_key)
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px",
|
||||
background: "#0b1220",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #1f2937",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, marginBottom: "8px" }}>
|
||||
<div style={{ fontWeight: 700, fontSize: "14px" }}>
|
||||
Geometry property
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
title={collapsed ? "Mo panel" : "Thu gon panel"}
|
||||
aria-label={collapsed ? "Mo panel Selected Geometry" : "Thu gon panel Selected Geometry"}
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 6,
|
||||
border: "1px solid #334155",
|
||||
background: "#0b1220",
|
||||
color: "#e2e8f0",
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
{collapsed ? <PlusIcon /> : <MinusIcon />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{collapsed ? null : (
|
||||
<div style={{ display: "grid", gap: "8px", fontSize: "13px" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: "8px",
|
||||
border: "1px solid #243244",
|
||||
borderRadius: "8px",
|
||||
padding: "8px",
|
||||
background: "#0f172a",
|
||||
}}
|
||||
>
|
||||
<div style={{ color: "#e2e8f0", fontWeight: 700, fontSize: "12px" }}>
|
||||
Thuộc tính GEO
|
||||
</div>
|
||||
<div style={{ color: "#94a3b8", fontSize: "11px" }}>
|
||||
Các giá trị này thuộc về GEO đang chọn, không phụ thuộc entity.
|
||||
</div>
|
||||
<div style={{ color: "#e2e8f0", fontWeight: 600, fontSize: "12px" }}>
|
||||
Loại GEO
|
||||
</div>
|
||||
<select
|
||||
value={geometryMetaForm.type_key}
|
||||
onChange={(event) => onGeometryMetaFormChange("type_key", event.target.value)}
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
>
|
||||
{!hasCurrentVisibleTypeOption && geometryMetaForm.type_key ? (
|
||||
<option value={geometryMetaForm.type_key}>
|
||||
Custom Type ({geometryMetaForm.type_key})
|
||||
</option>
|
||||
) : null}
|
||||
{groupedGeoTypeOptions.map((group) => (
|
||||
<optgroup
|
||||
key={group.id}
|
||||
label={`${group.label} (${group.geometryLabel})`}
|
||||
>
|
||||
{group.options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
{selectedTypeOption ? (
|
||||
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
|
||||
Đang chọn: <b>{selectedTypeOption.label}</b> ({selectedTypeOption.groupLabel})
|
||||
</div>
|
||||
) : geometryMetaForm.type_key ? (
|
||||
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
|
||||
Đang chọn: <b>{geometryMetaForm.type_key}</b>
|
||||
</div>
|
||||
) : null}
|
||||
<input
|
||||
value={geometryMetaForm.time_start}
|
||||
onChange={(event) => onGeometryMetaFormChange("time_start", event.target.value)}
|
||||
placeholder="time_start"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
<input
|
||||
value={geometryMetaForm.time_end}
|
||||
onChange={(event) => onGeometryMetaFormChange("time_end", event.target.value)}
|
||||
placeholder="time_end"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
{/*<input*/}
|
||||
{/* value={geometryMetaForm.binding}*/}
|
||||
{/* onChange={(event) => onGeometryMetaFormChange("binding", event.target.value)}*/}
|
||||
{/* placeholder="binding (geometry ids, comma separated)"*/}
|
||||
{/* disabled={isEntitySubmitting}*/}
|
||||
{/* style={entityInputStyle}*/}
|
||||
{/*/>*/}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleApplyGeoMeta}
|
||||
disabled={isEntitySubmitting}
|
||||
style={primaryGeometryButtonStyle}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
{visibleGeoApplyFeedback ? (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color:
|
||||
visibleGeoApplyFeedback.kind === "ok" ? "#22c55e" : "#fca5a5",
|
||||
}}
|
||||
>
|
||||
{visibleGeoApplyFeedback.text}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{changeCount > 0 ? (
|
||||
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
|
||||
Thay đổi sẽ vào lịch sử khi Commit.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const entityInputStyle: CSSProperties = {
|
||||
width: "100%",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #334155",
|
||||
background: "#111827",
|
||||
color: "#f8fafc",
|
||||
padding: "6px 8px",
|
||||
fontSize: "13px",
|
||||
};
|
||||
|
||||
const primaryGeometryButtonStyle: CSSProperties = {
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
padding: "7px 8px",
|
||||
cursor: "pointer",
|
||||
background: "#0f766e",
|
||||
color: "#ffffff",
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
function PlusIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M12 5v14M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MinusIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveFeatureGeometryPreset(feature: Feature): GeometryPreset {
|
||||
const explicitPreset = normalizeGeometryPreset(feature.properties.geometry_preset);
|
||||
if (explicitPreset) return explicitPreset;
|
||||
|
||||
const semanticType = normalizeTypeId(feature.properties.type) || normalizeTypeId(feature.properties.entity_type_id);
|
||||
if (semanticType) {
|
||||
const option = findGeometryTypeOption(semanticType);
|
||||
if (option) return option.geometryPreset;
|
||||
}
|
||||
|
||||
return mapGeometryTypeToPreset(feature.geometry.type);
|
||||
}
|
||||
|
||||
function normalizeGeometryPreset(value: unknown): GeometryPreset | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (
|
||||
normalized === "point" ||
|
||||
normalized === "line" ||
|
||||
normalized === "polygon" ||
|
||||
normalized === "circle-area"
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeTypeId(value: unknown): string | null {
|
||||
return normalizeGeoTypeKey(value);
|
||||
}
|
||||
|
||||
function mapGeometryTypeToPreset(
|
||||
geometryType: Feature["geometry"]["type"]
|
||||
): GeometryPreset {
|
||||
if (geometryType === "Point" || geometryType === "MultiPoint") {
|
||||
return "point";
|
||||
}
|
||||
if (geometryType === "LineString" || geometryType === "MultiLineString") {
|
||||
return "line";
|
||||
}
|
||||
return "polygon";
|
||||
}
|
||||
|
||||
function getAllowedGroupIdsForPreset(
|
||||
geometryPreset: GeometryPreset
|
||||
): GeometryTypeGroupId[] {
|
||||
if (geometryPreset === "point") {
|
||||
return ["point"];
|
||||
}
|
||||
|
||||
if (geometryPreset === "line") {
|
||||
return ["line"];
|
||||
}
|
||||
|
||||
if (geometryPreset === "circle-area") {
|
||||
return ["circle"];
|
||||
}
|
||||
|
||||
return ["polygon"];
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
type SubmitModalProps = {
|
||||
isSubmitModalOpen: boolean;
|
||||
submitContent: string;
|
||||
setSubmitContent: (content: string) => void;
|
||||
handleCancelSubmit: () => void;
|
||||
handleConfirmSubmit: () => void;
|
||||
};
|
||||
|
||||
export function SubmitModal({
|
||||
isSubmitModalOpen,
|
||||
submitContent,
|
||||
setSubmitContent,
|
||||
handleCancelSubmit,
|
||||
handleConfirmSubmit,
|
||||
}: SubmitModalProps) {
|
||||
if (!isSubmitModalOpen) return null;
|
||||
|
||||
const textAreaStyle = {
|
||||
width: "100%",
|
||||
marginTop: 8,
|
||||
padding: "8px 10px",
|
||||
borderRadius: 6,
|
||||
border: "1px solid #334155",
|
||||
background: "#0b1220",
|
||||
color: "white",
|
||||
boxSizing: "border-box",
|
||||
fontSize: 13,
|
||||
outline: "none",
|
||||
resize: "vertical",
|
||||
fontFamily: "inherit",
|
||||
height: 100,
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: "fixed",
|
||||
top: 0, left: 0, right: 0, bottom: 0,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000
|
||||
}}>
|
||||
<div style={{
|
||||
background: "#0b1220",
|
||||
padding: 20,
|
||||
borderRadius: 8,
|
||||
border: "1px solid #334155",
|
||||
width: 400,
|
||||
color: "white"
|
||||
}}>
|
||||
<h3 style={{ marginTop: 0 }}>Nội dung Submit</h3>
|
||||
<textarea
|
||||
value={submitContent}
|
||||
onChange={(e) => setSubmitContent(e.target.value)}
|
||||
placeholder="Nhập nội dung submit..."
|
||||
style={textAreaStyle}
|
||||
/>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 10, marginTop: 15 }}>
|
||||
<button onClick={handleCancelSubmit} style={{ padding: "8px 16px", borderRadius: 6, cursor: "pointer", border: "1px solid #334155", background: "transparent", color: "white" }}>Hủy</button>
|
||||
<button onClick={handleConfirmSubmit} style={{ padding: "8px 16px", borderRadius: 6, cursor: "pointer", border: "none", background: "#16a34a", color: "white", fontWeight: "bold" }}>Gửi Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
|
||||
import { Panel } from "./Panel";
|
||||
import { ModeHint } from "./ModeHint";
|
||||
|
||||
type ToolsPanelProps = {
|
||||
mode: EditorMode;
|
||||
setMode: (mode: EditorMode) => void;
|
||||
onUndo: () => void;
|
||||
};
|
||||
|
||||
export function ToolsPanel({ mode, setMode, onUndo }: ToolsPanelProps) {
|
||||
const toggleMode = (newMode: EditorMode) => {
|
||||
if (mode === newMode) {
|
||||
setMode("idle");
|
||||
} else {
|
||||
setMode(newMode);
|
||||
}
|
||||
};
|
||||
|
||||
const modeButtonStyle = (btnMode: EditorMode) =>
|
||||
({
|
||||
padding: "8px 10px",
|
||||
borderRadius: 6,
|
||||
border: "1px solid #334155",
|
||||
background: mode === btnMode ? "#16a34a" : "#111827",
|
||||
color: "white",
|
||||
cursor: "pointer",
|
||||
fontWeight: 800,
|
||||
fontSize: 12,
|
||||
minHeight: 34,
|
||||
boxSizing: "border-box",
|
||||
}) as const;
|
||||
|
||||
return (
|
||||
<Panel title="Tools" defaultOpen>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
<button style={modeButtonStyle("select")} onClick={() => toggleMode("select")} title="Select">
|
||||
Select
|
||||
</button>
|
||||
<button style={modeButtonStyle("draw")} onClick={() => toggleMode("draw")} title="Draw polygon">
|
||||
Draw
|
||||
</button>
|
||||
<button style={modeButtonStyle("add-point")} onClick={() => setMode("add-point")} title="Add point">
|
||||
Point
|
||||
</button>
|
||||
<button style={modeButtonStyle("add-line")} onClick={() => setMode("add-line")} title="Add line">
|
||||
Line
|
||||
</button>
|
||||
<button style={modeButtonStyle("add-path")} onClick={() => setMode("add-path")} title="Add path">
|
||||
Path
|
||||
</button>
|
||||
<button style={modeButtonStyle("add-circle")} onClick={() => setMode("add-circle")} title="Add circle">
|
||||
Circle
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 10, fontSize: 12, color: "#94a3b8" }}>
|
||||
Mode: <span style={{ color: "white", fontWeight: 850 }}>{mode}</span>
|
||||
</div>
|
||||
<ModeHint mode={mode} />
|
||||
|
||||
<div style={{ marginTop: 10, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
<button
|
||||
style={{
|
||||
...modeButtonStyle("idle"),
|
||||
background: "#111827",
|
||||
}}
|
||||
onClick={() => setMode("idle")}
|
||||
title="Tắt tool hiện tại"
|
||||
>
|
||||
Idle
|
||||
</button>
|
||||
<button
|
||||
style={{
|
||||
...modeButtonStyle("idle"),
|
||||
background: "#334155",
|
||||
}}
|
||||
onClick={onUndo}
|
||||
title="Undo thao tác gần nhất"
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { UndoAction } from "@/uhm/lib/editor/state/useEditorState";
|
||||
import { Panel } from "./Panel";
|
||||
|
||||
type UndoListPanelProps = {
|
||||
undoStack: UndoAction[];
|
||||
};
|
||||
|
||||
export function UndoListPanel({ undoStack }: UndoListPanelProps) {
|
||||
const recentUndoLabels = (() => {
|
||||
const seen = new Set<string>();
|
||||
const labels: string[] = [];
|
||||
for (let i = undoStack.length - 1; i >= 0 && labels.length < 8; i -= 1) {
|
||||
const label = formatUndoLabel(undoStack[i]);
|
||||
if (seen.has(label)) continue;
|
||||
seen.add(label);
|
||||
labels.push(label);
|
||||
}
|
||||
return labels.reverse();
|
||||
})();
|
||||
|
||||
return (
|
||||
<Panel title="Undo List" badge={String(recentUndoLabels.length)} defaultOpen={false}>
|
||||
{recentUndoLabels.length === 0 ? (
|
||||
<div style={{ color: "#94a3b8", fontSize: 13 }}>Chưa có thao tác</div>
|
||||
) : (
|
||||
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: 13, color: "#e2e8f0" }}>
|
||||
{recentUndoLabels.map((label, idx) => (
|
||||
<li key={`${label}-${idx}`} style={{ padding: "6px 0", borderBottom: "1px solid #1f2937" }}>
|
||||
{label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
export function formatUndoLabel(action: UndoAction) {
|
||||
switch (action.type) {
|
||||
case "create":
|
||||
return `Thêm mới #${action.id}`;
|
||||
case "delete":
|
||||
return `Xóa #${action.feature.properties.id}`;
|
||||
case "update":
|
||||
return `Chỉnh sửa #${action.id}`;
|
||||
case "properties":
|
||||
return `Cập nhật thuộc tính #${action.id}`;
|
||||
case "snapshot_entities":
|
||||
case "snapshot_wikis":
|
||||
case "snapshot_entity_wiki":
|
||||
case "group":
|
||||
return action.label;
|
||||
default:
|
||||
return "Tác vụ";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,812 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
import polylabel from "polylabel";
|
||||
import { BACKGROUND_LAYER_OPTIONS, BackgroundLayerVisibility } from "@/uhm/lib/map/styles/backgroundLayers";
|
||||
import { Feature, FeatureCollection, Geometry } from "@/uhm/lib/editor/state/useEditorState";
|
||||
import {
|
||||
FEATURE_STATE_SOURCE_IDS,
|
||||
PATH_ARROW_ICON_ID,
|
||||
RASTER_BASE_INSERT_BEFORE_LAYER_ID,
|
||||
RASTER_BASE_LAYER_ID,
|
||||
RASTER_BASE_SOURCE_ID,
|
||||
PATH_ARROW_SOURCE_ID
|
||||
} from "@/uhm/lib/map/constants";
|
||||
import { PATH_RENDER_BY_TYPE } from "@/uhm/lib/map/styles/style";
|
||||
import { getRasterTileTemplateUrl } from "@/uhm/api/tiles";
|
||||
import { newId } from "@/uhm/lib/utils/id";
|
||||
import { normalizeGeoTypeKey } from "@/uhm/lib/map/geo/geoTypeMap";
|
||||
|
||||
type Coordinate = [number, number];
|
||||
type PolygonCoordinates = Coordinate[][];
|
||||
type FeatureLabelInfo = {
|
||||
entityId: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export function applyBackgroundLayerVisibility(
|
||||
map: maplibregl.Map,
|
||||
visibility: BackgroundLayerVisibility
|
||||
) {
|
||||
syncRasterBaseVisibility(map, visibility[RASTER_BASE_LAYER_ID]);
|
||||
|
||||
for (const layer of BACKGROUND_LAYER_OPTIONS) {
|
||||
if (layer.id === RASTER_BASE_LAYER_ID) continue;
|
||||
if (!map.getLayer(layer.id)) continue;
|
||||
map.setLayoutProperty(
|
||||
layer.id,
|
||||
"visibility",
|
||||
visibility[layer.id] ? "visible" : "none"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function syncRasterBaseVisibility(map: maplibregl.Map, shouldShow: boolean) {
|
||||
if (shouldShow) {
|
||||
ensureRasterBaseLayer(map);
|
||||
return;
|
||||
}
|
||||
removeRasterBaseLayer(map);
|
||||
}
|
||||
|
||||
export function ensureRasterBaseLayer(map: maplibregl.Map) {
|
||||
if (!map.getSource(RASTER_BASE_SOURCE_ID)) {
|
||||
map.addSource(RASTER_BASE_SOURCE_ID, createRasterBaseSource());
|
||||
}
|
||||
|
||||
if (!map.getLayer(RASTER_BASE_LAYER_ID)) {
|
||||
const beforeId = map.getLayer(RASTER_BASE_INSERT_BEFORE_LAYER_ID)
|
||||
? RASTER_BASE_INSERT_BEFORE_LAYER_ID
|
||||
: undefined;
|
||||
map.addLayer(createRasterBaseLayer(), beforeId);
|
||||
}
|
||||
|
||||
map.setLayoutProperty(RASTER_BASE_LAYER_ID, "visibility", "visible");
|
||||
}
|
||||
|
||||
export function removeRasterBaseLayer(map: maplibregl.Map) {
|
||||
if (map.getLayer(RASTER_BASE_LAYER_ID)) {
|
||||
map.removeLayer(RASTER_BASE_LAYER_ID);
|
||||
}
|
||||
|
||||
if (map.getSource(RASTER_BASE_SOURCE_ID)) {
|
||||
map.removeSource(RASTER_BASE_SOURCE_ID);
|
||||
}
|
||||
}
|
||||
|
||||
export function createRasterBaseSource() {
|
||||
return {
|
||||
type: "raster" as const,
|
||||
tiles: [getRasterTileTemplateUrl()],
|
||||
tileSize: 256,
|
||||
minzoom: 0,
|
||||
maxzoom: 6,
|
||||
};
|
||||
}
|
||||
|
||||
export function createRasterBaseLayer() {
|
||||
return {
|
||||
id: RASTER_BASE_LAYER_ID,
|
||||
type: "raster" as const,
|
||||
source: RASTER_BASE_SOURCE_ID,
|
||||
paint: {
|
||||
"raster-opacity": 0.92,
|
||||
"raster-resampling": "linear" as const,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getSelectableLayers(map: maplibregl.Map): string[] {
|
||||
const selectableSources = ["countries", "places", PATH_ARROW_SOURCE_ID];
|
||||
const style = map.getStyle();
|
||||
if (!style || !style.layers) return [];
|
||||
|
||||
return style.layers
|
||||
.filter((layer) => "source" in layer && selectableSources.includes(layer.source as string))
|
||||
.map((layer) => layer.id);
|
||||
}
|
||||
|
||||
export function filterDraftByBinding(
|
||||
fc: FeatureCollection,
|
||||
selectedFeatureIds: (string | number)[],
|
||||
highlightFeatures?: FeatureCollection | null
|
||||
): FeatureCollection {
|
||||
const selectedIds = new Set(selectedFeatureIds.map(String));
|
||||
if (highlightFeatures?.features) {
|
||||
for (const f of highlightFeatures.features) {
|
||||
if (f.properties?.id != null) selectedIds.add(String(f.properties.id));
|
||||
}
|
||||
}
|
||||
|
||||
const childIds = new Set<string>();
|
||||
for (const feature of fc.features) {
|
||||
for (const id of normalizeBindingIds(feature.properties.binding)) {
|
||||
childIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedIds.size === 0) {
|
||||
return { ...fc, features: fc.features.filter((f) => !childIds.has(String(f.properties.id))) };
|
||||
}
|
||||
|
||||
const selectedChildren = new Set<string>();
|
||||
for (const feature of fc.features) {
|
||||
if (selectedIds.has(String(feature.properties.id))) {
|
||||
for (const id of normalizeBindingIds(feature.properties.binding)) {
|
||||
selectedChildren.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...fc,
|
||||
features: fc.features.filter((feature) => {
|
||||
const featureId = String(feature.properties.id);
|
||||
if (selectedIds.has(featureId)) return true;
|
||||
if (selectedChildren.has(featureId)) return true;
|
||||
return !childIds.has(featureId);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function filterDraftByGeometryVisibility(
|
||||
fc: FeatureCollection,
|
||||
visibility: Record<string, boolean> | null | undefined
|
||||
): FeatureCollection {
|
||||
if (!visibility) return fc;
|
||||
|
||||
return {
|
||||
...fc,
|
||||
features: fc.features.filter((feature) => {
|
||||
const id = String(feature.properties.id);
|
||||
// Kiểm tra ẩn theo ID cụ thể (ưu tiên cao nhất)
|
||||
if (visibility[id] === false) return false;
|
||||
|
||||
const key = getFeatureSemanticType(feature);
|
||||
if (!key) return true;
|
||||
// Kiểm tra ẩn theo loại (semantic type)
|
||||
return visibility[key] !== false;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeBindingIds(rawBinding: unknown): string[] {
|
||||
if (!Array.isArray(rawBinding)) return [];
|
||||
const deduped: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const rawId of rawBinding) {
|
||||
if (typeof rawId !== "string" && typeof rawId !== "number") continue;
|
||||
const id = String(rawId).trim();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
deduped.push(id);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
export function splitDraftFeatures(fc: FeatureCollection) {
|
||||
const polygons = {
|
||||
type: "FeatureCollection",
|
||||
features: fc.features.filter((f) =>
|
||||
f.geometry.type !== "Point" && f.geometry.type !== "MultiPoint"
|
||||
),
|
||||
} as FeatureCollection;
|
||||
|
||||
const points = {
|
||||
type: "FeatureCollection",
|
||||
features: fc.features.filter((f) =>
|
||||
f.geometry.type === "Point" || f.geometry.type === "MultiPoint"
|
||||
),
|
||||
} as FeatureCollection;
|
||||
|
||||
return { polygons, points };
|
||||
}
|
||||
|
||||
export function decoratePointFeaturesWithLabels(fc: FeatureCollection, labelContext: FeatureCollection = fc): FeatureCollection {
|
||||
const getLabel = createFeatureLabelResolver(labelContext);
|
||||
return {
|
||||
...fc,
|
||||
features: fc.features.map((feature) => ({
|
||||
...feature,
|
||||
properties: {
|
||||
...feature.properties,
|
||||
point_label: getLabel(feature),
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function decorateLineFeaturesWithLabels(fc: FeatureCollection, labelContext: FeatureCollection = fc): FeatureCollection {
|
||||
const getLabel = createFeatureLabelResolver(labelContext);
|
||||
return {
|
||||
...fc,
|
||||
features: fc.features.map((feature) => ({
|
||||
...feature,
|
||||
properties: {
|
||||
...feature.properties,
|
||||
line_label: isLineGeometry(feature.geometry) ? getLabel(feature) : null,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPolygonLabelFeatureCollection(fc: FeatureCollection, labelContext: FeatureCollection = fc): FeatureCollection {
|
||||
const getLabel = createFeatureLabelResolver(labelContext);
|
||||
const features: Feature[] = [];
|
||||
|
||||
for (const feature of fc.features) {
|
||||
const label = getLabel(feature);
|
||||
if (!label) continue;
|
||||
|
||||
const labelPoint = getPolygonLabelPoint(feature.geometry);
|
||||
if (!labelPoint) continue;
|
||||
|
||||
features.push({
|
||||
type: "Feature",
|
||||
properties: {
|
||||
...feature.properties,
|
||||
id: `${feature.properties.id}:polygon-label`,
|
||||
polygon_label: label,
|
||||
},
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates: labelPoint,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { type: "FeatureCollection", features };
|
||||
}
|
||||
|
||||
export function setSelectedFeatureState(
|
||||
map: maplibregl.Map,
|
||||
id: string | number | null,
|
||||
selected: boolean
|
||||
) {
|
||||
if (id === null) return;
|
||||
for (const sourceId of FEATURE_STATE_SOURCE_IDS) {
|
||||
if (!map.getSource(sourceId)) continue;
|
||||
map.setFeatureState({ source: sourceId, id }, { selected });
|
||||
}
|
||||
}
|
||||
|
||||
export function fitMapToFeatureCollection(
|
||||
map: maplibregl.Map,
|
||||
fc: FeatureCollection,
|
||||
padding?: number | maplibregl.PaddingOptions,
|
||||
options?: {
|
||||
duration?: number;
|
||||
maxZoom?: number;
|
||||
pointZoom?: number;
|
||||
}
|
||||
): boolean {
|
||||
const bbox = getFeatureCollectionBBox(fc);
|
||||
if (!bbox) return false;
|
||||
|
||||
const resolvedPadding = typeof padding === "number" || padding ? padding : 58;
|
||||
const duration = options?.duration ?? 0;
|
||||
const maxZoom = options?.maxZoom ?? 7;
|
||||
const pointZoom = options?.pointZoom ?? 6;
|
||||
|
||||
const lngSpan = Math.abs(bbox.maxLng - bbox.minLng);
|
||||
const latSpan = Math.abs(bbox.maxLat - bbox.minLat);
|
||||
if (lngSpan < 0.000001 && latSpan < 0.000001) {
|
||||
map.easeTo({
|
||||
center: [bbox.minLng, bbox.minLat],
|
||||
zoom: pointZoom,
|
||||
padding: resolvedPadding,
|
||||
duration,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
map.fitBounds(
|
||||
[
|
||||
[bbox.minLng, bbox.minLat],
|
||||
[bbox.maxLng, bbox.maxLat],
|
||||
],
|
||||
{
|
||||
padding: resolvedPadding,
|
||||
maxZoom,
|
||||
duration,
|
||||
}
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getFeatureCollectionBBox(
|
||||
fc: FeatureCollection
|
||||
): { minLng: number; minLat: number; maxLng: number; maxLat: number } | null {
|
||||
const points = fc.features.flatMap((feature) => collectCoordinatePairs(feature.geometry.coordinates));
|
||||
if (!points.length) return null;
|
||||
|
||||
let minLng = Number.POSITIVE_INFINITY;
|
||||
let minLat = Number.POSITIVE_INFINITY;
|
||||
let maxLng = Number.NEGATIVE_INFINITY;
|
||||
let maxLat = Number.NEGATIVE_INFINITY;
|
||||
|
||||
for (const [lng, lat] of points) {
|
||||
minLng = Math.min(minLng, lng);
|
||||
minLat = Math.min(minLat, lat);
|
||||
maxLng = Math.max(maxLng, lng);
|
||||
maxLat = Math.max(maxLat, lat);
|
||||
}
|
||||
|
||||
return { minLng, minLat, maxLng, maxLat };
|
||||
}
|
||||
|
||||
export function collectCoordinatePairs(value: unknown): Array<[number, number]> {
|
||||
if (!Array.isArray(value)) return [];
|
||||
if (
|
||||
value.length >= 2 &&
|
||||
typeof value[0] === "number" &&
|
||||
typeof value[1] === "number" &&
|
||||
Number.isFinite(value[0]) &&
|
||||
Number.isFinite(value[1])
|
||||
) {
|
||||
return [[value[0], value[1]]];
|
||||
}
|
||||
return value.flatMap((item) => collectCoordinatePairs(item));
|
||||
}
|
||||
|
||||
export function buildPathArrowFeatureCollection(fc: FeatureCollection): FeatureCollection {
|
||||
const features: Feature[] = [];
|
||||
|
||||
for (const feature of fc.features) {
|
||||
if (!isPathFeature(feature)) continue;
|
||||
|
||||
const coordinateGroups = getLineCoordinateGroups(feature.geometry);
|
||||
for (const coordinates of coordinateGroups) {
|
||||
const geometry = buildPathArrowGeometry(coordinates);
|
||||
if (!geometry) continue;
|
||||
features.push({
|
||||
type: "Feature",
|
||||
properties: { ...feature.properties },
|
||||
geometry,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: "FeatureCollection",
|
||||
features,
|
||||
};
|
||||
}
|
||||
|
||||
export function isPathFeature(feature: Feature): boolean {
|
||||
const featureType = getFeatureSemanticType(feature);
|
||||
return Boolean(featureType && PATH_RENDER_BY_TYPE[featureType]);
|
||||
}
|
||||
|
||||
export function getFeatureSemanticType(feature: Feature): string | null {
|
||||
const value = feature.properties.type || feature.properties.entity_type_id || null;
|
||||
return normalizeGeoTypeKey(value);
|
||||
}
|
||||
|
||||
export function buildPathArrowGeometry(coords: [number, number][]): Geometry | null {
|
||||
const sourceCoords = removeDuplicatePathCoords(coords);
|
||||
if (sourceCoords.length < 2) return null;
|
||||
|
||||
const origin = sourceCoords[0];
|
||||
const originLatRad = toRadians(origin[1]);
|
||||
const cosOriginLat = Math.max(Math.cos(originLatRad), 0.000001);
|
||||
const projected = sourceCoords.map((coord) => projectLngLat(coord, origin, cosOriginLat));
|
||||
const measured = buildMeasuredPath(projected);
|
||||
const totalLength = measured[measured.length - 1]?.distance || 0;
|
||||
if (totalLength <= 0) return null;
|
||||
|
||||
const headLength = clampNumber(totalLength * 0.24, totalLength * 0.12, totalLength * 0.45);
|
||||
const bodyEndDistance = Math.max(totalLength - headLength, totalLength * 0.35);
|
||||
const bodyPoints = measured
|
||||
.filter((point) => point.distance < bodyEndDistance)
|
||||
.map(({ x, y, distance }) => ({ x, y, distance }));
|
||||
bodyPoints.push(pointAtDistance(measured, bodyEndDistance));
|
||||
|
||||
if (bodyPoints.length < 2) return null;
|
||||
|
||||
const tailWidth = clampNumber(totalLength * 0.005, 8000, 40000);
|
||||
const shoulderWidth = clampNumber(totalLength * 0.015, 18000, 100000);
|
||||
const headWidth = shoulderWidth * 2.0;
|
||||
|
||||
const leftBody: ProjectedPoint[] = [];
|
||||
const rightBody: ProjectedPoint[] = [];
|
||||
|
||||
for (let i = 0; i < bodyPoints.length; i += 1) {
|
||||
const point = bodyPoints[i];
|
||||
const normal = normalAt(bodyPoints, i);
|
||||
const progress = bodyEndDistance > 0
|
||||
? Math.pow(clampNumber(point.distance / bodyEndDistance, 0, 1), 0.9)
|
||||
: 0;
|
||||
const width = tailWidth + (shoulderWidth - tailWidth) * progress;
|
||||
const half = width / 2;
|
||||
leftBody.push({
|
||||
x: point.x + normal.x * half,
|
||||
y: point.y + normal.y * half,
|
||||
});
|
||||
rightBody.push({
|
||||
x: point.x - normal.x * half,
|
||||
y: point.y - normal.y * half,
|
||||
});
|
||||
}
|
||||
|
||||
const base = bodyPoints[bodyPoints.length - 1];
|
||||
const tip = pointAtDistance(measured, totalLength);
|
||||
const headNormal = normalFromSegment(base, tip) || normalAt(bodyPoints, bodyPoints.length - 1);
|
||||
const headHalf = headWidth / 2;
|
||||
const headBaseLeft = {
|
||||
x: base.x + headNormal.x * headHalf,
|
||||
y: base.y + headNormal.y * headHalf,
|
||||
};
|
||||
const headBaseRight = {
|
||||
x: base.x - headNormal.x * headHalf,
|
||||
y: base.y - headNormal.y * headHalf,
|
||||
};
|
||||
|
||||
const ring = [
|
||||
...leftBody,
|
||||
headBaseLeft,
|
||||
{ x: tip.x, y: tip.y },
|
||||
headBaseRight,
|
||||
...rightBody.reverse(),
|
||||
leftBody[0],
|
||||
].map((point) => unprojectLngLat(point, origin, cosOriginLat));
|
||||
|
||||
if (ring.length < 4) return null;
|
||||
return {
|
||||
type: "Polygon",
|
||||
coordinates: [ring],
|
||||
};
|
||||
}
|
||||
|
||||
export type ProjectedPoint = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export type MeasuredPoint = ProjectedPoint & {
|
||||
distance: number;
|
||||
};
|
||||
|
||||
export function removeDuplicatePathCoords(coords: [number, number][]): [number, number][] {
|
||||
const result: [number, number][] = [];
|
||||
for (const coord of coords) {
|
||||
const last = result[result.length - 1];
|
||||
if (last && last[0] === coord[0] && last[1] === coord[1]) continue;
|
||||
result.push(coord);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function projectLngLat(
|
||||
coord: [number, number],
|
||||
origin: [number, number],
|
||||
cosOriginLat: number
|
||||
): ProjectedPoint {
|
||||
const earthRadiusMeters = 6371008.8;
|
||||
return {
|
||||
x: toRadians(coord[0] - origin[0]) * earthRadiusMeters * cosOriginLat,
|
||||
y: toRadians(coord[1] - origin[1]) * earthRadiusMeters,
|
||||
};
|
||||
}
|
||||
|
||||
export function unprojectLngLat(
|
||||
point: ProjectedPoint,
|
||||
origin: [number, number],
|
||||
cosOriginLat: number
|
||||
): [number, number] {
|
||||
const earthRadiusMeters = 6371008.8;
|
||||
return [
|
||||
origin[0] + toDegrees(point.x / (earthRadiusMeters * cosOriginLat)),
|
||||
origin[1] + toDegrees(point.y / earthRadiusMeters),
|
||||
];
|
||||
}
|
||||
|
||||
export function buildMeasuredPath(points: ProjectedPoint[]): MeasuredPoint[] {
|
||||
let distance = 0;
|
||||
return points.map((point, index) => {
|
||||
if (index > 0) {
|
||||
distance += distanceProjected(points[index - 1], point);
|
||||
}
|
||||
return {
|
||||
...point,
|
||||
distance,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function pointAtDistance(points: MeasuredPoint[], targetDistance: number): MeasuredPoint {
|
||||
if (targetDistance <= 0) return points[0];
|
||||
for (let i = 1; i < points.length; i += 1) {
|
||||
const prev = points[i - 1];
|
||||
const next = points[i];
|
||||
if (targetDistance > next.distance) continue;
|
||||
const segmentLength = next.distance - prev.distance;
|
||||
const t = segmentLength > 0 ? (targetDistance - prev.distance) / segmentLength : 0;
|
||||
return {
|
||||
x: prev.x + (next.x - prev.x) * t,
|
||||
y: prev.y + (next.y - prev.y) * t,
|
||||
distance: targetDistance,
|
||||
};
|
||||
}
|
||||
return points[points.length - 1];
|
||||
}
|
||||
|
||||
export function normalAt(points: ProjectedPoint[], index: number): ProjectedPoint {
|
||||
const prev = points[Math.max(0, index - 1)];
|
||||
const next = points[Math.min(points.length - 1, index + 1)];
|
||||
return normalFromSegment(prev, next) || { x: 0, y: 1 };
|
||||
}
|
||||
|
||||
export function normalFromSegment(a: ProjectedPoint, b: ProjectedPoint): ProjectedPoint | null {
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (length <= 0) return null;
|
||||
return {
|
||||
x: -dy / length,
|
||||
y: dx / length,
|
||||
};
|
||||
}
|
||||
|
||||
export function distanceProjected(a: ProjectedPoint, b: ProjectedPoint): number {
|
||||
return Math.hypot(b.x - a.x, b.y - a.y);
|
||||
}
|
||||
|
||||
export function toRadians(value: number): number {
|
||||
return (value * Math.PI) / 180;
|
||||
}
|
||||
|
||||
export function toDegrees(value: number): number {
|
||||
return (value * 180) / Math.PI;
|
||||
}
|
||||
|
||||
export function ensurePathArrowIcon(map: maplibregl.Map): boolean {
|
||||
if (map.hasImage(PATH_ARROW_ICON_ID)) return true;
|
||||
const imageData = createPathArrowImageData();
|
||||
if (!imageData) return false;
|
||||
map.addImage(PATH_ARROW_ICON_ID, imageData, { pixelRatio: 2 });
|
||||
return true;
|
||||
}
|
||||
|
||||
export function createPathArrowImageData(): ImageData | null {
|
||||
const size = 56;
|
||||
if (typeof document === "undefined") return null;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
|
||||
ctx.strokeStyle = "#0f172a";
|
||||
ctx.fillStyle = "#38bdf8";
|
||||
ctx.lineWidth = 4;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.lineCap = "round";
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(8, 16);
|
||||
ctx.lineTo(28, 16);
|
||||
ctx.lineTo(28, 10);
|
||||
ctx.lineTo(46, 28);
|
||||
ctx.lineTo(28, 46);
|
||||
ctx.lineTo(28, 40);
|
||||
ctx.lineTo(8, 40);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
return ctx.getImageData(0, 0, size, size);
|
||||
}
|
||||
|
||||
export function buildTypeMatchExpression(
|
||||
valueByType: Record<string, string | number | boolean>,
|
||||
fallback: string | number | boolean
|
||||
): maplibregl.ExpressionSpecification {
|
||||
const expression: unknown[] = ["match", getFeatureTypeExpression()];
|
||||
|
||||
for (const [typeId, value] of Object.entries(valueByType)) {
|
||||
expression.push(typeId, value);
|
||||
}
|
||||
|
||||
expression.push(fallback);
|
||||
return expression as maplibregl.ExpressionSpecification;
|
||||
}
|
||||
|
||||
export function getFeatureTypeExpression(): maplibregl.ExpressionSpecification {
|
||||
return [
|
||||
"coalesce",
|
||||
["get", "type"],
|
||||
["get", "entity_type_id"],
|
||||
"",
|
||||
] as maplibregl.ExpressionSpecification;
|
||||
}
|
||||
|
||||
export function roundZoom(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
function createFeatureLabelResolver(fc: FeatureCollection): (feature: Feature) => string | null {
|
||||
const directLabelsByFeatureId = new Map<string, FeatureLabelInfo>();
|
||||
const inheritedLabelsByChildId = new Map<string, FeatureLabelInfo | null>();
|
||||
|
||||
for (const feature of fc.features) {
|
||||
const labelInfo = getSingleEntityFeatureLabelInfo(feature);
|
||||
if (!labelInfo) continue;
|
||||
directLabelsByFeatureId.set(String(feature.properties.id), labelInfo);
|
||||
}
|
||||
|
||||
for (const feature of fc.features) {
|
||||
const parentLabel = directLabelsByFeatureId.get(String(feature.properties.id));
|
||||
const featureId = String(feature.properties.id);
|
||||
const bindingIds = normalizeBindingIds(feature.properties.binding);
|
||||
|
||||
if (parentLabel) {
|
||||
for (const childId of bindingIds) {
|
||||
mergeInheritedFeatureLabel(inheritedLabelsByChildId, childId, parentLabel);
|
||||
}
|
||||
}
|
||||
|
||||
for (const parentId of bindingIds) {
|
||||
const linkedParentLabel = directLabelsByFeatureId.get(parentId);
|
||||
if (linkedParentLabel) {
|
||||
mergeInheritedFeatureLabel(inheritedLabelsByChildId, featureId, linkedParentLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (feature) => {
|
||||
const featureId = String(feature.properties.id);
|
||||
const directEntityIds = getFeatureEntityIds(feature);
|
||||
if (directEntityIds.length > 0) {
|
||||
return directLabelsByFeatureId.get(featureId)?.label || null;
|
||||
}
|
||||
|
||||
return inheritedLabelsByChildId.get(featureId)?.label || null;
|
||||
};
|
||||
}
|
||||
|
||||
function mergeInheritedFeatureLabel(
|
||||
labelsByFeatureId: Map<string, FeatureLabelInfo | null>,
|
||||
targetFeatureId: string,
|
||||
labelInfo: FeatureLabelInfo
|
||||
) {
|
||||
const current = labelsByFeatureId.get(targetFeatureId);
|
||||
if (current === undefined) {
|
||||
labelsByFeatureId.set(targetFeatureId, labelInfo);
|
||||
} else if (current && current.entityId === labelInfo.entityId) {
|
||||
labelsByFeatureId.set(targetFeatureId, current);
|
||||
} else {
|
||||
labelsByFeatureId.set(targetFeatureId, null);
|
||||
}
|
||||
}
|
||||
|
||||
function getSingleEntityFeatureLabelInfo(feature: Feature): FeatureLabelInfo | null {
|
||||
const entityIds = getFeatureEntityIds(feature);
|
||||
if (entityIds.length !== 1) return null;
|
||||
|
||||
const label = getSingleEntityName(feature);
|
||||
if (!label) return null;
|
||||
|
||||
return { entityId: entityIds[0], label };
|
||||
}
|
||||
|
||||
function getFeatureEntityIds(feature: Feature): string[] {
|
||||
const rawEntityIds: unknown[] = Array.isArray(feature.properties.entity_ids)
|
||||
? feature.properties.entity_ids
|
||||
: (typeof feature.properties.entity_id === "string" || typeof feature.properties.entity_id === "number"
|
||||
? [feature.properties.entity_id]
|
||||
: []);
|
||||
|
||||
return Array.from(new Set(
|
||||
rawEntityIds
|
||||
.filter((id): id is string | number => typeof id === "string" || typeof id === "number")
|
||||
.map((id) => String(id).trim())
|
||||
.filter((id) => id.length > 0)
|
||||
));
|
||||
}
|
||||
|
||||
function getSingleEntityName(feature: Feature): string | null {
|
||||
const directName = typeof feature.properties.entity_name === "string"
|
||||
? feature.properties.entity_name.trim()
|
||||
: "";
|
||||
if (directName.length > 0) return directName;
|
||||
|
||||
const names = Array.isArray(feature.properties.entity_names)
|
||||
? Array.from(new Set(
|
||||
feature.properties.entity_names
|
||||
.filter((name): name is string => typeof name === "string")
|
||||
.map((name) => name.trim())
|
||||
.filter((name) => name.length > 0)
|
||||
))
|
||||
: [];
|
||||
|
||||
return names.length === 1 ? names[0] : null;
|
||||
}
|
||||
|
||||
function isLineGeometry(geometry: Geometry): boolean {
|
||||
return geometry.type === "LineString" || geometry.type === "MultiLineString";
|
||||
}
|
||||
|
||||
function getLineCoordinateGroups(geometry: Geometry): Coordinate[][] {
|
||||
if (geometry.type === "LineString") return [geometry.coordinates];
|
||||
if (geometry.type === "MultiLineString") return geometry.coordinates;
|
||||
return [];
|
||||
}
|
||||
|
||||
function getPolygonLabelPoint(geometry: Geometry): Coordinate | null {
|
||||
if (geometry.type === "Polygon") {
|
||||
return getPolygonLabelCandidate(geometry.coordinates)?.point || null;
|
||||
}
|
||||
|
||||
if (geometry.type === "MultiPolygon") {
|
||||
let best: { point: Coordinate; distance: number } | null = null;
|
||||
for (const polygon of geometry.coordinates) {
|
||||
const candidate = getPolygonLabelCandidate(polygon);
|
||||
if (!candidate) continue;
|
||||
if (!best || candidate.distance > best.distance) {
|
||||
best = candidate;
|
||||
}
|
||||
}
|
||||
return best?.point || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getPolygonLabelCandidate(polygon: PolygonCoordinates): { point: Coordinate; distance: number } | null {
|
||||
const outerRing = polygon[0];
|
||||
if (!outerRing || outerRing.length < 3) return null;
|
||||
|
||||
const bbox = getRingBbox(outerRing);
|
||||
if (!bbox) return null;
|
||||
|
||||
const width = bbox.maxX - bbox.minX;
|
||||
const height = bbox.maxY - bbox.minY;
|
||||
if (width <= 0 || height <= 0) {
|
||||
const fallback: Coordinate = [bbox.minX, bbox.minY];
|
||||
return { point: fallback, distance: 0 };
|
||||
}
|
||||
|
||||
const precision = Math.max(Math.max(width, height) / 100, 0.0001);
|
||||
const result = polylabel(polygon, precision);
|
||||
const x = result[0];
|
||||
const y = result[1];
|
||||
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
||||
return { point: [bbox.minX + width / 2, bbox.minY + height / 2], distance: 0 };
|
||||
}
|
||||
|
||||
return { point: [x, y], distance: Number.isFinite(result.distance) ? result.distance : 0 };
|
||||
}
|
||||
|
||||
function getRingBbox(ring: Coordinate[]): { minX: number; minY: number; maxX: number; maxY: number } | null {
|
||||
if (!ring.length) return null;
|
||||
|
||||
let minX = Number.POSITIVE_INFINITY;
|
||||
let minY = Number.POSITIVE_INFINITY;
|
||||
let maxX = Number.NEGATIVE_INFINITY;
|
||||
let maxY = Number.NEGATIVE_INFINITY;
|
||||
|
||||
for (const [x, y] of ring) {
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x);
|
||||
maxY = Math.max(maxY, y);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { minX, minY, maxX, maxY };
|
||||
}
|
||||
|
||||
export function buildClientFeatureId(): string {
|
||||
return newId();
|
||||
}
|
||||
|
||||
export function clampNumber(value: number, min: number, max: number): number {
|
||||
if (value < min) return min;
|
||||
if (value > max) return max;
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { MAP_MAX_ZOOM, MAP_MIN_ZOOM } from "@/uhm/lib/map/constants";
|
||||
import { clampNumber, roundZoom } from "./mapUtils";
|
||||
import { getBaseMapStyle } from "./useMapLayers";
|
||||
|
||||
const MAP_PROJECTION_STORAGE_KEY = "uhm:mapProjection";
|
||||
|
||||
export function applyMapProjection(map: maplibregl.Map, isGlobe: boolean) {
|
||||
map.setProjection({ type: isGlobe ? "globe" : "mercator" });
|
||||
}
|
||||
|
||||
export function useMapInstance() {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||
const [fatalInitError, setFatalInitError] = useState<string | null>(null);
|
||||
|
||||
const [zoomLevel, setZoomLevel] = useState(2);
|
||||
const [zoomBounds, setZoomBounds] = useState({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
|
||||
|
||||
const [isGlobeProjection, setIsGlobeProjection] = useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return window.localStorage.getItem(MAP_PROJECTION_STORAGE_KEY) === "globe";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const [isMapLoaded, setIsMapLoaded] = useState(false);
|
||||
const geolocationCenteredRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
MAP_PROJECTION_STORAGE_KEY,
|
||||
isGlobeProjection ? "globe" : "mercator"
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [isGlobeProjection]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
const map = new maplibregl.Map({
|
||||
container,
|
||||
attributionControl: false,
|
||||
minZoom: MAP_MIN_ZOOM,
|
||||
maxZoom: MAP_MAX_ZOOM,
|
||||
style: getBaseMapStyle(),
|
||||
center: [0, 20],
|
||||
zoom: 2,
|
||||
});
|
||||
|
||||
mapRef.current = map;
|
||||
|
||||
const syncZoomLevel = () => {
|
||||
setZoomLevel(roundZoom(map.getZoom()));
|
||||
};
|
||||
|
||||
map.on("load", () => {
|
||||
setZoomBounds({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
|
||||
syncZoomLevel();
|
||||
map.on("zoom", syncZoomLevel);
|
||||
setIsMapLoaded(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
map.off("zoom", syncZoomLevel);
|
||||
setIsMapLoaded(false);
|
||||
if (mapRef.current === map) {
|
||||
mapRef.current = null;
|
||||
}
|
||||
map.remove();
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("Map initialization failed", err);
|
||||
setFatalInitError(err instanceof Error ? err.message : "Map initialization failed.");
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Sync Map Projection
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
const apply = () => {
|
||||
if (mapRef.current !== map) return;
|
||||
if (typeof map.isStyleLoaded === "function" && !map.isStyleLoaded()) return;
|
||||
applyMapProjection(map, isGlobeProjection);
|
||||
};
|
||||
|
||||
if (typeof map.isStyleLoaded === "function" && map.isStyleLoaded()) {
|
||||
apply();
|
||||
return;
|
||||
}
|
||||
|
||||
map.once("load", apply);
|
||||
map.once("style.load", apply);
|
||||
return () => {
|
||||
map.off("load", apply);
|
||||
map.off("style.load", apply);
|
||||
};
|
||||
}, [isGlobeProjection]);
|
||||
|
||||
const handleZoomByStep = useCallback((delta: number) => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
setZoomLevel((prev) => {
|
||||
const next = clampNumber(prev + delta, zoomBounds.min, zoomBounds.max);
|
||||
map.easeTo({ zoom: next, duration: 120 });
|
||||
return next;
|
||||
});
|
||||
}, [zoomBounds]);
|
||||
|
||||
const handleZoomSliderChange = useCallback((nextRaw: number) => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !Number.isFinite(nextRaw)) return;
|
||||
const next = clampNumber(nextRaw, zoomBounds.min, zoomBounds.max);
|
||||
map.easeTo({ zoom: next, duration: 80 });
|
||||
setZoomLevel(next);
|
||||
}, [zoomBounds]);
|
||||
|
||||
return {
|
||||
mapRef,
|
||||
containerRef,
|
||||
fatalInitError,
|
||||
setFatalInitError,
|
||||
zoomLevel,
|
||||
zoomBounds,
|
||||
isGlobeProjection,
|
||||
setIsGlobeProjection,
|
||||
isMapLoaded,
|
||||
geolocationCenteredRef,
|
||||
handleZoomByStep,
|
||||
handleZoomSliderChange,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { initDrawing } from "@/uhm/lib/map/engines/drawingEngine";
|
||||
import { initSelect } from "@/uhm/lib/map/engines/selectingEngine";
|
||||
import { initPoint } from "@/uhm/lib/map/engines/pointEngine";
|
||||
import { initLine } from "@/uhm/lib/map/engines/lineEngine";
|
||||
import { initPath } from "@/uhm/lib/map/engines/pathEngine";
|
||||
import { initCircle } from "@/uhm/lib/map/engines/circleEngine";
|
||||
import { createEditingEngine } from "@/uhm/lib/map/engines/editingEngine";
|
||||
import { Feature, FeatureCollection, Geometry } from "@/uhm/lib/editor/state/useEditorState";
|
||||
import { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
|
||||
import { buildClientFeatureId, getSelectableLayers } from "./mapUtils";
|
||||
import { MapHoverPayload } from "../Map";
|
||||
|
||||
type EngineBinding = {
|
||||
cleanup: () => void;
|
||||
cancel?: () => void;
|
||||
clearSelection?: () => void;
|
||||
};
|
||||
|
||||
type UseMapInteractionProps = {
|
||||
mapRef: React.MutableRefObject<maplibregl.Map | null>;
|
||||
mode: EditorMode;
|
||||
modeRef: React.MutableRefObject<EditorMode>;
|
||||
draftRef: React.MutableRefObject<FeatureCollection>;
|
||||
allowGeometryEditing: boolean;
|
||||
selectedFeatureIds: (string | number)[];
|
||||
onSelectFeatureIdsRef: React.MutableRefObject<(ids: (string | number)[]) => void>;
|
||||
onSetModeRef: React.MutableRefObject<((mode: EditorMode) => void) | undefined>;
|
||||
onCreateRef: React.MutableRefObject<((feature: FeatureCollection["features"][number]) => void) | undefined>;
|
||||
onDeleteRef: React.MutableRefObject<((id: string | number) => void) | undefined>;
|
||||
onUpdateRef: React.MutableRefObject<((id: string | number, geometry: Geometry) => void) | undefined>;
|
||||
onHoverFeatureChangeRef: React.MutableRefObject<((payload: MapHoverPayload | null) => void) | undefined>;
|
||||
};
|
||||
|
||||
export function useMapInteraction({
|
||||
mapRef,
|
||||
mode,
|
||||
modeRef,
|
||||
draftRef,
|
||||
allowGeometryEditing,
|
||||
selectedFeatureIds,
|
||||
onSelectFeatureIdsRef,
|
||||
onSetModeRef,
|
||||
onCreateRef,
|
||||
onDeleteRef,
|
||||
onUpdateRef,
|
||||
onHoverFeatureChangeRef,
|
||||
}: UseMapInteractionProps) {
|
||||
const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | null>(null);
|
||||
const engineBindingsRef = useRef<Partial<Record<EditorMode, EngineBinding>>>({});
|
||||
const previousModeRef = useRef<EditorMode>(mode);
|
||||
const mapCleanupFnsRef = useRef<Array<() => void>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingEngineRef.current) {
|
||||
editingEngineRef.current = createEditingEngine({
|
||||
mapRef,
|
||||
onUpdate: (id, geometry) => onUpdateRef.current?.(id, geometry),
|
||||
});
|
||||
}
|
||||
}, [mapRef, onUpdateRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "select" || !selectedFeatureIds || selectedFeatureIds.length === 0) {
|
||||
editingEngineRef.current?.clearEditing();
|
||||
}
|
||||
}, [mode, selectedFeatureIds]);
|
||||
|
||||
useEffect(() => {
|
||||
const previousMode = previousModeRef.current;
|
||||
if (previousMode !== mode) {
|
||||
engineBindingsRef.current[previousMode]?.cancel?.();
|
||||
previousModeRef.current = mode;
|
||||
}
|
||||
|
||||
const map = mapRef.current;
|
||||
if (!map || !map.isStyleLoaded()) return;
|
||||
if (mode !== "draw") {
|
||||
(map.getSource("draw-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
});
|
||||
}
|
||||
if (mode !== "add-line") {
|
||||
(map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
});
|
||||
}
|
||||
if (mode !== "add-path") {
|
||||
(map.getSource("draw-path-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
});
|
||||
}
|
||||
if (mode !== "add-circle") {
|
||||
(map.getSource("draw-circle-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
});
|
||||
}
|
||||
}, [mode, mapRef]);
|
||||
|
||||
const setupMapInteractions = (map: maplibregl.Map) => {
|
||||
const drawingEngine = initDrawing(
|
||||
map,
|
||||
() => modeRef.current,
|
||||
(geometry: Geometry) => {
|
||||
const id = buildClientFeatureId();
|
||||
onCreateRef.current?.({
|
||||
type: "Feature",
|
||||
properties: {
|
||||
id,
|
||||
type: "country",
|
||||
geometry_preset: "polygon",
|
||||
entity_id: null,
|
||||
entity_ids: [],
|
||||
entity_name: null,
|
||||
entity_type_id: null,
|
||||
binding: [],
|
||||
},
|
||||
geometry,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const selectEngine = initSelect(
|
||||
map,
|
||||
() => modeRef.current,
|
||||
allowGeometryEditing
|
||||
? (id: string | number) => {
|
||||
editingEngineRef.current?.clearEditing();
|
||||
onSelectFeatureIdsRef.current?.([]);
|
||||
onDeleteRef.current?.(id);
|
||||
}
|
||||
: undefined,
|
||||
allowGeometryEditing
|
||||
? (feature) => {
|
||||
const rawId = feature.id ?? feature.properties?.id;
|
||||
const originalFeature = draftRef.current.features.find(
|
||||
(item) => String(item.properties.id) === String(rawId)
|
||||
);
|
||||
editingEngineRef.current?.beginEditing((originalFeature || feature) as any);
|
||||
}
|
||||
: undefined,
|
||||
(ids) => onSelectFeatureIdsRef.current?.(ids),
|
||||
(id: string | number) => onSetModeRef.current?.("replay", id)
|
||||
);
|
||||
|
||||
const cleanupPoint = initPoint(
|
||||
map,
|
||||
() => modeRef.current,
|
||||
(geometry: Geometry) => {
|
||||
const id = buildClientFeatureId();
|
||||
onCreateRef.current?.({
|
||||
type: "Feature",
|
||||
properties: {
|
||||
id,
|
||||
type: "city",
|
||||
geometry_preset: "point",
|
||||
entity_id: null,
|
||||
entity_ids: [],
|
||||
entity_name: null,
|
||||
entity_type_id: null,
|
||||
binding: [],
|
||||
},
|
||||
geometry,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const lineEngine = initLine(
|
||||
map,
|
||||
() => modeRef.current,
|
||||
(geometry: Geometry) => {
|
||||
const id = buildClientFeatureId();
|
||||
onCreateRef.current?.({
|
||||
type: "Feature",
|
||||
properties: {
|
||||
id,
|
||||
type: "defense_line",
|
||||
geometry_preset: "line",
|
||||
entity_id: null,
|
||||
entity_ids: [],
|
||||
entity_name: null,
|
||||
entity_type_id: null,
|
||||
binding: [],
|
||||
},
|
||||
geometry,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const pathEngine = initPath(
|
||||
map,
|
||||
() => modeRef.current,
|
||||
(geometry: Geometry) => {
|
||||
const id = buildClientFeatureId();
|
||||
onCreateRef.current?.({
|
||||
type: "Feature",
|
||||
properties: {
|
||||
id,
|
||||
type: "attack_route",
|
||||
geometry_preset: "line",
|
||||
entity_id: null,
|
||||
entity_ids: [],
|
||||
entity_name: null,
|
||||
entity_type_id: null,
|
||||
binding: [],
|
||||
},
|
||||
geometry,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const circleEngine = initCircle(
|
||||
map,
|
||||
() => modeRef.current,
|
||||
(geometry: Geometry) => {
|
||||
const id = buildClientFeatureId();
|
||||
onCreateRef.current?.({
|
||||
type: "Feature",
|
||||
properties: {
|
||||
id,
|
||||
type: "war",
|
||||
geometry_preset: "circle-area",
|
||||
entity_id: null,
|
||||
entity_ids: [],
|
||||
entity_name: null,
|
||||
entity_type_id: null,
|
||||
binding: [],
|
||||
},
|
||||
geometry,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
engineBindingsRef.current = {
|
||||
draw: drawingEngine,
|
||||
select: selectEngine,
|
||||
replay: selectEngine,
|
||||
"add-line": lineEngine,
|
||||
"add-path": pathEngine,
|
||||
"add-circle": circleEngine,
|
||||
};
|
||||
|
||||
mapCleanupFnsRef.current.push(
|
||||
circleEngine.cleanup,
|
||||
pathEngine.cleanup,
|
||||
lineEngine.cleanup,
|
||||
cleanupPoint,
|
||||
selectEngine.cleanup,
|
||||
drawingEngine.cleanup
|
||||
);
|
||||
|
||||
const handleHoverMove = (event: maplibregl.MapMouseEvent) => {
|
||||
const callback = onHoverFeatureChangeRef.current;
|
||||
if (!callback) return;
|
||||
|
||||
const selectableLayers = getSelectableLayers(map);
|
||||
if (!selectableLayers.length) {
|
||||
callback(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const features = map.queryRenderedFeatures(event.point, {
|
||||
layers: selectableLayers,
|
||||
}) as maplibregl.MapGeoJSONFeature[];
|
||||
|
||||
const feature = features[0];
|
||||
const rawFeatureId = feature?.id ?? feature?.properties?.id;
|
||||
if (rawFeatureId === undefined || rawFeatureId === null) {
|
||||
callback(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentFeature =
|
||||
draftRef.current.features.find(
|
||||
(item) => String(item.properties.id) === String(rawFeatureId)
|
||||
) || null;
|
||||
|
||||
callback({
|
||||
featureId: rawFeatureId,
|
||||
feature: currentFeature,
|
||||
point: { x: event.point.x, y: event.point.y },
|
||||
lngLat: { lng: event.lngLat.lng, lat: event.lngLat.lat },
|
||||
});
|
||||
};
|
||||
|
||||
const handleCanvasMouseLeave = () => {
|
||||
onHoverFeatureChangeRef.current?.(null);
|
||||
};
|
||||
|
||||
map.on("mousemove", handleHoverMove);
|
||||
mapCleanupFnsRef.current.push(() => map.off("mousemove", handleHoverMove));
|
||||
|
||||
map.getCanvasContainer().addEventListener("mouseleave", handleCanvasMouseLeave);
|
||||
mapCleanupFnsRef.current.push(() => {
|
||||
map.getCanvasContainer().removeEventListener("mouseleave", handleCanvasMouseLeave);
|
||||
});
|
||||
|
||||
if (allowGeometryEditing) {
|
||||
editingEngineRef.current?.bindEditEvents(map);
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupMapInteractions = () => {
|
||||
for (const cleanupFn of mapCleanupFnsRef.current) {
|
||||
cleanupFn();
|
||||
}
|
||||
mapCleanupFnsRef.current = [];
|
||||
engineBindingsRef.current = {};
|
||||
};
|
||||
|
||||
return {
|
||||
editingEngineRef,
|
||||
setupMapInteractions,
|
||||
cleanupMapInteractions,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
import { useEffect } from "react";
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { getVectorTileTemplateUrl } from "@/uhm/api/tiles";
|
||||
import {
|
||||
COUNTRY_FILL_COLOR_EXPRESSION,
|
||||
LINE_COLOR_BY_TYPE,
|
||||
PATH_RENDER_BY_TYPE,
|
||||
POLYGON_FILL_BY_TYPE,
|
||||
POLYGON_OPACITY_BY_TYPE,
|
||||
POLYGON_STROKE_BY_TYPE,
|
||||
} from "@/uhm/lib/map/styles/style";
|
||||
import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants";
|
||||
import { PATH_ARROW_ICON_ID, PATH_ARROW_SOURCE_ID, POLYGON_LABEL_SOURCE_ID } from "@/uhm/lib/map/constants";
|
||||
import { ensurePointGeotypeIcons, getAllGeotypeLabelLayers, getAllGeotypeLayers } from "@/uhm/lib/map/styles/geotypeLayers";
|
||||
import {
|
||||
applyBackgroundLayerVisibility,
|
||||
buildTypeMatchExpression,
|
||||
ensurePathArrowIcon,
|
||||
} from "./mapUtils";
|
||||
import { BackgroundLayerVisibility } from "@/uhm/lib/map/styles/backgroundLayers";
|
||||
import { FeatureCollection } from "@/uhm/lib/editor/state/useEditorState";
|
||||
|
||||
export function getBaseMapStyle(): maplibregl.StyleSpecification {
|
||||
return {
|
||||
version: 8,
|
||||
glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
|
||||
sources: {
|
||||
base: {
|
||||
type: "vector",
|
||||
tiles: [getVectorTileTemplateUrl()],
|
||||
minzoom: 0,
|
||||
maxzoom: 6,
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: "background",
|
||||
type: "background",
|
||||
paint: {
|
||||
"background-color": "#0b1220",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "graticules-line",
|
||||
type: "line",
|
||||
source: "base",
|
||||
"source-layer": "ne_10m_graticules_10",
|
||||
paint: {
|
||||
"line-color": "#334155",
|
||||
"line-width": [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["zoom"],
|
||||
0, 0.3,
|
||||
4, 0.6,
|
||||
6, 0.8,
|
||||
],
|
||||
"line-opacity": 0.55,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "land",
|
||||
type: "fill",
|
||||
source: "base",
|
||||
"source-layer": "ne_10m_land",
|
||||
paint: {
|
||||
"fill-color": "#1e293b",
|
||||
"fill-opacity": 0.25,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "bg-countries-fill",
|
||||
type: "fill",
|
||||
source: "base",
|
||||
"source-layer": "ne_10m_admin_0_countries",
|
||||
paint: {
|
||||
"fill-color": COUNTRY_FILL_COLOR_EXPRESSION,
|
||||
"fill-opacity": 0.38,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "bg-country-borders-line",
|
||||
type: "line",
|
||||
source: "base",
|
||||
"source-layer": "ne_10m_admin_0_boundary_lines_land",
|
||||
paint: {
|
||||
"line-color": "#cbd5e1",
|
||||
"line-width": [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["zoom"],
|
||||
0, 0.2,
|
||||
4, 0.5,
|
||||
6, 1.1,
|
||||
],
|
||||
"line-opacity": 0.85,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "country-labels",
|
||||
type: "symbol",
|
||||
source: "base",
|
||||
"source-layer": "country_labels",
|
||||
minzoom: 0,
|
||||
layout: {
|
||||
"text-field": [
|
||||
"coalesce",
|
||||
["get", "NAME_EN"],
|
||||
["get", "NAME"],
|
||||
["get", "ADMIN"],
|
||||
["get", "name"],
|
||||
"",
|
||||
],
|
||||
"text-size": [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["zoom"],
|
||||
0, 15,
|
||||
1, 16,
|
||||
2, 17,
|
||||
4, 19,
|
||||
6, 23,
|
||||
],
|
||||
"text-padding": 0,
|
||||
"text-max-width": 10,
|
||||
"text-allow-overlap": true,
|
||||
"text-ignore-placement": true,
|
||||
"symbol-placement": "point",
|
||||
},
|
||||
paint: {
|
||||
"text-color": "#e2e8f0",
|
||||
"text-halo-color": "#0b1220",
|
||||
"text-halo-width": 1.2,
|
||||
"text-halo-blur": 0.5,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "regions-line",
|
||||
type: "line",
|
||||
source: "base",
|
||||
"source-layer": "ne_10m_geography_regions_polys",
|
||||
paint: {
|
||||
"line-color": "#475569",
|
||||
"line-width": [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["zoom"],
|
||||
0, 0.2,
|
||||
4, 0.6,
|
||||
6, 1,
|
||||
],
|
||||
"line-opacity": 0.6,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "lakes-fill",
|
||||
type: "fill",
|
||||
source: "base",
|
||||
"source-layer": "ne_10m_lakes",
|
||||
paint: {
|
||||
"fill-color": "#1d4ed8",
|
||||
"fill-opacity": 0.45,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "rivers-line",
|
||||
type: "line",
|
||||
source: "base",
|
||||
"source-layer": "ne_10m_rivers_lake_centerlines",
|
||||
paint: {
|
||||
"line-color": "#38bdf8",
|
||||
"line-width": [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["zoom"],
|
||||
0, 0.25,
|
||||
4, 0.8,
|
||||
6, 1.5,
|
||||
],
|
||||
"line-opacity": 0.85,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "geolines-line",
|
||||
type: "line",
|
||||
source: "base",
|
||||
"source-layer": "ne_10m_geographic_lines",
|
||||
paint: {
|
||||
"line-color": "#94a3b8",
|
||||
"line-width": 1.2,
|
||||
"line-opacity": 0.8,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function setupMapLayers(
|
||||
map: maplibregl.Map,
|
||||
backgroundVisibility: BackgroundLayerVisibility,
|
||||
highlightFeatures: FeatureCollection | null,
|
||||
applyHighlightToMap: (fc: FeatureCollection) => void
|
||||
) {
|
||||
applyBackgroundLayerVisibility(map, backgroundVisibility);
|
||||
const hasPathArrowIcon = ensurePathArrowIcon(map);
|
||||
|
||||
// preview (drawing)
|
||||
map.addSource("draw-preview", {
|
||||
type: "geojson",
|
||||
data: { type: "FeatureCollection", features: [] },
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: "draw-preview-fill",
|
||||
type: "fill",
|
||||
source: "draw-preview",
|
||||
paint: {
|
||||
"fill-color": "#22c55e",
|
||||
"fill-opacity": 0.4,
|
||||
},
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: "draw-preview-line",
|
||||
type: "line",
|
||||
source: "draw-preview",
|
||||
paint: {
|
||||
"line-color": "#16a34a",
|
||||
"line-width": 2,
|
||||
},
|
||||
});
|
||||
|
||||
map.addSource("draw-circle-preview", {
|
||||
type: "geojson",
|
||||
data: { type: "FeatureCollection", features: [] },
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: "draw-circle-preview-fill",
|
||||
type: "fill",
|
||||
source: "draw-circle-preview",
|
||||
paint: {
|
||||
"fill-color": "#0ea5e9",
|
||||
"fill-opacity": 0.25,
|
||||
},
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: "draw-circle-preview-line",
|
||||
type: "line",
|
||||
source: "draw-circle-preview",
|
||||
paint: {
|
||||
"line-color": "#0284c7",
|
||||
"line-width": 2,
|
||||
"line-opacity": 0.95,
|
||||
},
|
||||
});
|
||||
|
||||
map.addSource("draw-line-preview", {
|
||||
type: "geojson",
|
||||
data: { type: "FeatureCollection", features: [] },
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: "draw-line-preview-line",
|
||||
type: "line",
|
||||
source: "draw-line-preview",
|
||||
paint: {
|
||||
"line-color": "#38bdf8",
|
||||
"line-width": 3,
|
||||
"line-opacity": 0.9,
|
||||
"line-dasharray": [1.2, 0.9],
|
||||
},
|
||||
});
|
||||
|
||||
map.addSource("draw-path-preview", {
|
||||
type: "geojson",
|
||||
data: { type: "FeatureCollection", features: [] },
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: "draw-path-preview-line",
|
||||
type: "line",
|
||||
source: "draw-path-preview",
|
||||
paint: {
|
||||
"line-color": "#38bdf8",
|
||||
"line-width": 3,
|
||||
"line-opacity": 0.9,
|
||||
"line-dasharray": [1.2, 0.9],
|
||||
},
|
||||
});
|
||||
|
||||
if (hasPathArrowIcon) {
|
||||
map.addLayer({
|
||||
id: "draw-path-preview-arrows",
|
||||
type: "symbol",
|
||||
source: "draw-path-preview",
|
||||
layout: {
|
||||
"symbol-placement": "line",
|
||||
"symbol-spacing": 56,
|
||||
"icon-image": PATH_ARROW_ICON_ID,
|
||||
"icon-size": 0.45,
|
||||
"icon-allow-overlap": true,
|
||||
"icon-ignore-placement": true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// data
|
||||
map.addSource("countries", {
|
||||
type: "geojson",
|
||||
data: { type: "FeatureCollection", features: [] },
|
||||
promoteId: "id",
|
||||
});
|
||||
|
||||
map.addSource(PATH_ARROW_SOURCE_ID, {
|
||||
type: "geojson",
|
||||
data: EMPTY_FEATURE_COLLECTION,
|
||||
promoteId: "id",
|
||||
});
|
||||
|
||||
map.addSource("places", {
|
||||
type: "geojson",
|
||||
data: { type: "FeatureCollection", features: [] },
|
||||
promoteId: "id",
|
||||
});
|
||||
|
||||
map.addSource(POLYGON_LABEL_SOURCE_ID, {
|
||||
type: "geojson",
|
||||
data: EMPTY_FEATURE_COLLECTION,
|
||||
promoteId: "id",
|
||||
});
|
||||
|
||||
ensurePointGeotypeIcons(map);
|
||||
|
||||
const geotypeLayers = getAllGeotypeLayers("countries", PATH_ARROW_SOURCE_ID, "places");
|
||||
for (const layer of geotypeLayers) {
|
||||
map.addLayer(layer);
|
||||
}
|
||||
|
||||
const geotypeLabelLayers = getAllGeotypeLabelLayers(POLYGON_LABEL_SOURCE_ID, "countries");
|
||||
for (const layer of geotypeLabelLayers) {
|
||||
map.addLayer(layer);
|
||||
}
|
||||
|
||||
// editing overlays
|
||||
map.addSource("edit-shape", {
|
||||
type: "geojson",
|
||||
data: { type: "FeatureCollection", features: [] },
|
||||
});
|
||||
map.addSource("edit-handles", {
|
||||
type: "geojson",
|
||||
data: { type: "FeatureCollection", features: [] },
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: "edit-shape-line",
|
||||
type: "line",
|
||||
source: "edit-shape",
|
||||
paint: {
|
||||
"line-color": "#38bdf8",
|
||||
"line-width": 3,
|
||||
},
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: "edit-handles-circle",
|
||||
type: "circle",
|
||||
source: "edit-handles",
|
||||
paint: {
|
||||
"circle-color": "#f97316",
|
||||
"circle-radius": 12,
|
||||
"circle-stroke-color": "#0f172a",
|
||||
"circle-stroke-width": 3,
|
||||
},
|
||||
});
|
||||
|
||||
map.addSource("entity-focus", {
|
||||
type: "geojson",
|
||||
data: EMPTY_FEATURE_COLLECTION,
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: "entity-focus-fill",
|
||||
type: "fill",
|
||||
source: "entity-focus",
|
||||
filter: [
|
||||
"any",
|
||||
["==", ["geometry-type"], "Polygon"],
|
||||
["==", ["geometry-type"], "MultiPolygon"],
|
||||
],
|
||||
paint: {
|
||||
"fill-color": "#fde047",
|
||||
"fill-opacity": 0.2,
|
||||
},
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: "entity-focus-line",
|
||||
type: "line",
|
||||
source: "entity-focus",
|
||||
paint: {
|
||||
"line-color": "#f59e0b",
|
||||
"line-width": [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["zoom"],
|
||||
1, 2.4,
|
||||
4, 4,
|
||||
6, 5.5,
|
||||
],
|
||||
"line-opacity": 0.98,
|
||||
},
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: "entity-focus-points",
|
||||
type: "circle",
|
||||
source: "entity-focus",
|
||||
filter: [
|
||||
"any",
|
||||
["==", ["geometry-type"], "Point"],
|
||||
["==", ["geometry-type"], "MultiPoint"],
|
||||
],
|
||||
paint: {
|
||||
"circle-color": "#f8fafc",
|
||||
"circle-radius": 8,
|
||||
"circle-stroke-color": "#f59e0b",
|
||||
"circle-stroke-width": 3,
|
||||
"circle-opacity": 1,
|
||||
},
|
||||
});
|
||||
applyHighlightToMap(highlightFeatures || EMPTY_FEATURE_COLLECTION);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { FeatureCollection } from "@/uhm/lib/editor/state/useEditorState";
|
||||
import { BackgroundLayerVisibility } from "@/uhm/lib/map/styles/backgroundLayers";
|
||||
import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants";
|
||||
import { FEATURE_STATE_SOURCE_IDS, PATH_ARROW_SOURCE_ID, POLYGON_LABEL_SOURCE_ID } from "@/uhm/lib/map/constants";
|
||||
import {
|
||||
applyBackgroundLayerVisibility,
|
||||
buildPolygonLabelFeatureCollection,
|
||||
buildPathArrowFeatureCollection,
|
||||
decorateLineFeaturesWithLabels,
|
||||
decoratePointFeaturesWithLabels,
|
||||
filterDraftByBinding,
|
||||
filterDraftByGeometryVisibility,
|
||||
fitMapToFeatureCollection,
|
||||
setSelectedFeatureState,
|
||||
splitDraftFeatures,
|
||||
} from "./mapUtils";
|
||||
|
||||
type UseMapSyncProps = {
|
||||
mapRef: React.MutableRefObject<maplibregl.Map | null>;
|
||||
draft: FeatureCollection;
|
||||
labelContextDraft?: FeatureCollection;
|
||||
backgroundVisibility: BackgroundLayerVisibility;
|
||||
geometryVisibility?: Record<string, boolean>;
|
||||
selectedFeatureIds: (string | number)[];
|
||||
respectBindingFilter: boolean;
|
||||
fitToDraftBounds: boolean;
|
||||
fitBoundsKey?: string | number | null;
|
||||
highlightFeatures?: FeatureCollection | null;
|
||||
focusFeatureCollection?: FeatureCollection | null;
|
||||
focusRequestKey?: string | number | null;
|
||||
focusPadding?: number | maplibregl.PaddingOptions;
|
||||
allowGeometryEditing: boolean;
|
||||
editingEngineRef: React.MutableRefObject<{
|
||||
editingRef: React.MutableRefObject<{ id: string | number } | null>;
|
||||
clearEditing: () => void;
|
||||
} | null>;
|
||||
geolocationCenteredRef: React.MutableRefObject<boolean>;
|
||||
};
|
||||
|
||||
export function useMapSync({
|
||||
mapRef,
|
||||
draft,
|
||||
labelContextDraft,
|
||||
backgroundVisibility,
|
||||
geometryVisibility,
|
||||
selectedFeatureIds,
|
||||
respectBindingFilter,
|
||||
fitToDraftBounds,
|
||||
fitBoundsKey,
|
||||
highlightFeatures,
|
||||
focusFeatureCollection,
|
||||
focusRequestKey,
|
||||
focusPadding,
|
||||
allowGeometryEditing,
|
||||
editingEngineRef,
|
||||
geolocationCenteredRef,
|
||||
}: UseMapSyncProps) {
|
||||
const draftRef = useRef<FeatureCollection>(draft);
|
||||
const labelContextDraftRef = useRef<FeatureCollection | undefined>(labelContextDraft);
|
||||
const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility);
|
||||
const geometryVisibilityRef = useRef<Record<string, boolean> | undefined>(geometryVisibility);
|
||||
const selectedFeatureIdsRef = useRef<(string | number)[]>(selectedFeatureIds);
|
||||
const respectBindingFilterRef = useRef(respectBindingFilter);
|
||||
const fitToDraftBoundsRef = useRef(fitToDraftBounds);
|
||||
const highlightFeaturesRef = useRef<FeatureCollection | null>(highlightFeatures || null);
|
||||
|
||||
const fitBoundsAppliedRef = useRef(false);
|
||||
|
||||
useEffect(() => { draftRef.current = draft; }, [draft]);
|
||||
useEffect(() => { labelContextDraftRef.current = labelContextDraft; }, [labelContextDraft]);
|
||||
useEffect(() => { backgroundVisibilityRef.current = backgroundVisibility; }, [backgroundVisibility]);
|
||||
useEffect(() => { geometryVisibilityRef.current = geometryVisibility; }, [geometryVisibility]);
|
||||
useEffect(() => { selectedFeatureIdsRef.current = selectedFeatureIds; }, [selectedFeatureIds]);
|
||||
useEffect(() => { respectBindingFilterRef.current = respectBindingFilter; }, [respectBindingFilter]);
|
||||
useEffect(() => { fitToDraftBoundsRef.current = fitToDraftBounds; }, [fitToDraftBounds]);
|
||||
useEffect(() => { highlightFeaturesRef.current = highlightFeatures || null; }, [highlightFeatures]);
|
||||
|
||||
useEffect(() => {
|
||||
fitBoundsAppliedRef.current = false;
|
||||
}, [fitBoundsKey]);
|
||||
|
||||
const applyDraftToMap = useCallback((fc: FeatureCollection) => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const countriesSource = map.getSource("countries") as maplibregl.GeoJSONSource | undefined;
|
||||
const placesSource = map.getSource("places") as maplibregl.GeoJSONSource | undefined;
|
||||
const polygonLabelSource = map.getSource(POLYGON_LABEL_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
|
||||
|
||||
if (!countriesSource || !placesSource || !polygonLabelSource) return;
|
||||
|
||||
for (const sourceId of FEATURE_STATE_SOURCE_IDS) {
|
||||
if (map.getSource(sourceId)) {
|
||||
map.removeFeatureState({ source: sourceId });
|
||||
}
|
||||
}
|
||||
|
||||
const visibleDraftRaw = respectBindingFilterRef.current
|
||||
? filterDraftByBinding(fc, selectedFeatureIdsRef.current, highlightFeaturesRef.current)
|
||||
: fc;
|
||||
const visibleDraft = filterDraftByGeometryVisibility(visibleDraftRaw, geometryVisibilityRef.current);
|
||||
const labelContext = labelContextDraftRef.current || fc;
|
||||
const { polygons, points } = splitDraftFeatures(visibleDraft);
|
||||
const labeledGeometries = decorateLineFeaturesWithLabels(polygons, labelContext);
|
||||
const labeledPoints = decoratePointFeaturesWithLabels(points, labelContext);
|
||||
const polygonLabels = buildPolygonLabelFeatureCollection(polygons, labelContext);
|
||||
const pathArrowShapes = buildPathArrowFeatureCollection(visibleDraft);
|
||||
|
||||
countriesSource.setData(labeledGeometries);
|
||||
placesSource.setData(labeledPoints);
|
||||
polygonLabelSource.setData(polygonLabels);
|
||||
(map.getSource(PATH_ARROW_SOURCE_ID) as maplibregl.GeoJSONSource | undefined)?.setData(pathArrowShapes);
|
||||
|
||||
const currentSelectedIds = selectedFeatureIdsRef.current;
|
||||
currentSelectedIds.forEach((id) => {
|
||||
setSelectedFeatureState(map, id, true);
|
||||
});
|
||||
requestAnimationFrame(() => {
|
||||
if (mapRef.current !== map) return;
|
||||
currentSelectedIds.forEach((id) => {
|
||||
setSelectedFeatureState(map, id, true);
|
||||
});
|
||||
});
|
||||
if (fitToDraftBoundsRef.current && !fitBoundsAppliedRef.current) {
|
||||
fitBoundsAppliedRef.current = fitMapToFeatureCollection(map, visibleDraft);
|
||||
}
|
||||
}, [mapRef]);
|
||||
|
||||
const applyHighlightToMap = useCallback((fc: FeatureCollection) => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const source = map.getSource("entity-focus") as maplibregl.GeoJSONSource | undefined;
|
||||
if (!source) return;
|
||||
source.setData(fc);
|
||||
}, [mapRef]);
|
||||
|
||||
const tryCenterToUserLocation = useCallback(() => {
|
||||
if (geolocationCenteredRef.current) return;
|
||||
if (fitToDraftBoundsRef.current) return;
|
||||
if (typeof window === "undefined") return;
|
||||
if (!("geolocation" in navigator)) return;
|
||||
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
geolocationCenteredRef.current = true;
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
if (mapRef.current !== map) return;
|
||||
const { longitude, latitude } = pos.coords;
|
||||
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return;
|
||||
|
||||
const currentZoom = map.getZoom();
|
||||
const nextZoom = Number.isFinite(currentZoom) ? Math.max(currentZoom, 5) : 5;
|
||||
map.easeTo({ center: [longitude, latitude], zoom: nextZoom, duration: 900 });
|
||||
},
|
||||
() => { },
|
||||
{ enableHighAccuracy: false, timeout: 4000, maximumAge: 60_000 }
|
||||
);
|
||||
}, [mapRef, geolocationCenteredRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !map.isStyleLoaded()) return;
|
||||
applyBackgroundLayerVisibility(map, backgroundVisibility);
|
||||
}, [backgroundVisibility, mapRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !map.isStyleLoaded()) return;
|
||||
const source = map.getSource("entity-focus") as maplibregl.GeoJSONSource | undefined;
|
||||
source?.setData(highlightFeatures || EMPTY_FEATURE_COLLECTION);
|
||||
}, [highlightFeatures, mapRef]);
|
||||
|
||||
useEffect(() => {
|
||||
applyDraftToMap(draft);
|
||||
const editingId = editingEngineRef.current?.editingRef?.current?.id;
|
||||
if (allowGeometryEditing && editingId !== undefined && editingId !== null) {
|
||||
const stillExists = draft.features.some((f) => f.properties.id === editingId);
|
||||
if (!stillExists) {
|
||||
editingEngineRef.current?.clearEditing();
|
||||
}
|
||||
}
|
||||
}, [
|
||||
allowGeometryEditing,
|
||||
draft,
|
||||
labelContextDraft,
|
||||
selectedFeatureIds,
|
||||
respectBindingFilter,
|
||||
geometryVisibility,
|
||||
highlightFeatures,
|
||||
applyDraftToMap,
|
||||
editingEngineRef,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusRequestKey === null || focusRequestKey === undefined) return;
|
||||
const map = mapRef.current;
|
||||
const target = focusFeatureCollection;
|
||||
if (!target || !target.features.length) return;
|
||||
if (!map) return;
|
||||
|
||||
let cancelled = false;
|
||||
let rafId: number | null = null;
|
||||
|
||||
const focus = () => {
|
||||
if (cancelled || mapRef.current !== map || !map.isStyleLoaded()) return;
|
||||
fitMapToFeatureCollection(map, target, focusPadding, {
|
||||
duration: 550,
|
||||
maxZoom: 10,
|
||||
pointZoom: 9,
|
||||
});
|
||||
};
|
||||
|
||||
if (map.isStyleLoaded()) {
|
||||
rafId = requestAnimationFrame(focus);
|
||||
} else {
|
||||
map.once("idle", focus);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [focusFeatureCollection, focusPadding, focusRequestKey, mapRef]);
|
||||
|
||||
return {
|
||||
applyDraftToMap,
|
||||
applyHighlightToMap,
|
||||
tryCenterToUserLocation,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
"use client";
|
||||
|
||||
import { FIXED_TIMELINE_END_YEAR, FIXED_TIMELINE_START_YEAR, clampYearValue } from "@/uhm/lib/utils/timeline";
|
||||
|
||||
type Props = {
|
||||
year: number;
|
||||
onYearChange: (year: number) => void;
|
||||
timeRange?: number;
|
||||
onTimeRangeChange?: (range: number) => void;
|
||||
isLoading: boolean;
|
||||
disabled: boolean;
|
||||
statusText?: string | null;
|
||||
filterEnabled?: boolean;
|
||||
onFilterEnabledChange?: (enabled: boolean) => void;
|
||||
};
|
||||
|
||||
export default function TimelineBar({
|
||||
year,
|
||||
onYearChange,
|
||||
timeRange,
|
||||
onTimeRangeChange,
|
||||
isLoading,
|
||||
disabled,
|
||||
statusText,
|
||||
filterEnabled,
|
||||
onFilterEnabledChange,
|
||||
}: Props) {
|
||||
const lower = FIXED_TIMELINE_START_YEAR;
|
||||
const upper = FIXED_TIMELINE_END_YEAR;
|
||||
const effectiveDisabled = disabled;
|
||||
const safeYear = clampYearValue(year, lower, upper);
|
||||
|
||||
const helperText = isLoading
|
||||
? "Đang tải geometry theo mốc thời gian..."
|
||||
: statusText || null;
|
||||
|
||||
const handleYearChange = (nextYear: number) => {
|
||||
onYearChange(clampYearValue(Math.trunc(nextYear), lower, upper));
|
||||
};
|
||||
|
||||
const handleTimeRangeChange = (nextValue: number) => {
|
||||
if (!onTimeRangeChange) return;
|
||||
const safe = Number.isFinite(nextValue) ? Math.trunc(nextValue) : 0;
|
||||
onTimeRangeChange(Math.max(0, Math.min(30, safe)));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "18px",
|
||||
right: "18px",
|
||||
bottom: "16px",
|
||||
zIndex: 10,
|
||||
background: "rgba(15, 23, 42, 0.9)",
|
||||
border: "1px solid rgba(148, 163, 184, 0.3)",
|
||||
borderRadius: "10px",
|
||||
padding: "10px 12px",
|
||||
color: "#e2e8f0",
|
||||
backdropFilter: "blur(2px)",
|
||||
}}
|
||||
title={helperText || undefined}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
{typeof filterEnabled === "boolean" && onFilterEnabledChange ? (
|
||||
<label
|
||||
title={filterEnabled ? "Dang bat loc timeline" : "Dang tat loc timeline (hien thi tat ca geometry)"}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
cursor: effectiveDisabled ? "not-allowed" : "pointer",
|
||||
userSelect: "none",
|
||||
opacity: effectiveDisabled ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: 36,
|
||||
height: 20,
|
||||
borderRadius: 999,
|
||||
border: "1px solid rgba(148, 163, 184, 0.45)",
|
||||
background: filterEnabled ? "rgba(34, 197, 94, 0.9)" : "rgba(148, 163, 184, 0.25)",
|
||||
position: "relative",
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 2,
|
||||
left: filterEnabled ? 18 : 2,
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 999,
|
||||
background: "#0b1220",
|
||||
border: "1px solid rgba(148, 163, 184, 0.35)",
|
||||
transition: "left 120ms ease",
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filterEnabled}
|
||||
onChange={(e) => onFilterEnabledChange(e.target.checked)}
|
||||
disabled={effectiveDisabled}
|
||||
aria-label="Toggle timeline filter"
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<span style={{ color: "#94a3b8", minWidth: 44 }}>{formatYear(lower)}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={lower}
|
||||
max={upper}
|
||||
step={1}
|
||||
value={safeYear}
|
||||
onChange={(event) => handleYearChange(Number(event.target.value))}
|
||||
disabled={effectiveDisabled}
|
||||
aria-label="Timeline year"
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
accentColor: "#22c55e",
|
||||
cursor: effectiveDisabled ? "not-allowed" : "pointer",
|
||||
opacity: effectiveDisabled ? 0.6 : 1,
|
||||
}}
|
||||
/>
|
||||
<span style={{ color: "#94a3b8", minWidth: 44, textAlign: "right" }}>
|
||||
{formatYear(upper)}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={lower}
|
||||
max={upper}
|
||||
step={1}
|
||||
value={safeYear}
|
||||
onChange={(event) => handleYearChange(Number(event.target.value))}
|
||||
disabled={effectiveDisabled}
|
||||
aria-label="Timeline exact year"
|
||||
style={{
|
||||
width: "128px",
|
||||
border: "1px solid rgba(148, 163, 184, 0.45)",
|
||||
borderRadius: "6px",
|
||||
padding: "6px 8px",
|
||||
background: "rgba(15, 23, 42, 0.7)",
|
||||
color: "#f8fafc",
|
||||
fontSize: "13px",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
{typeof timeRange === "number" && onTimeRangeChange ? (
|
||||
<label
|
||||
title="time_range (0-30)"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
color: "#94a3b8",
|
||||
whiteSpace: "nowrap",
|
||||
opacity: effectiveDisabled ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "12px" }}>Range</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={30}
|
||||
step={1}
|
||||
value={Math.max(0, Math.min(30, Math.trunc(timeRange)))}
|
||||
onChange={(event) => handleTimeRangeChange(Number(event.target.value))}
|
||||
disabled={effectiveDisabled}
|
||||
aria-label="Timeline range"
|
||||
style={{
|
||||
width: "84px",
|
||||
border: "1px solid rgba(148, 163, 184, 0.45)",
|
||||
borderRadius: "6px",
|
||||
padding: "6px 8px",
|
||||
background: "rgba(15, 23, 42, 0.7)",
|
||||
color: "#f8fafc",
|
||||
fontSize: "13px",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatYear(year: number): string {
|
||||
if (year < 0) {
|
||||
return `${Math.abs(year)} TCN`;
|
||||
}
|
||||
return `${year}`;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type CSSProperties } from "react";
|
||||
|
||||
export type UnifiedSearchKind = "entity" | "wiki" | "geo";
|
||||
|
||||
type Props = {
|
||||
kind: UnifiedSearchKind;
|
||||
onKindChange: (kind: UnifiedSearchKind) => void;
|
||||
query: string;
|
||||
onQueryChange: (query: string) => void;
|
||||
disabledGeo?: boolean;
|
||||
debounceMs?: number;
|
||||
onLocalQueryChange?: (query: string) => void;
|
||||
};
|
||||
|
||||
export default function UnifiedSearchBar({
|
||||
kind,
|
||||
onKindChange,
|
||||
query,
|
||||
onQueryChange,
|
||||
disabledGeo,
|
||||
debounceMs = 300,
|
||||
onLocalQueryChange,
|
||||
}: Props) {
|
||||
// Local input state to avoid propagating query changes (and triggering API) on every keystroke.
|
||||
const [localQuery, setLocalQuery] = useState(query);
|
||||
const debounceTimerRef = useRef<number | null>(null);
|
||||
|
||||
// Keep local input in sync when parent updates `query` externally (e.g. reset, preset, navigation).
|
||||
useEffect(() => {
|
||||
setLocalQuery(query);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
onLocalQueryChange?.(localQuery);
|
||||
}, [localQuery, onLocalQueryChange]);
|
||||
|
||||
// Debounce propagation upwards.
|
||||
useEffect(() => {
|
||||
if (localQuery === query) return;
|
||||
|
||||
if (debounceTimerRef.current != null) window.clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = window.setTimeout(() => {
|
||||
onQueryChange(localQuery);
|
||||
}, debounceMs);
|
||||
|
||||
return () => {
|
||||
if (debounceTimerRef.current != null) window.clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
};
|
||||
}, [localQuery, query, onQueryChange, debounceMs]);
|
||||
|
||||
const commitNow = () => {
|
||||
if (debounceTimerRef.current != null) window.clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
if (localQuery !== query) onQueryChange(localQuery);
|
||||
};
|
||||
|
||||
const selectStyle: CSSProperties = {
|
||||
width: 110,
|
||||
border: "1px solid #1f2937",
|
||||
background: "#0b1220",
|
||||
color: "#e5e7eb",
|
||||
borderRadius: 6,
|
||||
padding: "8px 10px",
|
||||
fontSize: 12,
|
||||
outline: "none",
|
||||
flex: "0 0 auto",
|
||||
};
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
width: "100%",
|
||||
border: "1px solid #1f2937",
|
||||
background: "#0b1220",
|
||||
color: "#e5e7eb",
|
||||
borderRadius: 6,
|
||||
padding: "8px 10px",
|
||||
fontSize: 12,
|
||||
outline: "none",
|
||||
minWidth: 0,
|
||||
};
|
||||
|
||||
const helperText =
|
||||
kind === "entity"
|
||||
? "Search entity theo name"
|
||||
: kind === "wiki"
|
||||
? "Search wiki theo title"
|
||||
: "Search geo theo entity name";
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: 10,
|
||||
background: "#0b1220",
|
||||
borderRadius: 8,
|
||||
border: "1px solid #1f2937",
|
||||
display: "grid",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 14 }}>Search</div>
|
||||
<div style={{ fontSize: 12, color: "#94a3b8" }}>{helperText}</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<select
|
||||
value={kind}
|
||||
onChange={(e) => onKindChange(e.target.value as UnifiedSearchKind)}
|
||||
style={selectStyle}
|
||||
aria-label="Search kind"
|
||||
>
|
||||
<option value="entity">Entity</option>
|
||||
<option value="wiki">Wiki</option>
|
||||
<option value="geo" disabled={Boolean(disabledGeo)}>
|
||||
Geo
|
||||
</option>
|
||||
</select>
|
||||
<input
|
||||
value={localQuery}
|
||||
onChange={(e) => setLocalQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commitNow();
|
||||
}}
|
||||
onBlur={() => commitNow()}
|
||||
placeholder={kind === "entity" ? "Nhập tên entity…" : kind === "wiki" ? "Nhập title wiki…" : "Nhập tên entity…"}
|
||||
style={inputStyle}
|
||||
aria-label="Search query"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { Entity } from "@/uhm/api/entities";
|
||||
import type { Wiki } from "@/uhm/api/wikis";
|
||||
|
||||
type TocItem = {
|
||||
id: string;
|
||||
level: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
entity: Entity | null;
|
||||
wiki: Wiki | null;
|
||||
isLoading: boolean;
|
||||
error?: string | null;
|
||||
onClose: () => void;
|
||||
onWikiLinkRequest: (request: { slug: string; rect: DOMRect }) => void;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function tiptapJsonToPlainText(node: unknown): string {
|
||||
if (node == null) return "";
|
||||
if (typeof node === "string") return node;
|
||||
if (Array.isArray(node)) return node.map(tiptapJsonToPlainText).join("");
|
||||
|
||||
if (isRecord(node)) {
|
||||
if (node.type === "text" && typeof node.text === "string") return node.text;
|
||||
if (node.type === "hardBreak") return "\n";
|
||||
if ("content" in node) return tiptapJsonToPlainText(node.content);
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function escapeHtml(input: string): string {
|
||||
return input
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll("\"", """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function normalizeWikiContentToHtml(raw: string | null | undefined): string {
|
||||
const value = String(raw || "").trim();
|
||||
if (!value.length) return "";
|
||||
|
||||
if (value[0] === "<") return value;
|
||||
|
||||
if (value[0] === "{") {
|
||||
try {
|
||||
const json: unknown = JSON.parse(value);
|
||||
const text = tiptapJsonToPlainText(json).trim();
|
||||
if (!text.length) return "";
|
||||
return `<p>${escapeHtml(text).replace(/\n/g, "<br/>")}</p>`;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
return `<p>${escapeHtml(value).replace(/\n/g, "<br/>")}</p>`;
|
||||
}
|
||||
|
||||
function slugifyHeading(raw: string): string {
|
||||
const input = String(raw || "").trim();
|
||||
if (!input.length) return "";
|
||||
return input
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+/, "")
|
||||
.replace(/-+$/, "")
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
function isExternalHref(href: string): boolean {
|
||||
const h = href.trim().toLowerCase();
|
||||
return (
|
||||
h.startsWith("http://") ||
|
||||
h.startsWith("https://") ||
|
||||
h.startsWith("mailto:") ||
|
||||
h.startsWith("tel:") ||
|
||||
h.startsWith("sms:")
|
||||
);
|
||||
}
|
||||
|
||||
function prepareWikiHtml(inputHtml: string): { html: string; toc: TocItem[] } {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(inputHtml, "text/html");
|
||||
|
||||
for (const el of Array.from(doc.querySelectorAll("script"))) el.remove();
|
||||
|
||||
for (const a of Array.from(doc.querySelectorAll("a[href]"))) {
|
||||
const href = String(a.getAttribute("href") || "").trim();
|
||||
if (!href.length) continue;
|
||||
if (href === "__missing__") continue;
|
||||
if (href.startsWith("#")) continue;
|
||||
if (href.startsWith("/")) continue;
|
||||
|
||||
if (isExternalHref(href)) {
|
||||
a.setAttribute("target", "_blank");
|
||||
a.setAttribute("rel", "noopener noreferrer");
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = href.match(/^([^?#]+)([?#].*)?$/);
|
||||
const slugPart = String(match?.[1] || "").replace(/^\/+/, "").trim();
|
||||
if (!slugPart.length) continue;
|
||||
a.setAttribute("href", `#wiki:${slugPart}`);
|
||||
a.setAttribute("data-wiki-slug", slugPart);
|
||||
a.setAttribute("target", "_self");
|
||||
}
|
||||
|
||||
const toc: TocItem[] = [];
|
||||
const seen = new Map<string, number>();
|
||||
const headings = Array.from(doc.body.querySelectorAll("h1,h2,h3,h4,h5,h6"));
|
||||
for (const h of headings) {
|
||||
const text = String(h.textContent || "").trim();
|
||||
if (!text.length) continue;
|
||||
|
||||
const level = Number(String(h.tagName || "").replace(/^H/i, "")) || 1;
|
||||
const existingId = String(h.getAttribute("id") || "").trim();
|
||||
if (existingId) {
|
||||
toc.push({ id: existingId, level, text });
|
||||
continue;
|
||||
}
|
||||
|
||||
const base = slugifyHeading(text) || "heading";
|
||||
const nextCount = (seen.get(base) || 0) + 1;
|
||||
seen.set(base, nextCount);
|
||||
const id = nextCount === 1 ? base : `${base}-${nextCount}`;
|
||||
|
||||
h.setAttribute("id", id);
|
||||
toc.push({ id, level, text });
|
||||
}
|
||||
|
||||
return { html: doc.body.innerHTML, toc };
|
||||
}
|
||||
|
||||
export default function PublicWikiSidebar({
|
||||
entity,
|
||||
wiki,
|
||||
isLoading,
|
||||
error,
|
||||
onClose,
|
||||
onWikiLinkRequest,
|
||||
}: Props) {
|
||||
const contentRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
|
||||
const processedWiki = useMemo(() => {
|
||||
if (!wiki) return { html: "", toc: [] as TocItem[] };
|
||||
|
||||
const html = normalizeWikiContentToHtml(wiki.content ?? "");
|
||||
try {
|
||||
return prepareWikiHtml(html);
|
||||
} catch (err) {
|
||||
console.error("Failed to process sidebar wiki HTML", err);
|
||||
return { html, toc: [] as TocItem[] };
|
||||
}
|
||||
}, [wiki]);
|
||||
const renderHtml = processedWiki.html;
|
||||
const toc = processedWiki.toc;
|
||||
const effectiveActiveHeadingId = toc.some((item) => item.id === activeHeadingId)
|
||||
? activeHeadingId
|
||||
: (toc[0]?.id ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toc.length) return;
|
||||
const root = contentRootRef.current;
|
||||
if (!root) return;
|
||||
|
||||
const headings = toc
|
||||
.map((item) => root.querySelector<HTMLElement>(`#${CSS.escape(item.id)}`))
|
||||
.filter((item): item is HTMLElement => Boolean(item));
|
||||
if (!headings.length) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((entry) => entry.isIntersecting)
|
||||
.sort((a, b) => (a.boundingClientRect.top ?? 0) - (b.boundingClientRect.top ?? 0));
|
||||
const top = visible[0]?.target as HTMLElement | undefined;
|
||||
if (top?.id) setActiveHeadingId(top.id);
|
||||
},
|
||||
{ root: null, rootMargin: "-18% 0px -70% 0px", threshold: [0, 1] }
|
||||
);
|
||||
|
||||
for (const heading of headings) observer.observe(heading);
|
||||
return () => observer.disconnect();
|
||||
}, [toc]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = contentRootRef.current;
|
||||
if (!root) return;
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const link = target?.closest?.("a[data-wiki-slug]") as HTMLAnchorElement | null;
|
||||
if (!link) return;
|
||||
event.preventDefault();
|
||||
|
||||
const slug = String(link.getAttribute("data-wiki-slug") || "").trim();
|
||||
if (!slug.length) return;
|
||||
onWikiLinkRequest({ slug, rect: link.getBoundingClientRect() });
|
||||
};
|
||||
|
||||
root.addEventListener("click", handleClick);
|
||||
return () => root.removeEventListener("click", handleClick);
|
||||
}, [onWikiLinkRequest, renderHtml]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-800 dark:bg-gray-950">
|
||||
<div className="border-b border-gray-200 px-4 py-4 dark:border-gray-800">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] uppercase tracking-[0.08em] text-gray-500 dark:text-gray-400">
|
||||
Wiki
|
||||
</div>
|
||||
<div className="mt-1 text-lg font-semibold leading-tight text-gray-900 dark:text-gray-100">
|
||||
{entity?.name?.trim() || wiki?.title?.trim() || "Wiki"}
|
||||
</div>
|
||||
{entity?.description?.trim() ? (
|
||||
<div className="mt-2 text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
{entity.description.trim()}
|
||||
</div>
|
||||
) : null}
|
||||
{wiki?.title?.trim() && wiki.title.trim() !== entity?.name?.trim() ? (
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{wiki.title.trim()}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-gray-200 text-sm text-gray-500 transition hover:bg-gray-50 hover:text-gray-800 dark:border-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.04] dark:hover:text-gray-100"
|
||||
aria-label="Close wiki sidebar"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{toc.length ? (
|
||||
<div className="border-b border-gray-200 px-3 py-2 dark:border-gray-800">
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{toc.slice(0, 8).map((item) => {
|
||||
const isActive = effectiveActiveHeadingId === item.id;
|
||||
return (
|
||||
<a
|
||||
key={item.id}
|
||||
href={`#${item.id}`}
|
||||
className={`shrink-0 rounded-full px-3 py-1 text-xs transition ${isActive
|
||||
? "bg-brand-50 text-brand-700 dark:bg-brand-500/10 dark:text-brand-300"
|
||||
: "bg-gray-50 text-gray-600 hover:bg-gray-100 dark:bg-white/[0.03] dark:text-gray-300 dark:hover:bg-white/[0.06]"
|
||||
}`}
|
||||
>
|
||||
{item.text}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="space-y-3 px-4 py-4">
|
||||
<div className="h-4 w-28 animate-pulse rounded bg-gray-100 dark:bg-white/[0.06]" />
|
||||
<div className="h-4 w-full animate-pulse rounded bg-gray-100 dark:bg-white/[0.06]" />
|
||||
<div className="h-4 w-4/5 animate-pulse rounded bg-gray-100 dark:bg-white/[0.06]" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="px-4 py-4 text-sm text-red-600 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
) : wiki ? (
|
||||
<div
|
||||
ref={contentRootRef}
|
||||
className="uhm-wiki-sidebar-view ql-editor text-sm text-gray-900 dark:text-gray-100"
|
||||
dangerouslySetInnerHTML={{ __html: renderHtml }}
|
||||
/>
|
||||
) : (
|
||||
<div className="px-4 py-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
Entity này chưa có wiki liên kết.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style jsx global>{`
|
||||
.uhm-wiki-sidebar-view.ql-editor {
|
||||
height: auto;
|
||||
overflow-y: visible;
|
||||
padding: 18px 18px 22px;
|
||||
}
|
||||
.uhm-wiki-sidebar-view.ql-editor p {
|
||||
margin: 0 0 0.75em;
|
||||
}
|
||||
.uhm-wiki-sidebar-view.ql-editor h1 {
|
||||
margin: 1.15em 0 0.6em;
|
||||
font-size: 1.6em;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.uhm-wiki-sidebar-view.ql-editor h2 {
|
||||
margin: 1.05em 0 0.55em;
|
||||
font-size: 1.3em;
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.uhm-wiki-sidebar-view.ql-editor h3,
|
||||
.uhm-wiki-sidebar-view.ql-editor h4,
|
||||
.uhm-wiki-sidebar-view.ql-editor h5,
|
||||
.uhm-wiki-sidebar-view.ql-editor h6 {
|
||||
margin: 0.95em 0 0.45em;
|
||||
font-size: 1.05em;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.uhm-wiki-sidebar-view.ql-editor ul,
|
||||
.uhm-wiki-sidebar-view.ql-editor ol {
|
||||
margin: 0 0 0.75em;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
.uhm-wiki-sidebar-view.ql-editor blockquote {
|
||||
margin: 0 0 0.75em;
|
||||
padding-left: 12px;
|
||||
border-left: 3px solid rgba(148, 163, 184, 0.6);
|
||||
color: rgba(71, 85, 105, 1);
|
||||
}
|
||||
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor blockquote {
|
||||
border-left-color: rgba(100, 116, 139, 0.6);
|
||||
color: rgba(203, 213, 225, 0.95);
|
||||
}
|
||||
.uhm-wiki-sidebar-view.ql-editor pre {
|
||||
margin: 0 0 0.75em;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(226, 232, 240, 1);
|
||||
border-radius: 10px;
|
||||
background: rgba(248, 250, 252, 1);
|
||||
overflow: auto;
|
||||
}
|
||||
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor pre {
|
||||
border-color: rgba(51, 65, 85, 1);
|
||||
background: rgba(2, 6, 23, 0.4);
|
||||
}
|
||||
.uhm-wiki-sidebar-view.ql-editor img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.uhm-wiki-sidebar-view.ql-editor a {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.uhm-wiki-sidebar-view.ql-editor a[href]:not([href=""]):not([href="__missing__"]) {
|
||||
color: #2563eb;
|
||||
}
|
||||
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor a[href]:not([href=""]):not([href="__missing__"]) {
|
||||
color: #60a5fa;
|
||||
}
|
||||
.uhm-wiki-sidebar-view.ql-editor a[href="__missing__"] {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
.uhm-wiki-sidebar-view.ql-editor a:not([href]),
|
||||
.uhm-wiki-sidebar-view.ql-editor a[href=""],
|
||||
.uhm-wiki-sidebar-view.ql-editor a[href="__missing__"] {
|
||||
color: #dc2626;
|
||||
}
|
||||
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor a:not([href]),
|
||||
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor a[href=""],
|
||||
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor a[href="__missing__"] {
|
||||
color: #f87171;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
BACKGROUND_LAYER_OPTIONS,
|
||||
BackgroundLayerVisibility,
|
||||
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
||||
} from "@/uhm/lib/map/styles/backgroundLayers";
|
||||
|
||||
const BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY = "uhm.backgroundLayerVisibility.v1";
|
||||
|
||||
export function loadBackgroundLayerVisibilityFromStorage(): BackgroundLayerVisibility {
|
||||
if (typeof window === "undefined") {
|
||||
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
const normalized = normalizeBackgroundLayerVisibility(parsed);
|
||||
return normalized || { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
|
||||
} catch (err) {
|
||||
console.warn("Load background layer visibility from storage failed", err);
|
||||
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
|
||||
}
|
||||
}
|
||||
|
||||
export function persistBackgroundLayerVisibility(visibility: BackgroundLayerVisibility) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY,
|
||||
JSON.stringify(visibility)
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn("Persist background layer visibility failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBackgroundLayerVisibility(raw: unknown): BackgroundLayerVisibility | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
|
||||
const source = raw as Record<string, unknown>;
|
||||
const next: BackgroundLayerVisibility = {
|
||||
...DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
||||
};
|
||||
|
||||
for (const layer of BACKGROUND_LAYER_OPTIONS) {
|
||||
const value = source[layer.id];
|
||||
if (typeof value === "boolean") {
|
||||
next[layer.id] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type {
|
||||
Feature,
|
||||
FeatureCollection,
|
||||
FeatureProperties,
|
||||
Geometry,
|
||||
} from "@/uhm/types/geo";
|
||||
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
|
||||
|
||||
export const deepClone = <T,>(obj: T): T => JSON.parse(JSON.stringify(obj));
|
||||
|
||||
export function geometryEquals(a: Geometry | undefined, b: Geometry | undefined): boolean {
|
||||
if (!a || !b) return false;
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
export function featureEquals(a: Feature | undefined, b: Feature | undefined): boolean {
|
||||
if (!a || !b) return false;
|
||||
return JSON.stringify(a.geometry) === JSON.stringify(b.geometry) &&
|
||||
JSON.stringify(a.properties) === JSON.stringify(b.properties);
|
||||
}
|
||||
|
||||
export function buildInitialMap(fc: FeatureCollection) {
|
||||
const map = new Map<FeatureProperties["id"], Feature>();
|
||||
for (const feature of fc.features) {
|
||||
map.set(feature.properties.id, deepClone(feature));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function diffDraftToInitial(
|
||||
draft: FeatureCollection,
|
||||
initialMap: Map<FeatureProperties["id"], Feature>
|
||||
) {
|
||||
const next = new Map<FeatureProperties["id"], Change>();
|
||||
const seen = new Set<FeatureProperties["id"]>();
|
||||
|
||||
for (const feature of draft.features) {
|
||||
const id = feature.properties.id;
|
||||
seen.add(id);
|
||||
const initialFeature = initialMap.get(id);
|
||||
if (!initialFeature) {
|
||||
next.set(id, { action: "create", feature: deepClone(feature) });
|
||||
} else if (!featureEquals(initialFeature, feature)) {
|
||||
next.set(id, { action: "update", id, geometry: deepClone(feature.geometry) });
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id] of initialMap.entries()) {
|
||||
if (!seen.has(id)) {
|
||||
next.set(id, { action: "delete", id });
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type {
|
||||
Feature,
|
||||
FeatureProperties,
|
||||
Geometry,
|
||||
GeometryChange,
|
||||
} from "@/uhm/types/geo";
|
||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
||||
|
||||
export type Change = GeometryChange;
|
||||
|
||||
export type UndoAction =
|
||||
| { type: "update"; id: FeatureProperties["id"]; prevGeometry: Geometry }
|
||||
| { type: "properties"; id: FeatureProperties["id"]; prevProperties: FeatureProperties }
|
||||
| { type: "delete"; feature: Feature }
|
||||
| { type: "create"; id: FeatureProperties["id"] }
|
||||
// Snapshot-scoped undo (affects commit snapshot but not GeoJSON draft directly)
|
||||
| { type: "snapshot_entities"; label: string; prev: EntitySnapshot[] }
|
||||
| { type: "snapshot_wikis"; label: string; prev: WikiSnapshot[] }
|
||||
| { type: "snapshot_entity_wiki"; label: string; prev: EntityWikiLinkSnapshot[] }
|
||||
| { type: "group"; label: string; actions: UndoAction[] };
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { FeatureCollection } from "@/uhm/types/geo";
|
||||
import { deepClone } from "@/uhm/lib/editor/draft/draftDiff";
|
||||
|
||||
export function useDraftState(initialData: FeatureCollection) {
|
||||
// Draft hiện tại (React state) để UI re-render khi dữ liệu thay đổi.
|
||||
const [draft, setDraft] = useState<FeatureCollection>(() => deepClone(initialData));
|
||||
// Draft ref để đọc giá trị mới nhất trong event handlers/engines mà không cần deps.
|
||||
const draftRef = useRef<FeatureCollection>(deepClone(initialData));
|
||||
|
||||
const commitDraft = useCallback((nextDraft: FeatureCollection) => {
|
||||
const cloned = deepClone(nextDraft);
|
||||
draftRef.current = cloned;
|
||||
setDraft(cloned);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
draftRef.current = draft;
|
||||
}, [draft]);
|
||||
|
||||
const resetDraft = useCallback((nextDraft: FeatureCollection) => {
|
||||
commitDraft(nextDraft);
|
||||
}, [commitDraft]);
|
||||
|
||||
return {
|
||||
draft,
|
||||
draftRef,
|
||||
commitDraft,
|
||||
resetDraft,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type { UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
|
||||
import { geometryEquals } from "@/uhm/lib/editor/draft/draftDiff";
|
||||
|
||||
type Options = {
|
||||
applyUndoAction: (action: UndoAction) => boolean;
|
||||
};
|
||||
|
||||
export function useUndoStack(options: Options) {
|
||||
const { applyUndoAction } = options;
|
||||
// Stack thao tác undo (append-only, pop khi undo).
|
||||
const [undoStack, setUndoStack] = useState<UndoAction[]>([]);
|
||||
const undoStackRef = useRef<UndoAction[]>([]);
|
||||
|
||||
const pushUndo = useCallback((action: UndoAction) => {
|
||||
const prev = undoStackRef.current;
|
||||
const last = prev[prev.length - 1];
|
||||
if (isSameUndo(last, action)) return;
|
||||
const next = [...prev, action];
|
||||
undoStackRef.current = next;
|
||||
setUndoStack(next);
|
||||
}, []);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
const current = undoStackRef.current;
|
||||
if (!current.length) return;
|
||||
|
||||
const last = current[current.length - 1];
|
||||
const didApply = applyUndoAction(last);
|
||||
if (!didApply) return;
|
||||
|
||||
const remaining = current.slice(0, -1);
|
||||
undoStackRef.current = remaining;
|
||||
setUndoStack(remaining);
|
||||
}, [applyUndoAction]);
|
||||
|
||||
const clearUndo = useCallback(() => {
|
||||
undoStackRef.current = [];
|
||||
setUndoStack([]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
undoStack,
|
||||
pushUndo,
|
||||
undo,
|
||||
clearUndo,
|
||||
};
|
||||
}
|
||||
|
||||
function isSameUndo(a: UndoAction | undefined, b: UndoAction) {
|
||||
if (!a) return false;
|
||||
if (a.type !== b.type) return false;
|
||||
switch (a.type) {
|
||||
case "create": {
|
||||
const next = b as Extract<UndoAction, { type: "create" }>;
|
||||
return a.id === next.id;
|
||||
}
|
||||
case "delete": {
|
||||
const next = b as Extract<UndoAction, { type: "delete" }>;
|
||||
return (
|
||||
a.feature.properties.id === next.feature.properties.id &&
|
||||
geometryEquals(a.feature.geometry, next.feature.geometry)
|
||||
);
|
||||
}
|
||||
case "update": {
|
||||
const next = b as Extract<UndoAction, { type: "update" }>;
|
||||
return (
|
||||
a.id === next.id &&
|
||||
geometryEquals(a.prevGeometry, next.prevGeometry)
|
||||
);
|
||||
}
|
||||
case "properties": {
|
||||
const next = b as Extract<UndoAction, { type: "properties" }>;
|
||||
return (
|
||||
a.id === next.id &&
|
||||
JSON.stringify(a.prevProperties) === JSON.stringify(next.prevProperties)
|
||||
);
|
||||
}
|
||||
case "snapshot_entities": {
|
||||
const next = b as Extract<UndoAction, { type: "snapshot_entities" }>;
|
||||
return a.label === next.label && JSON.stringify(a.prev) === JSON.stringify(next.prev);
|
||||
}
|
||||
case "snapshot_wikis": {
|
||||
const next = b as Extract<UndoAction, { type: "snapshot_wikis" }>;
|
||||
return a.label === next.label && JSON.stringify(a.prev) === JSON.stringify(next.prev);
|
||||
}
|
||||
case "snapshot_entity_wiki": {
|
||||
const next = b as Extract<UndoAction, { type: "snapshot_entity_wiki" }>;
|
||||
return a.label === next.label && JSON.stringify(a.prev) === JSON.stringify(next.prev);
|
||||
}
|
||||
case "group": {
|
||||
const next = b as Extract<UndoAction, { type: "group" }>;
|
||||
return a.label === next.label && JSON.stringify(a.actions) === JSON.stringify(next.actions);
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Entity } from "@/uhm/types/entities";
|
||||
import type { Feature, FeatureProperties } from "@/uhm/types/geo";
|
||||
import { normalizeFeatureEntityIds } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
||||
import { newId } from "@/uhm/lib/utils/id";
|
||||
|
||||
export function mergeEntitySearchResults(
|
||||
remoteRows: Entity[],
|
||||
localRows: Entity[]
|
||||
): Entity[] {
|
||||
const merged: Entity[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const row of localRows) {
|
||||
if (!row.id || seen.has(row.id)) continue;
|
||||
seen.add(row.id);
|
||||
merged.push(row);
|
||||
}
|
||||
|
||||
for (const row of remoteRows) {
|
||||
if (!row.id || seen.has(row.id)) continue;
|
||||
seen.add(row.id);
|
||||
merged.push(row);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function formatEntityNamesForDisplay(feature: Feature, entities: Entity[]): string {
|
||||
const entityIds = normalizeFeatureEntityIds(feature);
|
||||
if (!entityIds.length) return "Chưa gắn";
|
||||
|
||||
const names = entityIds
|
||||
.map((id) => entities.find((entity) => entity.id === id)?.name || id)
|
||||
.filter((name) => name.trim().length > 0);
|
||||
return names.join(", ");
|
||||
}
|
||||
|
||||
export function buildClientEntityId(): string {
|
||||
return newId();
|
||||
}
|
||||
|
||||
export function buildFeatureEntityPatch(
|
||||
_feature: Feature,
|
||||
entityIds: string[],
|
||||
entities: Entity[]
|
||||
): Partial<FeatureProperties> {
|
||||
const primaryEntityId = entityIds[0] || null;
|
||||
const primaryEntity = primaryEntityId
|
||||
? entities.find((entity) => entity.id === primaryEntityId) || null
|
||||
: null;
|
||||
const entityNames = entityIds
|
||||
.map((id) => entities.find((entity) => entity.id === id)?.name || "")
|
||||
.filter((name) => name.length > 0);
|
||||
|
||||
return {
|
||||
entity_id: primaryEntityId,
|
||||
entity_ids: entityIds,
|
||||
entity_name: primaryEntity?.name || null,
|
||||
entity_names: entityNames,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Feature, FeatureProperties } from "@/uhm/types/geo";
|
||||
import type { GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionTypes";
|
||||
import {
|
||||
normalizeFeatureBindingIds,
|
||||
parseBindingInput,
|
||||
} from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
||||
|
||||
export type GeometryMetadataPatch = {
|
||||
patch: Partial<FeatureProperties>;
|
||||
formState: GeometryMetaFormState;
|
||||
};
|
||||
|
||||
export function buildGeometryMetadataPatch(form: GeometryMetaFormState): GeometryMetadataPatch {
|
||||
const typeKey = form.type_key.trim();
|
||||
const timeStart = parseOptionalYearInput(form.time_start, "time_start");
|
||||
const timeEnd = parseOptionalYearInput(form.time_end, "time_end");
|
||||
if (timeStart !== null && timeEnd !== null && timeStart > timeEnd) {
|
||||
throw new Error("time_start phải <= time_end.");
|
||||
}
|
||||
|
||||
const bindingIds = parseBindingInput(form.binding);
|
||||
return {
|
||||
patch: {
|
||||
type: typeKey.length ? typeKey : undefined,
|
||||
time_start: timeStart,
|
||||
time_end: timeEnd,
|
||||
binding: bindingIds,
|
||||
},
|
||||
formState: {
|
||||
type_key: typeKey,
|
||||
time_start: timeStart != null ? String(timeStart) : "",
|
||||
time_end: timeEnd != null ? String(timeEnd) : "",
|
||||
binding: bindingIds.join(", "),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function formatBindingIdsForDisplay(feature: Feature): string {
|
||||
const bindingIds = normalizeFeatureBindingIds(feature);
|
||||
if (!bindingIds.length) return "Không có";
|
||||
return bindingIds.join(", ");
|
||||
}
|
||||
|
||||
function parseOptionalYearInput(raw: string, fieldName: string): number | null {
|
||||
const value = raw.trim();
|
||||
if (!value.length) return null;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw new Error(`${fieldName} phải là số.`);
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
import { useCallback } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { ApiError } from "@/uhm/api/http";
|
||||
import {
|
||||
createProject,
|
||||
createProjectCommit,
|
||||
fetchProjectCommits,
|
||||
fetchProjects,
|
||||
openSectionEditor,
|
||||
submitSection,
|
||||
} from "@/uhm/api/projects";
|
||||
import { buildEditorSnapshot, normalizeEditorSnapshot, toApiEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
||||
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
|
||||
import type { Feature, FeatureCollection, FeatureId, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
|
||||
import type { EditorSnapshot, Project, ProjectCommit, ProjectState, EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
|
||||
type EditorDraftApi = {
|
||||
draft: FeatureCollection;
|
||||
buildPayload: () => Change[];
|
||||
clearChanges: () => void;
|
||||
hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean;
|
||||
};
|
||||
|
||||
type Options = {
|
||||
editor: EditorDraftApi;
|
||||
editorUserId: string;
|
||||
emptyFeatureCollection: FeatureCollection;
|
||||
activeSection: Project | null;
|
||||
projectState: ProjectState | null;
|
||||
selectedProjectId: string;
|
||||
newSectionTitle: string;
|
||||
pendingSaveCount: number;
|
||||
snapshotEntities: EntitySnapshot[];
|
||||
snapshotWikis: WikiSnapshot[];
|
||||
snapshotEntityWikiLinks: EntityWikiLinkSnapshot[];
|
||||
baselineSnapshot: EditorSnapshot | null;
|
||||
commitTitle: string;
|
||||
setActiveSection: Dispatch<SetStateAction<Project | null>>;
|
||||
setSelectedProjectId: Dispatch<SetStateAction<string>>;
|
||||
setProjectState: Dispatch<SetStateAction<ProjectState | null>>;
|
||||
setBaselineSnapshot: Dispatch<SetStateAction<EditorSnapshot | null>>;
|
||||
setInitialData: Dispatch<SetStateAction<FeatureCollection>>;
|
||||
setProjectCommits: Dispatch<SetStateAction<ProjectCommit[]>>;
|
||||
setSnapshotEntities: Dispatch<SetStateAction<EntitySnapshot[]>>;
|
||||
setSnapshotWikis: Dispatch<SetStateAction<WikiSnapshot[]>>;
|
||||
setSnapshotEntityWikiLinks: Dispatch<SetStateAction<EntityWikiLinkSnapshot[]>>;
|
||||
setSelectedFeatureIds: Dispatch<SetStateAction<FeatureId[]>>;
|
||||
setEntityFormStatus: Dispatch<SetStateAction<string | null>>;
|
||||
setEntityStatus: Dispatch<SetStateAction<string | null>>;
|
||||
setIsSaving: Dispatch<SetStateAction<boolean>>;
|
||||
setIsSubmitting: Dispatch<SetStateAction<boolean>>;
|
||||
setIsOpeningSection: Dispatch<SetStateAction<boolean>>;
|
||||
setAvailableSections: Dispatch<SetStateAction<Project[]>>;
|
||||
setNewSectionTitle: Dispatch<SetStateAction<string>>;
|
||||
setCommitTitle: Dispatch<SetStateAction<string>>;
|
||||
};
|
||||
|
||||
export function useProjectCommands(options: Options) {
|
||||
const openSectionForEditing = useCallback(async (projectId: string) => {
|
||||
const editorPayload = await openSectionEditor(projectId);
|
||||
const snapshot = normalizeEditorSnapshot(editorPayload.snapshot);
|
||||
// When starting a fresh editor session from a commit snapshot, treat all rows as baseline state:
|
||||
// operations should not carry over as deltas into the next commit.
|
||||
const sessionSnapshot = snapshot ? toEditorSessionSnapshot(snapshot) : null;
|
||||
const commits = await fetchProjectCommits(projectId);
|
||||
const nextInitialData = sessionSnapshot?.editor_feature_collection || options.emptyFeatureCollection;
|
||||
|
||||
options.setActiveSection(editorPayload.project);
|
||||
options.setSelectedProjectId(editorPayload.project.id);
|
||||
options.setProjectState(editorPayload.state);
|
||||
options.setBaselineSnapshot(sessionSnapshot);
|
||||
options.setInitialData(nextInitialData);
|
||||
options.setProjectCommits(commits);
|
||||
options.setSnapshotEntities(sessionSnapshot?.entities || []);
|
||||
options.setSnapshotWikis(sessionSnapshot?.wikis || []);
|
||||
options.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []);
|
||||
options.setSelectedFeatureIds([]);
|
||||
options.setEntityFormStatus(null);
|
||||
}, [options]);
|
||||
|
||||
const commitSection = useCallback(async () => {
|
||||
if (!options.activeSection || !options.projectState) {
|
||||
options.setEntityStatus("Chưa mở được project editor.");
|
||||
return;
|
||||
}
|
||||
if (options.pendingSaveCount <= 0) {
|
||||
options.setEntityStatus("Không có thay đổi để Commit.");
|
||||
return;
|
||||
}
|
||||
|
||||
const geometryChanges = options.editor.buildPayload();
|
||||
options.setIsSaving(true);
|
||||
options.setEntityStatus(null);
|
||||
try {
|
||||
const snapshot = buildEditorSnapshot({
|
||||
project: options.activeSection,
|
||||
draft: options.editor.draft,
|
||||
changes: geometryChanges,
|
||||
snapshotEntities: options.snapshotEntities,
|
||||
snapshotWikis: options.snapshotWikis,
|
||||
snapshotEntityWikiLinks: options.snapshotEntityWikiLinks,
|
||||
previousSnapshot: options.baselineSnapshot,
|
||||
hasPersistedFeature: options.editor.hasPersistedFeature,
|
||||
});
|
||||
const editSummary = options.commitTitle.trim()
|
||||
|| `Edit ${new Date().toLocaleString()}`;
|
||||
|
||||
// Guardrail: commit payload can get large and some deployments reject/close connections for big bodies.
|
||||
// When that happens, browsers often surface it as "TypeError: Failed to fetch".
|
||||
try {
|
||||
const payloadText = JSON.stringify({ snapshot_json: toApiEditorSnapshot(snapshot), edit_summary: editSummary });
|
||||
const bytes = typeof Blob !== "undefined" ? new Blob([payloadText]).size : payloadText.length;
|
||||
const limitBytes = 3_500_000; // ~3.5MB (conservative vs common default body limits)
|
||||
if (bytes > limitBytes) {
|
||||
options.setEntityStatus(
|
||||
`Commit payload quá lớn (~${(bytes / (1024 * 1024)).toFixed(2)}MB). ` +
|
||||
`Hãy giảm bớt nội dung snapshot/changes hoặc chạy BE local với body limit lớn hơn.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// If stringify fails, let API call throw a more actionable error downstream.
|
||||
}
|
||||
|
||||
const result = await createProjectCommit(options.activeSection.id, {
|
||||
snapshot,
|
||||
edit_summary: editSummary,
|
||||
});
|
||||
|
||||
const sessionSnapshot = toEditorSessionSnapshot(snapshot);
|
||||
options.setProjectState(result.state);
|
||||
options.setBaselineSnapshot(sessionSnapshot);
|
||||
options.setSnapshotEntities(sessionSnapshot.entities || []);
|
||||
options.setSnapshotWikis(sessionSnapshot.wikis || []);
|
||||
options.setSnapshotEntityWikiLinks(sessionSnapshot.entity_wiki || []);
|
||||
options.setInitialData(options.editor.draft);
|
||||
options.editor.clearChanges();
|
||||
options.setCommitTitle("");
|
||||
options.setProjectCommits(await fetchProjectCommits(options.activeSection.id));
|
||||
options.setEntityFormStatus("Đã tạo commit.");
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
console.error("Commit failed", err.body);
|
||||
options.setEntityStatus(`Commit thất bại: ${err.body}`);
|
||||
return;
|
||||
}
|
||||
console.error("Commit error", err);
|
||||
options.setEntityStatus("Commit thất bại.");
|
||||
} finally {
|
||||
options.setIsSaving(false);
|
||||
}
|
||||
}, [options]);
|
||||
|
||||
const openSelectedSection = useCallback(async () => {
|
||||
const projectId = options.selectedProjectId.trim();
|
||||
if (!projectId) {
|
||||
options.setEntityStatus("Hãy chọn project để mở.");
|
||||
return;
|
||||
}
|
||||
if (options.pendingSaveCount > 0) {
|
||||
const confirmed = window.confirm("Project hiện tại có thay đổi chưa Commit. Mở project khác sẽ bỏ các thay đổi này. Tiếp tục?");
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
options.setIsOpeningSection(true);
|
||||
options.setEntityStatus(null);
|
||||
try {
|
||||
await openSectionForEditing(projectId);
|
||||
options.setEntityStatus("Đã mở project để chỉnh sửa.");
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
options.setEntityStatus(`Mở project thất bại: ${err.body}`);
|
||||
} else {
|
||||
options.setEntityStatus("Mở project thất bại.");
|
||||
}
|
||||
} finally {
|
||||
options.setIsOpeningSection(false);
|
||||
}
|
||||
}, [openSectionForEditing, options]);
|
||||
|
||||
const createAndOpenSection = useCallback(async () => {
|
||||
const title = options.newSectionTitle.trim();
|
||||
if (!title) {
|
||||
options.setEntityStatus("Tên project là bắt buộc.");
|
||||
return;
|
||||
}
|
||||
if (options.pendingSaveCount > 0) {
|
||||
const confirmed = window.confirm("Project hiện tại có thay đổi chưa Commit. Tạo project mới sẽ bỏ các thay đổi này. Tiếp tục?");
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
options.setIsOpeningSection(true);
|
||||
options.setEntityStatus(null);
|
||||
try {
|
||||
const project = await createProject({
|
||||
title,
|
||||
description: null,
|
||||
});
|
||||
const projects = await fetchProjects();
|
||||
options.setAvailableSections(projects);
|
||||
options.setNewSectionTitle("");
|
||||
await openSectionForEditing(project.id);
|
||||
options.setEntityStatus("Đã tạo và mở project mới.");
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
options.setEntityStatus(`Tạo project thất bại: ${err.body}`);
|
||||
} else {
|
||||
options.setEntityStatus("Tạo project thất bại.");
|
||||
}
|
||||
} finally {
|
||||
options.setIsOpeningSection(false);
|
||||
}
|
||||
}, [openSectionForEditing, options]);
|
||||
|
||||
const submitCurrentSection = useCallback(async (content: string) => {
|
||||
if (!options.activeSection || !options.projectState?.head_commit_id) {
|
||||
options.setEntityStatus("Project hiện tại chưa có head để submit.");
|
||||
return;
|
||||
}
|
||||
if (options.pendingSaveCount > 0) {
|
||||
options.setEntityStatus("Hãy Commit các thay đổi trước khi Submit.");
|
||||
return;
|
||||
}
|
||||
|
||||
options.setIsSubmitting(true);
|
||||
options.setEntityStatus(null);
|
||||
try {
|
||||
const submission = await submitSection(options.activeSection.id, content);
|
||||
options.setEntityStatus(`Đã submit, submission ${submission.id}.`);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
options.setEntityStatus(`Submit thất bại: ${err.body}`);
|
||||
} else {
|
||||
options.setEntityStatus("Submit thất bại.");
|
||||
}
|
||||
} finally {
|
||||
options.setIsSubmitting(false);
|
||||
}
|
||||
}, [options]);
|
||||
|
||||
const restoreCommit = useCallback(async (commitId: string) => {
|
||||
if (!options.activeSection || !options.projectState) {
|
||||
options.setEntityStatus("Chưa mở được project editor.");
|
||||
return;
|
||||
}
|
||||
if (options.pendingSaveCount > 0) {
|
||||
options.setEntityStatus("Hãy Commit hoặc Undo thay đổi hiện tại trước khi Restore.");
|
||||
return;
|
||||
}
|
||||
|
||||
options.setIsSaving(true);
|
||||
options.setEntityStatus(null);
|
||||
try {
|
||||
// FE-only restore: load snapshot from selected commit and apply to editor state.
|
||||
// Do NOT move project's head commit on backend.
|
||||
const commits = await fetchProjectCommits(options.activeSection.id);
|
||||
const target = commits.find((c: ProjectCommit) => c.id === commitId) || null;
|
||||
if (!target) {
|
||||
options.setEntityStatus("Không tìm thấy commit để restore.");
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = normalizeEditorSnapshot(target.snapshot_json);
|
||||
const sessionSnapshot = snapshot ? toEditorSessionSnapshot(snapshot) : null;
|
||||
const nextInitialData = sessionSnapshot?.editor_feature_collection || options.emptyFeatureCollection;
|
||||
|
||||
options.setBaselineSnapshot(sessionSnapshot);
|
||||
options.setInitialData(nextInitialData);
|
||||
options.setSnapshotEntities(sessionSnapshot?.entities || []);
|
||||
options.setSnapshotWikis(sessionSnapshot?.wikis || []);
|
||||
options.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []);
|
||||
options.setSelectedFeatureIds([]);
|
||||
options.setEntityFormStatus(null);
|
||||
|
||||
// Refresh commits list for UI, but keep projectState/head as-is.
|
||||
options.setProjectCommits(commits);
|
||||
options.setEntityFormStatus("Đã load snapshot từ commit (không đổi head trên BE).");
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
options.setEntityStatus(`Restore thất bại: ${err.body}`);
|
||||
} else {
|
||||
options.setEntityStatus("Restore thất bại.");
|
||||
}
|
||||
} finally {
|
||||
options.setIsSaving(false);
|
||||
}
|
||||
}, [options]);
|
||||
|
||||
return {
|
||||
openSectionForEditing,
|
||||
commitSection,
|
||||
openSelectedSection,
|
||||
createAndOpenSection,
|
||||
submitCurrentSection,
|
||||
restoreCommit,
|
||||
};
|
||||
}
|
||||
|
||||
function toEditorSessionSnapshot(snapshot: EditorSnapshot): EditorSnapshot {
|
||||
return {
|
||||
...snapshot,
|
||||
entities: toEditorSessionEntities(snapshot.entities),
|
||||
geometries: toEditorSessionGeometries(snapshot.geometries),
|
||||
geometry_entity: toEditorSessionGeometryEntity(snapshot.geometry_entity),
|
||||
wikis: toEditorSessionWikis(snapshot.wikis),
|
||||
entity_wiki: toEditorSessionEntityWikiLinks(snapshot.entity_wiki),
|
||||
};
|
||||
}
|
||||
|
||||
function toEditorSessionEntities(input: EditorSnapshot["entities"]): EntitySnapshot[] {
|
||||
const rows = Array.isArray(input) ? input : [];
|
||||
return rows
|
||||
.filter((e) => e && (typeof e.id === "string" || typeof e.id === "number"))
|
||||
.filter((e) => (e as any).operation !== "delete")
|
||||
.map((e) => {
|
||||
const { operation: _op, ...rest } = e;
|
||||
const id = String(e.id);
|
||||
const source: EntitySnapshot["source"] = e.source === "inline" ? "inline" : "ref";
|
||||
return {
|
||||
...(rest as Omit<EntitySnapshot, "id" | "source" | "operation">),
|
||||
id,
|
||||
source,
|
||||
operation: "reference",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function toEditorSessionGeometries(input: EditorSnapshot["geometries"]): GeometrySnapshot[] {
|
||||
const rows = Array.isArray(input) ? input : [];
|
||||
return rows
|
||||
.filter((g) => g && (typeof (g as any).id === "string" || typeof (g as any).id === "number"))
|
||||
.filter((g) => (g as any).operation !== "delete")
|
||||
.map((g) => {
|
||||
const { operation: _op, ...rest } = g as any;
|
||||
const id = String((g as any).id);
|
||||
const source: GeometrySnapshot["source"] = (g as any).source === "inline" ? "inline" : "ref";
|
||||
return {
|
||||
...(rest as Omit<GeometrySnapshot, "id" | "source" | "operation">),
|
||||
id,
|
||||
source,
|
||||
operation: "reference",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function toEditorSessionGeometryEntity(input: EditorSnapshot["geometry_entity"]): GeometryEntitySnapshot[] {
|
||||
const rows = Array.isArray(input) ? input : [];
|
||||
const deduped = new globalThis.Map<string, GeometryEntitySnapshot>();
|
||||
for (const row of rows) {
|
||||
if (!row) continue;
|
||||
if ((row as any).operation === "delete") continue;
|
||||
const geometry_id = typeof (row as any).geometry_id === "string" || typeof (row as any).geometry_id === "number"
|
||||
? String((row as any).geometry_id).trim()
|
||||
: "";
|
||||
const entity_id = typeof (row as any).entity_id === "string" || typeof (row as any).entity_id === "number"
|
||||
? String((row as any).entity_id).trim()
|
||||
: "";
|
||||
if (!geometry_id || !entity_id) continue;
|
||||
const key = `${geometry_id}::${entity_id}`;
|
||||
deduped.set(key, { geometry_id, entity_id, operation: "reference", base_links_hash: (row as any).base_links_hash });
|
||||
}
|
||||
return Array.from(deduped.values()).sort((a, b) => {
|
||||
const g = a.geometry_id.localeCompare(b.geometry_id);
|
||||
if (g !== 0) return g;
|
||||
return a.entity_id.localeCompare(b.entity_id);
|
||||
});
|
||||
}
|
||||
|
||||
function toEditorSessionWikis(input: EditorSnapshot["wikis"]): WikiSnapshot[] {
|
||||
const rows = Array.isArray(input) ? input : [];
|
||||
return rows
|
||||
.filter((w) => w && typeof w.id === "string" && w.id.trim().length > 0)
|
||||
.filter((w) => (w as any).operation !== "delete")
|
||||
.map((w) => {
|
||||
const { operation: _op, ...rest } = w;
|
||||
const source: WikiSnapshot["source"] = w.source === "inline" ? "inline" : "ref";
|
||||
return {
|
||||
...(rest as Omit<WikiSnapshot, "source" | "operation">),
|
||||
source,
|
||||
operation: "reference",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function toEditorSessionEntityWikiLinks(input: EditorSnapshot["entity_wiki"]): EntityWikiLinkSnapshot[] {
|
||||
const rows = Array.isArray(input) ? input : [];
|
||||
const deduped = new globalThis.Map<string, EntityWikiLinkSnapshot>();
|
||||
for (const row of rows) {
|
||||
if (!row || typeof row.entity_id !== "string" || typeof row.wiki_id !== "string") continue;
|
||||
if (row.operation === "delete") continue;
|
||||
const entity_id = row.entity_id.trim();
|
||||
const wiki_id = row.wiki_id.trim();
|
||||
if (!entity_id || !wiki_id) continue;
|
||||
const key = `${entity_id}::${wiki_id}`;
|
||||
deduped.set(key, { entity_id, wiki_id, operation: "reference" });
|
||||
}
|
||||
return Array.from(deduped.values()).sort((a, b) => {
|
||||
const e = a.entity_id.localeCompare(b.entity_id);
|
||||
if (e !== 0) return e;
|
||||
return a.wiki_id.localeCompare(b.wiki_id);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { GeometryPreset } from "@/uhm/lib/map/geo/geometryTypeOptions";
|
||||
|
||||
export type EditorMode =
|
||||
| "idle"
|
||||
| "draw"
|
||||
| "select"
|
||||
| "add-point"
|
||||
| "add-line"
|
||||
| "add-path"
|
||||
| "add-circle"
|
||||
| "replay";
|
||||
|
||||
export type TimelineRange = {
|
||||
min: number;
|
||||
max: number;
|
||||
};
|
||||
|
||||
export type EntityFormState = {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type GeometryMetaFormState = {
|
||||
type_key: string;
|
||||
time_start: string;
|
||||
time_end: string;
|
||||
binding: string;
|
||||
};
|
||||
|
||||
export type PendingEntityCreate = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: number;
|
||||
};
|
||||
|
||||
export type CreatedEntitySummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type { GeometryPreset };
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
BackgroundLayerVisibility,
|
||||
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
||||
} from "@/uhm/lib/map/styles/backgroundLayers";
|
||||
|
||||
export function useBackgroundSessionState() {
|
||||
// Trạng thái bật/tắt layer nền (khởi tạo default hidden; sẽ load từ storage ở page).
|
||||
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
|
||||
() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })
|
||||
);
|
||||
// Đảm bảo đã load visibility trước khi render map thật.
|
||||
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
|
||||
|
||||
return {
|
||||
backgroundVisibility,
|
||||
setBackgroundVisibility,
|
||||
isBackgroundVisibilityReady,
|
||||
setIsBackgroundVisibilityReady,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState } from "react";
|
||||
import type { Entity } from "@/uhm/types/entities";
|
||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||
import type { FeatureId } from "@/uhm/types/geo";
|
||||
import type {
|
||||
EntityFormState,
|
||||
GeometryMetaFormState,
|
||||
} from "@/uhm/lib/editor/session/sessionTypes";
|
||||
|
||||
export function useEntitySessionState() {
|
||||
// Entity catalog loaded from backend (global list, used for search/lookup).
|
||||
const [entityCatalog, setEntityCatalog] = useState<Entity[]>([]);
|
||||
// Snapshot entity store for the current editor session (single source of truth for snapshot.entities).
|
||||
const [snapshotEntities, setSnapshotEntities] = useState<EntitySnapshot[]>([]);
|
||||
// Thông báo trạng thái/lỗi liên quan entity/session.
|
||||
const [entityStatus, setEntityStatus] = useState<string | null>(null);
|
||||
// Features đang được chọn để thao tác bind entities/metadata.
|
||||
const [selectedFeatureIds, setSelectedFeatureIds] = useState<FeatureId[]>([]);
|
||||
// Form tạo entity mới (độc lập).
|
||||
const [entityForm, setEntityForm] = useState<EntityFormState>({
|
||||
name: "",
|
||||
description: "",
|
||||
});
|
||||
// Danh sách entity IDs đang chọn để bind vào geometry hiện tại.
|
||||
const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState<string[]>([]);
|
||||
// Form metadata geometry (time range + binding ids).
|
||||
const [geometryMetaForm, setGeometryMetaForm] = useState<GeometryMetaFormState>({
|
||||
type_key: "",
|
||||
time_start: "",
|
||||
time_end: "",
|
||||
binding: "",
|
||||
});
|
||||
// Cờ loading khi apply entity/metadata (local submit).
|
||||
const [isEntitySubmitting, setIsEntitySubmitting] = useState(false);
|
||||
// Thông báo trạng thái/lỗi cho form entity/metadata.
|
||||
const [entityFormStatus, setEntityFormStatus] = useState<string | null>(null);
|
||||
// Keyword search entity theo name.
|
||||
const [entitySearchQuery, setEntitySearchQuery] = useState("");
|
||||
// Kết quả search entity để user chọn.
|
||||
const [entitySearchResults, setEntitySearchResults] = useState<Entity[]>([]);
|
||||
// Entity ID đang được chọn trong dropdown kết quả search.
|
||||
const [selectedSearchEntityId, setSelectedSearchEntityId] = useState<string | null>(null);
|
||||
// Cờ loading khi search entity.
|
||||
const [isEntitySearchLoading, setIsEntitySearchLoading] = useState(false);
|
||||
|
||||
return {
|
||||
entityCatalog,
|
||||
setEntityCatalog,
|
||||
snapshotEntities,
|
||||
setSnapshotEntities,
|
||||
entityStatus,
|
||||
setEntityStatus,
|
||||
selectedFeatureIds,
|
||||
setSelectedFeatureIds,
|
||||
entityForm,
|
||||
setEntityForm,
|
||||
selectedGeometryEntityIds,
|
||||
setSelectedGeometryEntityIds,
|
||||
geometryMetaForm,
|
||||
setGeometryMetaForm,
|
||||
isEntitySubmitting,
|
||||
setIsEntitySubmitting,
|
||||
entityFormStatus,
|
||||
setEntityFormStatus,
|
||||
entitySearchQuery,
|
||||
setEntitySearchQuery,
|
||||
entitySearchResults,
|
||||
setEntitySearchResults,
|
||||
selectedSearchEntityId,
|
||||
setSelectedSearchEntityId,
|
||||
isEntitySearchLoading,
|
||||
setIsEntitySearchLoading,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { EditorSnapshot, Project, ProjectCommit, ProjectState } from "@/uhm/types/projects";
|
||||
|
||||
type Options = {
|
||||
defaultEditorUserId: string;
|
||||
};
|
||||
|
||||
type SectionTask = "idle" | "saving" | "submitting" | "opening-project";
|
||||
|
||||
export function useProjectSessionState(options: Options) {
|
||||
// Single state machine cho các tác vụ async của project (saving/submitting/opening).
|
||||
const [sectionTask, setSectionTask] = useState<SectionTask>("idle");
|
||||
const setTaskFlag = useCallback((task: Exclude<SectionTask, "idle">, next: SetStateAction<boolean>) => {
|
||||
setSectionTask((prev) => {
|
||||
const currentValue = prev === task;
|
||||
const nextValue = typeof next === "function" ? next(currentValue) : next;
|
||||
if (nextValue) return task;
|
||||
return prev === task ? "idle" : prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isSaving = sectionTask === "saving";
|
||||
const isSubmitting = sectionTask === "submitting";
|
||||
const isOpeningSection = sectionTask === "opening-project";
|
||||
const setIsSaving: Dispatch<SetStateAction<boolean>> = useCallback((next) => {
|
||||
setTaskFlag("saving", next);
|
||||
}, [setTaskFlag]);
|
||||
const setIsSubmitting: Dispatch<SetStateAction<boolean>> = useCallback((next) => {
|
||||
setTaskFlag("submitting", next);
|
||||
}, [setTaskFlag]);
|
||||
const setIsOpeningSection: Dispatch<SetStateAction<boolean>> = useCallback((next) => {
|
||||
setTaskFlag("opening-project", next);
|
||||
}, [setTaskFlag]);
|
||||
|
||||
// Danh sách projects để user chọn mở.
|
||||
const [availableSections, setAvailableSections] = useState<Project[]>([]);
|
||||
// Project ID đang được chọn trong dropdown.
|
||||
const [selectedProjectId, setSelectedProjectId] = useState("");
|
||||
// Title project mới (để create).
|
||||
const [newSectionTitle, setNewSectionTitle] = useState("");
|
||||
// Input title cho commit.
|
||||
const [commitTitle, setCommitTitle] = useState("");
|
||||
// User ID dùng để gắn vào commit/submit/lock.
|
||||
const [editorUserIdInput, setEditorUserIdInput] = useState(options.defaultEditorUserId);
|
||||
// Project đang mở để edit (null nếu chưa mở).
|
||||
const [activeSection, setActiveSection] = useState<Project | null>(null);
|
||||
// Trạng thái project (version/head/status/lock).
|
||||
const [projectState, setProjectState] = useState<ProjectState | null>(null);
|
||||
// Danh sách commits của project đang mở.
|
||||
const [sectionCommits, setProjectCommits] = useState<ProjectCommit[]>([]);
|
||||
// Baseline snapshot currently loaded for this editor session.
|
||||
const [baselineSnapshot, setBaselineSnapshot] = useState<EditorSnapshot | null>(null);
|
||||
|
||||
return {
|
||||
isSaving,
|
||||
setIsSaving,
|
||||
isSubmitting,
|
||||
setIsSubmitting,
|
||||
isOpeningSection,
|
||||
setIsOpeningSection,
|
||||
availableSections,
|
||||
setAvailableSections,
|
||||
selectedProjectId,
|
||||
setSelectedProjectId,
|
||||
newSectionTitle,
|
||||
setNewSectionTitle,
|
||||
commitTitle,
|
||||
setCommitTitle,
|
||||
editorUserIdInput,
|
||||
setEditorUserIdInput,
|
||||
activeSection,
|
||||
setActiveSection,
|
||||
projectState,
|
||||
setProjectState,
|
||||
sectionCommits,
|
||||
setProjectCommits,
|
||||
baselineSnapshot,
|
||||
setBaselineSnapshot,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useState } from "react";
|
||||
import type { TimelineRange } from "@/uhm/lib/editor/session/sessionTypes";
|
||||
import { clampYearValue } from "@/uhm/lib/utils/timeline";
|
||||
|
||||
type Options = {
|
||||
currentYear: number;
|
||||
fallbackTimelineRange: TimelineRange;
|
||||
};
|
||||
|
||||
export function useTimelineState(options: Options) {
|
||||
// Năm timeline "đã chốt" để fetch dữ liệu.
|
||||
const [timelineYear, setTimelineYear] = useState<number>(() =>
|
||||
clampYearValue(
|
||||
options.currentYear,
|
||||
options.fallbackTimelineRange.min,
|
||||
options.fallbackTimelineRange.max
|
||||
)
|
||||
);
|
||||
// Năm timeline đang chỉnh (debounce rồi đẩy sang timelineYear).
|
||||
const [timelineDraftYear, setTimelineDraftYear] = useState<number>(() =>
|
||||
clampYearValue(
|
||||
options.currentYear,
|
||||
options.fallbackTimelineRange.min,
|
||||
options.fallbackTimelineRange.max
|
||||
)
|
||||
);
|
||||
// Cờ loading khi fetch theo timeline.
|
||||
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
|
||||
// Thông báo trạng thái/lỗi khi fetch theo timeline.
|
||||
const [timelineStatus, setTimelineStatus] = useState<string | null>(null);
|
||||
|
||||
return {
|
||||
timelineYear,
|
||||
setTimelineYear,
|
||||
timelineDraftYear,
|
||||
setTimelineDraftYear,
|
||||
isTimelineLoading,
|
||||
setIsTimelineLoading,
|
||||
timelineStatus,
|
||||
setTimelineStatus,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
||||
|
||||
export function useWikiSessionState() {
|
||||
const [snapshotWikis, setSnapshotWikis] = useState<WikiSnapshot[]>([]);
|
||||
const [snapshotEntityWikiLinks, setSnapshotEntityWikiLinks] = useState<EntityWikiLinkSnapshot[]>([]);
|
||||
return { snapshotWikis, setSnapshotWikis, snapshotEntityWikiLinks, setSnapshotEntityWikiLinks };
|
||||
}
|
||||
@@ -0,0 +1,785 @@
|
||||
import { DEFAULT_GEOMETRY_TYPE_ID } from "@/uhm/lib/map/geo/geometryTypeOptions";
|
||||
import { normalizeGeoTypeKey, typeKeyToGeoTypeCode } from "@/uhm/lib/map/geo/geoTypeMap";
|
||||
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
|
||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||
import type { EntitySnapshotOperation } from "@/uhm/types/entities";
|
||||
import type { Feature, FeatureCollection, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
|
||||
import type { EditorSnapshot, Project } from "@/uhm/types/projects";
|
||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
||||
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
|
||||
function isRecord(value: unknown): value is UnknownRecord {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function sanitizeEntitySnapshotOperation(op: unknown): EntitySnapshotOperation {
|
||||
if (typeof op !== "string") return "reference";
|
||||
const v = op.trim();
|
||||
if (v === "create" || v === "update" || v === "delete" || v === "reference") return v;
|
||||
// Defensive: legacy/buggy data sometimes concatenates words (e.g. "reference delete").
|
||||
// Never guess "delete" here; prefer non-destructive behavior.
|
||||
return "reference";
|
||||
}
|
||||
|
||||
function getStringId(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number") return String(value);
|
||||
return "";
|
||||
}
|
||||
|
||||
function getRefId(value: unknown): string {
|
||||
if (!isRecord(value)) return "";
|
||||
return typeof value.id === "string" ? value.id : "";
|
||||
}
|
||||
|
||||
export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
const snapshot = raw as UnknownRecord;
|
||||
|
||||
// Accept legacy snapshots (v1) and new ones (v2+). We only require that a FeatureCollection,
|
||||
// if present, is structurally valid. Everything else is treated as optional.
|
||||
const fcRaw = snapshot.editor_feature_collection;
|
||||
const fc: FeatureCollection | undefined =
|
||||
isRecord(fcRaw) && fcRaw.type === "FeatureCollection" && Array.isArray(fcRaw.features)
|
||||
? (fcRaw as unknown as FeatureCollection)
|
||||
: undefined;
|
||||
|
||||
const entitiesRaw = snapshot.entities;
|
||||
const entities: EntitySnapshot[] | undefined = Array.isArray(entitiesRaw)
|
||||
? entitiesRaw
|
||||
.filter(isRecord)
|
||||
.map((e) => {
|
||||
const id = getStringId(e.id);
|
||||
const opRaw = typeof e.operation === "string" ? e.operation : undefined;
|
||||
const operation: EntitySnapshot["operation"] =
|
||||
opRaw === "delete" ? "delete" : "reference";
|
||||
const existingSource = e.source === "inline" || e.source === "ref" ? e.source : undefined;
|
||||
const refId = getRefId(e.ref);
|
||||
const source: "inline" | "ref" =
|
||||
existingSource || (refId || opRaw === "reference" ? "ref" : "inline");
|
||||
const rest: UnknownRecord = { ...e };
|
||||
delete rest.ref;
|
||||
|
||||
return {
|
||||
...(rest as unknown as Omit<EntitySnapshot, "id" | "source" | "operation">),
|
||||
id,
|
||||
source,
|
||||
operation,
|
||||
};
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const geometriesRaw = snapshot.geometries;
|
||||
const geometries: GeometrySnapshot[] | undefined = Array.isArray(geometriesRaw)
|
||||
? geometriesRaw
|
||||
.filter(isRecord)
|
||||
.map((g) => {
|
||||
const id = getStringId(g.id);
|
||||
const opRaw = typeof g.operation === "string" ? g.operation : undefined;
|
||||
const operation: GeometrySnapshot["operation"] =
|
||||
opRaw === "delete" ? "delete" : "reference";
|
||||
const existingSource = g.source === "inline" || g.source === "ref" ? g.source : undefined;
|
||||
const refId = getRefId(g.ref);
|
||||
const hasInlineGeometry = "draw_geometry" in g || "geometry" in g;
|
||||
const source: "inline" | "ref" = existingSource || (refId || !hasInlineGeometry ? "ref" : "inline");
|
||||
const rest: UnknownRecord = { ...g };
|
||||
delete rest.ref;
|
||||
const typeKey = normalizeGeoTypeKey(rest.type) || normalizeGeoTypeKey(rest.geo_type);
|
||||
delete rest.geo_type;
|
||||
|
||||
return {
|
||||
...(rest as unknown as Omit<GeometrySnapshot, "id" | "source" | "operation">),
|
||||
id,
|
||||
source,
|
||||
operation,
|
||||
type: typeKey,
|
||||
};
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const wikisRaw = snapshot.wikis;
|
||||
const wikis: WikiSnapshot[] | undefined = Array.isArray(wikisRaw)
|
||||
? wikisRaw
|
||||
.filter(isRecord)
|
||||
.map((w) => {
|
||||
const id = typeof w.id === "string" ? w.id : "";
|
||||
const opRaw = typeof w.operation === "string" ? w.operation : undefined;
|
||||
const operation: WikiSnapshot["operation"] =
|
||||
opRaw === "delete" ? "delete" : "reference";
|
||||
const existingSource = w.source === "inline" || w.source === "ref" ? w.source : undefined;
|
||||
const refId = getRefId(w.ref);
|
||||
const source: "inline" | "ref" =
|
||||
existingSource || (refId || opRaw === "reference" ? "ref" : "inline");
|
||||
const rest: UnknownRecord = { ...w };
|
||||
delete rest.ref;
|
||||
|
||||
return {
|
||||
...(rest as unknown as Omit<WikiSnapshot, "id" | "source" | "operation">),
|
||||
id,
|
||||
source,
|
||||
operation,
|
||||
};
|
||||
})
|
||||
: undefined;
|
||||
|
||||
// Legacy snapshots used link_scopes[{geometry_id, operation, entity_ids[]}]. New snapshots prefer
|
||||
// geometry_entity[{geometry_id, entity_id}]. If geometry_entity is missing but link_scopes exists,
|
||||
// migrate it by expanding each entity_id into a join row.
|
||||
const geometryEntityRaw = snapshot.geometry_entity;
|
||||
const geometryEntity: GeometryEntitySnapshot[] | undefined = Array.isArray(geometryEntityRaw)
|
||||
? geometryEntityRaw
|
||||
.filter(isRecord)
|
||||
.map((r) => {
|
||||
const geometry_id = getStringId(r.geometry_id);
|
||||
const entity_id = typeof r.entity_id === "string" ? r.entity_id : "";
|
||||
return {
|
||||
...(r as unknown as Omit<GeometryEntitySnapshot, "geometry_id" | "entity_id">),
|
||||
geometry_id,
|
||||
entity_id,
|
||||
};
|
||||
})
|
||||
.filter((r) => r.geometry_id.length > 0 && r.entity_id.length > 0)
|
||||
: undefined;
|
||||
|
||||
const legacyLinkScopes = snapshot.link_scopes;
|
||||
const migratedGeometryEntity: GeometryEntitySnapshot[] | undefined =
|
||||
!geometryEntity && Array.isArray(legacyLinkScopes)
|
||||
? legacyLinkScopes
|
||||
.filter(isRecord)
|
||||
.flatMap((s) => {
|
||||
const geometry_id = getStringId(s.geometry_id);
|
||||
const entity_ids = Array.isArray(s.entity_ids)
|
||||
? s.entity_ids.filter((x): x is string => typeof x === "string" && x.trim().length > 0)
|
||||
: [];
|
||||
return entity_ids.map((entity_id) => ({ geometry_id, entity_id: entity_id.trim() }))
|
||||
.filter((row) => row.geometry_id.length > 0 && row.entity_id.length > 0);
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const entityWikisRaw = snapshot.entity_wiki ?? snapshot.entity_wikis;
|
||||
const entityWikis: EntityWikiLinkSnapshot[] | undefined = Array.isArray(entityWikisRaw)
|
||||
? entityWikisRaw
|
||||
.filter(isRecord)
|
||||
.map((r) => {
|
||||
const entity_id = typeof r.entity_id === "string" ? r.entity_id : "";
|
||||
const wiki_id = typeof r.wiki_id === "string" ? r.wiki_id : "";
|
||||
const opRaw = typeof r.operation === "string" ? r.operation.trim() : "";
|
||||
const isDeleted =
|
||||
typeof r.is_deleted === "number"
|
||||
? r.is_deleted === 1
|
||||
: typeof r.is_deleted === "boolean"
|
||||
? r.is_deleted
|
||||
: false;
|
||||
const operation: EntityWikiLinkSnapshot["operation"] =
|
||||
isDeleted || opRaw === "delete"
|
||||
? "delete"
|
||||
: opRaw === "binding"
|
||||
? "binding"
|
||||
: "reference";
|
||||
return { entity_id, wiki_id, operation };
|
||||
})
|
||||
.filter((r) => r.entity_id.length > 0 && r.wiki_id.length > 0)
|
||||
: undefined;
|
||||
|
||||
// For editor UX, re-hydrate entity ids on features from geometry_entity. Snapshot persistence does not
|
||||
// store entity_id/entity_ids/entity_names on features anymore.
|
||||
const fcForEditor: FeatureCollection | undefined = (() => {
|
||||
if (!fc) return undefined;
|
||||
const hasLinks = Boolean(geometryEntity || migratedGeometryEntity);
|
||||
const links = geometryEntity || migratedGeometryEntity || [];
|
||||
const byGeom = new Map<string, string[]>();
|
||||
for (const row of links) {
|
||||
if ((row as any)?.operation === "delete") continue;
|
||||
const list = byGeom.get(row.geometry_id) || [];
|
||||
list.push(row.entity_id);
|
||||
byGeom.set(row.geometry_id, list);
|
||||
}
|
||||
const entityNameById = new Map<string, string>();
|
||||
for (const row of entities || []) {
|
||||
const id = typeof row?.id === "string" ? row.id : "";
|
||||
if (!id) continue;
|
||||
const name = typeof (row as any)?.name === "string" ? String((row as any).name).trim() : "";
|
||||
if (name) entityNameById.set(id, name);
|
||||
}
|
||||
const geometryById = new Map<string, GeometrySnapshot>();
|
||||
for (const row of geometries || []) {
|
||||
const id = typeof row?.id === "string" ? row.id : "";
|
||||
if (!id) continue;
|
||||
geometryById.set(id, row);
|
||||
}
|
||||
const cloned = JSON.parse(JSON.stringify(fc)) as FeatureCollection;
|
||||
for (const feature of cloned.features) {
|
||||
const gid = String(feature.properties.id);
|
||||
const entity_ids = byGeom.get(gid) || [];
|
||||
const p = feature.properties as unknown as UnknownRecord;
|
||||
|
||||
const existingTypeKey = normalizeGeoTypeKey(p.type) || normalizeGeoTypeKey(p.entity_type_id);
|
||||
const fallbackTypeKey = getDefaultTypeIdForFeature(feature);
|
||||
if (existingTypeKey) p.type = existingTypeKey;
|
||||
|
||||
if (entity_ids.length || hasLinks) {
|
||||
p.entity_ids = entity_ids;
|
||||
p.entity_id = entity_ids[0] || null;
|
||||
|
||||
// Generate denormalized names for UI/map usage.
|
||||
const primaryId = entity_ids[0] || null;
|
||||
const primaryName = primaryId ? (entityNameById.get(primaryId) || "") : "";
|
||||
const names = entity_ids.map((id) => entityNameById.get(id) || "").filter((n) => n.length > 0);
|
||||
p.entity_name = primaryName || null;
|
||||
p.entity_names = names;
|
||||
}
|
||||
|
||||
// Generate geometry metadata onto feature properties (optional in persisted snapshot).
|
||||
const geo = geometryById.get(gid) || null;
|
||||
if (geo) {
|
||||
const geoRecord = geo as unknown as UnknownRecord;
|
||||
// type can arrive as numeric geo_type, numeric string, or semantic key depending on backend version.
|
||||
const typeKey = normalizeGeoTypeKey(geoRecord.type)
|
||||
|| normalizeGeoTypeKey(geoRecord.geo_type)
|
||||
|| existingTypeKey
|
||||
|| fallbackTypeKey;
|
||||
if (typeKey) p.type = typeKey;
|
||||
if (Array.isArray(geo.binding) && geo.binding.length) p.binding = geo.binding;
|
||||
if (typeof geo.time_start === "number") p.time_start = geo.time_start;
|
||||
if (typeof geo.time_end === "number") p.time_end = geo.time_end;
|
||||
} else if (!existingTypeKey) {
|
||||
p.type = fallbackTypeKey;
|
||||
}
|
||||
}
|
||||
return cloned;
|
||||
})();
|
||||
|
||||
return {
|
||||
...(snapshot as unknown as EditorSnapshot),
|
||||
editor_feature_collection: fcForEditor,
|
||||
entities,
|
||||
geometries,
|
||||
wikis,
|
||||
geometry_entity: geometryEntity || migratedGeometryEntity,
|
||||
entity_wiki: entityWikis,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildEditorSnapshot(options: {
|
||||
project: Project;
|
||||
draft: FeatureCollection;
|
||||
changes: Change[];
|
||||
snapshotEntities: EntitySnapshot[];
|
||||
snapshotWikis: WikiSnapshot[];
|
||||
snapshotEntityWikiLinks: EntityWikiLinkSnapshot[];
|
||||
previousSnapshot: EditorSnapshot | null;
|
||||
hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean;
|
||||
}): EditorSnapshot {
|
||||
const changedIds = new Set(options.changes.map((change) =>
|
||||
String(change.action === "create" ? change.feature.properties.id : change.id)
|
||||
));
|
||||
const deletedIds = new Set(
|
||||
options.changes
|
||||
.filter((change): change is Extract<Change, { action: "delete" }> => change.action === "delete")
|
||||
.map((change) => String(change.id))
|
||||
);
|
||||
const currentDraftIds = new Set(options.draft.features.map((feature) => String(feature.properties.id)));
|
||||
const previousFeatures = new globalThis.Map<string, Feature>();
|
||||
for (const feature of options.previousSnapshot?.editor_feature_collection?.features || []) {
|
||||
previousFeatures.set(String(feature.properties.id), feature);
|
||||
if (!currentDraftIds.has(String(feature.properties.id))) {
|
||||
deletedIds.add(String(feature.properties.id));
|
||||
}
|
||||
}
|
||||
|
||||
const previousGeometryOps = new globalThis.Map<string, GeometrySnapshot["operation"]>();
|
||||
for (const item of options.previousSnapshot?.geometries || []) {
|
||||
const id = typeof item.id === "string" || typeof item.id === "number" ? String(item.id) : "";
|
||||
const operation = item.operation;
|
||||
if (id && operation) previousGeometryOps.set(id, operation);
|
||||
}
|
||||
|
||||
const entityRows = new globalThis.Map<string, EntitySnapshot>();
|
||||
|
||||
// Persist inline entity records across commits even when they're not currently bound.
|
||||
// Without this, "create entity" can disappear on the next commit unless the entity is referenced
|
||||
// by geometry_entity/entity_wiki or pinned via projectEntityRefs.
|
||||
for (const prev of options.previousSnapshot?.entities || []) {
|
||||
if (!prev) continue;
|
||||
const id = typeof prev.id === "string" || typeof prev.id === "number" ? String(prev.id) : "";
|
||||
if (!id || entityRows.has(id)) continue;
|
||||
if (prev.operation === "delete") continue;
|
||||
if (prev.source !== "inline") continue;
|
||||
// Carry forward as current-state inline entity; operation is a per-commit delta signal.
|
||||
const cloned = JSON.parse(JSON.stringify(prev)) as EntitySnapshot;
|
||||
const { operation: _op, ...rest } = cloned;
|
||||
entityRows.set(id, {
|
||||
...rest,
|
||||
id,
|
||||
source: "inline",
|
||||
operation: "reference",
|
||||
});
|
||||
}
|
||||
for (const row of options.snapshotEntities || []) {
|
||||
if (!row) continue;
|
||||
const id = typeof row.id === "string" || typeof row.id === "number" ? String(row.id) : "";
|
||||
if (!id) continue;
|
||||
const cloned = JSON.parse(JSON.stringify(row)) as EntitySnapshot;
|
||||
const name =
|
||||
typeof cloned?.name === "string" && cloned.name.trim().length
|
||||
? cloned.name.trim()
|
||||
: id;
|
||||
const source: "inline" | "ref" = cloned.source === "inline" ? "inline" : "ref";
|
||||
const opRaw = sanitizeEntitySnapshotOperation((cloned as any).operation);
|
||||
// Editor state should delete objects by removing them from the list.
|
||||
// Keep this defensive guard to avoid emitting delete markers unexpectedly.
|
||||
if (opRaw === "delete") continue;
|
||||
const operation: EntitySnapshot["operation"] = source === "ref" ? "reference" : opRaw;
|
||||
entityRows.set(id, {
|
||||
...cloned,
|
||||
id,
|
||||
source,
|
||||
name,
|
||||
operation,
|
||||
});
|
||||
}
|
||||
|
||||
// Entities referenced by wiki links should be present as "reference" too.
|
||||
for (const link of options.snapshotEntityWikiLinks || []) {
|
||||
const id = typeof link?.entity_id === "string" ? link.entity_id : "";
|
||||
if (!id || entityRows.has(id)) continue;
|
||||
entityRows.set(id, {
|
||||
id,
|
||||
source: "ref",
|
||||
operation: "reference",
|
||||
name: id,
|
||||
slug: null,
|
||||
description: null,
|
||||
status: 1,
|
||||
});
|
||||
}
|
||||
|
||||
for (const feature of options.draft.features) {
|
||||
for (const entityId of normalizeFeatureEntityIds(feature)) {
|
||||
if (entityRows.has(entityId)) continue;
|
||||
entityRows.set(entityId, {
|
||||
id: entityId,
|
||||
source: "ref",
|
||||
operation: "reference",
|
||||
name: entityId,
|
||||
slug: null,
|
||||
description: null,
|
||||
status: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const geometries: GeometrySnapshot[] = options.draft.features.map((feature) => {
|
||||
const id = String(feature.properties.id);
|
||||
const previousOperation = previousGeometryOps.get(id);
|
||||
const previousFeature = previousFeatures.get(id);
|
||||
const changedFromPreviousSnapshot = previousFeature
|
||||
? JSON.stringify(previousFeature) !== JSON.stringify(feature)
|
||||
: false;
|
||||
const operation: GeometrySnapshot["operation"] =
|
||||
previousOperation === "create"
|
||||
? "create"
|
||||
: !previousFeature && (options.previousSnapshot || !options.hasPersistedFeature(feature.properties.id))
|
||||
? "create"
|
||||
: changedIds.has(id) || changedFromPreviousSnapshot
|
||||
? "update"
|
||||
: "reference";
|
||||
const bbox = getFeatureBBox(feature);
|
||||
const typeKey = normalizeGeoTypeKey(feature.properties.type) || getDefaultTypeIdForFeature(feature);
|
||||
return {
|
||||
id,
|
||||
operation,
|
||||
source: "inline",
|
||||
type: typeKey,
|
||||
draw_geometry: feature.geometry,
|
||||
binding: normalizeFeatureBindingIds(feature),
|
||||
time_start: feature.properties.time_start ?? null,
|
||||
time_end: feature.properties.time_end ?? null,
|
||||
bbox: bbox
|
||||
? {
|
||||
min_lng: bbox.minLng,
|
||||
min_lat: bbox.minLat,
|
||||
max_lng: bbox.maxLng,
|
||||
max_lat: bbox.maxLat,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
for (const id of deletedIds) {
|
||||
geometries.push({
|
||||
id,
|
||||
source: "ref",
|
||||
operation: "delete",
|
||||
});
|
||||
}
|
||||
|
||||
const baselineGeometryEntity = new globalThis.Map<string, string | undefined>();
|
||||
for (const row of options.previousSnapshot?.geometry_entity || []) {
|
||||
if (!row) continue;
|
||||
if ((row as any).operation === "delete") continue;
|
||||
const geometry_id = typeof row.geometry_id === "string" || typeof row.geometry_id === "number" ? String(row.geometry_id).trim() : "";
|
||||
const entity_id = typeof row.entity_id === "string" || typeof row.entity_id === "number" ? String(row.entity_id).trim() : "";
|
||||
if (!geometry_id || !entity_id) continue;
|
||||
baselineGeometryEntity.set(`${geometry_id}::${entity_id}`, (row as any).base_links_hash);
|
||||
}
|
||||
|
||||
const currentGeometryEntityRows: GeometryEntitySnapshot[] = [];
|
||||
const currentGeometryEntityKeys = new Set<string>();
|
||||
for (const feature of options.draft.features) {
|
||||
const geometry_id = String(feature.properties.id).trim();
|
||||
if (!geometry_id) continue;
|
||||
for (const entity_id of normalizeFeatureEntityIds(feature)) {
|
||||
const key = `${geometry_id}::${entity_id}`;
|
||||
if (currentGeometryEntityKeys.has(key)) continue;
|
||||
currentGeometryEntityKeys.add(key);
|
||||
currentGeometryEntityRows.push({
|
||||
geometry_id,
|
||||
entity_id,
|
||||
operation: baselineGeometryEntity.has(key) ? "reference" : "binding",
|
||||
base_links_hash: baselineGeometryEntity.get(key),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Relations removed during this session are emitted as "delete" operations.
|
||||
// NOTE: The editor state itself should remove the relation row; the commit payload is the delta.
|
||||
for (const [key, base_links_hash] of baselineGeometryEntity.entries()) {
|
||||
if (currentGeometryEntityKeys.has(key)) continue;
|
||||
const [geometry_id, entity_id] = key.split("::");
|
||||
if (!geometry_id || !entity_id) continue;
|
||||
currentGeometryEntityRows.push({ geometry_id, entity_id, operation: "delete", base_links_hash });
|
||||
}
|
||||
|
||||
const geometryEntity = dedupeAndSortGeometryEntity(currentGeometryEntityRows);
|
||||
|
||||
// Persist snapshot without denormalized entity fields on features (many-to-many lives in geometry_entity[]).
|
||||
const draftForSnapshot = JSON.parse(JSON.stringify(options.draft)) as FeatureCollection;
|
||||
for (const feature of draftForSnapshot.features) {
|
||||
const p = feature.properties as unknown as UnknownRecord;
|
||||
// Do not send generate-only fields on the API payload. These are re-generated on load.
|
||||
delete p.type;
|
||||
delete p.time_start;
|
||||
delete p.time_end;
|
||||
delete p.binding;
|
||||
delete p.entity_id;
|
||||
delete p.entity_ids;
|
||||
delete p.entity_name;
|
||||
delete p.entity_names;
|
||||
delete p.entity_type_id;
|
||||
}
|
||||
|
||||
const previousWikis = new globalThis.Map<string, WikiSnapshot>();
|
||||
for (const item of options.previousSnapshot?.wikis || []) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
if ((item as any).operation === "delete") continue;
|
||||
const id = (item as WikiSnapshot).id;
|
||||
if (typeof id === "string" && id.length > 0) previousWikis.set(id, item as WikiSnapshot);
|
||||
}
|
||||
|
||||
// Wikis in snapshot_json are treated as current state (not a delta-table like geometries[]).
|
||||
// Operation semantics:
|
||||
// - create/update/delete: this commit changes the wiki itself
|
||||
// - reference: this wiki is a ref used for linking (entity<->wiki), not a modification
|
||||
const wikisCurrent: WikiSnapshot[] = (options.snapshotWikis || [])
|
||||
.filter((w) => {
|
||||
if (!w || typeof w.id !== "string" || w.id.trim().length === 0) return false;
|
||||
if (w.source === "ref") return true;
|
||||
// Keep explicit operations (e.g. delete) even if content is empty.
|
||||
if (w.operation === "create" || w.operation === "update") return true;
|
||||
// Inline wiki with no content: don't persist it (treat as not written).
|
||||
const title = typeof w.title === "string" ? w.title.trim() : "";
|
||||
const doc = typeof w.doc === "string" ? w.doc.trim() : "";
|
||||
return title.length > 0 || (w.doc !== null && doc.length > 0);
|
||||
})
|
||||
.map((w) => {
|
||||
const prev = previousWikis.get(w.id) || null;
|
||||
const cloned = JSON.parse(JSON.stringify(w)) as WikiSnapshot;
|
||||
|
||||
// Ref wiki: always mark as reference (used for linking, not changed here).
|
||||
if (cloned.source === "ref") {
|
||||
cloned.operation = "reference";
|
||||
return cloned;
|
||||
}
|
||||
|
||||
// Inline wiki: if explicitly marked create/update/delete by UI, keep it.
|
||||
if (cloned.operation === "create" || cloned.operation === "update" || cloned.operation === "delete") {
|
||||
return cloned;
|
||||
}
|
||||
|
||||
// Inline wiki with no explicit operation:
|
||||
// Keep a valid operation value, because backend validation may require it (oneof).
|
||||
if (!prev) {
|
||||
// New wiki that somehow has no op set: treat as create.
|
||||
cloned.operation = "create";
|
||||
return cloned;
|
||||
}
|
||||
|
||||
const changed = (() => {
|
||||
try {
|
||||
const prevComparable = { title: prev.title, doc: prev.doc };
|
||||
const nextComparable = { title: cloned.title, doc: cloned.doc };
|
||||
return JSON.stringify(prevComparable) !== JSON.stringify(nextComparable);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
})();
|
||||
|
||||
cloned.operation = changed ? "update" : "reference";
|
||||
return cloned;
|
||||
});
|
||||
|
||||
// Wikis removed during this session are emitted as "delete" operations.
|
||||
const currentWikiIds = new Set(wikisCurrent.map((w) => w.id));
|
||||
const deletedWikis: WikiSnapshot[] = [];
|
||||
for (const prev of previousWikis.values()) {
|
||||
if (!prev?.id) continue;
|
||||
if (currentWikiIds.has(prev.id)) continue;
|
||||
deletedWikis.push({
|
||||
id: prev.id,
|
||||
source: prev.source === "inline" ? "inline" : "ref",
|
||||
operation: "delete",
|
||||
title: typeof prev.title === "string" ? prev.title : "Untitled wiki",
|
||||
slug: (prev as any).slug ?? null,
|
||||
doc: (prev as any).doc ?? null,
|
||||
updated_at: (prev as any).updated_at ?? undefined,
|
||||
} as WikiSnapshot);
|
||||
}
|
||||
const wikis = [...wikisCurrent, ...deletedWikis];
|
||||
|
||||
const baselineEntityWiki = new Set<string>();
|
||||
for (const row of options.previousSnapshot?.entity_wiki || []) {
|
||||
if (!row || typeof (row as any).entity_id !== "string" || typeof (row as any).wiki_id !== "string") continue;
|
||||
if ((row as any).operation === "delete") continue;
|
||||
const entity_id = (row as any).entity_id.trim();
|
||||
const wiki_id = (row as any).wiki_id.trim();
|
||||
if (!entity_id || !wiki_id) continue;
|
||||
baselineEntityWiki.add(`${entity_id}::${wiki_id}`);
|
||||
}
|
||||
const currentEntityWikiKeys = new Set<string>();
|
||||
const entityWikisDelta: EntityWikiLinkSnapshot[] = [];
|
||||
for (const l of options.snapshotEntityWikiLinks || []) {
|
||||
if (!l || typeof l.entity_id !== "string" || typeof l.wiki_id !== "string") continue;
|
||||
if (l.operation === "delete") continue;
|
||||
const entity_id = l.entity_id.trim();
|
||||
const wiki_id = l.wiki_id.trim();
|
||||
if (!entity_id || !wiki_id) continue;
|
||||
const key = `${entity_id}::${wiki_id}`;
|
||||
if (currentEntityWikiKeys.has(key)) continue;
|
||||
currentEntityWikiKeys.add(key);
|
||||
entityWikisDelta.push({
|
||||
entity_id,
|
||||
wiki_id,
|
||||
operation: baselineEntityWiki.has(key) ? "reference" : "binding",
|
||||
});
|
||||
}
|
||||
for (const key of baselineEntityWiki) {
|
||||
if (currentEntityWikiKeys.has(key)) continue;
|
||||
const [entity_id, wiki_id] = key.split("::");
|
||||
if (!entity_id || !wiki_id) continue;
|
||||
entityWikisDelta.push({ entity_id, wiki_id, operation: "delete" });
|
||||
}
|
||||
const entityWikis = dedupeAndSortEntityWiki(entityWikisDelta);
|
||||
|
||||
return {
|
||||
editor_feature_collection: draftForSnapshot,
|
||||
entities: Array.from(entityRows.values())
|
||||
.map((e) => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
operation: e.operation,
|
||||
name: typeof e.name === "string" ? e.name : undefined,
|
||||
description: typeof (e as any).description === "string" ? (e as any).description : (e as any).description ?? null,
|
||||
}))
|
||||
.sort((a, b) => String(a.id).localeCompare(String(b.id))),
|
||||
geometries: geometries.slice().sort((a, b) => String(a.id).localeCompare(String(b.id))),
|
||||
geometry_entity: geometryEntity,
|
||||
wikis: wikis
|
||||
.map((w) => ({
|
||||
id: w.id,
|
||||
source: w.source,
|
||||
operation: w.operation,
|
||||
title: w.title,
|
||||
slug: (w as any).slug ?? null,
|
||||
doc: (w as any).doc ?? null,
|
||||
}))
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
entity_wiki: entityWikis,
|
||||
};
|
||||
}
|
||||
|
||||
export function toApiEditorSnapshot(snapshot: EditorSnapshot): EditorSnapshot {
|
||||
const cloned = JSON.parse(JSON.stringify(snapshot)) as EditorSnapshot;
|
||||
|
||||
if (Array.isArray(cloned.geometries)) {
|
||||
cloned.geometries = cloned.geometries.map((geometry) => {
|
||||
const row = { ...(geometry as unknown as UnknownRecord) };
|
||||
const typeKey = normalizeGeoTypeKey(row.type) || normalizeGeoTypeKey(row.geo_type);
|
||||
delete row.geo_type;
|
||||
|
||||
if (typeKey) {
|
||||
const typeCode = typeKeyToGeoTypeCode(typeKey);
|
||||
row.type = typeCode == null ? null : String(typeCode);
|
||||
} else if ("type" in row) {
|
||||
row.type = null;
|
||||
}
|
||||
|
||||
return row as unknown as GeometrySnapshot;
|
||||
});
|
||||
}
|
||||
|
||||
return cloned;
|
||||
}
|
||||
|
||||
function dedupeAndSortGeometryEntity(rows: GeometryEntitySnapshot[]): GeometryEntitySnapshot[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: GeometryEntitySnapshot[] = [];
|
||||
for (const row of rows) {
|
||||
const geometry_id = typeof row.geometry_id === "string" ? row.geometry_id : "";
|
||||
const entity_id = typeof row.entity_id === "string" ? row.entity_id : "";
|
||||
if (!geometry_id || !entity_id) continue;
|
||||
const opRaw = (row as any).operation;
|
||||
const operation: GeometryEntitySnapshot["operation"] =
|
||||
opRaw === "delete"
|
||||
? "delete"
|
||||
: opRaw === "binding" || opRaw === "reference"
|
||||
? opRaw
|
||||
: undefined;
|
||||
const key = `${geometry_id}::${entity_id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
deduped.push({ ...row, geometry_id, entity_id, operation });
|
||||
}
|
||||
deduped.sort((a, b) => {
|
||||
const g = a.geometry_id.localeCompare(b.geometry_id);
|
||||
if (g !== 0) return g;
|
||||
return a.entity_id.localeCompare(b.entity_id);
|
||||
});
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function dedupeAndSortEntityWiki(rows: EntityWikiLinkSnapshot[]): EntityWikiLinkSnapshot[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: EntityWikiLinkSnapshot[] = [];
|
||||
for (const row of rows) {
|
||||
const entity_id = typeof row.entity_id === "string" ? row.entity_id : "";
|
||||
const wiki_id = typeof row.wiki_id === "string" ? row.wiki_id : "";
|
||||
if (!entity_id || !wiki_id) continue;
|
||||
const opRaw = row.operation;
|
||||
const operation: EntityWikiLinkSnapshot["operation"] =
|
||||
opRaw === "delete"
|
||||
? "delete"
|
||||
: opRaw === "binding" || opRaw === "reference"
|
||||
? opRaw
|
||||
: "reference";
|
||||
const key = `${entity_id}::${wiki_id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
deduped.push({ entity_id, wiki_id, operation });
|
||||
}
|
||||
deduped.sort((a, b) => {
|
||||
const e = a.entity_id.localeCompare(b.entity_id);
|
||||
if (e !== 0) return e;
|
||||
return a.wiki_id.localeCompare(b.wiki_id);
|
||||
});
|
||||
return deduped;
|
||||
}
|
||||
|
||||
export function getDefaultTypeIdForFeature(feature: Feature): string {
|
||||
const preset = feature.properties.geometry_preset;
|
||||
if (preset === "line") return "defense_line";
|
||||
if (preset === "point") return "city";
|
||||
if (preset === "circle-area") return "war";
|
||||
if (preset === "polygon") return DEFAULT_GEOMETRY_TYPE_ID;
|
||||
|
||||
const geometryType = feature.geometry.type;
|
||||
if (geometryType === "LineString" || geometryType === "MultiLineString") {
|
||||
return "defense_line";
|
||||
}
|
||||
if (geometryType === "Point" || geometryType === "MultiPoint") {
|
||||
return "city";
|
||||
}
|
||||
return DEFAULT_GEOMETRY_TYPE_ID;
|
||||
}
|
||||
|
||||
export function normalizeFeatureEntityIds(feature: Feature): string[] {
|
||||
const fromArray = Array.isArray(feature.properties.entity_ids)
|
||||
? feature.properties.entity_ids.filter((id): id is string => typeof id === "string" && id.trim().length > 0)
|
||||
: [];
|
||||
|
||||
if (fromArray.length) {
|
||||
return uniqueEntityIds(fromArray);
|
||||
}
|
||||
|
||||
const single = feature.properties.entity_id;
|
||||
if (typeof single === "string" && single.trim().length > 0) {
|
||||
return [single.trim()];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function normalizeFeatureBindingIds(feature: Feature): string[] {
|
||||
const rawBinding = feature.properties.binding;
|
||||
if (!Array.isArray(rawBinding)) return [];
|
||||
return uniqueEntityIds(rawBinding
|
||||
.map((id) => {
|
||||
if (typeof id !== "string" && typeof id !== "number") return "";
|
||||
return String(id).trim();
|
||||
})
|
||||
.filter((id) => id.length > 0));
|
||||
}
|
||||
|
||||
export function parseBindingInput(raw: string): string[] {
|
||||
if (!raw.trim().length) return [];
|
||||
return uniqueEntityIds(
|
||||
raw
|
||||
.split(/[,\n]/)
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
export function uniqueEntityIds(ids: string[]): string[] {
|
||||
const deduped: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const rawId of ids) {
|
||||
const id = rawId.trim();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
deduped.push(id);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function getFeatureBBox(feature: Feature): { minLng: number; minLat: number; maxLng: number; maxLat: number } | null {
|
||||
const points = collectCoordinatePairs(feature.geometry.coordinates);
|
||||
if (!points.length) return null;
|
||||
let minLng = Number.POSITIVE_INFINITY;
|
||||
let minLat = Number.POSITIVE_INFINITY;
|
||||
let maxLng = Number.NEGATIVE_INFINITY;
|
||||
let maxLat = Number.NEGATIVE_INFINITY;
|
||||
for (const [lng, lat] of points) {
|
||||
minLng = Math.min(minLng, lng);
|
||||
minLat = Math.min(minLat, lat);
|
||||
maxLng = Math.max(maxLng, lng);
|
||||
maxLat = Math.max(maxLat, lat);
|
||||
}
|
||||
return { minLng, minLat, maxLng, maxLat };
|
||||
}
|
||||
|
||||
function collectCoordinatePairs(value: unknown): Array<[number, number]> {
|
||||
if (!Array.isArray(value)) return [];
|
||||
if (
|
||||
value.length >= 2 &&
|
||||
typeof value[0] === "number" &&
|
||||
typeof value[1] === "number" &&
|
||||
Number.isFinite(value[0]) &&
|
||||
Number.isFinite(value[1])
|
||||
) {
|
||||
return [[value[0], value[1]]];
|
||||
}
|
||||
return value.flatMap((item) => collectCoordinatePairs(item));
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user