Compare commits

...

20 Commits

Author SHA1 Message Date
cf880f573f quick question, answer
All checks were successful
Build and Release / release (push) Successful in 33s
2026-05-09 16:22:22 +07:00
3ec9c51085 update about us
All checks were successful
Build and Release / release (push) Successful in 35s
2026-05-09 16:01:01 +07:00
f5fa38ec5a about-us
All checks were successful
Build and Release / release (push) Successful in 34s
2026-05-09 15:19:40 +07:00
add1728916 chatbot
All checks were successful
Build and Release / release (push) Successful in 33s
2026-05-09 11:55:45 +07:00
taDuc
a9b8c4ab8b Merge branch 'master' of github.com:Pregnant-Guild/FE_User_history_web 2026-05-08 23:33:49 +07:00
taDuc
c945a56a33 json import export project | UI cleaner | binding geometry to each other 2026-05-08 23:26:27 +07:00
6f163c3730 UPDATE: fix buyg+ 2026-05-08 11:17:17 +07:00
taDuc
ce4bc4f2a5 change snapshot commit to new format 2026-05-08 01:44:17 +07:00
taDuc
8b1df73797 refactor state storge, UI editor 2026-05-07 13:38:52 +07:00
taDuc
a29a3a2049 add type 2026-05-03 19:47:37 +07:00
taDuc
f555909b09 renew name 2026-05-03 19:36:18 +07:00
taDuc
34a5c3d041 renew commit snapshot 2026-05-03 19:33:33 +07:00
taDuc
fca188f0be scale label 2026-05-03 01:04:50 +07:00
taDuc
12c351c68a pre view wiki 2026-05-02 21:13:29 +07:00
taDuc
a74047fd09 add editor 2026-05-02 02:48:17 +07:00
41af501b51 update: layout
All checks were successful
Build and Release / release (push) Successful in 27s
2026-04-29 16:32:46 +07:00
65806d197f readme
All checks were successful
Build and Release / release (push) Successful in 28s
2026-04-29 16:11:22 +07:00
7ff68659ad update: project module
All checks were successful
Build and Release / release (push) Successful in 27s
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 16:09:07 +07:00
2e80e45eab Merge branch 'master' of https://git.kain.io.vn/Kain344/History-user
All checks were successful
Build and Release / release (push) Successful in 27s
2026-04-28 14:21:23 +07:00
e0608eb05b update layout 2026-04-28 14:20:29 +07:00
107 changed files with 15814 additions and 556 deletions

312
EDITOR_LOCAL_STORAGE.md Normal file
View 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``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()``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`
-**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`**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
View File

@@ -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.
![TailAdmin - Next.js Dashboard Preview](./banner.png)
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
View File

