Compare commits
18 Commits
2e80e45eab
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| cf880f573f | |||
| 3ec9c51085 | |||
| f5fa38ec5a | |||
| add1728916 | |||
|
|
a9b8c4ab8b | ||
|
|
c945a56a33 | ||
| 6f163c3730 | |||
|
|
ce4bc4f2a5 | ||
|
|
8b1df73797 | ||
|
|
a29a3a2049 | ||
|
|
f555909b09 | ||
|
|
34a5c3d041 | ||
|
|
fca188f0be | ||
|
|
12c351c68a | ||
|
|
a74047fd09 | ||
| 41af501b51 | |||
| 65806d197f | |||
| 7ff68659ad |
312
EDITOR_LOCAL_STORAGE.md
Normal file
312
EDITOR_LOCAL_STORAGE.md
Normal file
@@ -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`
|
||||
201
README.md
201
README.md
@@ -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
|
||||
11
api.ts
11
api.ts
@@ -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`,
|
||||
}
|
||||
}
|
||||
|
||||
248
commit_snapshot.md
Normal file
248
commit_snapshot.md
Normal file
@@ -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”).
|
||||
@@ -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;
|
||||
|
||||
1085
package-lock.json
generated
1085
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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,10 +20,14 @@
|
||||
"@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",
|
||||
"react": "^19.2.0",
|
||||
"react-apexcharts": "^1.8.0",
|
||||
@@ -33,6 +37,7 @@
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-quill-new": "^3.8.3",
|
||||
"react-redux": "^9.2.0",
|
||||
"uuid": "^13.0.0",
|
||||
"sonner": "^2.0.7",
|
||||
"sweetalert2": "^11.26.24",
|
||||
"swiper": "^11.2.10",
|
||||
|
||||
BIN
public/images/map.jpeg
Normal file
BIN
public/images/map.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 161 KiB |
BIN
public/images/teamdev/ddk2.jpeg
Normal file
BIN
public/images/teamdev/ddk2.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
public/images/teamdev/ncda.jpeg
Normal file
BIN
public/images/teamdev/ncda.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
public/images/teamdev/tad.jpeg
Normal file
BIN
public/images/teamdev/tad.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
public/point.png
Normal file
BIN
public/point.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
26
scripts/dev.mjs
Normal file
26
scripts/dev.mjs
Normal file
@@ -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) {
|
||||
|
||||
122
src/app/editor/[id]/featureCommands.ts
Normal file
122
src/app/editor/[id]/featureCommands.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
"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;
|
||||
};
|
||||
|
||||
type Options = {
|
||||
editor: EditorDraftApi;
|
||||
selectedFeature: Feature | null;
|
||||
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,
|
||||
selectedFeature,
|
||||
geometryMetaForm,
|
||||
setGeometryMetaForm,
|
||||
selectedGeometryEntityIds,
|
||||
setSelectedGeometryEntityIds,
|
||||
entities,
|
||||
setIsEntitySubmitting,
|
||||
setEntityFormStatus,
|
||||
} = options;
|
||||
|
||||
const applyGeometryMetadata = useCallback(async (): Promise<{ ok: boolean; error?: string }> => {
|
||||
if (!selectedFeature) {
|
||||
const msg = "Hãy chọn 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.patchFeatureProperties(selectedFeature.properties.id, metadata.patch);
|
||||
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,
|
||||
selectedFeature,
|
||||
setEntityFormStatus,
|
||||
setGeometryMetaForm,
|
||||
setIsEntitySubmitting,
|
||||
]);
|
||||
|
||||
const applyEntitiesToSelectedGeometry = useCallback(async () => {
|
||||
if (!selectedFeature) {
|
||||
setEntityFormStatus("Hãy chọn một geometry trước.");
|
||||
return;
|
||||
}
|
||||
|
||||
const entityIds = uniqueEntityIds(selectedGeometryEntityIds);
|
||||
setIsEntitySubmitting(true);
|
||||
setEntityFormStatus(null);
|
||||
try {
|
||||
editor.patchFeatureProperties(
|
||||
selectedFeature.properties.id,
|
||||
buildFeatureEntityPatch(selectedFeature, entityIds, entities)
|
||||
);
|
||||
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,
|
||||
selectedFeature,
|
||||
selectedGeometryEntityIds,
|
||||
setEntityFormStatus,
|
||||
setIsEntitySubmitting,
|
||||
setSelectedGeometryEntityIds,
|
||||
]);
|
||||
|
||||
return {
|
||||
applyGeometryMetadata,
|
||||
applyEntitiesToSelectedGeometry,
|
||||
};
|
||||
}
|
||||
1688
src/app/editor/[id]/page.tsx
Normal file
1688
src/app/editor/[id]/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
7
src/app/editor/page.tsx
Normal file
7
src/app/editor/page.tsx
Normal file
@@ -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");
|
||||
}
|
||||
|
||||
7
src/app/loading.tsx
Normal file
7
src/app/loading.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
185
src/app/page.tsx
185
src/app/page.tsx
@@ -1,9 +1,186 @@
|
||||
import AdminLayout from "./user/layout";
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Map from "@/uhm/components/Map";
|
||||
import BackgroundLayersPanel from "@/uhm/components/BackgroundLayersPanel";
|
||||
import TimelineBar from "@/uhm/components/TimelineBar";
|
||||
import { fetchGeometriesByBBox } from "@/uhm/api/geometries";
|
||||
import { ApiError } from "@/uhm/api/http";
|
||||
import { API_BASE_URL } from "@/uhm/api/config";
|
||||
import {
|
||||
BackgroundLayerId,
|
||||
BackgroundLayerVisibility,
|
||||
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
||||
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
||||
} from "@/uhm/lib/backgroundLayers";
|
||||
import {
|
||||
loadBackgroundLayerVisibilityFromStorage,
|
||||
persistBackgroundLayerVisibility,
|
||||
} from "@/uhm/lib/editor/background/backgroundVisibilityStorage";
|
||||
import { EMPTY_FEATURE_COLLECTION, WORLD_BBOX } from "@/uhm/lib/geo/constants";
|
||||
import { clampYearToFixedRange, TIMELINE_DEBOUNCE_MS } from "@/uhm/lib/timeline";
|
||||
import type { Feature, FeatureCollection } from "@/uhm/types/geo";
|
||||
|
||||
const CURRENT_YEAR = new Date().getUTCFullYear();
|
||||
|
||||
export default function Page() {
|
||||
const [data, setData] = useState<FeatureCollection>(EMPTY_FEATURE_COLLECTION);
|
||||
const [selectedFeatureId, setSelectedFeatureId] = useState<string | number | null>(null);
|
||||
const [timelineYear, setTimelineYear] = useState<number>(() => clampYearToFixedRange(CURRENT_YEAR));
|
||||
const [timelineDraftYear, setTimelineDraftYear] = useState<number>(() => clampYearToFixedRange(CURRENT_YEAR));
|
||||
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 timelineFetchRequestRef = useRef(0);
|
||||
const [lastLoadedAt, setLastLoadedAt] = useState<string | null>(null);
|
||||
|
||||
const selectedFeature: Feature | null = useMemo(() => {
|
||||
if (selectedFeatureId === null) return null;
|
||||
return (
|
||||
data.features.find((feature) => String(feature.properties.id) === String(selectedFeatureId)) || null
|
||||
);
|
||||
}, [data.features, selectedFeatureId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedFeatureId === null) return;
|
||||
const stillExists = data.features.some((feature) => String(feature.properties.id) === String(selectedFeatureId));
|
||||
if (!stillExists) setSelectedFeatureId(null);
|
||||
}, [data.features, selectedFeatureId]);
|
||||
|
||||
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 });
|
||||
if (disposed || requestId !== timelineFetchRequestRef.current) return;
|
||||
setData(next);
|
||||
setLastLoadedAt(new Date().toISOString());
|
||||
} 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]);
|
||||
|
||||
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)));
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className=''>Page</div>
|
||||
</AdminLayout>
|
||||
<div style={{ display: "flex", minHeight: "100vh" }}>
|
||||
<div style={{ flex: 1, position: "relative", minHeight: "100vh" }}>
|
||||
{isBackgroundVisibilityReady ? (
|
||||
<Map
|
||||
mode="select"
|
||||
draft={data}
|
||||
selectedFeatureId={selectedFeatureId}
|
||||
onSelectFeatureId={setSelectedFeatureId}
|
||||
backgroundVisibility={backgroundVisibility}
|
||||
allowGeometryEditing={false}
|
||||
respectBindingFilter={false}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
|
||||
)}
|
||||
|
||||
<TimelineBar
|
||||
year={timelineDraftYear}
|
||||
onYearChange={handleTimelineYearChange}
|
||||
isLoading={isTimelineLoading}
|
||||
disabled={false}
|
||||
statusText={timelineStatus}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BackgroundLayersPanel
|
||||
visibility={backgroundVisibility}
|
||||
onToggleLayer={handleToggleBackgroundLayer}
|
||||
onShowAll={handleShowAllBackgroundLayers}
|
||||
onHideAll={handleHideAllBackgroundLayers}
|
||||
topContent={
|
||||
<div
|
||||
style={{
|
||||
padding: "10px",
|
||||
background: "#0b1220",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #1f2937",
|
||||
display: "grid",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 700, fontSize: "14px", color: "#f8fafc" }}>Viewer</div>
|
||||
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
|
||||
API: {API_BASE_URL}
|
||||
</div>
|
||||
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
|
||||
Year: {timelineYear} | Features: {data.features.length}
|
||||
</div>
|
||||
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
|
||||
{isTimelineLoading ? "Loading geometries..." : lastLoadedAt ? `Loaded: ${lastLoadedAt}` : "Not loaded yet"}
|
||||
</div>
|
||||
<div style={{ color: "#cbd5e1", fontSize: "13px", overflowWrap: "anywhere" }}>
|
||||
{selectedFeature ? `ID: ${String(selectedFeature.properties.id)}` : "Chưa chọn geometry"}
|
||||
</div>
|
||||
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
|
||||
{selectedFeature?.properties?.type ? `Type: ${String(selectedFeature.properties.type)}` : "Type: -"}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
300
src/app/user/about-us/page.tsx
Normal file
300
src/app/user/about-us/page.tsx
Normal file
@@ -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: "Đẹp trai cao m8",
|
||||
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,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;
|
||||
|
||||
|
||||
625
src/app/user/projects/[id]/page.tsx
Normal file
625
src/app/user/projects/[id]/page.tsx
Normal file
@@ -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">
|
||||
{[
|
||||
{
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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/sections";
|
||||
|
||||
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,169 @@ 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);
|
||||
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;
|
||||
});
|
||||
|
||||
// Helper format ngày
|
||||
const formatDate = (dateString: string | null | undefined) => {
|
||||
if (!dateString) return "-";
|
||||
const date = new Date(dateString);
|
||||
return `Updated on ${date.toLocaleDateString("vi-VN", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}`;
|
||||
};
|
||||
|
||||
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={`w-20 text-sm font-medium text-left hover:text-blue-500 transition-colors ${
|
||||
isActive ? "text-blue-600 dark:text-blue-400" : "text-gray-500 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{label} {isActive && (sortOrder === "asc" ? "↑" : "↓")}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
console.log(projects);
|
||||
|
||||
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 +264,141 @@ 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-[700px]">
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 dark:border-gray-800 bg-gray-50/50 dark:bg-[#161b22]">
|
||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300 w-40"></span>
|
||||
<div className="flex items-center gap-4 shrink-0">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 w-20">Sắp xếp:</span>
|
||||
<SortButton column="title" label="Tên" />
|
||||
<SortButton column="created_at" label="Ngày tạo" />
|
||||
<SortButton column="updated_at" label="Cập nhật" />
|
||||
</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 flex-col p-5 md:flex-row md:items-center justify-between hover:bg-gray-50 dark:hover:bg-[#161b22] transition-colors"
|
||||
>
|
||||
<div className="flex-1 pr-4 max-w-full md:max-w-[75%]">
|
||||
<div
|
||||
onClick={() => router.push(`/user/projects/${project.id}`)}
|
||||
className="flex items-center gap-2 mb-2 cursor-pointer hover:underline"
|
||||
>
|
||||
<div className="w-6 h-6 shrink-0 flex items-center justify-center">
|
||||
{project.user?.avatar_url ? (
|
||||
<div className="relative w-6 h-6 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-6 h-6 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="flex items-center max-w-[250px]">
|
||||
<span className="text-[14px] font-medium text-gray-700 dark:text-gray-300 truncate">
|
||||
{project.user?.display_name || "Unknown"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className="text-[14px] text-gray-400 dark:text-gray-600 shrink-0">/</span>
|
||||
|
||||
<h3 className="text-[14px] font-semibold text-blue-600 dark:text-[#58a6ff] truncate max-w-[300px]">
|
||||
{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 className="shrink-0 w-20 flex justify-start">
|
||||
{getStatusBadge(project.project_status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs text-gray-500 dark:text-[#8b949e] h-5">
|
||||
<span>{formatDate(project.updated_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center mt-4 md:mt-0 gap-3 w-[340px] justify-end shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => router.push(`/editor/${project.id}`)}
|
||||
>
|
||||
Editor
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isExportingProjectId === String(project.id)}
|
||||
onClick={() => handleExportHeadSnapshot(project)}
|
||||
// title="Export head commit snapshot_json"
|
||||
>
|
||||
ExportJSON
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => router.push(`/editor/${project.id}?only=wiki`)}
|
||||
>
|
||||
Editor only wiki
|
||||
</Button>
|
||||
|
||||
<div className="flex -space-x-2 overflow-hidden">
|
||||
{project.members && project.members.length > 0 ? (
|
||||
<>
|
||||
{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 group-hover:ring-gray-50 dark:ring-[#0d1117] dark:group-hover:ring-[#161b22] transition-colors"
|
||||
/>
|
||||
) : (
|
||||
<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 group-hover:ring-gray-50 dark:ring-[#0d1117] dark:group-hover:ring-[#161b22] transition-colors"
|
||||
>
|
||||
<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 group-hover:ring-gray-50 dark:ring-[#0d1117] dark:group-hover:ring-[#161b22] transition-colors z-10"
|
||||
>
|
||||
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
+{project.members.length - 4}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400 dark:text-gray-600 italic"></span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -145,7 +412,7 @@ export default function ProjectsPage() {
|
||||
)}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Tạo Dự án */}
|
||||
<Modal isOpen={isOpen} onClose={closeModal} className="max-w-[500px] m-4">
|
||||
@@ -154,19 +421,24 @@ export default function ProjectsPage() {
|
||||
<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 +446,48 @@ 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}
|
||||
// title="Tạo dự án và tạo commit đầu tiên từ JSON snapshot"
|
||||
>
|
||||
Tạo với JSON
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
82
src/app/user/quick-qa/page.tsx
Normal file
82
src/app/user/quick-qa/page.tsx
Normal file
@@ -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/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,
|
||||
|
||||
554
src/app/user/wikieditor/page.tsx
Normal file
554
src/app/user/wikieditor/page.tsx
Normal file
@@ -0,0 +1,554 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
||||
import ComponentCard from "@/components/common/ComponentCard";
|
||||
import Button from "@/components/ui/button/Button";
|
||||
import Badge from "@/components/ui/badge/Badge";
|
||||
import Label from "@/components/form/Label";
|
||||
|
||||
import { EditorContent, useEditor, type JSONContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import TiptapLink from "@tiptap/extension-link";
|
||||
|
||||
const STORAGE_KEY = "uhm_wiki_draft_v1";
|
||||
|
||||
type TocItem = {
|
||||
level: number;
|
||||
text: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
type WikiDraft = {
|
||||
schema_version: 1;
|
||||
title: string;
|
||||
doc: JSONContent;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
function slugify(input: string) {
|
||||
return input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
function textFromNode(node: any): string {
|
||||
if (!node) return "";
|
||||
if (node.type === "text") return node.text || "";
|
||||
if (Array.isArray(node.content)) return node.content.map(textFromNode).join("");
|
||||
return "";
|
||||
}
|
||||
|
||||
function buildToc(doc: JSONContent | null): TocItem[] {
|
||||
if (!doc) return [];
|
||||
const out: TocItem[] = [];
|
||||
const seen = new Map<string, number>();
|
||||
|
||||
const walk = (node: any) => {
|
||||
if (!node) return;
|
||||
if (node.type === "heading") {
|
||||
const level = Number(node.attrs?.level || 1);
|
||||
const text = textFromNode(node).trim();
|
||||
if (text) {
|
||||
const base = slugify(text) || "heading";
|
||||
const n = (seen.get(base) || 0) + 1;
|
||||
seen.set(base, n);
|
||||
const slug = n === 1 ? base : `${base}-${n}`;
|
||||
out.push({ level, text, slug });
|
||||
}
|
||||
}
|
||||
if (Array.isArray(node.content)) node.content.forEach(walk);
|
||||
};
|
||||
|
||||
walk(doc);
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderInlineText(node: any, key: string) {
|
||||
if (node.type !== "text") return null;
|
||||
const marks: any[] = Array.isArray(node.marks) ? node.marks : [];
|
||||
let el: React.ReactNode = node.text || "";
|
||||
|
||||
for (const m of marks) {
|
||||
if (m.type === "bold") el = <strong key={`${key}-b`}>{el}</strong>;
|
||||
else if (m.type === "italic") el = <em key={`${key}-i`}>{el}</em>;
|
||||
else if (m.type === "link") {
|
||||
const href = String(m.attrs?.href || "#");
|
||||
el = (
|
||||
<a
|
||||
key={`${key}-a`}
|
||||
href={href}
|
||||
target={m.attrs?.target || "_blank"}
|
||||
rel="noreferrer"
|
||||
className="text-brand-600 dark:text-brand-400 underline underline-offset-2"
|
||||
>
|
||||
{el}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <span key={key}>{el}</span>;
|
||||
}
|
||||
|
||||
function renderDoc(node: any, keyPrefix = "n", toc: TocItem[] = []) : React.ReactNode {
|
||||
if (!node) return null;
|
||||
const type = node.type;
|
||||
const content: any[] = Array.isArray(node.content) ? node.content : [];
|
||||
|
||||
if (type === "doc") {
|
||||
return <>{content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))}</>;
|
||||
}
|
||||
|
||||
if (type === "paragraph") {
|
||||
return (
|
||||
<p key={keyPrefix} className="text-sm leading-6 text-gray-800 dark:text-gray-200">
|
||||
{content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "heading") {
|
||||
const level = Number(node.attrs?.level || 1);
|
||||
const text = textFromNode(node).trim();
|
||||
const slug = toc.find((t) => t.text === text)?.slug || slugify(text);
|
||||
const cls =
|
||||
level === 1
|
||||
? "text-2xl font-bold"
|
||||
: level === 2
|
||||
? "text-xl font-semibold"
|
||||
: "text-lg font-semibold";
|
||||
return (
|
||||
<div key={keyPrefix} className="mt-5">
|
||||
<div id={slug} className={`${cls} text-gray-900 dark:text-gray-100`}>
|
||||
{content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "bulletList") {
|
||||
return (
|
||||
<ul key={keyPrefix} className="list-disc pl-5 text-sm text-gray-800 dark:text-gray-200">
|
||||
{content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "orderedList") {
|
||||
return (
|
||||
<ol key={keyPrefix} className="list-decimal pl-5 text-sm text-gray-800 dark:text-gray-200">
|
||||
{content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "listItem") {
|
||||
return <li key={keyPrefix}>{content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))}</li>;
|
||||
}
|
||||
|
||||
if (type === "blockquote") {
|
||||
return (
|
||||
<blockquote
|
||||
key={keyPrefix}
|
||||
className="border-l-4 border-gray-200 dark:border-gray-800 pl-4 text-sm text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))}
|
||||
</blockquote>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "codeBlock") {
|
||||
const code = content.map(textFromNode).join("");
|
||||
return (
|
||||
<pre
|
||||
key={keyPrefix}
|
||||
className="rounded-xl border border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-[#0d1117] p-4 overflow-auto text-xs"
|
||||
>
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "hardBreak") return <br key={keyPrefix} />;
|
||||
|
||||
if (type === "text") return renderInlineText(node, keyPrefix);
|
||||
|
||||
// fallback: render children
|
||||
return <span key={keyPrefix}>{content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))}</span>;
|
||||
}
|
||||
|
||||
type ViewMode = "edit" | "split" | "preview";
|
||||
|
||||
export default function WikiEditorPage() {
|
||||
const [view, setView] = useState<ViewMode>("split");
|
||||
const [showJson, setShowJson] = useState(false);
|
||||
const [title, setTitle] = useState("Untitled wiki");
|
||||
const [docJson, setDocJson] = useState<JSONContent | null>(null);
|
||||
const [savedAt, setSavedAt] = useState<string | null>(null);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const saveTimerRef = useRef<number | null>(null);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
}),
|
||||
TiptapLink.configure({
|
||||
openOnClick: false,
|
||||
autolink: true,
|
||||
linkOnPaste: true,
|
||||
}),
|
||||
],
|
||||
content: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "Write your wiki content here." }] },
|
||||
{ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Section" }] },
|
||||
{ type: "paragraph", content: [{ type: "text", text: "Use H1/H2/H3 and the TOC will follow." }] },
|
||||
],
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
setDocJson(editor.getJSON());
|
||||
setIsDirty(true);
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
// Keep editor styling independent from whatever global typography the app uses.
|
||||
class:
|
||||
"tiptap-editor focus:outline-none min-h-[360px] px-4 py-3",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Load draft
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw) as WikiDraft;
|
||||
if (parsed && typeof parsed === "object" && parsed.schema_version === 1 && parsed.doc) {
|
||||
setTitle(parsed.title || "Untitled wiki");
|
||||
editor.commands.setContent(parsed.doc as JSONContent);
|
||||
setDocJson(parsed.doc as JSONContent);
|
||||
setSavedAt(parsed.updated_at || "loaded");
|
||||
setIsDirty(false);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [editor]);
|
||||
|
||||
const toc = useMemo(() => buildToc(docJson), [docJson]);
|
||||
|
||||
const doSaveDraft = () => {
|
||||
if (!editor) return;
|
||||
const payload: WikiDraft = {
|
||||
schema_version: 1,
|
||||
title: title.trim() || "Untitled wiki",
|
||||
doc: editor.getJSON(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
|
||||
setSavedAt(new Date().toLocaleString("vi-VN"));
|
||||
setIsDirty(false);
|
||||
};
|
||||
|
||||
// Debounced autosave
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
if (!isDirty) return;
|
||||
|
||||
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = window.setTimeout(() => {
|
||||
doSaveDraft();
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editor, isDirty, title, docJson]);
|
||||
|
||||
const can = (cmd: () => boolean) => {
|
||||
try {
|
||||
return Boolean(editor && cmd());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const setLink = () => {
|
||||
if (!editor) return;
|
||||
const prev = editor.getAttributes("link")?.href as string | undefined;
|
||||
const href = window.prompt("Link URL", prev || "https://");
|
||||
if (href == null) return;
|
||||
const next = href.trim();
|
||||
if (!next.length) {
|
||||
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().extendMarkRange("link").setLink({ href: next }).run();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto pb-10">
|
||||
<PageBreadcrumb pageTitle="Wiki editor" paths={[{ name: "User", href: "/user" }]} />
|
||||
|
||||
<style jsx global>{`
|
||||
.tiptap-editor {
|
||||
color: inherit;
|
||||
}
|
||||
.tiptap-editor p {
|
||||
margin: 0.5rem 0;
|
||||
line-height: 1.65;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.tiptap-editor h1 {
|
||||
margin: 1rem 0 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.tiptap-editor h2 {
|
||||
margin: 0.9rem 0 0.4rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.tiptap-editor h3 {
|
||||
margin: 0.8rem 0 0.35rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.tiptap-editor ul,
|
||||
.tiptap-editor ol {
|
||||
margin: 0.6rem 0;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
.tiptap-editor li {
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
.tiptap-editor blockquote {
|
||||
margin: 0.75rem 0;
|
||||
padding-left: 0.75rem;
|
||||
border-left: 4px solid rgba(148, 163, 184, 0.55);
|
||||
color: rgba(100, 116, 139, 1);
|
||||
}
|
||||
.dark .tiptap-editor blockquote {
|
||||
border-left-color: rgba(71, 85, 105, 1);
|
||||
color: rgba(148, 163, 184, 1);
|
||||
}
|
||||
.tiptap-editor code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
"Liberation Mono", "Courier New", monospace;
|
||||
font-size: 0.85em;
|
||||
padding: 0.1rem 0.25rem;
|
||||
border-radius: 0.35rem;
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
}
|
||||
.tiptap-editor pre {
|
||||
margin: 0.8rem 0;
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(226, 232, 240, 1);
|
||||
background: rgba(248, 250, 252, 1);
|
||||
overflow: auto;
|
||||
}
|
||||
.dark .tiptap-editor pre {
|
||||
border-color: rgba(30, 41, 59, 1);
|
||||
background: rgba(13, 17, 23, 1);
|
||||
}
|
||||
.tiptap-editor pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
.tiptap-editor a {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.tiptap-editor hr {
|
||||
margin: 1rem 0;
|
||||
border: none;
|
||||
border-top: 1px solid rgba(226, 232, 240, 1);
|
||||
}
|
||||
.dark .tiptap-editor hr {
|
||||
border-top-color: rgba(30, 41, 59, 1);
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
<ComponentCard title="Wiki">
|
||||
<div className="p-4 flex flex-col gap-4">
|
||||
<div>
|
||||
<Label>Title</Label>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => {
|
||||
setTitle(e.target.value);
|
||||
setIsDirty(true);
|
||||
}}
|
||||
className="h-11 w-full rounded-xl border border-gray-200 bg-transparent px-4 py-2.5 text-sm text-gray-800 outline-none focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:text-white/90 dark:focus:border-brand-800"
|
||||
placeholder="Wiki title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge size="sm" variant="light" color="info">
|
||||
TipTap
|
||||
</Badge>
|
||||
{isDirty ? (
|
||||
<Badge size="sm" variant="light" color="warning">
|
||||
Unsaved
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" variant="light" color="success">
|
||||
Saved
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => setShowJson((v) => !v)}>
|
||||
{showJson ? "Hide JSON" : "Show JSON"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Last save: {savedAt || "-"}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-gray-600 dark:text-gray-300 mb-2">TOC</div>
|
||||
{toc.length === 0 ? (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">No headings</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{toc.map((t) => (
|
||||
<Link
|
||||
key={t.slug}
|
||||
href={`#${t.slug}`}
|
||||
className={`text-xs hover:underline text-gray-700 dark:text-gray-300 ${
|
||||
t.level === 1 ? "font-semibold" : t.level === 2 ? "pl-3" : "pl-6"
|
||||
}`}
|
||||
title={t.text}
|
||||
>
|
||||
{t.text}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-1 flex gap-2">
|
||||
<Button size="sm" className="bg-brand-500 hover:bg-brand-600 text-white" onClick={doSaveDraft} disabled={!editor}>
|
||||
Save now
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
setSavedAt(null);
|
||||
setIsDirty(false);
|
||||
}}
|
||||
>
|
||||
Clear draft
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
|
||||
<div className="lg:col-span-3 flex flex-col gap-6">
|
||||
<ComponentCard title="Editor">
|
||||
<div className="p-4">
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
<Button size="sm" variant="outline" onClick={() => setView("edit")}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setView("split")}>
|
||||
Split
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setView("preview")}>
|
||||
Preview
|
||||
</Button>
|
||||
|
||||
<div className="w-px h-7 bg-gray-200 dark:bg-gray-800 mx-1" />
|
||||
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleBold().run()} disabled={!can(() => editor!.can().toggleBold())}>
|
||||
B
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleItalic().run()} disabled={!can(() => editor!.can().toggleItalic())}>
|
||||
I
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleHeading({ level: 1 }).run()}>
|
||||
H1
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()}>
|
||||
H2
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()}>
|
||||
H3
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleBulletList().run()}>
|
||||
Bullets
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleOrderedList().run()}>
|
||||
Numbers
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleBlockquote().run()}>
|
||||
Quote
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleCodeBlock().run()}>
|
||||
Code
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={setLink} disabled={!editor}>
|
||||
Link
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().undo().run()} disabled={!can(() => editor!.can().undo())}>
|
||||
Undo
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().redo().run()} disabled={!can(() => editor!.can().redo())}>
|
||||
Redo
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={view === "split" ? "grid grid-cols-1 lg:grid-cols-2 gap-4" : ""}>
|
||||
{view !== "preview" ? (
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-[#0d1117]">
|
||||
{editor ? <EditorContent editor={editor} /> : <div className="p-4 text-sm text-gray-500">Loading editor...</div>}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{view !== "edit" ? (
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-[#0d1117] p-4">
|
||||
<div className="text-xs font-semibold text-gray-600 dark:text-gray-300 mb-2">
|
||||
Preview
|
||||
</div>
|
||||
{renderDoc(docJson, "p", toc)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
|
||||
{showJson ? (
|
||||
<ComponentCard title="Document JSON">
|
||||
<div className="p-4">
|
||||
<pre className="text-xs whitespace-pre-wrap break-words rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-[#0d1117] p-4 overflow-auto max-h-[520px]">
|
||||
{JSON.stringify({ title: title.trim() || "Untitled wiki", doc: docJson }, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
src/auth/tokenStore.ts
Normal file
78
src/auth/tokenStore.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
export type StoredTokens = {
|
||||
access_token: string;
|
||||
refresh_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" || typeof v.refresh_token !== "string") return null;
|
||||
if (!v.access_token.trim() || !v.refresh_token.trim()) return null;
|
||||
return { access_token: v.access_token, refresh_token: v.refresh_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 getRefreshToken(): string | null {
|
||||
return getStoredTokens()?.refresh_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 ??
|
||||
null;
|
||||
|
||||
const refresh =
|
||||
tokenContainer?.refresh_token ??
|
||||
tokenContainer?.refreshToken ??
|
||||
tokenContainer?.refresh ??
|
||||
null;
|
||||
if (typeof access === "string" && typeof refresh === "string" && access.trim() && refresh.trim()) {
|
||||
return { access_token: access, refresh_token: refresh };
|
||||
}
|
||||
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/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
|
||||
|
||||
223
src/components/ui/chat/ChatbotWidget.tsx
Normal file
223
src/components/ui/chat/ChatbotWidget.tsx
Normal file
@@ -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,10 +1,18 @@
|
||||
import axios from "axios"
|
||||
import { API_URL_ROOT } from "../../api"
|
||||
import {
|
||||
clearStoredTokens,
|
||||
extractTokensFromResponsePayload,
|
||||
getAccessToken,
|
||||
getRefreshToken,
|
||||
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
|
||||
})
|
||||
|
||||
@@ -19,12 +27,36 @@ const processQueue = (error?: any) => {
|
||||
queue = []
|
||||
}
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = getAccessToken()
|
||||
if (token) {
|
||||
const headers: any = config.headers || {}
|
||||
// Do not override if caller set Authorization explicitly (case-insensitive).
|
||||
const already =
|
||||
typeof headers.get === "function"
|
||||
? headers.get("Authorization")
|
||||
: headers.Authorization || headers.authorization
|
||||
if (!already) {
|
||||
if (typeof headers.set === "function") headers.set("Authorization", `Bearer ${token}`)
|
||||
else headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
config.headers = headers
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
(res) => {
|
||||
// Opportunistically persist tokens from signin/refresh responses.
|
||||
const tokens = extractTokensFromResponsePayload(res?.data)
|
||||
if (tokens) setStoredTokens(tokens)
|
||||
return res
|
||||
},
|
||||
async (err) => {
|
||||
const originalRequest = err.config
|
||||
|
||||
if (err.response?.status === 401 && !originalRequest._retry) {
|
||||
const url = String(originalRequest?.url || "")
|
||||
if (err.response?.status === 401 && !originalRequest._retry && !url.includes("/auth/")) {
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
queue.push({
|
||||
@@ -38,19 +70,55 @@ api.interceptors.response.use(
|
||||
isRefreshing = true
|
||||
|
||||
try {
|
||||
await axios.post(
|
||||
`${baseURL}/auth/refresh`,
|
||||
{},
|
||||
{ withCredentials: true }
|
||||
)
|
||||
const refreshToken = getRefreshToken()
|
||||
|
||||
const tryHeaderRefresh = async () => {
|
||||
if (!refreshToken) return null
|
||||
return axios.post(
|
||||
`${baseURL}/auth/refresh`,
|
||||
{},
|
||||
{ headers: { Authorization: `Bearer ${refreshToken}` } }
|
||||
)
|
||||
}
|
||||
|
||||
const tryCookieRefresh = async () => {
|
||||
return axios.post(`${baseURL}/auth/refresh`, {}, { withCredentials: true })
|
||||
}
|
||||
|
||||
let refreshRes: any = null
|
||||
try {
|
||||
refreshRes = (await tryHeaderRefresh()) || (await tryCookieRefresh())
|
||||
} catch (e: any) {
|
||||
// If header-based refresh fails (wrong token type), fall back to cookie refresh.
|
||||
if (refreshToken && e?.response?.status === 401) {
|
||||
refreshRes = await tryCookieRefresh()
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
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()) {
|
||||
// Keep refresh token if we have one; otherwise rely on cookies.
|
||||
if (refreshToken) setStoredTokens({ access_token: maybeAccess, refresh_token: refreshToken })
|
||||
}
|
||||
}
|
||||
|
||||
processQueue()
|
||||
|
||||
return api(originalRequest)
|
||||
} catch (refreshErr) {
|
||||
} catch (refreshErr: any) {
|
||||
processQueue(refreshErr)
|
||||
|
||||
window.location.href = "/signin"
|
||||
// Only force logout when refresh token/session is truly invalid (401).
|
||||
if (refreshErr?.response?.status === 401) {
|
||||
clearStoredTokens()
|
||||
window.location.href = "/signin"
|
||||
}
|
||||
|
||||
return Promise.reject(refreshErr)
|
||||
} finally {
|
||||
@@ -62,4 +130,4 @@ api.interceptors.response.use(
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
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);
|
||||
|
||||
9
src/interface/chatbot.ts
Normal file
9
src/interface/chatbot.ts
Normal file
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
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
|
||||
|
||||
@@ -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" },
|
||||
@@ -151,23 +166,41 @@ const AppSidebar: React.FC = () => {
|
||||
onClick={() => handleSubmenuToggle(index, menuType)}
|
||||
className={`menu-item group uppercase ${
|
||||
openSubmenu?.type === menuType && openSubmenu?.index === index
|
||||
? "menu-item-active" : "menu-item-inactive"
|
||||
? "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 +211,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 +259,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 +281,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 +305,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;
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
8
src/service/chatbotService.ts
Normal file
8
src/service/chatbotService.ts
Normal file
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
35
src/uhm/api/auth.ts
Normal file
35
src/uhm/api/auth.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
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;
|
||||
refresh_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 && res?.refresh_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);
|
||||
}
|
||||
24
src/uhm/api/config.ts
Normal file
24
src/uhm/api/config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// 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`,
|
||||
// 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;
|
||||
32
src/uhm/api/entities.ts
Normal file
32
src/uhm/api/entities.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
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 }): Promise<Entity[]> {
|
||||
const params = new URLSearchParams();
|
||||
// API mới dùng `name` thay vì `q`.
|
||||
if (query?.q) {
|
||||
params.set("name", query.q);
|
||||
}
|
||||
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()}`);
|
||||
}
|
||||
131
src/uhm/api/geometries.ts
Normal file
131
src/uhm/api/geometries.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
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/geoTypeMap";
|
||||
|
||||
export type { GeometriesBBoxQuery } from "@/uhm/types/api";
|
||||
|
||||
export type EntityGeometrySearchGeo = {
|
||||
id: string;
|
||||
geo_type: number;
|
||||
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;
|
||||
};
|
||||
|
||||
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.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)));
|
||||
}
|
||||
|
||||
return requestJson<SearchGeometriesByEntityNameResponse>(`${API_ENDPOINTS.geometries}/entity?${params.toString()}`);
|
||||
}
|
||||
|
||||
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 [];
|
||||
}
|
||||
218
src/uhm/api/http.ts
Normal file
218
src/uhm/api/http.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import type { ApiEnvelope } from "@/uhm/types/api";
|
||||
import { API_ENDPOINTS } from "@/uhm/api/config";
|
||||
import { getAccessToken, getRefreshToken, setStoredTokens, type StoredTokens, extractTokensFromResponsePayload } from "@/auth/tokenStore";
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// History API auth flow supports Bearer JWT and (in some deployments) cookie-based sessions.
|
||||
|
||||
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> {
|
||||
return requestJsonInternal<T>(input, init, options);
|
||||
}
|
||||
|
||||
export function jsonRequestInit(method: string, body: unknown): RequestInit {
|
||||
return {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
}
|
||||
|
||||
async function requestJsonInternal<T>(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
options?: RequestJsonOptions
|
||||
): Promise<T> {
|
||||
const nextInit = withAuthHeaders(init, options);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(input, nextInit);
|
||||
} catch (err) {
|
||||
// Browser "TypeError: Failed to fetch" typically means:
|
||||
// - CORS blocked (common when using 127.0.0.1 instead of localhost in dev),
|
||||
// - DNS/TLS/network error,
|
||||
// - request blocked by the browser.
|
||||
const origin = typeof window !== "undefined" ? window.location.origin : "<server>";
|
||||
const url = typeof input === "string" ? input : String(input);
|
||||
const details = { origin, url, apiBase: API_ENDPOINTS.projects.split("/projects")[0] };
|
||||
throw new ApiError("Network error (failed to fetch)", 0, stringifyPayload(details));
|
||||
}
|
||||
|
||||
// One-shot refresh + retry for protected endpoints.
|
||||
if (
|
||||
res.status === 401 &&
|
||||
!options?.skipRefresh &&
|
||||
!options?.skipAuth &&
|
||||
typeof input === "string" &&
|
||||
!String(input).includes("/auth/")
|
||||
) {
|
||||
const refreshed = await tryRefreshTokens();
|
||||
if (refreshed) {
|
||||
return requestJsonInternal<T>(input, init, { ...(options || {}), skipRefresh: true });
|
||||
}
|
||||
}
|
||||
|
||||
const payload = await parseJsonResponse(res);
|
||||
const envelope = isApiEnvelopeLike<T>(payload) ? payload : null;
|
||||
|
||||
if (!res.ok) {
|
||||
const message = extractErrorMessage(payload, envelope) || `Request failed with status ${res.status}`;
|
||||
const body = envelope ? stringifyPayload(envelope) : stringifyPayload(payload);
|
||||
const errors = envelope?.errors ? normalizeErrors(envelope.errors) : [];
|
||||
throw new ApiError(message, res.status, body, errors);
|
||||
}
|
||||
|
||||
if (envelope) {
|
||||
const isError =
|
||||
envelope.status === false ||
|
||||
envelope.status === "error";
|
||||
if (isError) {
|
||||
const message = extractErrorMessage(payload, envelope) || "Request failed";
|
||||
throw new ApiError(message, res.status, stringifyPayload(envelope), normalizeErrors(envelope.errors));
|
||||
}
|
||||
return (envelope.data ?? null) as T;
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
async function parseJsonResponse(res: Response): Promise<unknown> {
|
||||
const text = await res.text();
|
||||
if (!text.length) return null;
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function withAuthHeaders(init: RequestInit | undefined, options?: RequestJsonOptions): RequestInit | undefined {
|
||||
const baseInit: RequestInit = {
|
||||
...init,
|
||||
credentials: init?.credentials ?? "include",
|
||||
};
|
||||
|
||||
const headers = new Headers(baseInit.headers || undefined);
|
||||
|
||||
const override = options?.authToken;
|
||||
if (override) {
|
||||
headers.set("Authorization", `Bearer ${override}`);
|
||||
return { ...baseInit, headers };
|
||||
}
|
||||
|
||||
if (options?.skipAuth) return baseInit;
|
||||
|
||||
const access = getAccessToken();
|
||||
if (access) headers.set("Authorization", `Bearer ${access}`);
|
||||
return { ...baseInit, headers };
|
||||
}
|
||||
|
||||
let refreshInFlight: Promise<boolean> | null = null;
|
||||
|
||||
async function tryRefreshTokens(): Promise<boolean> {
|
||||
// Single-flight refresh for concurrent 401s.
|
||||
if (refreshInFlight) return refreshInFlight;
|
||||
refreshInFlight = (async () => {
|
||||
try {
|
||||
const refreshToken = getRefreshToken();
|
||||
|
||||
// Try header-based refresh first (per swagger), but fall back to cookie-based refresh if needed.
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await requestJsonInternal<unknown>(
|
||||
API_ENDPOINTS.authRefresh,
|
||||
{ method: "POST" },
|
||||
refreshToken
|
||||
? { skipRefresh: true, authToken: refreshToken }
|
||||
: { skipRefresh: true, skipAuth: true }
|
||||
);
|
||||
} catch (err) {
|
||||
if (refreshToken && err instanceof ApiError && err.status === 401) {
|
||||
payload = await requestJsonInternal<unknown>(
|
||||
API_ENDPOINTS.authRefresh,
|
||||
{ method: "POST" },
|
||||
{ skipRefresh: true, skipAuth: true }
|
||||
);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const next = extractTokensFromResponsePayload(payload) as StoredTokens | null;
|
||||
if (next) {
|
||||
setStoredTokens(next);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback: if server returns only access_token, keep existing refresh token (if any).
|
||||
const maybeAccess = (payload as any)?.access_token ?? (payload as any)?.data?.access_token;
|
||||
if (typeof maybeAccess === "string" && maybeAccess.trim()) {
|
||||
if (refreshToken) setStoredTokens({ access_token: maybeAccess, refresh_token: refreshToken });
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
try {
|
||||
return await refreshInFlight;
|
||||
} finally {
|
||||
refreshInFlight = null;
|
||||
}
|
||||
}
|
||||
153
src/uhm/api/sections.ts
Normal file
153
src/uhm/api/sections.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { API_BASE_URL, API_ENDPOINTS } from "@/uhm/api/config";
|
||||
import { ApiError, jsonRequestInit, requestJson } from "@/uhm/api/http";
|
||||
import type {
|
||||
CreateCommitInput,
|
||||
CreateSectionInput,
|
||||
EditorLoadResponse,
|
||||
RestoreCommitInput,
|
||||
Section,
|
||||
SectionCommit,
|
||||
SectionState,
|
||||
SectionSubmission,
|
||||
} from "@/uhm/types/sections";
|
||||
|
||||
export type {
|
||||
CreateCommitInput,
|
||||
CreateSectionInput,
|
||||
EditorLoadResponse,
|
||||
RestoreCommitInput,
|
||||
Section,
|
||||
SectionCommit,
|
||||
SectionState,
|
||||
SectionSubmission,
|
||||
} from "@/uhm/types/sections";
|
||||
|
||||
// Sections (API cũ) => Projects (API mới)
|
||||
|
||||
export async function fetchSections(): Promise<Section[]> {
|
||||
// /users/current/project requires JWT.
|
||||
return requestJson<Section[]>(API_ENDPOINTS.currentUserProjects);
|
||||
}
|
||||
|
||||
export async function createSection(input: CreateSectionInput): Promise<Section> {
|
||||
// POST /projects
|
||||
return requestJson<Section>(API_ENDPOINTS.projects, jsonRequestInit("POST", input));
|
||||
}
|
||||
|
||||
export async function openSectionEditor(sectionId: 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<Section>(`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}`);
|
||||
|
||||
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 fetchSectionCommits(sectionId);
|
||||
|
||||
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: SectionState = {
|
||||
status: project.project_status || "ACTIVE",
|
||||
head_commit_id: headCommitId,
|
||||
locked_by: project.locked_by ?? null,
|
||||
};
|
||||
|
||||
return {
|
||||
section: project,
|
||||
state,
|
||||
commit: headCommit,
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createSectionCommit(
|
||||
sectionId: string,
|
||||
input: CreateCommitInput
|
||||
): Promise<{ commit: SectionCommit; state: SectionState }> {
|
||||
// POST /projects/{id}/commits
|
||||
const commit = await requestJson<SectionCommit>(
|
||||
`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}/commits`,
|
||||
jsonRequestInit("POST", {
|
||||
snapshot_json: input.snapshot,
|
||||
edit_summary: input.edit_summary,
|
||||
})
|
||||
);
|
||||
|
||||
// Refresh project state (latest_commit_id may have moved).
|
||||
const project = await requestJson<Section>(`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}`);
|
||||
const state: SectionState = {
|
||||
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 fetchSectionCommits(sectionId: string): Promise<SectionCommit[]> {
|
||||
return requestJson<SectionCommit[]>(`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}/commits`);
|
||||
}
|
||||
|
||||
export async function restoreSectionCommit(
|
||||
sectionId: string,
|
||||
input: RestoreCommitInput
|
||||
): Promise<{ commit: SectionCommit | null; state: SectionState }> {
|
||||
// POST /projects/{id}/commits/restore
|
||||
await requestJson(
|
||||
`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}/commits/restore`,
|
||||
jsonRequestInit("POST", { commit_id: input.commit_id })
|
||||
);
|
||||
|
||||
// Reload commits + project to determine new head commit.
|
||||
const project = await requestJson<Section>(`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}`);
|
||||
const commits = await fetchSectionCommits(sectionId);
|
||||
const headCommitId = project.latest_commit_id ?? null;
|
||||
const headCommit = headCommitId ? commits.find((c) => c.id === headCommitId) || null : null;
|
||||
|
||||
const state: SectionState = {
|
||||
status: project.project_status || "ACTIVE",
|
||||
head_commit_id: headCommitId,
|
||||
locked_by: project.locked_by ?? null,
|
||||
};
|
||||
|
||||
return { commit: headCommit, state };
|
||||
}
|
||||
|
||||
export async function submitSection(sectionId: string): Promise<SectionSubmission> {
|
||||
// Submit latest commit of project
|
||||
const project = await requestJson<Section>(`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}`);
|
||||
const commitId = project.latest_commit_id;
|
||||
if (!commitId) {
|
||||
throw new Error("Project has no latest commit to submit");
|
||||
}
|
||||
|
||||
return requestJson<SectionSubmission>(
|
||||
API_ENDPOINTS.submissions,
|
||||
jsonRequestInit("POST", {
|
||||
project_id: sectionId,
|
||||
commit_id: commitId,
|
||||
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;
|
||||
20
src/uhm/api/tiles.ts
Normal file
20
src/uhm/api/tiles.ts
Normal file
@@ -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);
|
||||
}
|
||||
50
src/uhm/api/wikis.ts
Normal file
50
src/uhm/api/wikis.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { API_ENDPOINTS } from "@/uhm/api/config";
|
||||
import { requestJson } from "@/uhm/api/http";
|
||||
|
||||
export type Wiki = {
|
||||
id: string;
|
||||
title?: string;
|
||||
content?: string;
|
||||
is_deleted?: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export async function searchWikisByTitle(title: string, options?: { limit?: number; cursor?: string; entityId?: string }): Promise<Wiki[]> {
|
||||
const keyword = title.trim();
|
||||
if (!keyword.length) return [];
|
||||
|
||||
const params = new URLSearchParams({ title: keyword });
|
||||
if (options?.limit && Number.isFinite(options.limit)) params.set("limit", String(Math.trunc(options.limit)));
|
||||
if (options?.cursor) params.set("cursor", options.cursor);
|
||||
if (options?.entityId) params.set("entity_id", options.entityId);
|
||||
|
||||
return requestJson<Wiki[]>(`${API_ENDPOINTS.wikis}?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function fetchWikiById(id: string): Promise<Wiki> {
|
||||
const wikiId = String(id || "").trim();
|
||||
if (!wikiId) throw new Error("Missing wiki id");
|
||||
return requestJson<Wiki>(`${API_ENDPOINTS.wikis}/${encodeURIComponent(wikiId)}`);
|
||||
}
|
||||
|
||||
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 anyPayload = payload as any;
|
||||
if (typeof anyPayload.exists === "boolean") return anyPayload.exists;
|
||||
if (typeof anyPayload.exists === "number") return anyPayload.exists !== 0;
|
||||
if (typeof anyPayload.is_exists === "boolean") return anyPayload.is_exists;
|
||||
if (typeof anyPayload.is_exists === "number") return anyPayload.is_exists !== 0;
|
||||
}
|
||||
|
||||
// Be conservative: unknown payload shape, treat as "exists" to prevent creating conflicting slugs.
|
||||
return true;
|
||||
}
|
||||
9
src/uhm/components/AuthPanel.tsx
Normal file
9
src/uhm/components/AuthPanel.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
// FrontEndUser is the primary FE and follows BackEndGo cookie-based auth.
|
||||
// Users sign in via the app's /signin page; the editor reuses those httpOnly cookies.
|
||||
// This component remains as a no-op placeholder for any legacy imports.
|
||||
export default function AuthPanel() {
|
||||
return null;
|
||||
}
|
||||
|
||||
74
src/uhm/components/BackgroundLayersPanel.tsx
Normal file
74
src/uhm/components/BackgroundLayersPanel.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import {
|
||||
BACKGROUND_LAYER_OPTIONS,
|
||||
BackgroundLayerId,
|
||||
BackgroundLayerVisibility,
|
||||
} from "@/uhm/lib/backgroundLayers";
|
||||
|
||||
type Props = {
|
||||
visibility: BackgroundLayerVisibility;
|
||||
onToggleLayer: (id: BackgroundLayerId) => void;
|
||||
onShowAll: () => void;
|
||||
onHideAll: () => void;
|
||||
topContent?: ReactNode;
|
||||
width?: number;
|
||||
};
|
||||
|
||||
export default function BackgroundLayersPanel({
|
||||
visibility,
|
||||
onToggleLayer,
|
||||
onShowAll,
|
||||
onHideAll,
|
||||
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>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
524
src/uhm/components/Editor.tsx
Normal file
524
src/uhm/components/Editor.tsx
Normal file
@@ -0,0 +1,524 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type { UndoAction } from "@/uhm/lib/useEditorState";
|
||||
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
|
||||
|
||||
type Props = {
|
||||
mode: EditorMode;
|
||||
setMode: (mode: EditorMode) => void;
|
||||
entityStatus?: string | null;
|
||||
onUndo: () => void;
|
||||
onCommit: () => void;
|
||||
onSubmit: () => void;
|
||||
onRestoreCommit: (commitId: string) => void;
|
||||
isSaving: boolean;
|
||||
isSubmitting: boolean;
|
||||
sectionTitle: string;
|
||||
sectionStatus: string;
|
||||
commitTitle: string;
|
||||
commitNote: string;
|
||||
onCommitTitleChange: (title: string) => void;
|
||||
onCommitNoteChange: (note: 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[];
|
||||
createdEntities: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
}>;
|
||||
createdGeometries: Array<{
|
||||
id: string | number;
|
||||
geometryType: string;
|
||||
semanticType?: string | null;
|
||||
entityNames: string[];
|
||||
}>;
|
||||
width?: number;
|
||||
};
|
||||
|
||||
export default function Editor({
|
||||
mode,
|
||||
setMode,
|
||||
entityStatus,
|
||||
onUndo,
|
||||
onCommit,
|
||||
onSubmit,
|
||||
onRestoreCommit,
|
||||
isSaving,
|
||||
isSubmitting,
|
||||
sectionTitle,
|
||||
sectionStatus,
|
||||
commitTitle,
|
||||
commitNote,
|
||||
onCommitTitleChange,
|
||||
onCommitNoteChange,
|
||||
commitCount,
|
||||
hasHeadCommit,
|
||||
headCommitId,
|
||||
latestCommitLabel,
|
||||
commits,
|
||||
changesCount,
|
||||
undoStack,
|
||||
createdEntities,
|
||||
createdGeometries,
|
||||
width = 280,
|
||||
}: Props) {
|
||||
const toggleMode = (newMode: EditorMode) => {
|
||||
if (mode === newMode) {
|
||||
setMode("idle");
|
||||
} else {
|
||||
setMode(newMode);
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
})();
|
||||
|
||||
const formatCommitTitle = (commit: Props["commits"][number]) =>
|
||||
commit.edit_summary?.trim() || `Commit ${commit.id.slice(0, 8)}`;
|
||||
|
||||
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;
|
||||
|
||||
const primaryButtonStyle =
|
||||
({
|
||||
width: "100%",
|
||||
padding: "8px 10px",
|
||||
borderRadius: 6,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontWeight: 850,
|
||||
fontSize: 12,
|
||||
}) as const;
|
||||
|
||||
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 }}>
|
||||
<div style={{ fontWeight: 950, fontSize: 14, marginBottom: 10 }}>Editor</div>
|
||||
|
||||
<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" }}>{sectionStatus}</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>
|
||||
|
||||
<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>
|
||||
|
||||
{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>
|
||||
|
||||
<Panel title="Commit" defaultOpen>
|
||||
<input
|
||||
value={commitTitle}
|
||||
onChange={(event) => onCommitTitleChange(event.target.value)}
|
||||
placeholder="Commit title"
|
||||
disabled={isSaving || isSubmitting}
|
||||
style={textInputStyle}
|
||||
/>
|
||||
<textarea
|
||||
value={commitNote}
|
||||
onChange={(event) => onCommitNoteChange(event.target.value)}
|
||||
placeholder="Commit note"
|
||||
disabled={isSaving || isSubmitting}
|
||||
rows={3}
|
||||
style={textAreaStyle}
|
||||
/>
|
||||
<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={onSubmit}
|
||||
disabled={isSubmitting || !hasHeadCommit}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</Panel>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<Panel title="This Session" defaultOpen={false}>
|
||||
<div style={{ fontSize: 13, color: "#cbd5e1", marginBottom: 6 }}>
|
||||
Entities ({createdEntities.length})
|
||||
</div>
|
||||
{createdEntities.length === 0 ? (
|
||||
<div style={{ color: "#64748b", fontSize: 12, marginBottom: 10 }}>Chưa tạo entity mới</div>
|
||||
) : (
|
||||
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: 12, marginBottom: 10 }}>
|
||||
{createdEntities.map((entity) => (
|
||||
<li
|
||||
key={entity.id}
|
||||
style={{ padding: "6px 0", borderBottom: "1px solid #1f2937", color: "#e2e8f0" }}
|
||||
title={entity.id}
|
||||
>
|
||||
{entity.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div style={{ fontSize: 13, color: "#cbd5e1", marginBottom: 6 }}>
|
||||
Geometries mới chưa commit ({createdGeometries.length})
|
||||
</div>
|
||||
{createdGeometries.length === 0 ? (
|
||||
<div style={{ color: "#64748b", fontSize: 12 }}>Chưa có geometry mới chờ commit</div>
|
||||
) : (
|
||||
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: 12 }}>
|
||||
{createdGeometries.map((geometry) => (
|
||||
<li
|
||||
key={String(geometry.id)}
|
||||
style={{ padding: "6px 0", borderBottom: "1px solid #1f2937", color: "#e2e8f0" }}
|
||||
>
|
||||
#{geometry.id} [{geometry.geometryType}]{" "}
|
||||
{geometry.semanticType ? `- ${geometry.semanticType}` : ""}
|
||||
{geometry.entityNames.length ? ` | ${geometry.entityNames.join(", ")}` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const textAreaStyle = {
|
||||
...textInputStyle,
|
||||
marginTop: 8,
|
||||
resize: "vertical",
|
||||
fontFamily: "inherit",
|
||||
} as const;
|
||||
|
||||
function Panel({
|
||||
title,
|
||||
badge,
|
||||
defaultOpen,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
badge?: string | null;
|
||||
defaultOpen?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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":
|
||||
return action.label;
|
||||
default:
|
||||
return "Tác vụ";
|
||||
}
|
||||
}
|
||||
298
src/uhm/components/EntityWikiBindingsPanel.tsx
Normal file
298
src/uhm/components/EntityWikiBindingsPanel.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { Entity } from "@/uhm/types/entities";
|
||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
||||
|
||||
type EntityChoice = { id: string; name: string };
|
||||
type WikiChoice = { id: string; title: string; operation?: string };
|
||||
|
||||
type Props = {
|
||||
entities: EntityChoice[];
|
||||
wikis: WikiSnapshot[];
|
||||
links: EntityWikiLinkSnapshot[];
|
||||
setLinks: React.Dispatch<React.SetStateAction<EntityWikiLinkSnapshot[]>>;
|
||||
};
|
||||
|
||||
function wikiTitle(w: WikiSnapshot): string {
|
||||
const t = String(w.title || "").trim();
|
||||
return t.length ? t : "Untitled wiki";
|
||||
}
|
||||
|
||||
export default function EntityWikiBindingsPanel({ entities, wikis, links, setLinks }: Props) {
|
||||
const [activeEntityId, setActiveEntityId] = useState<string>("");
|
||||
const [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), operation: w.operation })),
|
||||
[wikis]
|
||||
);
|
||||
|
||||
const entityChoices = useMemo(() => {
|
||||
const cleaned = (entities || []).filter((e) => e && typeof e.id === "string" && e.id.trim().length > 0);
|
||||
cleaned.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return cleaned;
|
||||
}, [entities]);
|
||||
|
||||
// Don't auto-select entity. The user must explicitly pick one.
|
||||
// Only clear the selection if the currently selected entity is no longer available.
|
||||
useEffect(() => {
|
||||
if (!activeEntityId) return;
|
||||
const stillExists = entityChoices.some((e) => e.id === activeEntityId);
|
||||
if (!stillExists) {
|
||||
setActiveEntityId("");
|
||||
setActiveWikiId("");
|
||||
}
|
||||
}, [activeEntityId, entityChoices]);
|
||||
|
||||
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 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;
|
||||
|
||||
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" }}>{links.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>
|
||||
</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>
|
||||
|
||||
{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" }}>
|
||||
<div style={{ fontSize: 12, color: "#94a3b8" }}>Linked wikis ({activeLinks.size})</div>
|
||||
{Array.from(activeLinks).slice(0, 8).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={{
|
||||
color: "#e5e7eb",
|
||||
fontSize: 12,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{w?.title || "Untitled wiki"}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
{activeLinks.size > 8 ? (
|
||||
<div style={{ fontSize: 12, color: "#94a3b8" }}>+{activeLinks.size - 8} more…</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 12, color: "#94a3b8" }}>No wiki linked yet.</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
258
src/uhm/components/GeometryBindingPanel.tsx
Normal file
258
src/uhm/components/GeometryBindingPanel.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
type GeometryChoice = {
|
||||
id: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
geometries: GeometryChoice[];
|
||||
selectedGeometryId: string | null;
|
||||
selectedGeometryBindingIds: string[];
|
||||
onToggleBindGeometryForSelectedGeometry?: (geometryId: string, nextChecked: boolean) => void;
|
||||
statusText?: string | null;
|
||||
bindingFilterEnabled: boolean;
|
||||
onBindingFilterEnabledChange: (next: boolean) => void;
|
||||
};
|
||||
|
||||
export default function GeometryBindingPanel({
|
||||
geometries,
|
||||
selectedGeometryId,
|
||||
selectedGeometryBindingIds,
|
||||
onToggleBindGeometryForSelectedGeometry,
|
||||
statusText,
|
||||
bindingFilterEnabled,
|
||||
onBindingFilterEnabledChange,
|
||||
}: Props) {
|
||||
const canBindToggle =
|
||||
Boolean(selectedGeometryId) && typeof onToggleBindGeometryForSelectedGeometry === "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() }));
|
||||
cleaned.sort((a, b) => a.id.localeCompare(b.id));
|
||||
return cleaned;
|
||||
}, [geometries]);
|
||||
|
||||
const bindingSet = useMemo(() => new Set(selectedGeometryBindingIds || []), [selectedGeometryBindingIds]);
|
||||
|
||||
const visibleRows = rows.slice(0, 12);
|
||||
|
||||
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 : rows.length ? (
|
||||
<div style={{ marginTop: "10px", display: "grid", gap: "6px" }}>
|
||||
{visibleRows
|
||||
.filter((g) => g.id !== selectedGeometryId)
|
||||
.map((g) => {
|
||||
const isBound = bindingSet.has(g.id);
|
||||
return (
|
||||
<div
|
||||
key={g.id}
|
||||
style={{
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #1f2937",
|
||||
background: "transparent",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
opacity: canBindToggle ? 1 : 0.75,
|
||||
}}
|
||||
title={g.id}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "#e5e7eb",
|
||||
fontWeight: 700,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{g.label || g.id}
|
||||
</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={() => 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>
|
||||
);
|
||||
})}
|
||||
{rows.length > visibleRows.length ? (
|
||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>
|
||||
+{rows.length - visibleRows.length} more…
|
||||
</div>
|
||||
) : null}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
1847
src/uhm/components/Map.tsx
Normal file
1847
src/uhm/components/Map.tsx
Normal file
File diff suppressed because it is too large
Load Diff
423
src/uhm/components/ProjectEntityRefsPanel.tsx
Normal file
423
src/uhm/components/ProjectEntityRefsPanel.tsx
Normal file
@@ -0,0 +1,423 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||
import type { EntityFormState } from "@/uhm/lib/editor/session/sessionTypes";
|
||||
|
||||
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 activeEntity = useMemo(
|
||||
() => (activeEntityId ? entityRefs.find((e) => String(e.id) === String(activeEntityId)) || null : null),
|
||||
[activeEntityId, entityRefs]
|
||||
);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editDescription, setEditDescription] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeEntityId) return;
|
||||
if (!entityRefs.some((e) => String(e.id) === String(activeEntityId))) {
|
||||
setActiveEntityId(null);
|
||||
}
|
||||
}, [activeEntityId, entityRefs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeEntity) return;
|
||||
setEditName(typeof activeEntity.name === "string" ? activeEntity.name : "");
|
||||
setEditDescription(activeEntity.description == null ? "" : String(activeEntity.description));
|
||||
}, [activeEntity?.description, activeEntity?.id, activeEntity?.name]);
|
||||
|
||||
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 : entityRefs.length ? (
|
||||
<div style={{ marginTop: "10px", display: "grid", gap: "6px" }}>
|
||||
{entityRefs.slice(0, 8).map((e) => (
|
||||
<div
|
||||
key={e.id}
|
||||
style={{
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
border: activeEntityId === String(e.id) ? "1px solid #2563eb" : "1px solid #1f2937",
|
||||
background: "transparent",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveEntityId(String(e.id))}
|
||||
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={{ fontSize: "12px", color: "#e5e7eb", fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{e.name || e.id}
|
||||
</div>
|
||||
<div style={{ fontSize: "11px", color: "#94a3b8", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{e.id}
|
||||
</div>
|
||||
</button>
|
||||
{canBindToggle ? (
|
||||
<button
|
||||
type="button"
|
||||
title={selectedGeometryEntityIds!.includes(String(e.id)) ? "Unbind from selected geometry" : "Bind to selected geometry"}
|
||||
onClick={() =>
|
||||
onToggleBindEntityForSelectedGeometry!(
|
||||
String(e.id),
|
||||
!selectedGeometryEntityIds!.includes(String(e.id))
|
||||
)
|
||||
}
|
||||
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={
|
||||
selectedGeometryEntityIds!.includes(String(e.id))
|
||||
? `Unbind entity ${String(e.id)} from selected geometry`
|
||||
: `Bind entity ${String(e.id)} to selected geometry`
|
||||
}
|
||||
>
|
||||
{selectedGeometryEntityIds!.includes(String(e.id)) ? (
|
||||
<UnlockIcon />
|
||||
) : (
|
||||
<LockIcon />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{entityRefs.length > 8 ? <div style={{ fontSize: "12px", color: "#94a3b8" }}>+{entityRefs.length - 8} more…</div> : null}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>No entity ref yet for this project.</div>
|
||||
)}
|
||||
|
||||
{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={{ color: "#a7f3d0", fontWeight: 700, fontSize: "12px" }}>
|
||||
Sua entity
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
const entityInputStyle: CSSProperties = {
|
||||
width: "100%",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #334155",
|
||||
background: "#111827",
|
||||
color: "#f8fafc",
|
||||
padding: "6px 8px",
|
||||
fontSize: "13px",
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
413
src/uhm/components/SelectedGeometryPanel.tsx
Normal file
413
src/uhm/components/SelectedGeometryPanel.tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
"use client";
|
||||
|
||||
import { type CSSProperties, useMemo, useState } from "react";
|
||||
import { Entity } from "@/uhm/api/entities";
|
||||
import { Feature } from "@/uhm/lib/useEditorState";
|
||||
import {
|
||||
EntityGeometryPreset,
|
||||
EntityTypeGroupId,
|
||||
EntityTypeOption,
|
||||
findEntityTypeOption,
|
||||
groupEntityTypeOptions,
|
||||
} from "@/uhm/lib/entityTypeOptions";
|
||||
import type { GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionTypes";
|
||||
|
||||
type Props = {
|
||||
selectedFeature: Feature | null;
|
||||
selectedFeatureEntitySummary: string;
|
||||
selectedFeatureBindingSummary: string;
|
||||
entities: Entity[];
|
||||
selectedGeometryEntityIds: string[];
|
||||
onEntityIdsChange: (values: string[]) => void;
|
||||
entityTypeOptions: EntityTypeOption[];
|
||||
geometryMetaForm: GeometryMetaFormState;
|
||||
onGeometryMetaFormChange: (key: keyof GeometryMetaFormState, value: string) => void;
|
||||
isEntitySubmitting: boolean;
|
||||
onApplyGeometryMetadata: () => Promise<{ ok: boolean; error?: string }>;
|
||||
changeCount: number;
|
||||
};
|
||||
|
||||
export default function SelectedGeometryPanel({
|
||||
selectedFeature,
|
||||
selectedFeatureEntitySummary,
|
||||
selectedFeatureBindingSummary,
|
||||
entities,
|
||||
selectedGeometryEntityIds,
|
||||
onEntityIdsChange,
|
||||
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 (!selectedFeature) return null;
|
||||
|
||||
const groupedEntityTypeOptions = groupEntityTypeOptions(entityTypeOptions);
|
||||
const featureGeometryPreset = resolveFeatureGeometryPreset(selectedFeature);
|
||||
const allowedGroupIds = getAllowedGroupIdsForPreset(featureGeometryPreset);
|
||||
const groupedGeoTypeOptions = groupedEntityTypeOptions.filter((group) =>
|
||||
allowedGroupIds.includes(group.id)
|
||||
);
|
||||
const selectedTypeOption = findEntityTypeOption(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" }}>
|
||||
Entity & Geometry
|
||||
</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={{ color: "#e2e8f0" }}>
|
||||
ID: {String(selectedFeature.properties.id)}
|
||||
</div>
|
||||
<div style={{ color: "#cbd5e1" }}>
|
||||
Entities hiện tại: {selectedFeatureEntitySummary}
|
||||
</div>
|
||||
<div style={{ color: "#cbd5e1" }}>
|
||||
Binding hiện tại: {selectedFeatureBindingSummary}
|
||||
</div>
|
||||
<div style={{ color: "#cbd5e1" }}>
|
||||
Geometry preset: {formatGeometryPresetLabel(featureGeometryPreset)}
|
||||
</div>
|
||||
|
||||
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
|
||||
Entities đã chọn:
|
||||
</div>
|
||||
{selectedGeometryEntityIds.length ? (
|
||||
<div style={{ display: "grid", gap: "6px" }}>
|
||||
{selectedGeometryEntityIds.map((entityId) => {
|
||||
const entity = entities.find((item) => item.id === entityId) || null;
|
||||
const label = entity?.name
|
||||
? `${entity.name} (${entityId})`
|
||||
: entityId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entityId}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "8px",
|
||||
background: "#111827",
|
||||
border: "1px solid #334155",
|
||||
borderRadius: "6px",
|
||||
padding: "6px 8px",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "#e2e8f0" }}>{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onEntityIdsChange(
|
||||
selectedGeometryEntityIds.filter((id) => id !== entityId)
|
||||
)
|
||||
}
|
||||
disabled={isEntitySubmitting}
|
||||
style={removeButtonStyle}
|
||||
>
|
||||
Bỏ
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
|
||||
Chưa có entity nào được gắn.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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 removeButtonStyle: CSSProperties = {
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
padding: "4px 8px",
|
||||
cursor: "pointer",
|
||||
background: "#7f1d1d",
|
||||
color: "#ffffff",
|
||||
fontSize: "12px",
|
||||
};
|
||||
|
||||
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): EntityGeometryPreset {
|
||||
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 = findEntityTypeOption(semanticType);
|
||||
if (option) return option.geometryPreset;
|
||||
}
|
||||
|
||||
return mapGeometryTypeToPreset(feature.geometry.type);
|
||||
}
|
||||
|
||||
function normalizeGeometryPreset(value: unknown): EntityGeometryPreset | 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 {
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized.length ? normalized : null;
|
||||
}
|
||||
|
||||
function mapGeometryTypeToPreset(
|
||||
geometryType: Feature["geometry"]["type"]
|
||||
): EntityGeometryPreset {
|
||||
if (geometryType === "Point" || geometryType === "MultiPoint") {
|
||||
return "point";
|
||||
}
|
||||
if (geometryType === "LineString" || geometryType === "MultiLineString") {
|
||||
return "line";
|
||||
}
|
||||
return "polygon";
|
||||
}
|
||||
|
||||
function getAllowedGroupIdsForPreset(
|
||||
geometryPreset: EntityGeometryPreset
|
||||
): EntityTypeGroupId[] {
|
||||
if (geometryPreset === "point") {
|
||||
return ["point"];
|
||||
}
|
||||
|
||||
if (geometryPreset === "line") {
|
||||
return ["line"];
|
||||
}
|
||||
|
||||
if (geometryPreset === "circle-area") {
|
||||
return ["circle"];
|
||||
}
|
||||
|
||||
return ["polygon"];
|
||||
}
|
||||
|
||||
function formatGeometryPresetLabel(preset: EntityGeometryPreset | null): string {
|
||||
if (preset === "point") return "point - Điểm";
|
||||
if (preset === "line") return "line - Tuyến";
|
||||
if (preset === "circle-area") return "circle - Tròn";
|
||||
if (preset === "polygon") return "polygon - Đa giác";
|
||||
return "unknown";
|
||||
}
|
||||
161
src/uhm/components/TimelineBar.tsx
Normal file
161
src/uhm/components/TimelineBar.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { FIXED_TIMELINE_END_YEAR, FIXED_TIMELINE_START_YEAR, clampYearValue } from "@/uhm/lib/timeline";
|
||||
|
||||
type Props = {
|
||||
year: number;
|
||||
onYearChange: (year: number) => void;
|
||||
isLoading: boolean;
|
||||
disabled: boolean;
|
||||
statusText?: string | null;
|
||||
filterEnabled?: boolean;
|
||||
onFilterEnabledChange?: (enabled: boolean) => void;
|
||||
};
|
||||
|
||||
export default function TimelineBar({
|
||||
year,
|
||||
onYearChange,
|
||||
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));
|
||||
};
|
||||
|
||||
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",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatYear(year: number): string {
|
||||
if (year < 0) {
|
||||
return `${Math.abs(year)} TCN`;
|
||||
}
|
||||
return `${year}`;
|
||||
}
|
||||
134
src/uhm/components/UnifiedSearchBar.tsx
Normal file
134
src/uhm/components/UnifiedSearchBar.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
619
src/uhm/components/WikiSidebarPanel.tsx
Normal file
619
src/uhm/components/WikiSidebarPanel.tsx
Normal file
@@ -0,0 +1,619 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, type ComponentProps } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import "react-quill-new/dist/quill.snow.css";
|
||||
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import Button from "@/components/ui/button/Button";
|
||||
import Label from "@/components/form/Label";
|
||||
|
||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
import { newId } from "@/uhm/lib/id";
|
||||
import type ReactQuill from "react-quill-new";
|
||||
import { checkWikiSlugExists } from "@/uhm/api/wikis";
|
||||
|
||||
type ReactQuillProps = ComponentProps<typeof ReactQuill>;
|
||||
|
||||
const ReactQuillEditor = dynamic<ReactQuillProps>(() => import("react-quill-new"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="h-[480px] w-full animate-pulse bg-gray-100 rounded-lg" />,
|
||||
});
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
wikis: WikiSnapshot[];
|
||||
setWikis: React.Dispatch<React.SetStateAction<WikiSnapshot[]>>;
|
||||
autoOpen?: boolean;
|
||||
requestedActiveId?: string | null;
|
||||
};
|
||||
|
||||
function clampTitle(title: string) {
|
||||
const t = title.trim();
|
||||
return t.length ? t.slice(0, 120) : "Untitled wiki";
|
||||
}
|
||||
|
||||
export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen, requestedActiveId }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const activeWiki = useMemo(() => wikis.find((w) => w.id === activeId) || null, [activeId, wikis]);
|
||||
|
||||
const [wikiTitle, setWikiTitle] = useState("");
|
||||
const [wikiSlug, setWikiSlug] = useState("");
|
||||
const [wikiDocHtml, setWikiDocHtml] = useState("");
|
||||
const [wikiSaveError, setWikiSaveError] = useState<string | null>(null);
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [createTitle, setCreateTitle] = useState("");
|
||||
const [createSlug, setCreateSlug] = useState("");
|
||||
const [createSlugTouched, setCreateSlugTouched] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [isCheckingCreateSlug, setIsCheckingCreateSlug] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoOpen) return;
|
||||
// open once on mount
|
||||
setOpen(true);
|
||||
}, [autoOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!requestedActiveId) return;
|
||||
if (wikis.some((w) => w.id === requestedActiveId)) {
|
||||
setActiveId(requestedActiveId);
|
||||
}
|
||||
}, [requestedActiveId, wikis]);
|
||||
|
||||
// keep editor content in sync when switching wiki
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
setWikiTitle(activeWiki?.title || "");
|
||||
setWikiSlug(typeof activeWiki?.slug === "string" ? activeWiki.slug : "");
|
||||
setWikiDocHtml(normalizeWikiDocForQuill(activeWiki?.doc || null));
|
||||
setWikiSaveError(null);
|
||||
}, [activeWiki?.doc, activeWiki?.slug, activeWiki?.title, open]);
|
||||
|
||||
const ensureActive = () => {
|
||||
if (activeId && wikis.some((w) => w.id === activeId)) return;
|
||||
setActiveId(wikis[0]?.id || null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
ensureActive();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [wikis.length]);
|
||||
|
||||
const openEditor = () => {
|
||||
if (!wikis.length) {
|
||||
const id = newId();
|
||||
const seed: WikiSnapshot = {
|
||||
id,
|
||||
source: "inline",
|
||||
operation: "create",
|
||||
title: "Untitled wiki",
|
||||
slug: null,
|
||||
doc: "",
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
setWikis((prev) => [seed, ...prev]);
|
||||
setActiveId(id);
|
||||
}
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const createWikiAndOpen = (title?: string, slug?: string | null) => {
|
||||
const id = newId();
|
||||
const seedTitle = clampTitle(title || "Untitled wiki");
|
||||
const seed: WikiSnapshot = {
|
||||
id,
|
||||
source: "inline",
|
||||
operation: "create",
|
||||
title: seedTitle,
|
||||
slug: slug ?? null,
|
||||
doc: "",
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
setWikis((prev) => [seed, ...prev]);
|
||||
setActiveId(id);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleCreateWikiFromPanel = async () => {
|
||||
const title = clampTitle(createTitle);
|
||||
const slug = normalizeWikiSlugInput(createSlug);
|
||||
if (!slug) {
|
||||
setCreateError("Slug la bat buoc. Hay thu mot slug khac.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCheckingCreateSlug(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
const exists = await checkWikiSlugExists(slug);
|
||||
if (exists) {
|
||||
setCreateError("Slug da ton tai. Hay thu slug khac.");
|
||||
return;
|
||||
}
|
||||
createWikiAndOpen(title, slug);
|
||||
setCreateTitle("");
|
||||
setCreateSlug("");
|
||||
setCreateSlugTouched(false);
|
||||
setIsCreateOpen(false);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Khong check duoc slug.";
|
||||
setCreateError(msg);
|
||||
} finally {
|
||||
setIsCheckingCreateSlug(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeWiki = (id: string) => {
|
||||
setWikis((prev) => prev.filter((w) => w.id !== id));
|
||||
if (activeId === id) setActiveId(null);
|
||||
};
|
||||
|
||||
const saveWiki = async () => {
|
||||
if (!activeId) return;
|
||||
const payload = wikiDocHtml;
|
||||
const nextTitle = clampTitle(wikiTitle);
|
||||
const nextSlug = normalizeWikiSlugInput(wikiSlug);
|
||||
|
||||
const current = wikis.find((w) => w.id === activeId) || null;
|
||||
// Check uniqueness only when creating a brand-new wiki.
|
||||
if (current?.operation === "create" && nextSlug) {
|
||||
try {
|
||||
const exists = await checkWikiSlugExists(nextSlug);
|
||||
if (exists) {
|
||||
setWikiSaveError("Slug da ton tai. Hay thu slug khac.");
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Khong check duoc slug.";
|
||||
setWikiSaveError(msg);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setWikiSaveError(null);
|
||||
setWikis((prev) =>
|
||||
prev.map((w) =>
|
||||
w.id !== activeId
|
||||
? w
|
||||
: {
|
||||
...w,
|
||||
source: w.source,
|
||||
operation: w.operation === "create" ? "create" : "update",
|
||||
title: nextTitle,
|
||||
slug: nextSlug,
|
||||
doc: payload,
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
)
|
||||
);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
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" }}>Wiki</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{wikis.length}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
title={collapsed ? "Mo panel" : "Thu gon panel"}
|
||||
aria-label={collapsed ? "Mo panel Wiki" : "Thu gon panel Wiki"}
|
||||
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 : wikis.length ? (
|
||||
<div style={{ marginTop: "10px", display: "grid", gap: "6px" }}>
|
||||
{wikis.slice(0, 8).map((w) => (
|
||||
<div
|
||||
key={w.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #1f2937",
|
||||
background: "transparent",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveId(w.id);
|
||||
setOpen(true);
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
textAlign: "left",
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: "#e5e7eb",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
title={w.title}
|
||||
>
|
||||
{w.title}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeWiki(w.id)}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "#111827",
|
||||
color: "#fca5a5",
|
||||
cursor: "pointer",
|
||||
borderRadius: "6px",
|
||||
padding: "6px 8px",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
title="Remove"
|
||||
>
|
||||
Del
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{wikis.length > 8 ? (
|
||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>+{wikis.length - 8} more…</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>
|
||||
No wiki yet for this project.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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 wiki mới
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setIsCreateOpen((v) => {
|
||||
const next = !v;
|
||||
if (next) {
|
||||
setCreateError(null);
|
||||
setIsCheckingCreateSlug(false);
|
||||
setCreateSlugTouched(false);
|
||||
}
|
||||
return next;
|
||||
})
|
||||
}
|
||||
title={isCreateOpen ? "Dong" : "Mo"}
|
||||
aria-label={isCreateOpen ? "Dong tao wiki" : "Mo tao wiki"}
|
||||
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",
|
||||
}}
|
||||
>
|
||||
{isCreateOpen ? <CloseIcon /> : <PlusIcon />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isCreateOpen ? (
|
||||
<>
|
||||
<input
|
||||
value={createTitle}
|
||||
onChange={(e) => {
|
||||
const nextTitle = e.target.value;
|
||||
setCreateTitle(nextTitle);
|
||||
setCreateError(null);
|
||||
if (!createSlugTouched) {
|
||||
setCreateSlug(slugifyWikiTitle(nextTitle));
|
||||
}
|
||||
}}
|
||||
placeholder="Tieu de wiki"
|
||||
disabled={isCheckingCreateSlug}
|
||||
style={{
|
||||
width: "100%",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #334155",
|
||||
background: "#111827",
|
||||
color: "#f8fafc",
|
||||
padding: "6px 8px",
|
||||
fontSize: "13px",
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
value={createSlug}
|
||||
onChange={(e) => {
|
||||
setCreateSlugTouched(true);
|
||||
setCreateSlug(e.target.value);
|
||||
setCreateError(null);
|
||||
}}
|
||||
placeholder="Slug"
|
||||
disabled={isCheckingCreateSlug}
|
||||
style={{
|
||||
width: "100%",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #334155",
|
||||
background: "#111827",
|
||||
color: "#f8fafc",
|
||||
padding: "6px 8px",
|
||||
fontSize: "13px",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateWikiFromPanel}
|
||||
disabled={isCheckingCreateSlug}
|
||||
style={{
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
padding: "7px 8px",
|
||||
cursor: isCheckingCreateSlug ? "not-allowed" : "pointer",
|
||||
background: "#2563eb",
|
||||
color: "#ffffff",
|
||||
fontWeight: 600,
|
||||
opacity: isCheckingCreateSlug ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
Tạo wiki mới
|
||||
</button>
|
||||
{createError ? (
|
||||
<div style={{ color: "#fca5a5", fontSize: 12 }}>
|
||||
{createError}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
isOpen={open}
|
||||
onClose={() => setOpen(false)}
|
||||
showCloseButton={false}
|
||||
// Defensive: even if Modal defaults change, keep wiki popup free of the "X" close button.
|
||||
className="max-w-[1100px] m-4 [&>button]:hidden"
|
||||
>
|
||||
<div className="p-6 bg-white rounded-3xl dark:bg-gray-900">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">Project</div>
|
||||
<div className="text-sm font-mono break-all text-gray-700 dark:text-gray-200">{projectId}</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" className="bg-brand-500 hover:bg-brand-600 text-white" onClick={saveWiki} disabled={!activeId}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
<div className="lg:col-span-1">
|
||||
<div className="text-xs font-semibold text-gray-700 dark:text-gray-200 mb-2">Wikis</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{wikis.map((w) => (
|
||||
<button
|
||||
key={w.id}
|
||||
type="button"
|
||||
onClick={() => setActiveId(w.id)}
|
||||
className={`text-left rounded-xl border px-3 py-2 text-sm transition ${
|
||||
w.id === activeId
|
||||
? "border-brand-500 bg-brand-50 dark:bg-brand-500/10"
|
||||
: "border-gray-200 dark:border-gray-800 bg-white dark:bg-[#0d1117]"
|
||||
}`}
|
||||
title={w.title}
|
||||
>
|
||||
<div className="font-medium truncate">{w.title}</div>
|
||||
<div className="text-[11px] text-gray-500 dark:text-gray-400 truncate">{w.id}</div>
|
||||
</button>
|
||||
))}
|
||||
<Button size="sm" variant="outline" onClick={openEditor}>
|
||||
+ New wiki
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-3">
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<div>
|
||||
<Label>Title</Label>
|
||||
<input
|
||||
value={wikiTitle}
|
||||
onChange={(e) => setWikiTitle(e.target.value)}
|
||||
className="h-11 w-full rounded-xl border border-gray-200 bg-transparent px-4 py-2.5 text-sm text-gray-800 outline-none focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:text-white/90 dark:focus:border-brand-800"
|
||||
placeholder="Wiki title"
|
||||
disabled={!activeId}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Slug</Label>
|
||||
<input
|
||||
value={wikiSlug}
|
||||
onChange={(e) => setWikiSlug(e.target.value)}
|
||||
className="h-11 w-full rounded-xl border border-gray-200 bg-transparent px-4 py-2.5 text-sm text-gray-800 outline-none focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:text-white/90 dark:focus:border-brand-800"
|
||||
placeholder="wiki-slug"
|
||||
disabled={!activeId}
|
||||
/>
|
||||
</div>
|
||||
{wikiSaveError ? (
|
||||
<div className="text-xs text-red-600 dark:text-red-300">
|
||||
{wikiSaveError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-[#0d1117] overflow-hidden">
|
||||
<ReactQuillEditor
|
||||
theme="snow"
|
||||
value={wikiDocHtml}
|
||||
onChange={(content: string) => setWikiDocHtml(content)}
|
||||
modules={QUILL_MODULES}
|
||||
className="min-h-[320px]"
|
||||
placeholder="Nhap noi dung wiki..."
|
||||
readOnly={!activeId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
Stored in snapshot_json on commit. This page does not write to DB yet.
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const QUILL_MODULES = {
|
||||
toolbar: [
|
||||
[{ header: [1, 2, 3, false] }],
|
||||
["bold", "italic", "underline", "strike"],
|
||||
[{ list: "ordered" }, { list: "bullet" }],
|
||||
["blockquote", "code-block"],
|
||||
["link", "image"],
|
||||
["clean"],
|
||||
],
|
||||
};
|
||||
|
||||
function normalizeWikiDocForQuill(doc: string | null): string {
|
||||
const raw = (doc || "").trim();
|
||||
if (!raw.length) return "";
|
||||
|
||||
// New format (Quill): HTML string.
|
||||
if (raw[0] === "<") return raw;
|
||||
|
||||
// Legacy format (Tiptap): JSON string.
|
||||
if (raw[0] === "{") {
|
||||
try {
|
||||
const json: unknown = JSON.parse(raw);
|
||||
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(raw).replace(/\n/g, "<br/>")}</p>`;
|
||||
}
|
||||
|
||||
function normalizeWikiSlugInput(raw: string): string | null {
|
||||
const s = raw.trim();
|
||||
return s.length ? s : null;
|
||||
}
|
||||
|
||||
function slugifyWikiTitle(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 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("'", "'");
|
||||
}
|
||||
29
src/uhm/lib/backgroundLayers.ts
Normal file
29
src/uhm/lib/backgroundLayers.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export const BACKGROUND_LAYER_OPTIONS = [
|
||||
{ id: "raster-base-layer", label: "Raster" },
|
||||
{ id: "graticules-line", label: "Graticules" },
|
||||
{ id: "land", label: "Land" },
|
||||
{ id: "bg-countries-fill", label: "Countries" },
|
||||
{ id: "bg-country-borders-line", label: "Country Borders" },
|
||||
{ id: "country-labels", label: "Country Labels" },
|
||||
{ id: "regions-line", label: "Regions" },
|
||||
{ id: "lakes-fill", label: "Lakes" },
|
||||
{ id: "rivers-line", label: "Rivers" },
|
||||
{ id: "geolines-line", label: "Geolines" },
|
||||
] as const;
|
||||
|
||||
export type BackgroundLayerId = (typeof BACKGROUND_LAYER_OPTIONS)[number]["id"];
|
||||
export type BackgroundLayerVisibility = Record<BackgroundLayerId, boolean>;
|
||||
|
||||
// Tạo map visibility mặc định cho toàn bộ background layers.
|
||||
function buildBackgroundLayerVisibility(value: boolean): BackgroundLayerVisibility {
|
||||
return BACKGROUND_LAYER_OPTIONS.reduce((acc, option) => {
|
||||
acc[option.id] = value;
|
||||
return acc;
|
||||
}, {} as BackgroundLayerVisibility);
|
||||
}
|
||||
|
||||
export const DEFAULT_BACKGROUND_LAYER_VISIBILITY =
|
||||
buildBackgroundLayerVisibility(true);
|
||||
|
||||
export const HIDDEN_BACKGROUND_LAYER_VISIBILITY =
|
||||
buildBackgroundLayerVisibility(false);
|
||||
58
src/uhm/lib/editor/background/backgroundVisibilityStorage.ts
Normal file
58
src/uhm/lib/editor/background/backgroundVisibilityStorage.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
BACKGROUND_LAYER_OPTIONS,
|
||||
BackgroundLayerVisibility,
|
||||
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
||||
} from "@/uhm/lib/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;
|
||||
}
|
||||
|
||||
55
src/uhm/lib/editor/draft/draftDiff.ts
Normal file
55
src/uhm/lib/editor/draft/draftDiff.ts
Normal file
@@ -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;
|
||||
}
|
||||
21
src/uhm/lib/editor/draft/editorTypes.ts
Normal file
21
src/uhm/lib/editor/draft/editorTypes.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
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/sections";
|
||||
|
||||
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[] };
|
||||
31
src/uhm/lib/editor/draft/useDraftState.ts
Normal file
31
src/uhm/lib/editor/draft/useDraftState.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
93
src/uhm/lib/editor/draft/useUndoStack.ts
Normal file
93
src/uhm/lib/editor/draft/useUndoStack.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useCallback, 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 pushUndo = useCallback((action: UndoAction) => {
|
||||
setUndoStack((prev) => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (isSameUndo(last, action)) return prev;
|
||||
return [...prev, action];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
let applied = false;
|
||||
setUndoStack((prev) => {
|
||||
if (applied) return prev;
|
||||
if (!prev.length) return prev;
|
||||
|
||||
const last = prev[prev.length - 1];
|
||||
const remaining = prev.slice(0, -1);
|
||||
applied = true;
|
||||
|
||||
const didApply = applyUndoAction(last);
|
||||
return didApply ? remaining : prev;
|
||||
});
|
||||
}, [applyUndoAction]);
|
||||
|
||||
const clearUndo = useCallback(() => {
|
||||
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);
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
61
src/uhm/lib/editor/entity/entityBinding.ts
Normal file
61
src/uhm/lib/editor/entity/entityBinding.ts
Normal file
@@ -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/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,
|
||||
};
|
||||
}
|
||||
52
src/uhm/lib/editor/geometry/geometryMetadata.ts
Normal file
52
src/uhm/lib/editor/geometry/geometryMetadata.ts
Normal file
@@ -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);
|
||||
}
|
||||
408
src/uhm/lib/editor/section/useSectionCommands.ts
Normal file
408
src/uhm/lib/editor/section/useSectionCommands.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
import { useCallback } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { ApiError } from "@/uhm/api/http";
|
||||
import {
|
||||
createSection,
|
||||
createSectionCommit,
|
||||
fetchSectionCommits,
|
||||
fetchSections,
|
||||
openSectionEditor,
|
||||
submitSection,
|
||||
} from "@/uhm/api/sections";
|
||||
import { buildEditorSnapshot, normalizeEditorSnapshot } 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, Section, SectionCommit, SectionState, EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
|
||||
type EditorDraftApi = {
|
||||
draft: FeatureCollection;
|
||||
buildPayload: () => Change[];
|
||||
clearChanges: () => void;
|
||||
hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean;
|
||||
};
|
||||
|
||||
type Options = {
|
||||
editor: EditorDraftApi;
|
||||
editorUserId: string;
|
||||
emptyFeatureCollection: FeatureCollection;
|
||||
activeSection: Section | null;
|
||||
sectionState: SectionState | null;
|
||||
selectedSectionId: string;
|
||||
newSectionTitle: string;
|
||||
pendingSaveCount: number;
|
||||
snapshotEntities: EntitySnapshot[];
|
||||
snapshotWikis: WikiSnapshot[];
|
||||
snapshotEntityWikiLinks: EntityWikiLinkSnapshot[];
|
||||
baselineSnapshot: EditorSnapshot | null;
|
||||
commitTitle: string;
|
||||
commitNote: string;
|
||||
setActiveSection: Dispatch<SetStateAction<Section | null>>;
|
||||
setSelectedSectionId: Dispatch<SetStateAction<string>>;
|
||||
setSectionState: Dispatch<SetStateAction<SectionState | null>>;
|
||||
setBaselineSnapshot: Dispatch<SetStateAction<EditorSnapshot | null>>;
|
||||
setInitialData: Dispatch<SetStateAction<FeatureCollection>>;
|
||||
setSectionCommits: Dispatch<SetStateAction<SectionCommit[]>>;
|
||||
setSnapshotEntities: Dispatch<SetStateAction<EntitySnapshot[]>>;
|
||||
setSnapshotWikis: Dispatch<SetStateAction<WikiSnapshot[]>>;
|
||||
setSnapshotEntityWikiLinks: Dispatch<SetStateAction<EntityWikiLinkSnapshot[]>>;
|
||||
setSelectedFeatureId: Dispatch<SetStateAction<FeatureId | null>>;
|
||||
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<Section[]>>;
|
||||
setNewSectionTitle: Dispatch<SetStateAction<string>>;
|
||||
setCommitTitle: Dispatch<SetStateAction<string>>;
|
||||
setCommitNote: Dispatch<SetStateAction<string>>;
|
||||
};
|
||||
|
||||
export function useSectionCommands(options: Options) {
|
||||
const openSectionForEditing = useCallback(async (sectionId: string) => {
|
||||
const editorPayload = await openSectionEditor(sectionId);
|
||||
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 fetchSectionCommits(sectionId);
|
||||
const nextInitialData = sessionSnapshot?.editor_feature_collection || options.emptyFeatureCollection;
|
||||
|
||||
options.setActiveSection(editorPayload.section);
|
||||
options.setSelectedSectionId(editorPayload.section.id);
|
||||
options.setSectionState(editorPayload.state);
|
||||
options.setBaselineSnapshot(sessionSnapshot);
|
||||
options.setInitialData(nextInitialData);
|
||||
options.setSectionCommits(commits);
|
||||
options.setSnapshotEntities(sessionSnapshot?.entities || []);
|
||||
options.setSnapshotWikis(sessionSnapshot?.wikis || []);
|
||||
options.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []);
|
||||
options.setSelectedFeatureId(null);
|
||||
options.setEntityFormStatus(null);
|
||||
}, [options]);
|
||||
|
||||
const commitSection = useCallback(async () => {
|
||||
if (!options.activeSection || !options.sectionState) {
|
||||
options.setEntityStatus("Chưa mở được section 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({
|
||||
section: 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()
|
||||
|| options.commitNote.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: 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 createSectionCommit(options.activeSection.id, {
|
||||
snapshot,
|
||||
edit_summary: editSummary,
|
||||
});
|
||||
|
||||
const sessionSnapshot = toEditorSessionSnapshot(snapshot);
|
||||
options.setSectionState(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.setCommitNote("");
|
||||
options.setSectionCommits(await fetchSectionCommits(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 sectionId = options.selectedSectionId.trim();
|
||||
if (!sectionId) {
|
||||
options.setEntityStatus("Hãy chọn section để mở.");
|
||||
return;
|
||||
}
|
||||
if (options.pendingSaveCount > 0) {
|
||||
const confirmed = window.confirm("Section hiện tại có thay đổi chưa Commit. Mở section 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(sectionId);
|
||||
options.setEntityStatus("Đã mở section để chỉnh sửa.");
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
options.setEntityStatus(`Mở section thất bại: ${err.body}`);
|
||||
} else {
|
||||
options.setEntityStatus("Mở section 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 section là bắt buộc.");
|
||||
return;
|
||||
}
|
||||
if (options.pendingSaveCount > 0) {
|
||||
const confirmed = window.confirm("Section hiện tại có thay đổi chưa Commit. Tạo section 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 section = await createSection({
|
||||
title,
|
||||
description: null,
|
||||
});
|
||||
const sections = await fetchSections();
|
||||
options.setAvailableSections(sections);
|
||||
options.setNewSectionTitle("");
|
||||
await openSectionForEditing(section.id);
|
||||
options.setEntityStatus("Đã tạo và mở section mới.");
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
options.setEntityStatus(`Tạo section thất bại: ${err.body}`);
|
||||
} else {
|
||||
options.setEntityStatus("Tạo section thất bại.");
|
||||
}
|
||||
} finally {
|
||||
options.setIsOpeningSection(false);
|
||||
}
|
||||
}, [openSectionForEditing, options]);
|
||||
|
||||
const submitCurrentSection = useCallback(async () => {
|
||||
if (!options.activeSection || !options.sectionState?.head_commit_id) {
|
||||
options.setEntityStatus("Section 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);
|
||||
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.sectionState) {
|
||||
options.setEntityStatus("Chưa mở được section 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 fetchSectionCommits(options.activeSection.id);
|
||||
const target = commits.find((c: SectionCommit) => 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.setSelectedFeatureId(null);
|
||||
options.setEntityFormStatus(null);
|
||||
|
||||
// Refresh commits list for UI, but keep sectionState/head as-is.
|
||||
options.setSectionCommits(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);
|
||||
});
|
||||
}
|
||||
41
src/uhm/lib/editor/session/sessionTypes.ts
Normal file
41
src/uhm/lib/editor/session/sessionTypes.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { EntityGeometryPreset } from "@/uhm/lib/entityTypeOptions";
|
||||
|
||||
export type EditorMode =
|
||||
| "idle"
|
||||
| "draw"
|
||||
| "select"
|
||||
| "add-point"
|
||||
| "add-line"
|
||||
| "add-path"
|
||||
| "add-circle";
|
||||
|
||||
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 = EntityGeometryPreset;
|
||||
21
src/uhm/lib/editor/session/useBackgroundSessionState.ts
Normal file
21
src/uhm/lib/editor/session/useBackgroundSessionState.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
BackgroundLayerVisibility,
|
||||
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
||||
} from "@/uhm/lib/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,
|
||||
};
|
||||
}
|
||||
74
src/uhm/lib/editor/session/useEntitySessionState.ts
Normal file
74
src/uhm/lib/editor/session/useEntitySessionState.ts
Normal file
@@ -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);
|
||||
// Feature đang được chọn để thao tác bind entities/metadata.
|
||||
const [selectedFeatureId, setSelectedFeatureId] = useState<FeatureId | null>(null);
|
||||
// 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,
|
||||
selectedFeatureId,
|
||||
setSelectedFeatureId,
|
||||
entityForm,
|
||||
setEntityForm,
|
||||
selectedGeometryEntityIds,
|
||||
setSelectedGeometryEntityIds,
|
||||
geometryMetaForm,
|
||||
setGeometryMetaForm,
|
||||
isEntitySubmitting,
|
||||
setIsEntitySubmitting,
|
||||
entityFormStatus,
|
||||
setEntityFormStatus,
|
||||
entitySearchQuery,
|
||||
setEntitySearchQuery,
|
||||
entitySearchResults,
|
||||
setEntitySearchResults,
|
||||
selectedSearchEntityId,
|
||||
setSelectedSearchEntityId,
|
||||
isEntitySearchLoading,
|
||||
setIsEntitySearchLoading,
|
||||
};
|
||||
}
|
||||
85
src/uhm/lib/editor/session/useSectionSessionState.ts
Normal file
85
src/uhm/lib/editor/session/useSectionSessionState.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { EditorSnapshot, Section, SectionCommit, SectionState } from "@/uhm/types/sections";
|
||||
|
||||
type Options = {
|
||||
defaultEditorUserId: string;
|
||||
};
|
||||
|
||||
type SectionTask = "idle" | "saving" | "submitting" | "opening-section";
|
||||
|
||||
export function useSectionSessionState(options: Options) {
|
||||
// Single state machine cho các tác vụ async của section (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-section";
|
||||
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-section", next);
|
||||
}, [setTaskFlag]);
|
||||
|
||||
// Danh sách sections để user chọn mở.
|
||||
const [availableSections, setAvailableSections] = useState<Section[]>([]);
|
||||
// Section ID đang được chọn trong dropdown.
|
||||
const [selectedSectionId, setSelectedSectionId] = useState("");
|
||||
// Title section mới (để create).
|
||||
const [newSectionTitle, setNewSectionTitle] = useState("");
|
||||
// Input title cho commit.
|
||||
const [commitTitle, setCommitTitle] = useState("");
|
||||
// Input note cho commit.
|
||||
const [commitNote, setCommitNote] = useState("");
|
||||
// User ID dùng để gắn vào commit/submit/lock.
|
||||
const [editorUserIdInput, setEditorUserIdInput] = useState(options.defaultEditorUserId);
|
||||
// Section đang mở để edit (null nếu chưa mở).
|
||||
const [activeSection, setActiveSection] = useState<Section | null>(null);
|
||||
// Trạng thái section (version/head/status/lock).
|
||||
const [sectionState, setSectionState] = useState<SectionState | null>(null);
|
||||
// Danh sách commits của section đang mở.
|
||||
const [sectionCommits, setSectionCommits] = useState<SectionCommit[]>([]);
|
||||
// Baseline snapshot currently loaded for this editor session.
|
||||
const [baselineSnapshot, setBaselineSnapshot] = useState<EditorSnapshot | null>(null);
|
||||
|
||||
return {
|
||||
isSaving,
|
||||
setIsSaving,
|
||||
isSubmitting,
|
||||
setIsSubmitting,
|
||||
isOpeningSection,
|
||||
setIsOpeningSection,
|
||||
availableSections,
|
||||
setAvailableSections,
|
||||
selectedSectionId,
|
||||
setSelectedSectionId,
|
||||
newSectionTitle,
|
||||
setNewSectionTitle,
|
||||
commitTitle,
|
||||
setCommitTitle,
|
||||
commitNote,
|
||||
setCommitNote,
|
||||
editorUserIdInput,
|
||||
setEditorUserIdInput,
|
||||
activeSection,
|
||||
setActiveSection,
|
||||
sectionState,
|
||||
setSectionState,
|
||||
sectionCommits,
|
||||
setSectionCommits,
|
||||
baselineSnapshot,
|
||||
setBaselineSnapshot,
|
||||
};
|
||||
}
|
||||
42
src/uhm/lib/editor/session/useTimelineState.ts
Normal file
42
src/uhm/lib/editor/session/useTimelineState.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useState } from "react";
|
||||
import type { TimelineRange } from "@/uhm/lib/editor/session/sessionTypes";
|
||||
import { clampYearValue } from "@/uhm/lib/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,
|
||||
};
|
||||
}
|
||||
9
src/uhm/lib/editor/session/useWikiSessionState.ts
Normal file
9
src/uhm/lib/editor/session/useWikiSessionState.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
||||
|
||||
export function useWikiSessionState() {
|
||||
const [snapshotWikis, setSnapshotWikis] = useState<WikiSnapshot[]>([]);
|
||||
const [snapshotEntityWikiLinks, setSnapshotEntityWikiLinks] = useState<EntityWikiLinkSnapshot[]>([]);
|
||||
return { snapshotWikis, setSnapshotWikis, snapshotEntityWikiLinks, setSnapshotEntityWikiLinks };
|
||||
}
|
||||
752
src/uhm/lib/editor/snapshot/editorSnapshot.ts
Normal file
752
src/uhm/lib/editor/snapshot/editorSnapshot.ts
Normal file
@@ -0,0 +1,752 @@
|
||||
import { DEFAULT_ENTITY_TYPE_ID } from "@/uhm/lib/entityTypeOptions";
|
||||
import { geoTypeCodeToTypeKey, typeKeyToGeoTypeCode } from "@/uhm/lib/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, Section } from "@/uhm/types/sections";
|
||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
||||
|
||||
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;
|
||||
|
||||
return {
|
||||
...(rest as unknown as Omit<GeometrySnapshot, "id" | "source" | "operation">),
|
||||
id,
|
||||
source,
|
||||
operation,
|
||||
};
|
||||
})
|
||||
: 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) || [];
|
||||
if (entity_ids.length || hasLinks) {
|
||||
const props = feature.properties as unknown as UnknownRecord;
|
||||
props.entity_ids = entity_ids;
|
||||
props.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);
|
||||
props.entity_name = primaryName || null;
|
||||
props.entity_names = names;
|
||||
}
|
||||
|
||||
// Generate geometry metadata onto feature properties (optional in persisted snapshot).
|
||||
const geo = geometryById.get(gid) || null;
|
||||
if (geo) {
|
||||
const p = feature.properties as unknown as UnknownRecord;
|
||||
// type (semantic key) is derived from geometries[].type (numeric code in string form).
|
||||
const typeCode = typeof geo.type === "string" && geo.type.trim().length ? Number(geo.type) : NaN;
|
||||
const typeKey = geoTypeCodeToTypeKey(Number.isFinite(typeCode) ? typeCode : null);
|
||||
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;
|
||||
}
|
||||
}
|
||||
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: {
|
||||
section: Section;
|
||||
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 = feature.properties.type || getDefaultTypeIdForFeature(feature);
|
||||
const typeCode = typeKeyToGeoTypeCode(typeKey);
|
||||
return {
|
||||
id,
|
||||
operation,
|
||||
source: "inline",
|
||||
// BE currently expects geometries[].type as a string. We send the geo_type SMALLINT code as a string.
|
||||
type: String(typeCode ?? 0),
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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_ENTITY_TYPE_ID;
|
||||
|
||||
const geometryType = feature.geometry.type;
|
||||
if (geometryType === "LineString" || geometryType === "MultiLineString") {
|
||||
return "defense_line";
|
||||
}
|
||||
if (geometryType === "Point" || geometryType === "MultiPoint") {
|
||||
return "city";
|
||||
}
|
||||
return DEFAULT_ENTITY_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));
|
||||
}
|
||||
247
src/uhm/lib/engine/circleEngine.ts
Normal file
247
src/uhm/lib/engine/circleEngine.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { Geometry } from "@/uhm/lib/useEditorState";
|
||||
import type { ModeGetter } from "@/uhm/lib/engine/engineTypes";
|
||||
|
||||
const EARTH_RADIUS_METERS = 6371008.8;
|
||||
const CIRCLE_SEGMENTS = 72;
|
||||
const MIN_RADIUS_METERS = 1;
|
||||
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
};
|
||||
|
||||
// Khởi tạo engine vẽ circle bằng thao tác kéo chuột từ tâm ra biên.
|
||||
export function initCircle(
|
||||
map: maplibregl.Map,
|
||||
getMode: ModeGetter,
|
||||
onComplete: (geometry: Geometry) => void
|
||||
) {
|
||||
let center: [number, number] | null = null;
|
||||
let radiusMeters = 0;
|
||||
let isDragging = false;
|
||||
let dragPanDisabledByCircle = false;
|
||||
|
||||
// Xóa dữ liệu preview circle trên map.
|
||||
const clearPreview = () => {
|
||||
(map.getSource("draw-circle-preview") as maplibregl.GeoJSONSource | undefined)?.setData(
|
||||
EMPTY_PREVIEW
|
||||
);
|
||||
};
|
||||
|
||||
// Bật lại drag pan nếu trước đó bị tắt khi đang kéo vẽ circle.
|
||||
const releaseDragPan = () => {
|
||||
if (!dragPanDisabledByCircle) return;
|
||||
dragPanDisabledByCircle = false;
|
||||
if (!map.dragPan.isEnabled()) {
|
||||
map.dragPan.enable();
|
||||
}
|
||||
};
|
||||
|
||||
// Reset toàn bộ trạng thái vẽ circle tạm thời.
|
||||
const resetDrawingState = () => {
|
||||
center = null;
|
||||
radiusMeters = 0;
|
||||
isDragging = false;
|
||||
clearPreview();
|
||||
releaseDragPan();
|
||||
};
|
||||
|
||||
// Cập nhật polygon preview theo tâm và bán kính hiện tại.
|
||||
const updatePreview = () => {
|
||||
if (!center || radiusMeters < MIN_RADIUS_METERS) {
|
||||
clearPreview();
|
||||
return;
|
||||
}
|
||||
|
||||
const ring = buildCircleRing(center, radiusMeters, CIRCLE_SEGMENTS);
|
||||
(map.getSource("draw-circle-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
type: "Feature",
|
||||
properties: {},
|
||||
geometry: {
|
||||
type: "Polygon",
|
||||
coordinates: [ring],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
// Bắt đầu phiên vẽ circle khi nhấn chuột trái.
|
||||
const onMouseDown = (e: maplibregl.MapMouseEvent) => {
|
||||
if (getMode() !== "add-circle") return;
|
||||
if ((e.originalEvent as MouseEvent | undefined)?.button !== 0) return;
|
||||
|
||||
center = [e.lngLat.lng, e.lngLat.lat];
|
||||
radiusMeters = 0;
|
||||
isDragging = true;
|
||||
clearPreview();
|
||||
|
||||
if (map.dragPan.isEnabled()) {
|
||||
map.dragPan.disable();
|
||||
dragPanDisabledByCircle = true;
|
||||
} else {
|
||||
dragPanDisabledByCircle = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Cập nhật bán kính theo vị trí chuột trong lúc kéo.
|
||||
const onMouseMove = (e: maplibregl.MapMouseEvent) => {
|
||||
const canvas = map.getCanvas();
|
||||
if (getMode() !== "add-circle") {
|
||||
if (canvas.style.cursor === "crosshair") {
|
||||
canvas.style.cursor = "";
|
||||
}
|
||||
if (isDragging) {
|
||||
resetDrawingState();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.style.cursor = "crosshair";
|
||||
if (!isDragging || !center) return;
|
||||
|
||||
radiusMeters = distanceMeters(center, [e.lngLat.lng, e.lngLat.lat]);
|
||||
updatePreview();
|
||||
};
|
||||
|
||||
// Hoàn tất circle và trả geometry cho callback.
|
||||
const finishCircle = () => {
|
||||
if (!isDragging || !center) {
|
||||
resetDrawingState();
|
||||
return;
|
||||
}
|
||||
|
||||
if (radiusMeters < MIN_RADIUS_METERS) {
|
||||
resetDrawingState();
|
||||
return;
|
||||
}
|
||||
|
||||
const ring = buildCircleRing(center, radiusMeters, CIRCLE_SEGMENTS);
|
||||
onComplete({
|
||||
type: "Polygon",
|
||||
coordinates: [ring],
|
||||
});
|
||||
resetDrawingState();
|
||||
};
|
||||
|
||||
// Kết thúc thao tác kéo bằng mouseup chuột trái.
|
||||
const onMouseUp = (e: maplibregl.MapMouseEvent) => {
|
||||
if (getMode() !== "add-circle") return;
|
||||
if ((e.originalEvent as MouseEvent | undefined)?.button !== 0) return;
|
||||
finishCircle();
|
||||
};
|
||||
|
||||
// Hủy phiên vẽ circle khi nhấn Escape.
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (getMode() !== "add-circle") return;
|
||||
if (e.key !== "Escape") return;
|
||||
e.preventDefault();
|
||||
resetDrawingState();
|
||||
};
|
||||
|
||||
map.on("mousedown", onMouseDown);
|
||||
map.on("mousemove", onMouseMove);
|
||||
map.on("mouseup", onMouseUp);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
|
||||
const cleanup = () => {
|
||||
map.off("mousedown", onMouseDown);
|
||||
map.off("mousemove", onMouseMove);
|
||||
map.off("mouseup", onMouseUp);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
resetDrawingState();
|
||||
if (map.getCanvas().style.cursor === "crosshair") {
|
||||
map.getCanvas().style.cursor = "";
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
cleanup,
|
||||
cancel: resetDrawingState,
|
||||
};
|
||||
}
|
||||
|
||||
// Tạo vòng polygon xấp xỉ hình tròn từ tâm, bán kính và số phân đoạn.
|
||||
function buildCircleRing(
|
||||
center: [number, number],
|
||||
radiusMeters: number,
|
||||
segments: number
|
||||
): [number, number][] {
|
||||
const ring: [number, number][] = [];
|
||||
for (let i = 0; i <= segments; i += 1) {
|
||||
const bearingDeg = (i / segments) * 360; // Chia đều 360 do quanh tâm để tạo các điểm trên vòng tròn.
|
||||
ring.push(destinationPoint(center, radiusMeters, bearingDeg));
|
||||
}
|
||||
return ring;
|
||||
}
|
||||
|
||||
// Tính khoảng cách hai điểm theo công thức Haversine (đơn vị mét).
|
||||
function distanceMeters(a: [number, number], b: [number, number]): number {
|
||||
const lat1 = toRad(a[1]);
|
||||
const lat2 = toRad(b[1]);
|
||||
const dLat = lat2 - lat1; // Delta vĩ độ (radian).
|
||||
const dLng = toRad(b[0] - a[0]); // Delta kinh độ (radian).
|
||||
|
||||
const sinLat = Math.sin(dLat / 2); // Thành phần sin(dLat/2) của công thức Haversine.
|
||||
const sinLng = Math.sin(dLng / 2); // Thành phần sin(dLng/2) của công thức Haversine.
|
||||
const h =
|
||||
sinLat * sinLat +
|
||||
Math.cos(lat1) * Math.cos(lat2) * sinLng * sinLng; // h = haversine(d/R), độ lớn cung tròn chuẩn hóa.
|
||||
const c = 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h)); // Góc tâm (radian) giữa hai điểm trên mặt cầu.
|
||||
return EARTH_RADIUS_METERS * c; // Khoảng cách cung tròn: d = R * c.
|
||||
}
|
||||
|
||||
// Tính tọa độ điểm đích từ tâm, khoảng cách và góc phương vị.
|
||||
function destinationPoint(
|
||||
center: [number, number],
|
||||
distance: number,
|
||||
bearingDeg: number
|
||||
): [number, number] {
|
||||
const lat1 = toRad(center[1]);
|
||||
const lng1 = toRad(center[0]);
|
||||
const bearing = toRad(bearingDeg);
|
||||
const angularDistance = distance / EARTH_RADIUS_METERS; // d/R: khoảng cách góc trên mặt cầu.
|
||||
|
||||
const sinLat1 = Math.sin(lat1);
|
||||
const cosLat1 = Math.cos(lat1);
|
||||
const sinAngular = Math.sin(angularDistance);
|
||||
const cosAngular = Math.cos(angularDistance);
|
||||
|
||||
const sinLat2 =
|
||||
sinLat1 * cosAngular +
|
||||
cosLat1 * sinAngular * Math.cos(bearing); // Công thức vĩ độ điểm đích theo great-circle.
|
||||
const lat2 = Math.asin(clamp(sinLat2, -1, 1)); // Kẹp [-1,1] để tránh sai số số học trước khi asin.
|
||||
|
||||
const y = Math.sin(bearing) * sinAngular * cosLat1; // Tử số atan2 cho biến thiên kinh độ.
|
||||
const x = cosAngular - sinLat1 * Math.sin(lat2); // Mẫu số atan2 cho biến thiên kinh độ.
|
||||
const lng2 = lng1 + Math.atan2(y, x); // Kinh độ đích = kinh độ gốc + delta kinh độ.
|
||||
|
||||
return [normalizeLng(toDeg(lng2)), toDeg(lat2)];
|
||||
}
|
||||
|
||||
// Chuẩn hóa kinh độ về miền [-180, 180].
|
||||
function normalizeLng(lng: number): number {
|
||||
let normalized = ((lng + 540) % 360) - 180; // Wrap về khoảng [-180, 180).
|
||||
if (normalized === -180) normalized = 180;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// Kẹp giá trị trong đoạn [min, max].
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
if (value < min) return min;
|
||||
if (value > max) return max;
|
||||
return value;
|
||||
}
|
||||
|
||||
// Đổi đơn vị góc từ độ sang radian.
|
||||
function toRad(value: number): number {
|
||||
return (value * Math.PI) / 180; // Đổi độ sang radian.
|
||||
}
|
||||
|
||||
// Đổi đơn vị góc từ radian sang độ.
|
||||
function toDeg(value: number): number {
|
||||
return (value * 180) / Math.PI; // Đổi radian sang độ.
|
||||
}
|
||||
127
src/uhm/lib/engine/drawingEngine.ts
Normal file
127
src/uhm/lib/engine/drawingEngine.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { Geometry } from "@/uhm/lib/useEditorState";
|
||||
import type { ModeGetter } from "@/uhm/lib/engine/engineTypes";
|
||||
|
||||
// Khởi tạo engine vẽ polygon tự do theo chuỗi click.
|
||||
export function initDrawing(
|
||||
map: maplibregl.Map,
|
||||
getMode: ModeGetter,
|
||||
onComplete: (geometry: Geometry) => void
|
||||
) {
|
||||
let coords: [number, number][] = [];
|
||||
|
||||
const clearPreview = () => {
|
||||
(map.getSource("draw-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
});
|
||||
};
|
||||
|
||||
const cancelDrawing = () => {
|
||||
coords = [];
|
||||
clearPreview();
|
||||
};
|
||||
|
||||
// Đóng vòng polygon nếu điểm cuối chưa trùng điểm đầu.
|
||||
function closePolygon(c: [number, number][]) {
|
||||
if (c.length < 3) return c;
|
||||
const first = c[0];
|
||||
const last = c[c.length - 1];
|
||||
|
||||
if (first[0] !== last[0] || first[1] !== last[1]) {
|
||||
return [...c, first];
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
// Cập nhật layer preview trong lúc đang vẽ.
|
||||
function update(c: [number, number][]) {
|
||||
const closed = closePolygon(c);
|
||||
|
||||
(map.getSource("draw-preview") as maplibregl.GeoJSONSource)?.setData({
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
type: "Feature",
|
||||
properties: {},
|
||||
geometry: {
|
||||
type: "Polygon",
|
||||
coordinates: [closed],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Ghi nhận đỉnh polygon mới khi click map.
|
||||
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
||||
if (getMode() !== "draw") return;
|
||||
|
||||
coords.push([e.lngLat.lng, e.lngLat.lat] as [number, number]);
|
||||
update(coords);
|
||||
}
|
||||
|
||||
// Render preview polygon với điểm chuột hiện tại.
|
||||
function onMove(e: maplibregl.MapLayerMouseEvent) {
|
||||
if (getMode() !== "draw" || coords.length === 0) return;
|
||||
|
||||
const preview: [number, number][] = [
|
||||
...coords,
|
||||
[e.lngLat.lng, e.lngLat.lat] as [number, number],
|
||||
];
|
||||
update(preview);
|
||||
}
|
||||
|
||||
// Hoàn tất polygon, trả geometry ra ngoài và reset preview.
|
||||
function finishDrawing() {
|
||||
if (getMode() !== "draw" || coords.length < 3) return;
|
||||
|
||||
const geometry: Geometry = {
|
||||
type: "Polygon",
|
||||
coordinates: [closePolygon(coords)],
|
||||
};
|
||||
|
||||
onComplete(geometry);
|
||||
cancelDrawing();
|
||||
}
|
||||
|
||||
// Lắng nghe Enter để chốt polygon.
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (getMode() !== "draw") return;
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
finishDrawing();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancelDrawing();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Backspace") {
|
||||
e.preventDefault();
|
||||
coords = coords.slice(0, -1);
|
||||
if (coords.length) {
|
||||
update(coords);
|
||||
} else {
|
||||
clearPreview();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
map.on("click", onClick);
|
||||
map.on("mousemove", onMove);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
|
||||
const cleanup = () => {
|
||||
map.off("click", onClick);
|
||||
map.off("mousemove", onMove);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
cancelDrawing();
|
||||
};
|
||||
|
||||
return {
|
||||
cleanup,
|
||||
cancel: cancelDrawing,
|
||||
};
|
||||
}
|
||||
226
src/uhm/lib/engine/editingEngine.ts
Normal file
226
src/uhm/lib/engine/editingEngine.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { Geometry } from "@/uhm/lib/useEditorState";
|
||||
|
||||
export type EditingHandle = {
|
||||
id: string | number;
|
||||
ring: [number, number][];
|
||||
original: Geometry;
|
||||
};
|
||||
|
||||
export type EditingAPI = {
|
||||
beginEditing: (feature: maplibregl.MapGeoJSONFeature) => void;
|
||||
clearEditing: () => void;
|
||||
bindEditEvents: (map: maplibregl.Map) => void;
|
||||
};
|
||||
|
||||
// Tạo engine chỉnh sửa polygon đã có (kéo đỉnh, thêm đỉnh, commit/cancel).
|
||||
export function createEditingEngine(options: {
|
||||
mapRef: React.MutableRefObject<maplibregl.Map | null>;
|
||||
onUpdate: (id: string | number, geometry: Geometry) => void;
|
||||
}) {
|
||||
const { mapRef, onUpdate } = options;
|
||||
const editingRef = { current: null as EditingHandle | null };
|
||||
const dragStateRef = { current: null as { idx: number } | null };
|
||||
const modifierRef = { current: { ctrl: false, meta: false } };
|
||||
|
||||
// Hủy trạng thái chỉnh sửa hiện tại và dọn hai source edit.
|
||||
const clearEditing = () => {
|
||||
editingRef.current = null;
|
||||
dragStateRef.current = null;
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
const empty: GeoJSON.FeatureCollection = { type: "FeatureCollection", features: [] };
|
||||
(map.getSource("edit-shape") as maplibregl.GeoJSONSource | undefined)?.setData(empty);
|
||||
(map.getSource("edit-handles") as maplibregl.GeoJSONSource | undefined)?.setData(empty);
|
||||
};
|
||||
|
||||
// Đồng bộ polygon tạm và các handle point lên map source.
|
||||
const updateEditSources = () => {
|
||||
const editing = editingRef.current;
|
||||
const map = mapRef.current;
|
||||
if (!editing || !map) return;
|
||||
|
||||
const closedRing = [...editing.ring, editing.ring[0]];
|
||||
const shape: GeoJSON.FeatureCollection<GeoJSON.Polygon> = {
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
type: "Feature",
|
||||
geometry: { type: "Polygon", coordinates: [closedRing] },
|
||||
properties: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const handles: GeoJSON.FeatureCollection<GeoJSON.Point> = {
|
||||
type: "FeatureCollection",
|
||||
features: editing.ring.map((c, idx) => ({
|
||||
type: "Feature",
|
||||
geometry: { type: "Point", coordinates: c },
|
||||
properties: { idx },
|
||||
})),
|
||||
};
|
||||
|
||||
(map.getSource("edit-shape") as maplibregl.GeoJSONSource | undefined)?.setData(shape);
|
||||
(map.getSource("edit-handles") as maplibregl.GeoJSONSource | undefined)?.setData(handles);
|
||||
};
|
||||
|
||||
// Chốt chỉnh sửa và emit geometry mới cho caller.
|
||||
const finishEditing = () => {
|
||||
const editing = editingRef.current;
|
||||
if (!editing) return;
|
||||
const geometry: Geometry = {
|
||||
type: "Polygon",
|
||||
coordinates: [[...editing.ring, editing.ring[0]]],
|
||||
};
|
||||
onUpdate(editing.id, geometry);
|
||||
clearEditing();
|
||||
};
|
||||
|
||||
// Thoát chế độ chỉnh sửa mà không lưu thay đổi.
|
||||
const cancelEditing = () => {
|
||||
clearEditing();
|
||||
};
|
||||
|
||||
// Bắt đầu chỉnh sửa từ feature polygon được chọn.
|
||||
const beginEditing = (feature: maplibregl.MapGeoJSONFeature) => {
|
||||
if (feature.geometry.type !== "Polygon") return;
|
||||
const coords = (feature.geometry.coordinates?.[0] ?? []) as [number, number][];
|
||||
if (coords.length < 4) return;
|
||||
|
||||
// remove duplicated closing point
|
||||
const ring = coords.slice(0, -1).map((c) => [c[0], c[1]] as [number, number]);
|
||||
editingRef.current = {
|
||||
id: feature.id ?? feature.properties?.id,
|
||||
ring,
|
||||
original: feature.geometry as Geometry,
|
||||
};
|
||||
updateEditSources();
|
||||
};
|
||||
|
||||
// Kiểm tra trạng thái nhấn phím modifier để bật thao tác chèn đỉnh.
|
||||
const isModifierPressed = (e?: maplibregl.MapLayerMouseEvent | maplibregl.MapMouseEvent) => {
|
||||
const oe = e?.originalEvent as MouseEvent | undefined;
|
||||
return (
|
||||
modifierRef.current.ctrl ||
|
||||
modifierRef.current.meta ||
|
||||
!!oe?.ctrlKey ||
|
||||
!!oe?.metaKey
|
||||
);
|
||||
};
|
||||
|
||||
// Gắn toàn bộ sự kiện phục vụ chỉnh sửa hình.
|
||||
const bindEditEvents = (map: maplibregl.Map) => {
|
||||
// Bắt đầu kéo một handle point.
|
||||
const onHandleDown = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
if (!editingRef.current) return;
|
||||
const feature = e.features?.[0];
|
||||
const idx = feature?.properties?.idx;
|
||||
if (idx === undefined) return;
|
||||
e.preventDefault();
|
||||
dragStateRef.current = { idx };
|
||||
map.getCanvas().style.cursor = "grabbing";
|
||||
map.dragPan.disable();
|
||||
};
|
||||
|
||||
// Cập nhật vị trí đỉnh trong lúc kéo chuột.
|
||||
const onHandleMove = (e: maplibregl.MapMouseEvent) => {
|
||||
const drag = dragStateRef.current;
|
||||
const editing = editingRef.current;
|
||||
if (!drag || !editing) return;
|
||||
|
||||
editing.ring[drag.idx] = [e.lngLat.lng, e.lngLat.lat];
|
||||
updateEditSources();
|
||||
};
|
||||
|
||||
// Kết thúc kéo đỉnh và khôi phục trạng thái tương tác map.
|
||||
const stopDragging = () => {
|
||||
dragStateRef.current = null;
|
||||
map.getCanvas().style.cursor = "";
|
||||
map.dragPan.enable();
|
||||
};
|
||||
|
||||
// Bắt phím điều khiển phiên chỉnh sửa (Enter/Escape + modifier flags).
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Control") {
|
||||
modifierRef.current.ctrl = true;
|
||||
} else if (e.key === "Meta") {
|
||||
modifierRef.current.meta = true;
|
||||
}
|
||||
if (!editingRef.current) return;
|
||||
if (e.key === "Enter") {
|
||||
finishEditing();
|
||||
} else if (e.key === "Escape") {
|
||||
cancelEditing();
|
||||
}
|
||||
};
|
||||
|
||||
// Hạ cờ modifier khi nhả phím.
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === "Control") {
|
||||
modifierRef.current.ctrl = false;
|
||||
} else if (e.key === "Meta") {
|
||||
modifierRef.current.meta = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Chèn thêm một đỉnh mới vào ring tại vị trí gần điểm click nhất.
|
||||
const onInsertHandle = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
if (!editingRef.current) return;
|
||||
if (!isModifierPressed(e)) return;
|
||||
e.preventDefault();
|
||||
const editing = editingRef.current;
|
||||
const ring = editing.ring;
|
||||
const click = [e.lngLat.lng, e.lngLat.lat] as [number, number];
|
||||
let nearestIdx = 0;
|
||||
let bestDist = Number.POSITIVE_INFINITY;
|
||||
ring.forEach((pt, idx) => {
|
||||
const dx = pt[0] - click[0];
|
||||
const dy = pt[1] - click[1];
|
||||
const d = dx * dx + dy * dy; // Dùng khoảng cách Euclid bình phương để so sánh nhanh, không cần sqrt.
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
nearestIdx = idx;
|
||||
}
|
||||
});
|
||||
const insertIdx = nearestIdx + 1;
|
||||
ring.splice(insertIdx, 0, click);
|
||||
dragStateRef.current = { idx: insertIdx };
|
||||
map.getCanvas().style.cursor = "grabbing";
|
||||
map.dragPan.disable();
|
||||
updateEditSources();
|
||||
};
|
||||
|
||||
// Ngắt kéo nếu con trỏ rời canvas.
|
||||
const onCanvasLeave = () => {
|
||||
stopDragging();
|
||||
};
|
||||
|
||||
map.on("mousedown", "edit-handles-circle", onHandleDown);
|
||||
map.on("mousedown", "edit-shape-line", onInsertHandle);
|
||||
map.on("mousemove", onHandleMove);
|
||||
map.on("mouseup", stopDragging);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
document.addEventListener("keyup", onKeyUp);
|
||||
map.getCanvas().addEventListener("mouseleave", onCanvasLeave);
|
||||
|
||||
map.on("remove", () => {
|
||||
map.off("mousedown", "edit-handles-circle", onHandleDown);
|
||||
map.off("mousedown", "edit-shape-line", onInsertHandle);
|
||||
map.off("mousemove", onHandleMove);
|
||||
map.off("mouseup", stopDragging);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
document.removeEventListener("keyup", onKeyUp);
|
||||
map.getCanvas().removeEventListener("mouseleave", onCanvasLeave);
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
beginEditing,
|
||||
clearEditing,
|
||||
bindEditEvents,
|
||||
updateEditSources,
|
||||
editingRef,
|
||||
dragStateRef,
|
||||
};
|
||||
}
|
||||
4
src/uhm/lib/engine/engineTypes.ts
Normal file
4
src/uhm/lib/engine/engineTypes.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
|
||||
|
||||
export type ModeGetter = () => EditorMode;
|
||||
|
||||
140
src/uhm/lib/engine/lineEngine.ts
Normal file
140
src/uhm/lib/engine/lineEngine.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { Geometry } from "@/uhm/lib/useEditorState";
|
||||
import type { ModeGetter } from "@/uhm/lib/engine/engineTypes";
|
||||
|
||||
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
};
|
||||
|
||||
// Khởi tạo engine vẽ line (gấp khúc, không mũi tên).
|
||||
export function initLine(
|
||||
map: maplibregl.Map,
|
||||
getMode: ModeGetter,
|
||||
onComplete: (geometry: Geometry) => void
|
||||
) {
|
||||
let coords: [number, number][] = [];
|
||||
|
||||
// Xóa dữ liệu preview line.
|
||||
const clearPreview = () => {
|
||||
(map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData(
|
||||
EMPTY_PREVIEW
|
||||
);
|
||||
};
|
||||
|
||||
// Hủy phiên vẽ line hiện tại.
|
||||
const cancelLine = () => {
|
||||
coords = [];
|
||||
clearPreview();
|
||||
};
|
||||
|
||||
// Cập nhật line preview theo danh sách tọa độ tạm.
|
||||
const updatePreview = (lineCoords: [number, number][]) => {
|
||||
if (lineCoords.length < 2) {
|
||||
clearPreview();
|
||||
return;
|
||||
}
|
||||
|
||||
(map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
type: "Feature",
|
||||
properties: {},
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: lineCoords,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
// Chốt line khi đủ số đỉnh tối thiểu.
|
||||
const finishLine = () => {
|
||||
if (getMode() !== "add-line" || coords.length < 2) return;
|
||||
|
||||
const geometry: Geometry = {
|
||||
type: "LineString",
|
||||
coordinates: [...coords],
|
||||
};
|
||||
|
||||
onComplete(geometry);
|
||||
cancelLine();
|
||||
};
|
||||
|
||||
// Xóa đỉnh cuối cùng trong line đang vẽ.
|
||||
const removeLastVertex = () => {
|
||||
if (!coords.length) return;
|
||||
coords = coords.slice(0, -1);
|
||||
updatePreview(coords);
|
||||
};
|
||||
|
||||
// Thêm một đỉnh line khi click map.
|
||||
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
if (getMode() !== "add-line") return;
|
||||
|
||||
coords.push([e.lngLat.lng, e.lngLat.lat]);
|
||||
updatePreview(coords);
|
||||
};
|
||||
|
||||
// Cập nhật preview động theo vị trí chuột.
|
||||
const onMove = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
const canvas = map.getCanvas();
|
||||
|
||||
if (getMode() !== "add-line") {
|
||||
if (coords.length) {
|
||||
cancelLine();
|
||||
}
|
||||
if (canvas.style.cursor === "crosshair") {
|
||||
canvas.style.cursor = "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.style.cursor = "crosshair";
|
||||
if (coords.length === 0) return;
|
||||
updatePreview([...coords, [e.lngLat.lng, e.lngLat.lat]]);
|
||||
};
|
||||
|
||||
// Xử lý phím nóng Enter/Escape/Backspace cho chế độ vẽ line.
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (getMode() !== "add-line") return;
|
||||
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
finishLine();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancelLine();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Backspace") {
|
||||
e.preventDefault();
|
||||
removeLastVertex();
|
||||
}
|
||||
};
|
||||
|
||||
map.on("click", onClick);
|
||||
map.on("mousemove", onMove);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
|
||||
const cleanup = () => {
|
||||
map.off("click", onClick);
|
||||
map.off("mousemove", onMove);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
cancelLine();
|
||||
if (map.getCanvas().style.cursor === "crosshair") {
|
||||
map.getCanvas().style.cursor = "";
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
cleanup,
|
||||
cancel: cancelLine,
|
||||
};
|
||||
}
|
||||
142
src/uhm/lib/engine/pathEngine.ts
Normal file
142
src/uhm/lib/engine/pathEngine.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { Geometry } from "@/uhm/lib/useEditorState";
|
||||
import type { ModeGetter } from "@/uhm/lib/engine/engineTypes";
|
||||
|
||||
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
};
|
||||
|
||||
// Khởi tạo engine vẽ path (gấp khúc, sẽ render có mũi tên ở layer path).
|
||||
export function initPath(
|
||||
map: maplibregl.Map,
|
||||
getMode: ModeGetter,
|
||||
onComplete: (geometry: Geometry) => void
|
||||
) {
|
||||
let coords: [number, number][] = [];
|
||||
|
||||
// Xóa dữ liệu preview path.
|
||||
const clearPreview = () => {
|
||||
(map.getSource("draw-path-preview") as maplibregl.GeoJSONSource | undefined)?.setData(
|
||||
EMPTY_PREVIEW
|
||||
);
|
||||
};
|
||||
|
||||
// Cập nhật path preview theo danh sách tọa độ tạm.
|
||||
const updatePreview = (lineCoords: [number, number][]) => {
|
||||
if (lineCoords.length < 2) {
|
||||
clearPreview();
|
||||
return;
|
||||
}
|
||||
|
||||
(map.getSource("draw-path-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
type: "Feature",
|
||||
properties: {},
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: lineCoords,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
// Chốt path khi đủ số đỉnh tối thiểu.
|
||||
const finishPath = () => {
|
||||
if (getMode() !== "add-path" || coords.length < 2) return;
|
||||
|
||||
const geometry: Geometry = {
|
||||
type: "LineString",
|
||||
coordinates: [...coords],
|
||||
};
|
||||
|
||||
onComplete(geometry);
|
||||
coords = [];
|
||||
clearPreview();
|
||||
};
|
||||
|
||||
// Hủy phiên vẽ path hiện tại.
|
||||
const cancelPath = () => {
|
||||
coords = [];
|
||||
clearPreview();
|
||||
};
|
||||
|
||||
// Xóa đỉnh cuối cùng của path đang vẽ.
|
||||
const removeLastVertex = () => {
|
||||
if (coords.length === 0) return;
|
||||
coords = coords.slice(0, -1);
|
||||
updatePreview(coords);
|
||||
};
|
||||
|
||||
// Thêm một đỉnh path khi click map.
|
||||
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
if (getMode() !== "add-path") return;
|
||||
|
||||
coords.push([e.lngLat.lng, e.lngLat.lat]);
|
||||
updatePreview(coords);
|
||||
};
|
||||
|
||||
// Cập nhật preview path động theo vị trí chuột.
|
||||
const onMove = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
const canvas = map.getCanvas();
|
||||
|
||||
if (getMode() !== "add-path") {
|
||||
if (coords.length) {
|
||||
cancelPath();
|
||||
}
|
||||
if (canvas.style.cursor === "crosshair") {
|
||||
canvas.style.cursor = "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.style.cursor = "crosshair";
|
||||
if (coords.length === 0) return;
|
||||
|
||||
updatePreview([...coords, [e.lngLat.lng, e.lngLat.lat]]);
|
||||
};
|
||||
|
||||
// Xử lý phím nóng Enter/Escape/Backspace cho chế độ vẽ path.
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (getMode() !== "add-path") return;
|
||||
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
finishPath();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancelPath();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Backspace") {
|
||||
e.preventDefault();
|
||||
removeLastVertex();
|
||||
}
|
||||
};
|
||||
|
||||
map.on("click", onClick);
|
||||
map.on("mousemove", onMove);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
|
||||
const cleanup = () => {
|
||||
map.off("click", onClick);
|
||||
map.off("mousemove", onMove);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
cancelPath();
|
||||
if (map.getCanvas().style.cursor === "crosshair") {
|
||||
map.getCanvas().style.cursor = "";
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
cleanup,
|
||||
cancel: cancelPath,
|
||||
};
|
||||
}
|
||||
45
src/uhm/lib/engine/pointEngine.ts
Normal file
45
src/uhm/lib/engine/pointEngine.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { Geometry } from "@/uhm/lib/useEditorState";
|
||||
import type { ModeGetter } from "@/uhm/lib/engine/engineTypes";
|
||||
|
||||
// Khởi tạo engine thêm point bằng click đơn.
|
||||
export function initPoint(
|
||||
map: maplibregl.Map,
|
||||
getMode: ModeGetter,
|
||||
onComplete: (geometry: Geometry) => void
|
||||
) {
|
||||
// Thêm point mới khi đang ở chế độ add-point.
|
||||
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
||||
if (getMode() !== "add-point") return;
|
||||
|
||||
const geometry: Geometry = {
|
||||
type: "Point",
|
||||
coordinates: [e.lngLat.lng, e.lngLat.lat],
|
||||
};
|
||||
|
||||
onComplete?.(geometry);
|
||||
}
|
||||
|
||||
// Cập nhật trạng thái con trỏ theo mode add-point.
|
||||
function onMove() {
|
||||
const canvas = map.getCanvas();
|
||||
if (getMode() === "add-point") {
|
||||
canvas.style.cursor = "crosshair";
|
||||
return;
|
||||
}
|
||||
if (canvas.style.cursor === "crosshair") {
|
||||
canvas.style.cursor = "";
|
||||
}
|
||||
}
|
||||
|
||||
map.on("click", onClick);
|
||||
map.on("mousemove", onMove);
|
||||
|
||||
return () => {
|
||||
map.off("click", onClick);
|
||||
map.off("mousemove", onMove);
|
||||
if (map.getCanvas().style.cursor === "crosshair") {
|
||||
map.getCanvas().style.cursor = "";
|
||||
}
|
||||
};
|
||||
}
|
||||
258
src/uhm/lib/engine/selectingEngine.ts
Normal file
258
src/uhm/lib/engine/selectingEngine.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
import type { ModeGetter } from "@/uhm/lib/engine/engineTypes";
|
||||
|
||||
// Khởi tạo engine chọn feature và context menu edit/delete.
|
||||
export function initSelect(
|
||||
map: maplibregl.Map,
|
||||
getMode: ModeGetter,
|
||||
onDelete?: (id: string | number) => void,
|
||||
onEdit?: (feature: maplibregl.MapGeoJSONFeature) => void,
|
||||
onSelectId?: (id: string | number | null) => void
|
||||
) {
|
||||
const SELECTABLE_LAYERS = [
|
||||
"countries-fill",
|
||||
"countries-line",
|
||||
"routes-line",
|
||||
"routes-path-arrow-fill",
|
||||
"routes-path-arrow-line",
|
||||
"routes-path-hit",
|
||||
"places-circle",
|
||||
"places-symbol",
|
||||
] as const;
|
||||
const FEATURE_STATE_SOURCES = [
|
||||
"countries",
|
||||
"places",
|
||||
"path-arrow-shapes",
|
||||
] as const;
|
||||
const selectedIds = new Set<number | string>();
|
||||
const hasContextActions = Boolean(onDelete || onEdit);
|
||||
let contextMenu: HTMLDivElement | null = null;
|
||||
let docClickHandler: ((ev: MouseEvent) => void) | null = null;
|
||||
|
||||
// Bỏ highlight feature-state của toàn bộ đối tượng đang chọn.
|
||||
function clearSelection(emit = true) {
|
||||
if (!selectedIds.size) return;
|
||||
selectedIds.forEach((id) => setSelectionStateForId(id, false));
|
||||
selectedIds.clear();
|
||||
if (emit) {
|
||||
onSelectId?.(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Chọn hoặc toggle đối tượng; giữ Alt để chọn cộng dồn/tắt chọn.
|
||||
function selectFeature(feature: maplibregl.MapGeoJSONFeature, additive: boolean) {
|
||||
const id = feature.id ?? feature.properties?.id;
|
||||
if (id === undefined || id === null) return;
|
||||
|
||||
if (!additive) {
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
if (additive && selectedIds.has(id)) {
|
||||
// Alt + click on an already selected feature removes it from the selection
|
||||
setSelectionStateForId(id, false);
|
||||
selectedIds.delete(id);
|
||||
onSelectId?.(selectedIds.size === 1 ? Array.from(selectedIds)[0] : null);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectionStateForId(id, true);
|
||||
selectedIds.add(id);
|
||||
onSelectId?.(selectedIds.size === 1 ? id : null);
|
||||
}
|
||||
|
||||
// Chọn feature theo click trái, hỗ trợ additive bằng Alt.
|
||||
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
||||
if (getMode() !== "select") return;
|
||||
const selectableLayers = getSelectableLayers();
|
||||
if (!selectableLayers.length) return;
|
||||
|
||||
const features = map.queryRenderedFeatures(e.point, {
|
||||
layers: selectableLayers,
|
||||
}) as maplibregl.MapGeoJSONFeature[];
|
||||
|
||||
if (!features.length) {
|
||||
clearSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
const additive = !!e.originalEvent?.altKey;
|
||||
selectFeature(features[0], additive);
|
||||
}
|
||||
|
||||
// Hiển thị menu ngữ cảnh (sửa/xóa) khi click chuột phải.
|
||||
// Mở menu thao tác khi click phải lên feature.
|
||||
function onRightClick(e: maplibregl.MapLayerMouseEvent) {
|
||||
if (getMode() !== "select") return;
|
||||
const selectableLayers = getSelectableLayers();
|
||||
if (!selectableLayers.length) return;
|
||||
|
||||
e.preventDefault(); // block browser menu
|
||||
|
||||
const features = map.queryRenderedFeatures(e.point, {
|
||||
layers: selectableLayers,
|
||||
}) as maplibregl.MapGeoJSONFeature[];
|
||||
|
||||
if (!features.length) return;
|
||||
|
||||
const feature = features[0];
|
||||
const id = feature.id ?? feature.properties?.id;
|
||||
if (id === undefined || id === null) return;
|
||||
|
||||
// if right-clicked item not selected, make it the sole selection
|
||||
if (!selectedIds.has(id)) {
|
||||
clearSelection();
|
||||
selectFeature(feature, false);
|
||||
}
|
||||
|
||||
showContextMenu(
|
||||
e.originalEvent?.clientX ?? e.point.x,
|
||||
e.originalEvent?.clientY ?? e.point.y,
|
||||
feature
|
||||
);
|
||||
}
|
||||
|
||||
// Đổi cursor pointer khi hover lên đối tượng có thể chọn.
|
||||
function onMove(e: maplibregl.MapLayerMouseEvent) {
|
||||
if (getMode() !== "select") return;
|
||||
const selectableLayers = getSelectableLayers();
|
||||
if (!selectableLayers.length) {
|
||||
map.getCanvas().style.cursor = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const features = map.queryRenderedFeatures(e.point, {
|
||||
layers: selectableLayers,
|
||||
});
|
||||
|
||||
map.getCanvas().style.cursor = features.length ? "pointer" : "";
|
||||
}
|
||||
|
||||
function getSelectableLayers(): string[] {
|
||||
return SELECTABLE_LAYERS.filter((layerId) => Boolean(map.getLayer(layerId)));
|
||||
}
|
||||
|
||||
function setSelectionStateForId(id: string | number, selected: boolean) {
|
||||
for (const source of FEATURE_STATE_SOURCES) {
|
||||
if (!map.getSource(source)) continue;
|
||||
map.setFeatureState({ source, id }, { selected });
|
||||
}
|
||||
}
|
||||
|
||||
map.on("click", onClick);
|
||||
map.on("mousemove", onMove);
|
||||
if (hasContextActions) {
|
||||
map.on("contextmenu", onRightClick);
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
map.off("click", onClick);
|
||||
map.off("mousemove", onMove);
|
||||
if (hasContextActions) {
|
||||
map.off("contextmenu", onRightClick);
|
||||
}
|
||||
clearSelection(false);
|
||||
hideContextMenu();
|
||||
};
|
||||
|
||||
return {
|
||||
cleanup,
|
||||
clearSelection,
|
||||
};
|
||||
|
||||
// Ẩn và dọn dẹp context menu hiện tại.
|
||||
function hideContextMenu() {
|
||||
if (contextMenu) {
|
||||
contextMenu.remove();
|
||||
contextMenu = null;
|
||||
}
|
||||
if (docClickHandler) {
|
||||
document.removeEventListener("click", docClickHandler);
|
||||
docClickHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Render menu ngữ cảnh tối giản gần vị trí con trỏ.
|
||||
function showContextMenu(
|
||||
x: number,
|
||||
y: number,
|
||||
clickedFeature: maplibregl.MapGeoJSONFeature
|
||||
) {
|
||||
hideContextMenu();
|
||||
|
||||
const menu = document.createElement("div");
|
||||
menu.style.position = "fixed";
|
||||
menu.style.left = `${x}px`;
|
||||
menu.style.top = `${y}px`;
|
||||
menu.style.background = "#0f172a";
|
||||
menu.style.color = "white";
|
||||
menu.style.border = "1px solid #1f2937";
|
||||
menu.style.borderRadius = "6px";
|
||||
menu.style.boxShadow = "0 4px 12px rgba(0,0,0,0.2)";
|
||||
menu.style.zIndex = "9999";
|
||||
menu.style.minWidth = "120px";
|
||||
menu.style.fontSize = "14px";
|
||||
menu.style.padding = "4px 0";
|
||||
|
||||
// Tạo một item thao tác trong context menu.
|
||||
const createItem = (label: string, onClick: () => void) => {
|
||||
const item = document.createElement("div");
|
||||
item.textContent = label;
|
||||
item.style.padding = "8px 12px";
|
||||
item.style.cursor = "pointer";
|
||||
item.onmouseenter = () => (item.style.background = "#1f2937");
|
||||
item.onmouseleave = () => (item.style.background = "transparent");
|
||||
item.onclick = () => {
|
||||
onClick();
|
||||
hideContextMenu();
|
||||
};
|
||||
return item;
|
||||
};
|
||||
|
||||
const selectedCount = selectedIds.size || 1;
|
||||
let hasMenuItems = false;
|
||||
|
||||
if (
|
||||
selectedCount === 1 &&
|
||||
clickedFeature.source === "countries" &&
|
||||
clickedFeature.geometry?.type === "Polygon" &&
|
||||
onEdit
|
||||
) {
|
||||
const single = clickedFeature;
|
||||
menu.appendChild(createItem("Chỉnh sửa", () => onEdit(single)));
|
||||
hasMenuItems = true;
|
||||
}
|
||||
|
||||
if (onDelete) {
|
||||
menu.appendChild(
|
||||
createItem(
|
||||
selectedCount > 1 ? `Xóa ${selectedCount} mục` : "Xóa",
|
||||
() => {
|
||||
const ids = selectedIds.size
|
||||
? Array.from(selectedIds)
|
||||
: [clickedFeature.id ?? clickedFeature.properties?.id];
|
||||
ids.forEach((eachId) => {
|
||||
if (eachId !== undefined && eachId !== null) onDelete(eachId);
|
||||
});
|
||||
clearSelection();
|
||||
}
|
||||
)
|
||||
);
|
||||
hasMenuItems = true;
|
||||
}
|
||||
|
||||
if (!hasMenuItems) return;
|
||||
|
||||
document.body.appendChild(menu);
|
||||
contextMenu = menu;
|
||||
|
||||
// Đóng menu khi click ra ngoài vùng menu.
|
||||
const onDocClick = (ev: MouseEvent) => {
|
||||
if (!menu.contains(ev.target as Node)) {
|
||||
hideContextMenu();
|
||||
}
|
||||
};
|
||||
docClickHandler = onDocClick;
|
||||
setTimeout(() => document.addEventListener("click", onDocClick), 0);
|
||||
}
|
||||
}
|
||||
122
src/uhm/lib/entityTypeOptions.ts
Normal file
122
src/uhm/lib/entityTypeOptions.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
export type EntityTypeGroupId =
|
||||
| "line"
|
||||
| "polygon"
|
||||
| "circle"
|
||||
| "point";
|
||||
|
||||
export type EntityGeometryPreset = "line" | "polygon" | "circle-area" | "point";
|
||||
|
||||
export type EntityTypeGroup = {
|
||||
id: EntityTypeGroupId;
|
||||
label: string;
|
||||
geometryLabel: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type EntityTypeOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
groupId: EntityTypeGroupId;
|
||||
groupLabel: string;
|
||||
geometryPreset: EntityGeometryPreset;
|
||||
};
|
||||
|
||||
export const ENTITY_TYPE_GROUPS: EntityTypeGroup[] = [
|
||||
{
|
||||
id: "line",
|
||||
label: "line - Tuyến",
|
||||
geometryLabel: "Line",
|
||||
description: "Các tuyến line/path (gấp khúc).",
|
||||
},
|
||||
{
|
||||
id: "polygon",
|
||||
label: "polygon - Đa giác",
|
||||
geometryLabel: "Polygon",
|
||||
description: "Vùng lãnh thổ dạng đa giác.",
|
||||
},
|
||||
{
|
||||
id: "circle",
|
||||
label: "circle - Tròn",
|
||||
geometryLabel: "Circle",
|
||||
description: "Vùng sự kiện theo bán kính ảnh hưởng.",
|
||||
},
|
||||
{
|
||||
id: "point",
|
||||
label: "point - Điểm",
|
||||
geometryLabel: "Point",
|
||||
description: "Địa điểm đơn lẻ.",
|
||||
},
|
||||
];
|
||||
|
||||
const GROUP_BY_ID: Record<EntityTypeGroupId, EntityTypeGroup> = {
|
||||
line: ENTITY_TYPE_GROUPS[0],
|
||||
polygon: ENTITY_TYPE_GROUPS[1],
|
||||
circle: ENTITY_TYPE_GROUPS[2],
|
||||
point: ENTITY_TYPE_GROUPS[3],
|
||||
};
|
||||
|
||||
const RAW_ENTITY_TYPE_OPTIONS: Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
groupId: EntityTypeGroupId;
|
||||
geometryPreset: EntityGeometryPreset;
|
||||
}> = [
|
||||
{ value: "defense_line", label: "Defense Line", groupId: "line", geometryPreset: "line" },
|
||||
|
||||
{ value: "attack_route", label: "Attack Route", groupId: "line", geometryPreset: "line" },
|
||||
{ value: "retreat_route", label: "Retreat Route", groupId: "line", geometryPreset: "line" },
|
||||
{ value: "invasion_route", label: "Invasion Route", groupId: "line", geometryPreset: "line" },
|
||||
{ value: "migration_route", label: "Migration Route", groupId: "line", geometryPreset: "line" },
|
||||
{ value: "refugee_route", label: "Refugee Route", groupId: "line", geometryPreset: "line" },
|
||||
{ value: "trade_route", label: "Trade Route", groupId: "line", geometryPreset: "line" },
|
||||
{ value: "shipping_route", label: "Shipping Route", groupId: "line", geometryPreset: "line" },
|
||||
|
||||
{ value: "country", label: "Country", groupId: "polygon", geometryPreset: "polygon" },
|
||||
{ value: "state", label: "State", groupId: "polygon", geometryPreset: "polygon" },
|
||||
{ value: "empire", label: "Empire", groupId: "polygon", geometryPreset: "polygon" },
|
||||
{ value: "kingdom", label: "Kingdom", groupId: "polygon", geometryPreset: "polygon" },
|
||||
|
||||
{ value: "war", label: "War", groupId: "circle", geometryPreset: "circle-area" },
|
||||
{ value: "battle", label: "Battle", groupId: "circle", geometryPreset: "circle-area" },
|
||||
{ value: "civilization", label: "Civilization", groupId: "circle", geometryPreset: "circle-area" },
|
||||
{ value: "rebellion_zone", label: "Rebellion Zone", groupId: "circle", geometryPreset: "circle-area" },
|
||||
|
||||
{ value: "person_deathplace", label: "Person Deathplace", groupId: "point", geometryPreset: "point" },
|
||||
{ value: "person_birthplace", label: "Person Birthplace", groupId: "point", geometryPreset: "point" },
|
||||
{ value: "person_activity", label: "Person Activity", groupId: "point", geometryPreset: "point" },
|
||||
{ value: "temple", label: "Temple", groupId: "point", geometryPreset: "point" },
|
||||
{ value: "capital", label: "Capital", groupId: "point", geometryPreset: "point" },
|
||||
{ value: "city", label: "City", groupId: "point", geometryPreset: "point" },
|
||||
{ value: "fortress", label: "Fortress", groupId: "point", geometryPreset: "point" },
|
||||
{ value: "castle", label: "Castle", groupId: "point", geometryPreset: "point" },
|
||||
{ value: "ruin", label: "Ruin", groupId: "point", geometryPreset: "point" },
|
||||
{ value: "port", label: "Port", groupId: "point", geometryPreset: "point" },
|
||||
{ value: "bridge", label: "Bridge", groupId: "point", geometryPreset: "point" },
|
||||
];
|
||||
|
||||
export const ENTITY_TYPE_OPTIONS: EntityTypeOption[] = RAW_ENTITY_TYPE_OPTIONS.map((item) => ({
|
||||
...item,
|
||||
groupLabel: GROUP_BY_ID[item.groupId].label,
|
||||
}));
|
||||
|
||||
export const DEFAULT_ENTITY_TYPE_ID = "country";
|
||||
|
||||
// Gom option theo group để render select phân nhóm.
|
||||
export function groupEntityTypeOptions(options: EntityTypeOption[] = ENTITY_TYPE_OPTIONS): Array<{
|
||||
id: EntityTypeGroupId;
|
||||
label: string;
|
||||
geometryLabel: string;
|
||||
description: string;
|
||||
options: EntityTypeOption[];
|
||||
}> {
|
||||
return ENTITY_TYPE_GROUPS.map((group) => ({
|
||||
...group,
|
||||
options: options.filter((option) => option.groupId === group.id),
|
||||
})).filter((group) => group.options.length > 0);
|
||||
}
|
||||
|
||||
// Tìm option theo type id, trả null nếu không tồn tại.
|
||||
export function findEntityTypeOption(typeId: string | null | undefined): EntityTypeOption | null {
|
||||
if (!typeId) return null;
|
||||
return ENTITY_TYPE_OPTIONS.find((option) => option.value === typeId) || null;
|
||||
}
|
||||
14
src/uhm/lib/geo/constants.ts
Normal file
14
src/uhm/lib/geo/constants.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { FeatureCollection } from "@/uhm/types/geo";
|
||||
|
||||
export const WORLD_BBOX = {
|
||||
minLng: -180,
|
||||
minLat: -90,
|
||||
maxLng: 180,
|
||||
maxLat: 90,
|
||||
} as const;
|
||||
|
||||
export const EMPTY_FEATURE_COLLECTION: FeatureCollection = {
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
};
|
||||
|
||||
33
src/uhm/lib/geoTypeMap.json
Normal file
33
src/uhm/lib/geoTypeMap.json
Normal file
@@ -0,0 +1,33 @@
|
||||
[
|
||||
{ "type_key": "defense_line", "geo_type_code": 1 },
|
||||
{ "type_key": "attack_route", "geo_type_code": 2 },
|
||||
{ "type_key": "retreat_route", "geo_type_code": 3 },
|
||||
{ "type_key": "invasion_route", "geo_type_code": 4 },
|
||||
{ "type_key": "migration_route", "geo_type_code": 5 },
|
||||
{ "type_key": "refugee_route", "geo_type_code": 6 },
|
||||
{ "type_key": "trade_route", "geo_type_code": 7 },
|
||||
{ "type_key": "shipping_route", "geo_type_code": 8 },
|
||||
|
||||
{ "type_key": "country", "geo_type_code": 9 },
|
||||
{ "type_key": "state", "geo_type_code": 10 },
|
||||
{ "type_key": "empire", "geo_type_code": 11 },
|
||||
{ "type_key": "kingdom", "geo_type_code": 12 },
|
||||
|
||||
{ "type_key": "war", "geo_type_code": 13 },
|
||||
{ "type_key": "battle", "geo_type_code": 14 },
|
||||
{ "type_key": "civilization", "geo_type_code": 15 },
|
||||
{ "type_key": "rebellion_zone", "geo_type_code": 16 },
|
||||
|
||||
{ "type_key": "person_deathplace", "geo_type_code": 17 },
|
||||
{ "type_key": "person_birthplace", "geo_type_code": 18 },
|
||||
{ "type_key": "person_activity", "geo_type_code": 19 },
|
||||
{ "type_key": "temple", "geo_type_code": 20 },
|
||||
{ "type_key": "capital", "geo_type_code": 21 },
|
||||
{ "type_key": "city", "geo_type_code": 22 },
|
||||
{ "type_key": "fortress", "geo_type_code": 23 },
|
||||
{ "type_key": "castle", "geo_type_code": 24 },
|
||||
{ "type_key": "ruin", "geo_type_code": 25 },
|
||||
{ "type_key": "port", "geo_type_code": 26 },
|
||||
{ "type_key": "bridge", "geo_type_code": 27 }
|
||||
]
|
||||
|
||||
34
src/uhm/lib/geoTypeMap.ts
Normal file
34
src/uhm/lib/geoTypeMap.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import rows from "@/uhm/lib/geoTypeMap.json";
|
||||
|
||||
export type GeoTypeMapRow = {
|
||||
type_key: string;
|
||||
geo_type_code: number;
|
||||
};
|
||||
|
||||
const MAP_ROWS: GeoTypeMapRow[] = rows as GeoTypeMapRow[];
|
||||
|
||||
const CODE_BY_KEY = new Map<string, number>();
|
||||
const KEY_BY_CODE = new Map<number, string>();
|
||||
|
||||
for (const row of MAP_ROWS) {
|
||||
const key = typeof row?.type_key === "string" ? row.type_key.trim().toLowerCase() : "";
|
||||
const code = typeof row?.geo_type_code === "number" ? row.geo_type_code : Number.NaN;
|
||||
if (!key.length) continue;
|
||||
if (!Number.isFinite(code)) continue;
|
||||
CODE_BY_KEY.set(key, Math.trunc(code));
|
||||
KEY_BY_CODE.set(Math.trunc(code), key);
|
||||
}
|
||||
|
||||
export function typeKeyToGeoTypeCode(key: string | null | undefined): number | null {
|
||||
if (!key) return null;
|
||||
const normalized = key.trim().toLowerCase();
|
||||
if (!normalized.length) return null;
|
||||
return CODE_BY_KEY.get(normalized) ?? null;
|
||||
}
|
||||
|
||||
export function geoTypeCodeToTypeKey(code: number | null | undefined): string | null {
|
||||
if (code == null) return null;
|
||||
if (!Number.isFinite(code)) return null;
|
||||
return KEY_BY_CODE.get(Math.trunc(code)) ?? null;
|
||||
}
|
||||
|
||||
8
src/uhm/lib/id.ts
Normal file
8
src/uhm/lib/id.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
|
||||
// Centralized ID generator for all client-created identifiers in FrontEndUser.
|
||||
// UUIDv7 is time-ordered (RFC 9562) and works well for sorting by creation time.
|
||||
export function newId(): string {
|
||||
return uuidv7();
|
||||
}
|
||||
|
||||
13
src/uhm/lib/map/constants.ts
Normal file
13
src/uhm/lib/map/constants.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const DEFAULT_POINT_ICON_ID = "point-icon-default";
|
||||
export const POINT_ICON_URL = "/point.png";
|
||||
export const PATH_ARROW_ICON_ID = "path-arrow-icon";
|
||||
|
||||
export const MAP_MIN_ZOOM = 2;
|
||||
export const MAP_MAX_ZOOM = 10;
|
||||
|
||||
export const RASTER_BASE_SOURCE_ID = "rasterBase";
|
||||
export const RASTER_BASE_LAYER_ID = "raster-base-layer";
|
||||
export const RASTER_BASE_INSERT_BEFORE_LAYER_ID = "graticules-line";
|
||||
|
||||
export const PATH_ARROW_SOURCE_ID = "path-arrow-shapes";
|
||||
export const FEATURE_STATE_SOURCE_IDS = ["countries", "places", PATH_ARROW_SOURCE_ID] as const;
|
||||
76
src/uhm/lib/map/style.ts
Normal file
76
src/uhm/lib/map/style.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
|
||||
export const COUNTRY_COLOR_KEY_EXPRESSION: maplibregl.ExpressionSpecification = [
|
||||
"coalesce",
|
||||
["get", "MAPCOLOR7"],
|
||||
["get", "MAPCOLOR9"],
|
||||
["get", "scalerank"],
|
||||
0,
|
||||
];
|
||||
|
||||
export const COUNTRY_FILL_COLOR_EXPRESSION: maplibregl.ExpressionSpecification = [
|
||||
"match",
|
||||
COUNTRY_COLOR_KEY_EXPRESSION,
|
||||
1, "#ef4444",
|
||||
2, "#f97316",
|
||||
3, "#f59e0b",
|
||||
4, "#22c55e",
|
||||
5, "#06b6d4",
|
||||
6, "#3b82f6",
|
||||
7, "#8b5cf6",
|
||||
8, "#a855f7",
|
||||
9, "#d946ef",
|
||||
10, "#14b8a6",
|
||||
"#64748b",
|
||||
];
|
||||
|
||||
export const POLYGON_FILL_BY_TYPE: Record<string, string> = {
|
||||
country: "#2563eb",
|
||||
state: "#0ea5e9",
|
||||
empire: "#f59e0b",
|
||||
kingdom: "#d97706",
|
||||
war: "#dc2626",
|
||||
battle: "#f43f5e",
|
||||
civilization: "#14b8a6",
|
||||
rebellion_zone: "#7c3aed",
|
||||
};
|
||||
|
||||
export const POLYGON_STROKE_BY_TYPE: Record<string, string> = {
|
||||
country: "#1e3a8a",
|
||||
state: "#0c4a6e",
|
||||
empire: "#7c2d12",
|
||||
kingdom: "#9a3412",
|
||||
war: "#7f1d1d",
|
||||
battle: "#9f1239",
|
||||
civilization: "#134e4a",
|
||||
rebellion_zone: "#4c1d95",
|
||||
};
|
||||
|
||||
export const POLYGON_OPACITY_BY_TYPE: Record<string, number> = {
|
||||
war: 0.3,
|
||||
battle: 0.34,
|
||||
civilization: 0.38,
|
||||
rebellion_zone: 0.32,
|
||||
};
|
||||
|
||||
export const LINE_COLOR_BY_TYPE: Record<string, string> = {
|
||||
defense_line: "#f97316",
|
||||
attack_route: "#ef4444",
|
||||
retreat_route: "#94a3b8",
|
||||
invasion_route: "#b91c1c",
|
||||
migration_route: "#0ea5e9",
|
||||
refugee_route: "#06b6d4",
|
||||
trade_route: "#eab308",
|
||||
shipping_route: "#2563eb",
|
||||
};
|
||||
|
||||
export const PATH_RENDER_BY_TYPE: Record<string, boolean> = {
|
||||
attack_route: true,
|
||||
retreat_route: true,
|
||||
invasion_route: true,
|
||||
migration_route: true,
|
||||
refugee_route: true,
|
||||
trade_route: true,
|
||||
shipping_route: true,
|
||||
};
|
||||
|
||||
25
src/uhm/lib/timeline.ts
Normal file
25
src/uhm/lib/timeline.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { TimelineRange } from "@/uhm/lib/editor/session/sessionTypes";
|
||||
|
||||
// Single source of truth for the app-wide timeline range.
|
||||
export const FIXED_TIMELINE_START_YEAR = -2000;
|
||||
export const FIXED_TIMELINE_END_YEAR = 2000;
|
||||
|
||||
export const FIXED_TIMELINE_RANGE: TimelineRange = {
|
||||
min: FIXED_TIMELINE_START_YEAR,
|
||||
max: FIXED_TIMELINE_END_YEAR,
|
||||
};
|
||||
|
||||
// UI debounce when user drags timeline before triggering data fetch.
|
||||
export const TIMELINE_DEBOUNCE_MS = 180;
|
||||
|
||||
export function clampYearValue(year: number, minYear: number, maxYear: number): number {
|
||||
const lower = Math.min(minYear, maxYear);
|
||||
const upper = Math.max(minYear, maxYear);
|
||||
if (year < lower) return lower;
|
||||
if (year > upper) return upper;
|
||||
return year;
|
||||
}
|
||||
|
||||
export function clampYearToFixedRange(year: number): number {
|
||||
return clampYearValue(year, FIXED_TIMELINE_START_YEAR, FIXED_TIMELINE_END_YEAR);
|
||||
}
|
||||
52
src/uhm/lib/useEditorSessionState.ts
Normal file
52
src/uhm/lib/useEditorSessionState.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useState } from "react";
|
||||
import type { FeatureCollection } from "@/uhm/types/geo";
|
||||
import { useBackgroundSessionState } from "@/uhm/lib/editor/session/useBackgroundSessionState";
|
||||
import { useEntitySessionState } from "@/uhm/lib/editor/session/useEntitySessionState";
|
||||
import { useSectionSessionState } from "@/uhm/lib/editor/session/useSectionSessionState";
|
||||
import { useTimelineState } from "@/uhm/lib/editor/session/useTimelineState";
|
||||
import { useWikiSessionState } from "@/uhm/lib/editor/session/useWikiSessionState";
|
||||
import type { EditorMode, TimelineRange } from "@/uhm/lib/editor/session/sessionTypes";
|
||||
|
||||
export type {
|
||||
EditorMode,
|
||||
EntityFormState,
|
||||
GeometryMetaFormState,
|
||||
TimelineRange,
|
||||
} from "@/uhm/lib/editor/session/sessionTypes";
|
||||
|
||||
type Options = {
|
||||
emptyFeatureCollection: FeatureCollection;
|
||||
defaultEditorUserId: string;
|
||||
fallbackTimelineRange: TimelineRange;
|
||||
currentYear: number;
|
||||
};
|
||||
|
||||
export function useEditorSessionState(options: Options) {
|
||||
// Mode thao tác map/editor hiện tại.
|
||||
const [mode, setMode] = useState<EditorMode>("idle");
|
||||
// FeatureCollection "gốc" của session hiện tại (global timeline hoặc section snapshot).
|
||||
const [initialData, setInitialData] = useState<FeatureCollection>(options.emptyFeatureCollection);
|
||||
|
||||
const section = useSectionSessionState({
|
||||
defaultEditorUserId: options.defaultEditorUserId,
|
||||
});
|
||||
const entity = useEntitySessionState();
|
||||
const timeline = useTimelineState({
|
||||
currentYear: options.currentYear,
|
||||
fallbackTimelineRange: options.fallbackTimelineRange,
|
||||
});
|
||||
const background = useBackgroundSessionState();
|
||||
const wiki = useWikiSessionState();
|
||||
|
||||
return {
|
||||
mode,
|
||||
setMode,
|
||||
initialData,
|
||||
setInitialData,
|
||||
...section,
|
||||
...entity,
|
||||
...timeline,
|
||||
...background,
|
||||
...wiki,
|
||||
};
|
||||
}
|
||||
282
src/uhm/lib/useEditorState.ts
Normal file
282
src/uhm/lib/useEditorState.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react";
|
||||
import type {
|
||||
Feature,
|
||||
FeatureCollection,
|
||||
FeatureProperties,
|
||||
Geometry,
|
||||
} from "@/uhm/types/geo";
|
||||
import { buildInitialMap, deepClone, diffDraftToInitial } from "@/uhm/lib/editor/draft/draftDiff";
|
||||
import { useDraftState } from "@/uhm/lib/editor/draft/useDraftState";
|
||||
import { useUndoStack } from "@/uhm/lib/editor/draft/useUndoStack";
|
||||
import type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
|
||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
||||
|
||||
export type { Feature, FeatureCollection, FeatureProperties, Geometry } from "@/uhm/types/geo";
|
||||
export type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
|
||||
|
||||
type SnapshotUndoApi = {
|
||||
snapshotEntitiesRef: { current: EntitySnapshot[] };
|
||||
setSnapshotEntities: Dispatch<SetStateAction<EntitySnapshot[]>>;
|
||||
snapshotWikisRef: { current: WikiSnapshot[] };
|
||||
setSnapshotWikis: Dispatch<SetStateAction<WikiSnapshot[]>>;
|
||||
snapshotEntityWikiLinksRef: { current: EntityWikiLinkSnapshot[] };
|
||||
setSnapshotEntityWikiLinks: Dispatch<SetStateAction<EntityWikiLinkSnapshot[]>>;
|
||||
};
|
||||
|
||||
// State trung tâm của editor:
|
||||
// - draft: dữ liệu nguồn để render UI
|
||||
// - changes: map các thay đổi chờ lưu
|
||||
// - undoStack: lịch sử thao tác tối thiểu để hoàn tác
|
||||
export function useEditorState(initialData: FeatureCollection, snapshotUndo?: SnapshotUndoApi) {
|
||||
const { draft, draftRef, commitDraft, resetDraft } = useDraftState(initialData);
|
||||
|
||||
// Map baseline (id -> feature) để diff draft hiện tại ra changes.
|
||||
const initialMapRef = useRef<Map<FeatureProperties["id"], Feature>>(
|
||||
buildInitialMap(initialData)
|
||||
);
|
||||
// Version counter để ép diff recalculation sau khi reset/clear baseline.
|
||||
const [baselineVersion, setBaselineVersion] = useState(0);
|
||||
|
||||
const applyUndoAction = useCallback((action: UndoAction): boolean => {
|
||||
switch (action.type) {
|
||||
case "create": {
|
||||
commitDraft({
|
||||
...draftRef.current,
|
||||
features: draftRef.current.features.filter((feature) =>
|
||||
feature.properties.id !== action.id
|
||||
),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
case "delete": {
|
||||
const feature = deepClone(action.feature);
|
||||
commitDraft({
|
||||
...draftRef.current,
|
||||
features: [...draftRef.current.features, feature],
|
||||
});
|
||||
return true;
|
||||
}
|
||||
case "update": {
|
||||
const idx = draftRef.current.features.findIndex((feature) =>
|
||||
feature.properties.id === action.id
|
||||
);
|
||||
if (idx === -1) return false;
|
||||
const nextFeatures = [...draftRef.current.features];
|
||||
nextFeatures[idx] = {
|
||||
...nextFeatures[idx],
|
||||
geometry: deepClone(action.prevGeometry),
|
||||
};
|
||||
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||
return true;
|
||||
}
|
||||
case "properties": {
|
||||
const idx = draftRef.current.features.findIndex((feature) =>
|
||||
feature.properties.id === action.id
|
||||
);
|
||||
if (idx === -1) return false;
|
||||
const nextFeatures = [...draftRef.current.features];
|
||||
nextFeatures[idx] = {
|
||||
...nextFeatures[idx],
|
||||
properties: deepClone(action.prevProperties),
|
||||
};
|
||||
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||
return true;
|
||||
}
|
||||
case "snapshot_entities": {
|
||||
if (!snapshotUndo) return false;
|
||||
snapshotUndo.setSnapshotEntities(deepClone(action.prev));
|
||||
return true;
|
||||
}
|
||||
case "snapshot_wikis": {
|
||||
if (!snapshotUndo) return false;
|
||||
snapshotUndo.setSnapshotWikis(deepClone(action.prev));
|
||||
return true;
|
||||
}
|
||||
case "snapshot_entity_wiki": {
|
||||
if (!snapshotUndo) return false;
|
||||
snapshotUndo.setSnapshotEntityWikiLinks(deepClone(action.prev));
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}, [commitDraft, draftRef, snapshotUndo]);
|
||||
|
||||
const { undoStack, pushUndo, undo, clearUndo } = useUndoStack({ applyUndoAction });
|
||||
|
||||
useEffect(() => {
|
||||
resetDraft(deepClone(initialData));
|
||||
clearUndo();
|
||||
initialMapRef.current = buildInitialMap(initialData);
|
||||
setBaselineVersion((version) => version + 1);
|
||||
}, [clearUndo, initialData, resetDraft]);
|
||||
|
||||
const changes = useMemo(() => {
|
||||
const baseline = initialMapRef.current;
|
||||
return diffDraftToInitial(draft, baseline);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [draft, baselineVersion]);
|
||||
const changeCount = useMemo(() => changes.size, [changes]);
|
||||
|
||||
function createFeature(feature: Feature) {
|
||||
const featureClone = deepClone(feature);
|
||||
commitDraft({
|
||||
...draftRef.current,
|
||||
features: [...draftRef.current.features, featureClone],
|
||||
});
|
||||
pushUndo({ type: "create", id: featureClone.properties.id });
|
||||
}
|
||||
|
||||
function patchFeatureProperties(
|
||||
id: FeatureProperties["id"],
|
||||
patch: Partial<FeatureProperties>
|
||||
) {
|
||||
const idx = draftRef.current.features.findIndex((feature) => feature.properties.id === id);
|
||||
if (idx === -1) return;
|
||||
|
||||
const nextFeatures = [...draftRef.current.features];
|
||||
const prevProperties = deepClone(nextFeatures[idx].properties);
|
||||
nextFeatures[idx] = {
|
||||
...nextFeatures[idx],
|
||||
properties: {
|
||||
...nextFeatures[idx].properties,
|
||||
...deepClone(patch),
|
||||
},
|
||||
};
|
||||
|
||||
if (JSON.stringify(prevProperties) === JSON.stringify(nextFeatures[idx].properties)) {
|
||||
return;
|
||||
}
|
||||
|
||||
pushUndo({ type: "properties", id, prevProperties });
|
||||
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||
}
|
||||
|
||||
function updateFeature(id: FeatureProperties["id"], newGeometry: Geometry) {
|
||||
const idx = draftRef.current.features.findIndex((feature) => feature.properties.id === id);
|
||||
if (idx === -1) return;
|
||||
|
||||
const prevFeature = draftRef.current.features[idx];
|
||||
const prevGeometry = deepClone(prevFeature.geometry);
|
||||
const nextFeatures = [...draftRef.current.features];
|
||||
nextFeatures[idx] = {
|
||||
...prevFeature,
|
||||
geometry: deepClone(newGeometry),
|
||||
};
|
||||
|
||||
pushUndo({ type: "update", id, prevGeometry });
|
||||
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||
}
|
||||
|
||||
function deleteFeature(id: FeatureProperties["id"]) {
|
||||
const idx = draftRef.current.features.findIndex((feature) => feature.properties.id === id);
|
||||
if (idx === -1) return;
|
||||
|
||||
const feature = draftRef.current.features[idx];
|
||||
const nextFeatures = [...draftRef.current.features];
|
||||
nextFeatures.splice(idx, 1);
|
||||
|
||||
pushUndo({ type: "delete", feature: deepClone(feature) });
|
||||
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||
}
|
||||
|
||||
function buildPayload(): Change[] {
|
||||
return Array.from(changes.values()).map((change) => deepClone(change));
|
||||
}
|
||||
|
||||
function clearChanges() {
|
||||
clearUndo();
|
||||
initialMapRef.current = buildInitialMap(draftRef.current);
|
||||
setBaselineVersion((version) => version + 1);
|
||||
}
|
||||
|
||||
function hasPersistedFeature(id: FeatureProperties["id"]) {
|
||||
return initialMapRef.current.has(id);
|
||||
}
|
||||
|
||||
const setSnapshotEntitiesUndoable = useCallback((
|
||||
next: SetStateAction<EntitySnapshot[]>,
|
||||
label = "Cập nhật entities"
|
||||
) => {
|
||||
if (!snapshotUndo) return;
|
||||
snapshotUndo.setSnapshotEntities((prev) => {
|
||||
const prevClone = deepClone(prev);
|
||||
const computed = typeof next === "function" ? (next as (p: EntitySnapshot[]) => EntitySnapshot[])(prev) : next;
|
||||
let changed = true;
|
||||
try {
|
||||
changed = JSON.stringify(prev) !== JSON.stringify(computed);
|
||||
} catch {
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
pushUndo({ type: "snapshot_entities", label, prev: prevClone });
|
||||
}
|
||||
return computed;
|
||||
});
|
||||
}, [pushUndo, snapshotUndo]);
|
||||
|
||||
const setSnapshotWikisUndoable = useCallback((
|
||||
next: SetStateAction<WikiSnapshot[]>,
|
||||
label = "Cập nhật wikis"
|
||||
) => {
|
||||
if (!snapshotUndo) return;
|
||||
snapshotUndo.setSnapshotWikis((prev) => {
|
||||
const prevClone = deepClone(prev);
|
||||
const computed = typeof next === "function" ? (next as (p: WikiSnapshot[]) => WikiSnapshot[])(prev) : next;
|
||||
let changed = true;
|
||||
try {
|
||||
changed = JSON.stringify(prev) !== JSON.stringify(computed);
|
||||
} catch {
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
pushUndo({ type: "snapshot_wikis", label, prev: prevClone });
|
||||
}
|
||||
return computed;
|
||||
});
|
||||
}, [pushUndo, snapshotUndo]);
|
||||
|
||||
const setSnapshotEntityWikiLinksUndoable = useCallback((
|
||||
next: SetStateAction<EntityWikiLinkSnapshot[]>,
|
||||
label = "Cập nhật entity-wiki"
|
||||
) => {
|
||||
if (!snapshotUndo) return;
|
||||
snapshotUndo.setSnapshotEntityWikiLinks((prev) => {
|
||||
const prevClone = deepClone(prev);
|
||||
const computed = typeof next === "function"
|
||||
? (next as (p: EntityWikiLinkSnapshot[]) => EntityWikiLinkSnapshot[])(prev)
|
||||
: next;
|
||||
let changed = true;
|
||||
try {
|
||||
changed = JSON.stringify(prev) !== JSON.stringify(computed);
|
||||
} catch {
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
pushUndo({ type: "snapshot_entity_wiki", label, prev: prevClone });
|
||||
}
|
||||
return computed;
|
||||
});
|
||||
}, [pushUndo, snapshotUndo]);
|
||||
|
||||
return {
|
||||
draft,
|
||||
changes,
|
||||
undoStack,
|
||||
changeCount,
|
||||
createFeature,
|
||||
patchFeatureProperties,
|
||||
updateFeature,
|
||||
deleteFeature,
|
||||
undo,
|
||||
buildPayload,
|
||||
clearChanges,
|
||||
hasPersistedFeature,
|
||||
// Snapshot undo helpers (no-op if snapshotUndo not provided)
|
||||
setSnapshotEntities: setSnapshotEntitiesUndoable,
|
||||
setSnapshotWikis: setSnapshotWikisUndoable,
|
||||
setSnapshotEntityWikiLinks: setSnapshotEntityWikiLinksUndoable,
|
||||
};
|
||||
}
|
||||
18
src/uhm/types/api.ts
Normal file
18
src/uhm/types/api.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export type ApiEnvelope<T> = {
|
||||
// API cũ: "success" | "error"
|
||||
// API mới: boolean (true/false)
|
||||
status: boolean | "success" | "error" | string;
|
||||
data?: T;
|
||||
message?: string;
|
||||
errors?: unknown;
|
||||
pagination?: unknown;
|
||||
};
|
||||
|
||||
export type GeometriesBBoxQuery = {
|
||||
minLng: number;
|
||||
minLat: number;
|
||||
maxLng: number;
|
||||
maxLat: number;
|
||||
time?: number;
|
||||
entity_id?: string;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user