@@ -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
View 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**`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"``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"``"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”).

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
public/point.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

26
scripts/dev.mjs Normal file
View 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);
});

View File

@@ -1,56 +0,0 @@
"use client";
import { useSidebar } from "@/context/SidebarContext";
import AppHeader from "@/layout/AppHeader";
import AppSidebar from "@/layout/AppSidebar";
import Backdrop from "@/layout/Backdrop";
import { apiGetCurrentUser } from "@/service/auth";
import { setUserData } from "@/store/features/userSlice";
import React, { useEffect } from "react";
import { useDispatch } from "react-redux";
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const { isExpanded, isHovered, isMobileOpen } = useSidebar();
const dispatch = useDispatch()
useEffect(() => {
const fetchUser = async () => {
try {
const userData = await apiGetCurrentUser();
dispatch(setUserData(userData.data));
} catch (err) {
console.error("Lỗi:", err);
}
};
fetchUser();
}, [])
// Dynamic class for main content margin based on sidebar state
const mainContentMargin = isMobileOpen
? "ml-0"
: isExpanded || isHovered
? "lg:ml-[290px]"
: "lg:ml-[90px]";
return (
<div className="min-h-screen xl:flex">
{/* Sidebar and Backdrop */}
<AppSidebar />
<Backdrop />
{/* Main Content Area */}
<div
className={`flex-1 transition-all duration-300 ease-in-out ${mainContentMargin}`}
>
{/* Header */}
<AppHeader />
{/* Page Content */}
<div className="p-4 mx-auto max-w-(--breakpoint-2xl) md:p-6">{children}</div>
</div>
</div>
);
}

View File

@@ -1,42 +0,0 @@
import type { Metadata } from "next";
import { EcommerceMetrics } from "@/components/ecommerce/EcommerceMetrics";
import React from "react";
import MonthlyTarget from "@/components/ecommerce/MonthlyTarget";
import MonthlySalesChart from "@/components/ecommerce/MonthlySalesChart";
import StatisticsChart from "@/components/ecommerce/StatisticsChart";
import RecentOrders from "@/components/ecommerce/RecentOrders";
import DemographicCard from "@/components/ecommerce/DemographicCard";
export const metadata: Metadata = {
title:
"Admin Dashboard",
description: "This is Dashboard Home for History Web",
};
export default function Ecommerce() {
return (
<div className="grid grid-cols-12 gap-4 md:gap-6">
<div className="col-span-12 space-y-6 xl:col-span-7">
<EcommerceMetrics />
<MonthlySalesChart />
</div>
<div className="col-span-12 xl:col-span-5">
<MonthlyTarget />
</div>
<div className="col-span-12">
<StatisticsChart />
</div>
<div className="col-span-12 xl:col-span-5">
<DemographicCard />
</div>
<div className="col-span-12 xl:col-span-7">
<RecentOrders />
</div>
</div>
);
}

View File

@@ -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) {

View 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

File diff suppressed because it is too large Load Diff

7
src/app/editor/page.tsx Normal file
View 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
View 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>
);
}

186
src/app/page.tsx Normal file
View File

@@ -0,0 +1,186 @@
"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 (
<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>
);
}

View File

@@ -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>
);
}

View 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 (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 sự
kiện theo đúng tiến trình thời gian.
</p>
<p>
Đây không gian tập trung tri thức đưc tinh lọc, nơi các chuyên
gia, nhà sử học 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 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 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 lịch sử 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 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">
&copy; {new Date().getFullYear()} GeoHistory Project. All rights
reserved.
</div>
</div>
</footer>
</div>
);
}

View File

@@ -8,6 +8,7 @@ import { apiGetCurrentUser } from "@/service/auth";
import { setUserData } from "@/store/features/userSlice";
import React, { useEffect } from "react";
import { useDispatch } from "react-redux";
import ChatbotWidget from "@/components/ui/chat/ChatbotWidget";
export default function AdminLayout({
children,
@@ -35,7 +36,7 @@ export default function AdminLayout({
? "ml-0"
: isExpanded || isHovered
? "lg:ml-[290px]"
: "lg:ml-[90px]";
: "lg:ml-[0px]";
return (
<div className="min-h-screen xl:flex">
@@ -51,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>
);
}

View File

@@ -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;

View 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 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 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 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>
);
}

View File

@@ -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">
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 (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> 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>

View 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>
);
}

View File

@@ -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,

View 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
View 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;
}

View File

@@ -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

View File

@@ -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"

View File

@@ -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,

View File

@@ -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>

View File

@@ -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

View 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ị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>
);
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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
View File

@@ -0,0 +1,9 @@
export interface ChatbotPayload {
project_id?: string;
question: string;
}
export interface ChatbotResponse {
status: boolean;
data: string;
}

View File

@@ -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",
}

View File

@@ -178,4 +178,3 @@ const AppHeader: React.FC = () => {
};
export default AppHeader;

View File

@@ -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;

View File

@@ -14,4 +14,4 @@ const Backdrop: React.FC = () => {
);
};
export default Backdrop;
export default Backdrop;

View File

@@ -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;
};

View 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;
};

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}

View 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;
}

View 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>
);
}

View 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 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 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 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 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ụ";
}
}

View 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>
);
}

View 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

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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 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";
}

View 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}`;
}

View 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>
);
}

View 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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;")
.replaceAll("'", "&#39;");
}

View 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);

View 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;
}

View 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;
}

View 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[] };

View 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,
};
}

View 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;
}
}

View 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,
};
}

View 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);
}

View 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);
});
}

View 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;

View 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,
};
}

View 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,
};
}

View 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,
};
}

View 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,
};
}

View 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 };
}

View 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));
}

View 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 độ.
}

View 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,
};
}

View 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,
};
}

View File

@@ -0,0 +1,4 @@
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
export type ModeGetter = () => EditorMode;

View 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,
};
}

View 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,
};
}

View 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 = "";
}
};
}

View 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);
}
}

View 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;
}

View 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: [],
};

View 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
View 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
View 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();
}

View 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
View 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
View 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);
}

View 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,
};
}

Some files were not shown because too many files have changed in this diff Show More