Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 82dfd7fa56 | |||
| 051835b9bd | |||
| 282b365287 | |||
| 3b4ff71b9a | |||
| dc6d048645 | |||
| ee468fe4fe | |||
| 8f0e912d9e | |||
| b5dcda83a9 | |||
| 457eee4ffa | |||
| 7e025fb449 | |||
| 8c0bff8082 | |||
| 2a3e908c44 | |||
| 6599d8bf21 | |||
| 194b3ad3c2 | |||
| 488eee1a25 | |||
| f5514b8fb5 | |||
| e7c8322c63 | |||
| 663347889b | |||
| 97d505dcc7 | |||
| c09928a2b2 | |||
| 047f662736 | |||
| 3808086529 | |||
| 7424bc43b0 | |||
| a8097c95d4 | |||
| 4c81862bb4 | |||
| 3682f25282 | |||
| 57e3d6b3e5 | |||
| b220798978 | |||
| dca3ca67ad | |||
| 494195c532 | |||
| 2ad8f9793b |
@@ -1 +1,206 @@
|
||||
xamluoccampuchia
|
||||
# Ultimate History Map
|
||||
|
||||
> Explore history through maps, timelines, and connected knowledge.
|
||||
|
||||
## Overview
|
||||
|
||||
**Ultimate History Map** is an interactive history platform designed to make historical knowledge easier to explore, understand, and connect through maps, timelines, and structured wiki content.
|
||||
|
||||
The project helps users learn history in a more visual and intuitive way. Instead of reading isolated articles or looking at static maps, users can explore historical information directly on a map, move through time, and connect people, places, countries, wars, and events within the same experience.
|
||||
|
||||
Ultimate History Map is built around one core idea: history should not only be read — it should be seen, explored, and understood in context.
|
||||
|
||||
## Why Ultimate History Map?
|
||||
|
||||
History is one of the most important subjects for understanding the world, but it is often difficult to approach.
|
||||
|
||||
Many historical resources already exist, but they are usually separated across books, articles, maps, timelines, videos, and databases. This makes it hard for learners to understand how historical events relate to real places, time periods, people, countries, and surrounding events.
|
||||
|
||||
Some historical map platforms allow users to view countries across time, but they often focus mainly on borders. That is useful, but not enough for learning history deeply. History is not only about borders. It is also about people, wars, cities, cultures, migrations, political changes, ideas, and the connections between them.
|
||||
|
||||
Ultimate History Map aims to solve these problems by bringing historical maps, wiki-style information, timelines, and structured historical data into one connected platform.
|
||||
|
||||
## The Solution
|
||||
|
||||
Ultimate History Map provides a central place where users can explore historical information visually.
|
||||
|
||||
The platform allows users to:
|
||||
|
||||
* view historical maps by time period,
|
||||
* explore countries, people, wars, places, and events on the map,
|
||||
* search for historical information quickly,
|
||||
* read wiki-style content connected directly to map objects,
|
||||
* move from a wiki article back to the related location or event on the map,
|
||||
* understand historical context through surrounding events and related entities,
|
||||
* and access data that can be contributed, reviewed, and improved over time.
|
||||
|
||||
The goal is not only to display historical data, but to make that data easier to understand.
|
||||
|
||||
## Core Experience
|
||||
|
||||
The main user experience is centered around the map.
|
||||
|
||||
A user can choose a point in time and see what the world looked like at that moment. Countries, territories, places, events, people, and conflicts can appear based on the selected time period.
|
||||
|
||||
When the user selects something on the map, the platform shows related wiki content. When the user reads a wiki page and follows related links, the map can automatically focus on the relevant place, entity, or event.
|
||||
|
||||
This creates a two-way connection between map and knowledge:
|
||||
|
||||
* the map helps users discover historical information,
|
||||
* and the wiki helps users understand what they are seeing on the map.
|
||||
|
||||
For example, a user may search for a historical figure such as Quang Trung, view related places and events on the map, read connected wiki content, and continue exploring surrounding events from the same historical context.
|
||||
|
||||
## User Roles
|
||||
|
||||
Ultimate History Map is designed for multiple types of users.
|
||||
|
||||
### Learners and Explorers
|
||||
|
||||
General users who want to learn history in a more visual and accessible way. They can search, explore, read, and understand historical events through the map and wiki system.
|
||||
|
||||
### Teachers, Researchers, and Historians
|
||||
|
||||
Users with historical knowledge who can create or manage historical content, contribute map data, and help improve the quality of the platform.
|
||||
|
||||
### Contributors
|
||||
|
||||
Community members who can help add new information, improve existing content, and participate in building a larger historical knowledge base.
|
||||
|
||||
### Historians and Reviewers
|
||||
|
||||
Trusted reviewers who help verify submitted content before it becomes official data on the platform.
|
||||
|
||||
### Admins
|
||||
|
||||
Platform managers who handle users, roles, projects, submissions, and overall system moderation.
|
||||
|
||||
## Key Features
|
||||
|
||||
Ultimate History Map already includes the core features required for a functional historical map platform.
|
||||
|
||||
### Interactive Historical Map
|
||||
|
||||
The map can display default map layers, custom drawings, icons, and historical layers based on time and location.
|
||||
|
||||
### Time and Area Filtering
|
||||
|
||||
Map data and related information can be filtered by selected time and map bounds, allowing users to explore only the content relevant to a specific period and area.
|
||||
|
||||
### Wiki System
|
||||
|
||||
Historical content can be written, viewed, and connected to map objects. This allows the platform to work not only as a map, but also as a structured historical knowledge base.
|
||||
|
||||
### Visual Editor
|
||||
|
||||
The editor allows contributors to add and manage historical information directly on the map. This makes data creation more intuitive than editing raw coordinates or disconnected documents.
|
||||
|
||||
### Version Control
|
||||
|
||||
Historical data can be versioned, allowing changes to be tracked and managed over time.
|
||||
|
||||
### Team and Project Management
|
||||
|
||||
The platform supports project-based management, including contributors, team workflows, and progress tracking.
|
||||
|
||||
### Review and Approval Workflow
|
||||
|
||||
Submitted data can be reviewed before becoming official, helping maintain quality and reliability.
|
||||
|
||||
### Role-based Management
|
||||
|
||||
The system supports different roles such as admin and historian, allowing responsibilities to be separated clearly.
|
||||
|
||||
## What Makes It Different?
|
||||
|
||||
Ultimate History Map is not just a historical map and not just a wiki.
|
||||
|
||||
Most historical maps focus mainly on showing borders at different points in time. Ultimate History Map aims to go further by connecting multiple types of historical data in one place.
|
||||
|
||||
It can represent:
|
||||
|
||||
* countries and territories,
|
||||
* people,
|
||||
* places,
|
||||
* wars,
|
||||
* battles,
|
||||
* historical events,
|
||||
* routes,
|
||||
* and related wiki knowledge.
|
||||
|
||||
Another important difference is the interaction between the map and wiki content. Instead of using external wiki links that are disconnected from the map, Ultimate History Map is designed so that map objects and wiki content can reference each other directly.
|
||||
|
||||
This makes the learning experience more connected. Users can move from a map object to an explanation, then from that explanation to another related object on the map.
|
||||
|
||||
The platform is also designed with future event replay features in mind, especially battle replay and event replay. These features aim to help users understand historical processes as sequences, not just static descriptions.
|
||||
|
||||
## Example Use Cases
|
||||
|
||||
Ultimate History Map can be used in many learning and research scenarios.
|
||||
|
||||
For example, users can:
|
||||
|
||||
* learn about a historical figure such as Quang Trung,
|
||||
* see what was happening around the world in the year 1900,
|
||||
* observe country borders during a specific period,
|
||||
* select a war and understand how it progressed,
|
||||
* explore related places, people, and events from a wiki page,
|
||||
* follow a historical route such as migration, attack, or expansion,
|
||||
* or compare different historical periods through the map.
|
||||
|
||||
These use cases are designed to make history easier to understand for both casual learners and serious researchers.
|
||||
|
||||
## Current Status
|
||||
|
||||
Ultimate History Map has completed its basic core platform and is already usable.
|
||||
|
||||
The current system includes:
|
||||
|
||||
* interactive map visualization,
|
||||
* default map layers,
|
||||
* custom drawings and icons,
|
||||
* time-based and area-based filtering,
|
||||
* wiki functionality,
|
||||
* editor tools,
|
||||
* version control,
|
||||
* team control,
|
||||
* content submission and approval,
|
||||
* admin management features,
|
||||
* and role-based workflows for admins and historians.
|
||||
|
||||
Advanced learning and replay features are still under development, but the foundation of the platform is already in place.
|
||||
|
||||
## Future Extensions
|
||||
|
||||
Ultimate History Map is designed to grow beyond a map viewer.
|
||||
|
||||
Planned and possible future extensions include:
|
||||
|
||||
* multilingual support,
|
||||
* structured history courses,
|
||||
* exams and quizzes,
|
||||
* video-based learning content,
|
||||
* battle replay,
|
||||
* event replay,
|
||||
* improved community contribution workflows,
|
||||
* and AI agents that can help users ask questions, understand context, and interact with the map more easily.
|
||||
|
||||
These extensions aim to turn the platform into a broader historical learning ecosystem.
|
||||
|
||||
## Technology
|
||||
|
||||
The README focuses mainly on the product idea and user value, but the platform is supported by a modern web and geospatial stack.
|
||||
|
||||
* **Go** is used for the backend because of its performance, simplicity, and reliability for building scalable services.
|
||||
* **Next.js** is used for the frontend because it provides a modern web experience and supports SEO-friendly pages.
|
||||
* **PostgreSQL** and **PostGIS** are used for structured data, geospatial storage, and efficient map-based queries.
|
||||
|
||||
The technology is chosen to support long-term scalability, reliable data management, and interactive map-based experiences.
|
||||
|
||||
## Mission
|
||||
|
||||
Ultimate History Map is built with the goal of creating community value.
|
||||
|
||||
History should not only exist as disconnected text, static maps, or scattered documents. It should be connected through time, place, people, events, and context.
|
||||
|
||||
Ultimate History Map aims to make history more visual, accessible, trustworthy, and meaningful for everyone.
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
# Commit Snapshot (`commits.snapshot_json`) - Chuẩn Hiện Tại (FrontEndUser / UHM)
|
||||
|
||||
Tài liệu này mô tả **snapshot_json** mà `FrontEndUser` (module UHM editor) tạo ra khi bấm **Commit** trong `/editor/[id]`, và gửi lên endpoint `POST /projects/{id}/commits`.
|
||||
|
||||
Nguồn tham chiếu trong code (FrontEndUser):
|
||||
|
||||
- Types:
|
||||
- `src/uhm/types/sections.ts` (`EditorSnapshot`, `EntityWikiLinkSnapshot`)
|
||||
- `src/uhm/types/geo.ts` (`FeatureCollection`, `GeometrySnapshot`, `GeometryEntitySnapshot`)
|
||||
- `src/uhm/types/entities.ts` (`EntitySnapshot`)
|
||||
- `src/uhm/types/wiki.ts` (`WikiSnapshot`)
|
||||
- Build/normalize snapshot:
|
||||
- `src/uhm/lib/editor/snapshot/editorSnapshot.ts` (`buildEditorSnapshot`, `normalizeEditorSnapshot`)
|
||||
|
||||
## 1) Root Shape
|
||||
|
||||
FE hiện tại không dùng `schema_version`. `snapshot_json` là một object có các phần sau:
|
||||
|
||||
```ts
|
||||
export type EditorSnapshot = {
|
||||
editor_feature_collection?: FeatureCollection;
|
||||
entities?: EntitySnapshot[];
|
||||
geometries?: GeometrySnapshot[];
|
||||
geometry_entity?: GeometryEntitySnapshot[];
|
||||
wikis?: WikiSnapshot[];
|
||||
entity_wiki?: EntityWikiLinkSnapshot[];
|
||||
};
|
||||
```
|
||||
|
||||
Lưu ý:
|
||||
|
||||
- FE có thể **đọc** cả `entity_wiki` và legacy alias `entity_wikis` khi load snapshot (normalize), nhưng khi commit FE ghi `entity_wiki`.
|
||||
- `editor_feature_collection` là nguồn để render editor/map. Các join table (`geometry_entity`, `entity_wiki`) là nguồn quan hệ.
|
||||
|
||||
## 2) Types (TypeScript) - Đúng Theo FE Hiện Tại
|
||||
|
||||
### 2.1 GeoJSON (editor_feature_collection)
|
||||
|
||||
```ts
|
||||
export type Geometry =
|
||||
| { type: "Point"; coordinates: [number, number] }
|
||||
| { type: "MultiPoint"; coordinates: [number, number][] }
|
||||
| { type: "LineString"; coordinates: [number, number][] }
|
||||
| { type: "MultiLineString"; coordinates: [number, number][][] }
|
||||
| { type: "Polygon"; coordinates: [number, number][][] }
|
||||
| { type: "MultiPolygon"; coordinates: [number, number][][][] };
|
||||
|
||||
export type FeatureId = string | number;
|
||||
|
||||
export type FeatureProperties = {
|
||||
id: FeatureId;
|
||||
type?: string | null;
|
||||
geometry_preset?: string | null;
|
||||
time_start?: number | null;
|
||||
time_end?: number | null;
|
||||
binding?: string[];
|
||||
|
||||
// UI-only / legacy fields (FE sẽ strip khi persist snapshot):
|
||||
entity_id?: string | null;
|
||||
entity_ids?: string[];
|
||||
entity_name?: string | null;
|
||||
entity_names?: string[];
|
||||
entity_type_id?: string | null;
|
||||
};
|
||||
|
||||
export type Feature = {
|
||||
type: "Feature";
|
||||
properties: FeatureProperties;
|
||||
geometry: Geometry;
|
||||
};
|
||||
|
||||
export type FeatureCollection = {
|
||||
type: "FeatureCollection";
|
||||
features: Feature[];
|
||||
};
|
||||
```
|
||||
|
||||
### 2.2 Snapshot rows
|
||||
|
||||
```ts
|
||||
export type SnapshotSource = "inline" | "ref";
|
||||
|
||||
export type EntitySnapshotOperation = "create" | "update" | "delete" | "reference";
|
||||
export type GeometrySnapshotOperation = "create" | "update" | "delete" | "reference";
|
||||
export type WikiSnapshotOperation = "create" | "update" | "delete" | "reference";
|
||||
|
||||
export type EntitySnapshot = {
|
||||
id: string;
|
||||
source: SnapshotSource;
|
||||
operation?: EntitySnapshotOperation;
|
||||
name?: string;
|
||||
slug?: string | null;
|
||||
description?: string | null;
|
||||
status?: number | null;
|
||||
base_updated_at?: string;
|
||||
base_hash?: string;
|
||||
};
|
||||
|
||||
export type GeometrySnapshot = {
|
||||
id: string;
|
||||
source: SnapshotSource;
|
||||
operation?: GeometrySnapshotOperation;
|
||||
type?: string | null;
|
||||
draw_geometry?: Geometry;
|
||||
geometry?: Geometry; // legacy
|
||||
binding?: string[];
|
||||
time_start?: number | null;
|
||||
time_end?: number | null;
|
||||
bbox?: {
|
||||
min_lng: number;
|
||||
min_lat: number;
|
||||
max_lng: number;
|
||||
max_lat: number;
|
||||
} | null;
|
||||
base_updated_at?: string;
|
||||
base_hash?: string;
|
||||
};
|
||||
|
||||
// FE stores wiki doc as a string (commonly HTML; in some flows it may be a JSON-stringified editor payload).
|
||||
export type WikiDoc = string | null;
|
||||
|
||||
export type WikiSnapshot = {
|
||||
id: string;
|
||||
source: SnapshotSource;
|
||||
operation?: WikiSnapshotOperation;
|
||||
title: string;
|
||||
slug?: string | null;
|
||||
doc: WikiDoc;
|
||||
updated_at?: string;
|
||||
};
|
||||
```
|
||||
|
||||
### 2.3 Join tables
|
||||
|
||||
```ts
|
||||
export type GeometryEntitySnapshot = {
|
||||
geometry_id: string;
|
||||
entity_id: string;
|
||||
base_links_hash?: string;
|
||||
};
|
||||
|
||||
export type EntityWikiLinkSnapshot = {
|
||||
entity_id: string;
|
||||
wiki_id: string;
|
||||
operation?: "reference" | "binding" | "delete";
|
||||
};
|
||||
```
|
||||
|
||||
## 3) Quy Ước FE Khi Build Snapshot (buildEditorSnapshot)
|
||||
|
||||
### 3.1 Feature.properties entity fields bị strip
|
||||
|
||||
Khi persist snapshot, FE chủ động xoá các field denormalize trên feature properties:
|
||||
`entity_id`, `entity_ids`, `entity_name`, `entity_names`, `entity_type_id`.
|
||||
|
||||
Quan hệ geometry ↔ entity chỉ nằm ở `geometry_entity[]`.
|
||||
|
||||
### 3.2 entities[]
|
||||
|
||||
FE cố gắng đảm bảo mọi entity có `name` không rỗng (fallback sang `id`) và có `source`.
|
||||
|
||||
`operation` được dùng như "delta" trong commit:
|
||||
|
||||
- `"create"|"update"|"delete"`: thay đổi record entity
|
||||
- `"reference"`: đưa entity vào context snapshot (pin/link) nhưng commit không sửa record entity
|
||||
|
||||
### 3.3 geometries[]
|
||||
|
||||
FE sinh 1 `GeometrySnapshot` cho mỗi feature đang tồn tại trong `editor_feature_collection.features[]`:
|
||||
|
||||
- `id = String(feature.properties.id)`
|
||||
- `source:"inline"`
|
||||
- `draw_geometry = feature.geometry`
|
||||
- `binding`, `time_start`, `time_end`, `bbox` (nếu tính được)
|
||||
- `type`: FE hiện gửi **string code** (geo_type smallint) dưới dạng string
|
||||
- `operation`:
|
||||
- `"create"` nếu geometry mới
|
||||
- `"update"` nếu geometry thay đổi
|
||||
- `undefined` nếu geometry không đổi
|
||||
|
||||
Nếu feature bị xoá khỏi draft, FE thêm 1 row:
|
||||
|
||||
```json
|
||||
{ "id": "…", "source": "ref", "operation": "delete" }
|
||||
```
|
||||
|
||||
### 3.4 geometry_entity[]
|
||||
|
||||
`geometry_entity` là danh sách quan hệ many-to-many geometry ↔ entity. Mỗi row là một cặp:
|
||||
|
||||
```ts
|
||||
{ geometry_id: string; entity_id: string }
|
||||
```
|
||||
|
||||
### 3.5 wikis[]
|
||||
|
||||
- Wiki `source:"ref"` (được add từ search): FE set `operation:"reference"` và `doc:null`.
|
||||
- Wiki `source:"inline"` (được tạo/sửa trong editor):
|
||||
- nếu UI set explicit `create|update|delete` thì giữ nguyên
|
||||
- nếu không có operation:
|
||||
- wiki mới: FE coi là `"create"`
|
||||
- wiki cũ không đổi: FE gán `"reference"`
|
||||
- wiki cũ có đổi nội dung: FE gán `"update"`
|
||||
|
||||
### 3.6 entity_wiki[]
|
||||
|
||||
Type trong FE cho UI state cho phép `"binding"` và `"delete"`.
|
||||
|
||||
Khi build snapshot để commit, FE map link “đang bật” về `"reference"` để tương thích với backend (một số backend chỉ chấp nhận `"reference"|"delete"`).
|
||||
|
||||
## 4) Ví Dụ snapshot_json (rút gọn)
|
||||
|
||||
```json
|
||||
{
|
||||
"editor_feature_collection": {
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": { "id": "019e…", "type": "country", "time_start": 1000, "time_end": 1500 },
|
||||
"geometry": { "type": "Polygon", "coordinates": [[[100, 10], [101, 10], [101, 11], [100, 10]]] }
|
||||
}
|
||||
]
|
||||
},
|
||||
"entities": [
|
||||
{ "id": "019e…", "source": "inline", "operation": "reference", "name": "ent1", "description": null, "status": 1 }
|
||||
],
|
||||
"geometries": [
|
||||
{ "id": "019e…", "source": "inline", "operation": "update", "type": "9", "draw_geometry": { "type": "Polygon", "coordinates": [] }, "binding": [], "time_start": 1000, "time_end": 1500, "bbox": null }
|
||||
],
|
||||
"geometry_entity": [
|
||||
{ "geometry_id": "019e…", "entity_id": "019e…" }
|
||||
],
|
||||
"wikis": [
|
||||
{ "id": "019e…", "source": "ref", "operation": "reference", "title": "Existing wiki", "doc": null, "updated_at": "2026-05-08T00:00:00.000Z" }
|
||||
],
|
||||
"entity_wiki": [
|
||||
{ "entity_id": "019e…", "wiki_id": "019e…", "operation": "reference" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 5) Compat Notes (khi load snapshot cũ)
|
||||
|
||||
FE normalize khi load snapshot:
|
||||
|
||||
- Nếu thấy `entity_wikis` (plural) sẽ đọc như `entity_wiki`.
|
||||
- Nếu join link có `operation:"reference"` thì FE coi như link active (UI biểu diễn như “binding”).
|
||||
@@ -20,9 +20,6 @@
|
||||
"@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",
|
||||
@@ -44,7 +41,8 @@
|
||||
"swiper": "^11.2.10",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"uuid": "^13.0.0",
|
||||
"yet-another-react-lightbox": "^3.30.1"
|
||||
"yet-another-react-lightbox": "^3.30.1",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
|
||||
|
After Width: | Height: | Size: 448 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 430 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,281 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import type { Entity } from "@/uhm/api/entities";
|
||||
import type { EntityGeometriesSearchItem, EntityGeometrySearchGeo } from "@/uhm/api/geometries";
|
||||
import type { Wiki } from "@/uhm/api/wikis";
|
||||
import UnifiedSearchBar, { type UnifiedSearchKind } from "@/uhm/components/ui/UnifiedSearchBar";
|
||||
|
||||
type EditorSearchResultsProps = {
|
||||
searchKind: UnifiedSearchKind;
|
||||
onSearchKindChange: (kind: UnifiedSearchKind) => void;
|
||||
searchQuery: string;
|
||||
onSearchQueryChange: (query: string) => void;
|
||||
onLocalSearchQueryChange: (query: string) => void;
|
||||
searchQueryDraft: string;
|
||||
entitySearchResults: Entity[];
|
||||
isEntitySearchLoading: boolean;
|
||||
onAddEntityRefToProject: (entity: Entity) => void;
|
||||
wikiSearchResults: Wiki[];
|
||||
isWikiSearching: boolean;
|
||||
onAddWikiRefToProject: (wiki: Wiki) => void;
|
||||
geoSearchResults: EntityGeometriesSearchItem[];
|
||||
isGeoSearching: boolean;
|
||||
onImportGeoFromSearch: (
|
||||
entityItem: EntityGeometriesSearchItem,
|
||||
geo: EntityGeometrySearchGeo
|
||||
) => void;
|
||||
};
|
||||
|
||||
export function EditorSearchResults({
|
||||
searchKind,
|
||||
onSearchKindChange,
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
onLocalSearchQueryChange,
|
||||
searchQueryDraft,
|
||||
entitySearchResults,
|
||||
isEntitySearchLoading,
|
||||
onAddEntityRefToProject,
|
||||
wikiSearchResults,
|
||||
isWikiSearching,
|
||||
onAddWikiRefToProject,
|
||||
geoSearchResults,
|
||||
isGeoSearching,
|
||||
onImportGeoFromSearch,
|
||||
}: EditorSearchResultsProps) {
|
||||
// Draft query quyết định có render kết quả hay không; query chính đã debounce ở page.
|
||||
const hasQuery = searchQueryDraft.trim().length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<UnifiedSearchBar
|
||||
kind={searchKind}
|
||||
onKindChange={onSearchKindChange}
|
||||
query={searchQuery}
|
||||
onQueryChange={onSearchQueryChange}
|
||||
onLocalQueryChange={onLocalSearchQueryChange}
|
||||
/>
|
||||
|
||||
{searchKind === "entity" && hasQuery ? (
|
||||
<SearchBox
|
||||
title="Entity Results"
|
||||
status={isEntitySearchLoading ? "Searching..." : `${entitySearchResults.length} results`}
|
||||
>
|
||||
{entitySearchResults.slice(0, 8).map((entity) => (
|
||||
<ResultRow
|
||||
key={entity.id}
|
||||
title={entity.name}
|
||||
subtitle={entity.id}
|
||||
actionLabel="Add"
|
||||
actionTitle="Add entity ref to project snapshot"
|
||||
onAction={() => onAddEntityRefToProject(entity)}
|
||||
/>
|
||||
))}
|
||||
{!isEntitySearchLoading && entitySearchResults.length === 0 ? <EmptyResult /> : null}
|
||||
</SearchBox>
|
||||
) : null}
|
||||
|
||||
{searchKind === "wiki" && hasQuery ? (
|
||||
<SearchBox
|
||||
title="Wiki Results"
|
||||
status={isWikiSearching ? "Searching..." : `${wikiSearchResults.length} results`}
|
||||
>
|
||||
{wikiSearchResults.slice(0, 8).map((wiki) => (
|
||||
<ResultRow
|
||||
key={wiki.id}
|
||||
title={(wiki.title || "").trim() || "Untitled wiki"}
|
||||
subtitle={wiki.id}
|
||||
actionLabel="Add"
|
||||
actionTitle="Add wiki ref to project snapshot"
|
||||
onAction={() => onAddWikiRefToProject(wiki)}
|
||||
/>
|
||||
))}
|
||||
{!isWikiSearching && wikiSearchResults.length === 0 ? <EmptyResult /> : null}
|
||||
</SearchBox>
|
||||
) : null}
|
||||
|
||||
{searchKind === "geo" && hasQuery ? (
|
||||
<SearchBox
|
||||
title="Geo Results"
|
||||
status={isGeoSearching ? "Searching..." : `${geoSearchResults.length} entities`}
|
||||
>
|
||||
{geoSearchResults.slice(0, 6).map((item) => (
|
||||
<GeoResultGroup
|
||||
key={item.entity_id}
|
||||
item={item}
|
||||
onImportGeoFromSearch={onImportGeoFromSearch}
|
||||
/>
|
||||
))}
|
||||
{!isGeoSearching && geoSearchResults.length === 0 ? <EmptyResult /> : null}
|
||||
</SearchBox>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchBox({
|
||||
title,
|
||||
status,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
status: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ padding: 10, background: "#0b1220", borderRadius: 8, border: "1px solid #1f2937" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 13, color: "white" }}>{title}</div>
|
||||
<div style={{ fontSize: 12, color: "#94a3b8" }}>{status}</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, display: "grid", gap: 6 }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultRow({
|
||||
title,
|
||||
subtitle,
|
||||
actionLabel,
|
||||
actionTitle,
|
||||
onAction,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
actionLabel: string;
|
||||
actionTitle: string;
|
||||
onAction: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: 8,
|
||||
borderRadius: 6,
|
||||
border: "1px solid #1f2937",
|
||||
background: "transparent",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ color: "#e5e7eb", fontSize: 12, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{title}
|
||||
</div>
|
||||
<div style={{ color: "#94a3b8", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{subtitle}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAction}
|
||||
style={actionButtonStyle}
|
||||
title={actionTitle}
|
||||
>
|
||||
{actionLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GeoResultGroup({
|
||||
item,
|
||||
onImportGeoFromSearch,
|
||||
}: {
|
||||
item: EntityGeometriesSearchItem;
|
||||
onImportGeoFromSearch: (
|
||||
entityItem: EntityGeometriesSearchItem,
|
||||
geo: EntityGeometrySearchGeo
|
||||
) => void;
|
||||
}) {
|
||||
const geometries = Array.isArray(item.geometries) ? item.geometries : [];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: 8,
|
||||
borderRadius: 6,
|
||||
border: "1px solid #1f2937",
|
||||
background: "transparent",
|
||||
display: "grid",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ color: "#e5e7eb", fontSize: 12, fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{item.name?.trim() || item.entity_id}
|
||||
</div>
|
||||
<div style={{ color: "#94a3b8", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{item.entity_id}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#94a3b8", flex: "0 0 auto" }}>
|
||||
{geometries.length} geos
|
||||
</div>
|
||||
</div>
|
||||
{item.description?.trim() ? (
|
||||
<div style={{ color: "#cbd5e1", fontSize: 12, lineHeight: 1.35 }}>
|
||||
{item.description.trim()}
|
||||
</div>
|
||||
) : null}
|
||||
{geometries.length ? (
|
||||
<div style={{ display: "grid", gap: 6, maxHeight: 200, overflowY: "auto", paddingRight: 4 }}>
|
||||
{geometries.map((geo) => (
|
||||
<div
|
||||
key={geo.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 8,
|
||||
padding: 8,
|
||||
borderRadius: 6,
|
||||
border: "1px solid #243244",
|
||||
background: "#0f172a",
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ color: "#e5e7eb", fontSize: 12, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
#{geo.id}
|
||||
</div>
|
||||
<div style={{ color: "#94a3b8", fontSize: 11 }}>
|
||||
type: {geo.type || "unknown"}{" "}
|
||||
{geo.time_start != null || geo.time_end != null
|
||||
? `| time: ${geo.time_start ?? "?"} -> ${geo.time_end ?? "?"}`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onImportGeoFromSearch(item, geo)}
|
||||
style={{ ...actionButtonStyle, flex: "0 0 auto" }}
|
||||
title="Import geometry into current editor draft"
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 12, color: "#94a3b8" }}>No geometry linked.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyResult() {
|
||||
return <div style={{ fontSize: 12, color: "#94a3b8" }}>No results.</div>;
|
||||
}
|
||||
|
||||
const actionButtonStyle: CSSProperties = {
|
||||
border: "none",
|
||||
background: "#111827",
|
||||
color: "#93c5fd",
|
||||
cursor: "pointer",
|
||||
borderRadius: 6,
|
||||
padding: "6px 8px",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||
|
||||
type ResizeHandleProps = {
|
||||
onDrag: (deltaX: number) => void;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export function ResizeHandle({ onDrag, title }: ResizeHandleProps) {
|
||||
// Theo dõi pointer toàn window để resize vẫn mượt khi cursor đi ra khỏi handle.
|
||||
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
const startX = event.clientX;
|
||||
let lastX = startX;
|
||||
|
||||
const onMove = (e: PointerEvent) => {
|
||||
const deltaX = e.clientX - lastX;
|
||||
if (deltaX !== 0) {
|
||||
onDrag(deltaX);
|
||||
lastX = e.clientX;
|
||||
}
|
||||
};
|
||||
const onUp = () => {
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.removeEventListener("pointerup", onUp);
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", onMove);
|
||||
window.addEventListener("pointerup", onUp);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
title={title}
|
||||
onPointerDown={handlePointerDown}
|
||||
style={{
|
||||
width: 6,
|
||||
cursor: "col-resize",
|
||||
background: "rgba(148, 163, 184, 0.08)",
|
||||
borderLeft: "1px solid rgba(148, 163, 184, 0.18)",
|
||||
borderRight: "1px solid rgba(148, 163, 184, 0.18)",
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { ProjectCommit } from "@/uhm/api/projects";
|
||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||
import type { Feature, Geometry } from "@/uhm/types/geo";
|
||||
import type { BattleReplay } from "@/uhm/types/projects";
|
||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
import { normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline";
|
||||
|
||||
// Giới hạn kích thước panel khi drag resize để tránh layout bị vỡ.
|
||||
export function clampNumber(value: number, min: number, max: number): number {
|
||||
if (value < min) return min;
|
||||
if (value > max) return max;
|
||||
return value;
|
||||
}
|
||||
|
||||
// Tạo label ngắn cho commit history, ưu tiên summary người dùng nhập.
|
||||
export function formatCommitTitle(commit: ProjectCommit): string {
|
||||
return commit.edit_summary?.trim() || `Commit ${commit.id.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
// Kiểm tra feature có nằm trong năm timeline đang active hay không.
|
||||
export function isFeatureVisibleAtYear(feature: Feature, year: number): boolean {
|
||||
const start = normalizeTimelineYearValue(feature.properties.time_start);
|
||||
const end = normalizeTimelineYearValue(feature.properties.time_end);
|
||||
if (start !== null && year < start) return false;
|
||||
if (end !== null && year > end) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Chuẩn hóa wiki snapshot để so sánh dirty-state ổn định, không phụ thuộc thứ tự mảng.
|
||||
export function normalizeWikisForCompare(input: WikiSnapshot[] | null | undefined) {
|
||||
const list = Array.isArray(input) ? input : [];
|
||||
return list
|
||||
.filter((w) => w && typeof w.id === "string" && w.id.trim().length > 0)
|
||||
.filter((w) => {
|
||||
if (w.source === "ref") return true;
|
||||
if (w.operation === "create" || w.operation === "update" || w.operation === "delete") return true;
|
||||
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) => ({
|
||||
id: w.id,
|
||||
source: w.source,
|
||||
title: typeof w.title === "string" ? w.title.trim() : "",
|
||||
slug: typeof w.slug === "string" ? w.slug : null,
|
||||
doc: w.doc === null ? null : typeof w.doc === "string" ? w.doc.trim() : null,
|
||||
}))
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
// Chuẩn hóa entity snapshot để phát hiện thay đổi name/description/source.
|
||||
export function normalizeEntitiesForCompare(input: EntitySnapshot[] | null | undefined) {
|
||||
const list = Array.isArray(input) ? input : [];
|
||||
return list
|
||||
.filter((e) => e && (typeof e.id === "string" || typeof e.id === "number"))
|
||||
.map((e) => ({
|
||||
id: String(e.id),
|
||||
source: e.source,
|
||||
name: typeof e.name === "string" ? e.name.trim() : "",
|
||||
description: e.description == null ? null : String(e.description),
|
||||
time_start: normalizeTimelineYearValue(e.time_start),
|
||||
time_end: normalizeTimelineYearValue(e.time_end),
|
||||
}))
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
// Chuẩn hóa binding entity-wiki để dirty check không bị nhiễu bởi thứ tự.
|
||||
export function normalizeEntityWikiLinksForCompare(
|
||||
input: Array<{ entity_id: string; wiki_id: string; operation?: string }> | null | undefined
|
||||
) {
|
||||
const list = Array.isArray(input) ? input : [];
|
||||
return list
|
||||
.filter((l) => l && typeof l.entity_id === "string" && typeof l.wiki_id === "string")
|
||||
.map((l) => ({
|
||||
entity_id: l.entity_id,
|
||||
wiki_id: l.wiki_id,
|
||||
operation: l.operation === "delete" ? "delete" : "binding",
|
||||
}))
|
||||
.sort((a, b) => (a.entity_id + a.wiki_id).localeCompare(b.entity_id + b.wiki_id));
|
||||
}
|
||||
|
||||
// Chuẩn hóa replay để phát hiện thay đổi script/target geometry.
|
||||
export function normalizeReplaysForCompare(input: BattleReplay[] | null | undefined) {
|
||||
const list = Array.isArray(input) ? input : [];
|
||||
return list
|
||||
.filter((replay) => replay && typeof replay.geometry_id === "string" && replay.geometry_id.trim().length > 0)
|
||||
.map((replay) => ({
|
||||
id: typeof replay.id === "string" ? replay.id : replay.geometry_id,
|
||||
geometry_id: replay.geometry_id,
|
||||
target_geometry_ids: normalizeReplayTargetGeometryIdsForCompare(
|
||||
replay.target_geometry_ids,
|
||||
replay.geometry_id
|
||||
),
|
||||
detail: Array.isArray(replay.detail) ? replay.detail : [],
|
||||
}))
|
||||
.sort((a, b) => a.geometry_id.localeCompare(b.geometry_id));
|
||||
}
|
||||
|
||||
// Bảo toàn geometry chính ở vị trí đầu và loại bỏ id trùng trong replay target list.
|
||||
function normalizeReplayTargetGeometryIdsForCompare(
|
||||
input: string[] | null | undefined,
|
||||
geometryId: string
|
||||
) {
|
||||
const orderedIds: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const pushId = (rawId: string | number | null | undefined) => {
|
||||
if (rawId == null) return;
|
||||
const id = String(rawId).trim();
|
||||
if (!id || seen.has(id)) return;
|
||||
seen.add(id);
|
||||
orderedIds.push(id);
|
||||
};
|
||||
|
||||
pushId(geometryId);
|
||||
for (const rawId of input || []) pushId(rawId);
|
||||
return orderedIds;
|
||||
}
|
||||
|
||||
// Validate tối thiểu geometry trả về từ search trước khi đưa vào draft.
|
||||
export function normalizeGeoSearchGeometry(value: unknown): Geometry | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const geometry = value as Record<string, unknown>;
|
||||
if (typeof geometry.type !== "string") return null;
|
||||
if (!("coordinates" in geometry)) return null;
|
||||
return value as Geometry;
|
||||
}
|
||||
|
||||
// Chuẩn hóa danh sách binding id từ API search GEO.
|
||||
export function normalizeGeoSearchBindingIds(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const deduped: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const rawId of value) {
|
||||
if (typeof rawId !== "string" && typeof rawId !== "number") continue;
|
||||
const id = String(rawId).trim();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
deduped.push(id);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
@@ -43,6 +43,7 @@ export function useFeatureCommands(options: Options) {
|
||||
setEntityFormStatus,
|
||||
} = options;
|
||||
|
||||
// Áp metadata GEO (type/time/binding) cho toàn bộ selectedFeatures.
|
||||
const applyGeometryMetadata = useCallback(async (): Promise<{ ok: boolean; error?: string }> => {
|
||||
if (!selectedFeatures || selectedFeatures.length === 0) {
|
||||
const msg = "Hãy chọn ít nhất một geometry trước.";
|
||||
@@ -50,12 +51,6 @@ export function useFeatureCommands(options: Options) {
|
||||
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);
|
||||
@@ -90,6 +85,7 @@ export function useFeatureCommands(options: Options) {
|
||||
setIsEntitySubmitting,
|
||||
]);
|
||||
|
||||
// Áp danh sách entity đã chọn vào toàn bộ selectedFeatures.
|
||||
const applyEntitiesToSelectedGeometry = useCallback(async () => {
|
||||
if (!selectedFeatures || selectedFeatures.length === 0) {
|
||||
setEntityFormStatus("Hãy chọn ít nhất một geometry trước.");
|
||||
|
||||
@@ -1,7 +1,131 @@
|
||||
import { redirect } from "next/navigation";
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ApiError } from "@/uhm/api/http";
|
||||
import { fetchProjects, type Project } from "@/uhm/api/projects";
|
||||
|
||||
export default function EditorIndexPage() {
|
||||
// Editor must be opened from a specific project (see /user/projects).
|
||||
redirect("/user/projects");
|
||||
}
|
||||
const router = useRouter();
|
||||
// State danh sách project mà user hiện tại có quyền mở trong editor.
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
// State loading cho lần tải đầu của route /editor.
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
// State lỗi hiển thị trực tiếp khi API hoặc auth không hợp lệ.
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Sắp xếp project mới cập nhật lên đầu để user mở nhanh project đang làm.
|
||||
const sortedProjects = useMemo(() => {
|
||||
return [...projects].sort((a, b) => {
|
||||
const aTime = Date.parse(a.updated_at || a.created_at || "");
|
||||
const bTime = Date.parse(b.updated_at || b.created_at || "");
|
||||
return (Number.isFinite(bTime) ? bTime : 0) - (Number.isFinite(aTime) ? aTime : 0);
|
||||
});
|
||||
}, [projects]);
|
||||
|
||||
// Route /editor là landing page: tải project list và để /editor/[id] xử lý editor đầy đủ.
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const rows = await fetchProjects();
|
||||
if (!disposed) setProjects(rows || []);
|
||||
} catch (err) {
|
||||
if (disposed) return;
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
router.replace("/signin");
|
||||
return;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "Không tải được danh sách project.");
|
||||
} finally {
|
||||
if (!disposed) setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void loadProjects();
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<main style={{ minHeight: "100vh", background: "#0b1220", color: "#e5e7eb", padding: 24 }}>
|
||||
<div style={{ maxWidth: 960, margin: "0 auto" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16 }}>
|
||||
<div>
|
||||
<h1 style={{ margin: 0, fontSize: 28, fontWeight: 800 }}>Editor</h1>
|
||||
<p style={{ margin: "8px 0 0", color: "#94a3b8", fontSize: 14 }}>
|
||||
Chọn project để mở route <code>/editor/[id]</code>.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/user/projects")}
|
||||
style={{
|
||||
border: "1px solid #334155",
|
||||
background: "#111827",
|
||||
color: "#e5e7eb",
|
||||
borderRadius: 10,
|
||||
padding: "10px 12px",
|
||||
cursor: "pointer",
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Quản lý project
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section
|
||||
style={{
|
||||
marginTop: 24,
|
||||
border: "1px solid #1f2937",
|
||||
borderRadius: 16,
|
||||
background: "#111827",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div style={{ padding: 24, color: "#94a3b8" }}>Đang tải project...</div>
|
||||
) : error ? (
|
||||
<div style={{ padding: 24, color: "#fecaca" }}>{error}</div>
|
||||
) : sortedProjects.length === 0 ? (
|
||||
<div style={{ padding: 24, color: "#94a3b8" }}>
|
||||
Chưa có project. Vào trang quản lý project để tạo mới.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "grid" }}>
|
||||
{sortedProjects.map((project) => (
|
||||
<button
|
||||
key={project.id}
|
||||
type="button"
|
||||
onClick={() => router.push(`/editor/${project.id}`)}
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: 6,
|
||||
textAlign: "left",
|
||||
border: 0,
|
||||
borderBottom: "1px solid #1f2937",
|
||||
background: "transparent",
|
||||
color: "#e5e7eb",
|
||||
padding: 16,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 15, fontWeight: 800 }}>
|
||||
{project.title || `Project ${project.id}`}
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: "#94a3b8" }}>
|
||||
{project.project_status || "ACTIVE"} · {project.id}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
|
||||
|
||||
@font-face {
|
||||
font-family: 'SF Pro Display';
|
||||
src: url('/font/SF-Pro-Display/SF-Pro-Display-Regular.otf') format('otf');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'SF Pro Display';
|
||||
src: url('/font/SF-Pro-Display/SF-Pro-Display-Medium.otf') format('otf');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'SF Pro Display';
|
||||
src: url('/font/SF-Pro-Display/SF-Pro-Display-Semibold.otf') format('otf');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'SF Pro Display';
|
||||
src: url('/font/SF-Pro-Display/SF-Pro-Display-Bold.otf') format('otf');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@import './fonts.css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--font-*: initial;
|
||||
--font-outfit: Outfit, sans-serif;
|
||||
--font-inter: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-inter: "SF Pro Display", ui-sans-serif, system-ui, sans-serif;
|
||||
|
||||
--breakpoint-*: initial;
|
||||
--breakpoint-2xsm: 375px;
|
||||
@@ -819,4 +821,12 @@ html {
|
||||
|
||||
.dark .ql-editor {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
/* Fix scrollbar jump for modals (SweetAlert2, Headless UI, etc.) */
|
||||
body.swal2-shown,
|
||||
body.modal-open,
|
||||
body[data-scroll-locked] {
|
||||
padding-right: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
@@ -1,15 +1,35 @@
|
||||
import { Inter } from 'next/font/google';
|
||||
import localFont from 'next/font/local';
|
||||
import './globals.css';
|
||||
import "flatpickr/dist/flatpickr.css";
|
||||
import { SidebarProvider } from '@/context/SidebarContext';
|
||||
import { ThemeProvider } from '@/context/ThemeContext';
|
||||
import { Toaster } from 'sonner';
|
||||
import StoreProvider from '@/store/StoreProvider';
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
|
||||
const sfPro = localFont({
|
||||
src: [
|
||||
{
|
||||
path: '../../public/font/SF-Pro-Display/SF-Pro-Display-Regular.otf',
|
||||
weight: '400',
|
||||
style: 'normal',
|
||||
},
|
||||
{
|
||||
path: '../../public/font/SF-Pro-Display/SF-Pro-Display-Medium.otf',
|
||||
weight: '500',
|
||||
style: 'normal',
|
||||
},
|
||||
{
|
||||
path: '../../public/font/SF-Pro-Display/SF-Pro-Display-Semibold.otf',
|
||||
weight: '600',
|
||||
style: 'normal',
|
||||
},
|
||||
{
|
||||
path: '../../public/font/SF-Pro-Display/SF-Pro-Display-Bold.otf',
|
||||
weight: '700',
|
||||
style: 'normal',
|
||||
},
|
||||
],
|
||||
})
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
@@ -17,7 +37,7 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={`${inter.className} dark:bg-gray-900`}>
|
||||
<body className={`${sfPro.className} dark:bg-gray-900`}>
|
||||
<StoreProvider>
|
||||
<ThemeProvider>
|
||||
<SidebarProvider>{children} <Toaster closeButton richColors position="top-right" /> </SidebarProvider>
|
||||
|
||||
@@ -5,10 +5,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Map, { type MapHoverPayload } from "@/uhm/components/Map";
|
||||
import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar";
|
||||
import TimelineBar from "@/uhm/components/ui/TimelineBar";
|
||||
import mapLayersStyles from "@/styles/MapLayers.module.css";
|
||||
import { fetchEntities, type Entity } from "@/uhm/api/entities";
|
||||
import { fetchGeometriesByBBox } from "@/uhm/api/geometries";
|
||||
import { ApiError } from "@/uhm/api/http";
|
||||
import { fetchWikiBySlug, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
|
||||
import { fetchWikiBySlug, getContentByVersionWikiId, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
|
||||
import {
|
||||
BACKGROUND_LAYER_OPTIONS,
|
||||
type BackgroundLayerId,
|
||||
@@ -46,6 +47,8 @@ type LinkEntityPopupState = {
|
||||
left: number;
|
||||
};
|
||||
|
||||
type CachedWiki = Wiki & { __fetched?: boolean };
|
||||
|
||||
const EMPTY_RELATIONS: RelationIndex = {
|
||||
entitiesById: {},
|
||||
entityGeometriesById: {},
|
||||
@@ -80,26 +83,48 @@ export default function Page() {
|
||||
total: 0,
|
||||
});
|
||||
const [hoverAnchor, setHoverAnchor] = useState<MapHoverPayload | null>(null);
|
||||
const [isMapLayersCollapsed, setIsMapLayersCollapsed] = useState(false);
|
||||
const [activeEntityId, setActiveEntityId] = useState<string | null>(null);
|
||||
const [activeWikiSlug, setActiveWikiSlug] = useState<string | null>(null);
|
||||
const [wikiCache, setWikiCache] = useState<Record<string, Wiki>>({});
|
||||
const [wikiCache, setWikiCache] = useState<Record<string, CachedWiki>>({});
|
||||
const [isActiveWikiLoading, setIsActiveWikiLoading] = useState(false);
|
||||
const [activeWikiError, setActiveWikiError] = useState<string | null>(null);
|
||||
const [linkEntityPopup, setLinkEntityPopup] = useState<LinkEntityPopupState | null>(null);
|
||||
const [entityFocusToken, setEntityFocusToken] = useState(0);
|
||||
|
||||
const [sidebarWidth, setSidebarWidth] = useState<number>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const saved = localStorage.getItem("public-wiki-sidebar-width");
|
||||
if (saved) {
|
||||
const parsed = parseInt(saved, 10);
|
||||
if (!isNaN(parsed) && parsed >= 320 && parsed <= 800) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return 420;
|
||||
});
|
||||
const [isLargeScreen, setIsLargeScreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const handleResize = () => {
|
||||
setIsLargeScreen(window.innerWidth >= 1024);
|
||||
};
|
||||
handleResize();
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
const maxDragWidth = typeof window !== "undefined"
|
||||
? Math.min(800, window.innerWidth - 340)
|
||||
: 800;
|
||||
|
||||
const timelineFetchRequestRef = useRef(0);
|
||||
const hoverHideTimerRef = useRef<number | null>(null);
|
||||
const hoverPopupHoveredRef = useRef(false);
|
||||
const linkEntityPopupRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const selectedFeature = useMemo(() => {
|
||||
if (!selectedFeatureIds || selectedFeatureIds.length === 0) return null;
|
||||
return (
|
||||
data.features.find((feature) => String(feature.properties.id) === String(selectedFeatureIds[0])) || null
|
||||
);
|
||||
}, [data.features, selectedFeatureIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedFeatureIds || selectedFeatureIds.length === 0) return;
|
||||
const stillExistIds = selectedFeatureIds.filter(id =>
|
||||
@@ -249,6 +274,10 @@ export default function Page() {
|
||||
const activeEntityGeometries = activeEntityId
|
||||
? relations.entityGeometriesById[activeEntityId] || EMPTY_FEATURE_COLLECTION
|
||||
: EMPTY_FEATURE_COLLECTION;
|
||||
const mapLabelContextDraft = useMemo(
|
||||
() => buildEntityLabelContextDraft(data, relations.geometryEntityIds, relations.entitiesById),
|
||||
[data, relations.entitiesById, relations.geometryEntityIds]
|
||||
);
|
||||
|
||||
const activeWiki = useMemo(() => {
|
||||
if (!activeWikiSlug) return null;
|
||||
@@ -335,7 +364,7 @@ export default function Page() {
|
||||
|
||||
selectEntity(onlyEntityId, {
|
||||
sourceFeatureId: selectedFeatureIds[0],
|
||||
focusMap: false,
|
||||
focusMap: true,
|
||||
selectGeometry: false,
|
||||
});
|
||||
}, [activeEntityId, relations.geometryEntityIds, selectEntity, selectedFeatureIds]);
|
||||
@@ -382,6 +411,8 @@ export default function Page() {
|
||||
};
|
||||
}, [linkEntityPopup]);
|
||||
|
||||
const cachedWiki = activeWikiSlug ? wikiCache[activeWikiSlug] : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeWikiSlug) {
|
||||
setIsActiveWikiLoading(false);
|
||||
@@ -389,10 +420,13 @@ export default function Page() {
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = wikiCache[activeWikiSlug] || relations.wikiBySlug[activeWikiSlug] || null;
|
||||
if (cached?.content) {
|
||||
if (cachedWiki && (cachedWiki.__fetched || cachedWiki.id === "__not_found__")) {
|
||||
setIsActiveWikiLoading(false);
|
||||
setActiveWikiError(null);
|
||||
if (cachedWiki.id === "__not_found__") {
|
||||
setActiveWikiError("Không tìm thấy wiki cho entity đã chọn.");
|
||||
} else {
|
||||
setActiveWikiError(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -403,9 +437,30 @@ export default function Page() {
|
||||
try {
|
||||
const row = await fetchWikiBySlug(activeWikiSlug);
|
||||
if (disposed) return;
|
||||
|
||||
if (row) {
|
||||
setWikiCache((prev) => ({ ...prev, [activeWikiSlug]: row }));
|
||||
let versionContent = row.content;
|
||||
try {
|
||||
if (row.content_sample?.[0]?.id) {
|
||||
const res = await getContentByVersionWikiId(row.content_sample[0].id);
|
||||
if (res?.data?.content) {
|
||||
versionContent = res.data.content;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch version content:", err);
|
||||
}
|
||||
|
||||
if (disposed) return;
|
||||
setWikiCache((prev) => ({
|
||||
...prev,
|
||||
[activeWikiSlug]: { ...row, content: versionContent, __fetched: true },
|
||||
}));
|
||||
} else {
|
||||
setWikiCache((prev) => ({
|
||||
...prev,
|
||||
[activeWikiSlug]: { id: "__not_found__", project_id: "" },
|
||||
}));
|
||||
setActiveWikiError("Không tìm thấy wiki cho entity đã chọn.");
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -419,7 +474,7 @@ export default function Page() {
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [activeWikiSlug, relations.wikiBySlug, wikiCache]);
|
||||
}, [activeWikiSlug, cachedWiki]);
|
||||
|
||||
const handleWikiLinkRequest = useCallback(async ({ slug, rect }: { slug: string; rect: DOMRect }) => {
|
||||
const linkedEntityIds = relations.wikiEntityIdsBySlug[slug] || [];
|
||||
@@ -465,18 +520,20 @@ export default function Page() {
|
||||
{isBackgroundVisibilityReady ? (
|
||||
<Map
|
||||
mode="select"
|
||||
draft={data}
|
||||
renderDraft={data}
|
||||
labelContextDraft={mapLabelContextDraft}
|
||||
labelTimelineYear={timelineDraftYear}
|
||||
selectedFeatureIds={selectedFeatureIds}
|
||||
onSelectFeatureIds={setSelectedFeatureIds}
|
||||
backgroundVisibility={backgroundVisibility}
|
||||
geometryVisibility={geometryVisibility}
|
||||
allowGeometryEditing={false}
|
||||
respectBindingFilter={true}
|
||||
applyGeometryBindingFilter={true}
|
||||
onHoverFeatureChange={handleMapHoverChange}
|
||||
highlightFeatures={activeEntityGeometries}
|
||||
focusFeatureCollection={activeEntityGeometries}
|
||||
focusRequestKey={entityFocusToken}
|
||||
focusPadding={activeEntityId ? { top: 84, right: 500, bottom: 116, left: 84 } : { top: 84, right: 84, bottom: 116, left: 84 }}
|
||||
focusPadding={activeEntityId && isLargeScreen ? { top: 84, right: sidebarWidth + 80, bottom: 116, left: 84 } : { top: 84, right: 84, bottom: 116, left: 84 }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-screen w-full bg-[#0b1220]" />
|
||||
@@ -490,28 +547,58 @@ export default function Page() {
|
||||
isLoading={isTimelineLoading}
|
||||
disabled={false}
|
||||
statusText={timelineStatus}
|
||||
style={activeEntityId && isLargeScreen ? { right: `${sidebarWidth + 32}px` } : undefined}
|
||||
/>
|
||||
|
||||
<div className="absolute left-4 top-4 z-20 w-[280px] max-w-[calc(100vw-2rem)] overflow-hidden rounded-xl border border-white/10 bg-slate-950/92 shadow-xl backdrop-blur">
|
||||
<div className="border-b border-white/10 px-4 py-3">
|
||||
<div className="text-sm font-semibold text-white">Map Layers</div>
|
||||
<div className="mt-1 text-xs text-slate-400">{helperText}</div>
|
||||
<div className={mapLayersStyles.panel}>
|
||||
<div className={`${mapLayersStyles.header} ${isMapLayersCollapsed ? mapLayersStyles.headerCollapsed : ""}`}>
|
||||
<div>
|
||||
<div className={mapLayersStyles.title}>Map Layers</div>
|
||||
<div className={mapLayersStyles.subtitle}>{helperText}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsMapLayersCollapsed(!isMapLayersCollapsed)}
|
||||
className={mapLayersStyles.collapseButton}
|
||||
aria-label={isMapLayersCollapsed ? "Expand Map Layers" : "Collapse Map Layers"}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={`${mapLayersStyles.collapseIcon} ${isMapLayersCollapsed ? mapLayersStyles.iconRotated : ""}`}
|
||||
>
|
||||
<polyline points="18 15 12 9 6 15" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 px-4 py-4">
|
||||
<div className={`${mapLayersStyles.content} ${isMapLayersCollapsed ? mapLayersStyles.contentCollapsed : ""}`}>
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between text-[11px] uppercase tracking-[0.08em] text-slate-500">
|
||||
<div className={mapLayersStyles.sectionHeader}>
|
||||
<span>Background</span>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={handleShowAllBackgroundLayers} className="text-slate-300 transition hover:text-white">
|
||||
<div className={mapLayersStyles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleShowAllBackgroundLayers}
|
||||
className={mapLayersStyles.actionButton}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button type="button" onClick={handleHideAllBackgroundLayers} className="text-slate-300 transition hover:text-white">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleHideAllBackgroundLayers}
|
||||
className={mapLayersStyles.actionButton}
|
||||
>
|
||||
Off
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className={mapLayersStyles.listContainer}>
|
||||
{BACKGROUND_LAYER_OPTIONS.map((layer) => {
|
||||
const active = Boolean(backgroundVisibility[layer.id]);
|
||||
return (
|
||||
@@ -519,12 +606,58 @@ export default function Page() {
|
||||
key={layer.id}
|
||||
type="button"
|
||||
onClick={() => handleToggleBackgroundLayer(layer.id)}
|
||||
className={`rounded-md border px-2.5 py-1 text-xs transition ${active
|
||||
? "border-sky-400/40 bg-sky-500/10 text-sky-200"
|
||||
: "border-white/10 bg-white/[0.03] text-slate-400 hover:text-slate-200"
|
||||
}`}
|
||||
className={`${mapLayersStyles.listItem} ${
|
||||
active ? mapLayersStyles.listItemActiveSky : mapLayersStyles.listItemInactive
|
||||
}`}
|
||||
>
|
||||
{layer.label}
|
||||
<div className={mapLayersStyles.listItemLeft}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={mapLayersStyles.itemHashIcon}
|
||||
>
|
||||
<line x1="4" y1="9" x2="20" y2="9" />
|
||||
<line x1="4" y1="15" x2="20" y2="15" />
|
||||
<line x1="10" y1="3" x2="8" y2="21" />
|
||||
<line x1="16" y1="3" x2="14" y2="21" />
|
||||
</svg>
|
||||
<span className={mapLayersStyles.itemName}>{layer.label}</span>
|
||||
</div>
|
||||
|
||||
{active ? (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={mapLayersStyles.eyeIcon}
|
||||
>
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={mapLayersStyles.eyeIconInactive}
|
||||
>
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
|
||||
<line x1="1" y1="1" x2="23" y2="23" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -532,10 +665,10 @@ export default function Page() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 text-[11px] uppercase tracking-[0.08em] text-slate-500">
|
||||
<div className={mapLayersStyles.sectionTitle}>
|
||||
Geometry
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className={mapLayersStyles.listContainer}>
|
||||
{GEO_TYPE_KEYS.map((typeKey) => {
|
||||
const active = geometryVisibility[typeKey] !== false;
|
||||
return (
|
||||
@@ -548,12 +681,60 @@ export default function Page() {
|
||||
[typeKey]: prev[typeKey] === false,
|
||||
}));
|
||||
}}
|
||||
className={`rounded-md border px-2.5 py-1 text-xs capitalize transition ${active
|
||||
? "border-emerald-400/40 bg-emerald-500/10 text-emerald-200"
|
||||
: "border-white/10 bg-white/[0.03] text-slate-400 hover:text-slate-200"
|
||||
}`}
|
||||
className={`${mapLayersStyles.listItem} ${
|
||||
active ? mapLayersStyles.listItemActiveEmerald : mapLayersStyles.listItemInactive
|
||||
}`}
|
||||
>
|
||||
{typeKey.replaceAll("_", " ")}
|
||||
<div className={mapLayersStyles.listItemLeft}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={mapLayersStyles.itemHashIcon}
|
||||
>
|
||||
<line x1="4" y1="9" x2="20" y2="9" />
|
||||
<line x1="4" y1="15" x2="20" y2="15" />
|
||||
<line x1="10" y1="3" x2="8" y2="21" />
|
||||
<line x1="16" y1="3" x2="14" y2="21" />
|
||||
</svg>
|
||||
<span className={mapLayersStyles.itemName}>
|
||||
{typeKey.replaceAll("_", " ")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{active ? (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={mapLayersStyles.eyeIcon}
|
||||
>
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={mapLayersStyles.eyeIconInactive}
|
||||
>
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
|
||||
<line x1="1" y1="1" x2="23" y2="23" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -626,7 +807,7 @@ export default function Page() {
|
||||
) : null}
|
||||
|
||||
{activeEntity ? (
|
||||
<aside className="absolute bottom-4 right-4 top-4 z-20 w-[420px] max-w-[calc(100vw-2rem)]">
|
||||
<aside className="absolute bottom-4 right-4 top-4 z-20 max-w-[calc(100vw-2rem)]">
|
||||
<PublicWikiSidebar
|
||||
entity={activeEntity}
|
||||
wiki={activeWiki}
|
||||
@@ -637,8 +818,12 @@ export default function Page() {
|
||||
setActiveWikiSlug(null);
|
||||
setActiveWikiError(null);
|
||||
setLinkEntityPopup(null);
|
||||
setSelectedFeatureIds([]);
|
||||
}}
|
||||
onWikiLinkRequest={handleWikiLinkRequest}
|
||||
sidebarWidth={sidebarWidth}
|
||||
onSidebarWidthChange={setSidebarWidth}
|
||||
maxDragWidth={maxDragWidth}
|
||||
/>
|
||||
</aside>
|
||||
) : null}
|
||||
@@ -768,6 +953,46 @@ function normalizeRelationArrays(target: Record<string, string[]>) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildEntityLabelContextDraft(
|
||||
draft: FeatureCollection,
|
||||
geometryEntityIds: Record<string, string[]>,
|
||||
entitiesById: Record<string, Entity>
|
||||
): FeatureCollection {
|
||||
if (!draft.features.length) return draft;
|
||||
|
||||
return {
|
||||
...draft,
|
||||
features: draft.features.map((feature) => {
|
||||
const entityIds = geometryEntityIds[String(feature.properties.id)] || [];
|
||||
if (!entityIds.length) return feature;
|
||||
|
||||
const candidates = entityIds.map((id) => {
|
||||
const entity = entitiesById[id] || null;
|
||||
const name = String(entity?.name || id).trim();
|
||||
if (!name) return null;
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
time_start: entity?.time_start ?? null,
|
||||
time_end: entity?.time_end ?? null,
|
||||
};
|
||||
}).filter((candidate) => candidate !== null);
|
||||
|
||||
return {
|
||||
...feature,
|
||||
properties: {
|
||||
...feature.properties,
|
||||
entity_id: entityIds[0] || null,
|
||||
entity_ids: entityIds,
|
||||
entity_name: candidates[0]?.name || null,
|
||||
entity_names: candidates.map((candidate) => candidate.name),
|
||||
entity_label_candidates: candidates,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function clampNumber(value: number, min: number, max: number): number {
|
||||
if (!Number.isFinite(value)) return min;
|
||||
if (value < min) return min;
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function LandingPage() {
|
||||
|
||||
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">
|
||||
<div className="relative min-h-screen max-w-[1200px] mx-auto 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
|
||||
@@ -61,27 +61,13 @@ export default function LandingPage() {
|
||||
<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">
|
||||
<header className="sticky top-0 w-full px-6 py-4 flex justify-between items-center backdrop-blur-sm bg-[#FDFBF7]/70 z-40 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">
|
||||
<main className="max-w-6xl mx-auto px-6 pt-8 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">
|
||||
|
||||
@@ -17,6 +17,7 @@ import "yet-another-react-lightbox/plugins/captions.css";
|
||||
import Image from "next/image";
|
||||
import { URL_MEDIA } from "../../../../../api";
|
||||
import { MediaItem } from "@/components/tables/MediaTable";
|
||||
import StickyHeader from "@/components/ui/StickyHeader";
|
||||
|
||||
export default function ApplicationDetailPage() {
|
||||
const application = useSelector(
|
||||
@@ -132,26 +133,14 @@ export default function ApplicationDetailPage() {
|
||||
});
|
||||
};
|
||||
|
||||
// console.log("Application Detail:", application);
|
||||
const path =[
|
||||
{name: "Thư viện", href:"/user/library/"}
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto p-6 bg-white dark:bg-zinc-900 rounded-2xl shadow-sm border dark:border-zinc-800">
|
||||
<div className="flex justify-between items-center mb-8 border-b dark:border-zinc-800 pb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold dark:text-zinc-50 tracking-tight">
|
||||
Chi tiết đơn đăng ký
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex items-center gap-1.5 px-2 py-1 rounded-md backdrop-blur-md border ${config.container}`}
|
||||
>
|
||||
<span className={`text-[12px] font-bold uppercase tracking-wider`}>
|
||||
{application.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<>
|
||||
<StickyHeader header={`Chi tiết đơn đăng ký`} paths={path} />
|
||||
<div className="max-w-5xl mx-auto mb-8 p-6 bg-white dark:bg-zinc-900 rounded-2xl shadow-sm border dark:border-zinc-800">
|
||||
<div className="space-y-10">
|
||||
<section>
|
||||
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest mb-4 block">
|
||||
@@ -219,7 +208,7 @@ export default function ApplicationDetailPage() {
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="group flex items-center gap-2 px-8 py-3 bg-red-100 hover:bg-red-500 text-red-600 hover:text-white dark:bg-red-950/20 dark:hover:bg-red-600 dark:text-red-400 dark:hover:text-white text-sm font-black rounded-xl transition-all duration-300 disabled:opacity-50"
|
||||
className="group flex items-center gap-2 px-8 py-3 bg-red-100 hover:bg-red-500 text-red-600 hover:text-white text-sm font-black rounded-4xl transition-all duration-300 disabled:opacity-50"
|
||||
>
|
||||
{isDeleting ? "ĐANG XỬ LÝ..." : "XÓA"}
|
||||
</button>
|
||||
@@ -291,6 +280,7 @@ export default function ApplicationDetailPage() {
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div></>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import StickyHeader from "@/components/ui/StickyHeader";
|
||||
import AccountDetails from "@/components/user-profile/AccountDetails";
|
||||
import UserInfoCard from "@/components/user-profile/UserInfoCard";
|
||||
import UserMetaCard from "@/components/user-profile/UserMetaCard";
|
||||
@@ -31,11 +32,9 @@ export default function Profile() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] lg:p-6">
|
||||
<h3 className="mb-5 text-lg font-semibold text-gray-800 dark:text-white/90 lg:mb-7">
|
||||
Profile
|
||||
</h3>
|
||||
<div className="space-y-6">
|
||||
<StickyHeader header={`Thông tin tài khoản`} />
|
||||
<div className="md:px-12 flex mx-auto ">
|
||||
<div className="xl:max-w-82 pr-4 border-r border-gray-300 md:max-w-72">
|
||||
<UserMetaCard data={user ?? {}} />
|
||||
<UserInfoCard data={{ ...user, openEdit: true }} />
|
||||
<AccountDetails data={user ?? {}} />
|
||||
|
||||
@@ -9,7 +9,6 @@ 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,
|
||||
}: {
|
||||
@@ -31,26 +30,21 @@ export default function AdminLayout({
|
||||
}, [])
|
||||
|
||||
|
||||
// Dynamic class for main content margin based on sidebar state
|
||||
const mainContentMargin = isMobileOpen
|
||||
? "ml-0"
|
||||
: isExpanded || isHovered
|
||||
? "lg:ml-[290px]"
|
||||
: "lg:ml-[0px]";
|
||||
: "lg:ml-[88px]";
|
||||
|
||||
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>
|
||||
{/* <AppHeader /> */}
|
||||
<div className="mx-auto p-4 pt-0 max-w-[1240px]">{children}</div>
|
||||
</div>
|
||||
<ChatbotWidget />
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { apiGetCurrentUserApplications, apiGetCurrentUserMedia } from "@/service
|
||||
import MediaLibrary from "@/components/user-profile/Media";
|
||||
import { Application } from "@/interface/historian";
|
||||
import Loading from "@/app/loading";
|
||||
import StickyHeader from "@/components/ui/StickyHeader";
|
||||
|
||||
export default function LibraryPage() {
|
||||
const [mediaData, setMediaData] = useState<MediaDto | null>(null);
|
||||
@@ -37,34 +38,33 @@ export default function LibraryPage() {
|
||||
const hasNoData = (!mediaData?.data || mediaData.data.length === 0) && applications.length === 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50/50 p-4 dark:bg-zinc-950 lg:p-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-gray-900 dark:text-white">
|
||||
Thư viện
|
||||
</h1>
|
||||
<div className="min-h-screen bg-gray-50/50 dark:bg-zinc-950">
|
||||
|
||||
<StickyHeader header="Thư viện của tôi" />
|
||||
|
||||
<div className="px-4 pb-4 lg:px-8 lg:pb-8">
|
||||
{hasNoData ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-gray-200 py-24 text-center">
|
||||
<p className="text-lg font-medium text-gray-500">
|
||||
Chưa có nội dung nào trong thư viện
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-12">
|
||||
{(mediaData?.data?.length ?? 0) > 0 && (
|
||||
<section>
|
||||
<MediaLibrary data={mediaData!} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{applications.length > 0 && (
|
||||
<section>
|
||||
<ApplicationLibrary applications={applications} />
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasNoData ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-gray-200 py-24 text-center dark:border-zinc-800">
|
||||
<p className="text-lg font-medium text-gray-500 dark:text-gray-400">
|
||||
Chưa có nội dung nào trong thư viện
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-12">
|
||||
{(mediaData?.data?.length ?? 0) > 0 && (
|
||||
<section>
|
||||
<MediaLibrary data={mediaData!} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{applications.length > 0 && (
|
||||
<section>
|
||||
<ApplicationLibrary applications={applications} />
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,20 +9,20 @@ import DemographicCard from "@/components/ecommerce/DemographicCard";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title:
|
||||
"Admin Dashboard",
|
||||
"Home Page",
|
||||
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">
|
||||
<div className="grid grid-cols-12 gap-4 md:gap-6 2xl:gap-8">
|
||||
<div className="col-span-12 space-y-6 xl:col-span-7 2xl:col-span-8">
|
||||
<EcommerceMetrics />
|
||||
|
||||
<MonthlySalesChart />
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 xl:col-span-5">
|
||||
<div className="col-span-12 xl:col-span-5 2xl:col-span-4">
|
||||
<MonthlyTarget />
|
||||
</div>
|
||||
|
||||
@@ -30,11 +30,11 @@ export default function Ecommerce() {
|
||||
<StatisticsChart />
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 xl:col-span-5">
|
||||
<div className="col-span-12 xl:col-span-5 2xl:col-span-4">
|
||||
<DemographicCard />
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 xl:col-span-7">
|
||||
<div className="col-span-12 xl:col-span-7 2xl:col-span-8">
|
||||
<RecentOrders />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,8 @@ 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";
|
||||
import StickyHeader from "@/components/ui/StickyHeader";
|
||||
import { PencilIcon } from "@/icons";
|
||||
|
||||
type TabType = "overview" | "members" | "settings";
|
||||
|
||||
@@ -216,12 +218,12 @@ export default function ProjectDetailsPage() {
|
||||
);
|
||||
|
||||
// console.log(project)
|
||||
const path =[
|
||||
{name: "Quản lý dự án", href:"/user/projects/"}
|
||||
]
|
||||
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" }]}
|
||||
/>
|
||||
<StickyHeader header={`Chi tiết dự án`} paths={path} />
|
||||
<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">
|
||||
@@ -308,11 +310,9 @@ export default function ProjectDetailsPage() {
|
||||
))}
|
||||
|
||||
<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 size="sm" variant="primary" className="rounded-4xl!" onClick={() => router.push(`/editor/${id}`)}>
|
||||
<PencilIcon />
|
||||
Editor
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
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 { toast } from "sonner";
|
||||
import { useModal } from "@/hooks/useModal";
|
||||
@@ -11,28 +10,64 @@ import { Modal } from "@/components/ui/modal";
|
||||
import Button from "@/components/ui/button/Button";
|
||||
import Label from "@/components/form/Label";
|
||||
import Badge from "@/components/ui/badge/Badge";
|
||||
import { CreateProjectPayload, Project } from "@/interface/project";
|
||||
import { apiCreateProject, apiCreateProjectCommit, apiGetProjectCommits, getCurrentProject } from "@/service/projectService";
|
||||
import { normalizeEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
||||
import type { EditorSnapshot } from "@/uhm/types/projects";
|
||||
import { CreateProjectPayload, Project, ProjectMember } from "@/interface/project";
|
||||
import {
|
||||
apiCreateProject,
|
||||
apiCreateProjectCommit,
|
||||
apiGetProjectCommits,
|
||||
getCurrentProject,
|
||||
} from "@/service/projectService";
|
||||
import { normalizeEditorSnapshot, toApiEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
||||
import type { EditorSnapshot, ProjectCommit } from "@/uhm/types/projects";
|
||||
import StickyHeader from "@/components/ui/StickyHeader";
|
||||
|
||||
export type ProjectSortColumn = "created_at" | "updated_at" | "title";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function extractProjectCommitList(value: unknown): ProjectCommit[] {
|
||||
let rows: unknown[] = [];
|
||||
if (Array.isArray(value)) {
|
||||
rows = value;
|
||||
} else if (isRecord(value)) {
|
||||
if (Array.isArray(value.items)) {
|
||||
rows = value.items;
|
||||
} else if (Array.isArray(value.data)) {
|
||||
rows = value.data;
|
||||
} else if (isRecord(value.data) && Array.isArray(value.data.items)) {
|
||||
rows = value.data.items;
|
||||
}
|
||||
}
|
||||
return rows.filter((row): row is ProjectCommit => isRecord(row) && typeof row.id === "string");
|
||||
}
|
||||
|
||||
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 [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 [formData, setFormData] = useState<CreateProjectPayload>({
|
||||
title: "",
|
||||
description: "",
|
||||
status: "PRIVATE",
|
||||
});
|
||||
const importJsonInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [importSnapshot, setImportSnapshot] = useState<EditorSnapshot | null>(null);
|
||||
const [importSnapshotName, setImportSnapshotName] = useState<string | null>(null);
|
||||
const [importSnapshot, setImportSnapshot] = useState<EditorSnapshot | null>(
|
||||
null,
|
||||
);
|
||||
const [importSnapshotName, setImportSnapshotName] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
@@ -51,9 +86,17 @@ export default function ProjectsPage() {
|
||||
fetchProjects();
|
||||
}, []);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
// 1. Cập nhật lại hàm handleChange để đảm bảo bắt giá trị chính xác từ thẻ select
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<
|
||||
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
||||
>,
|
||||
) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: value, // Đảm bảo value từ select được gán chính xác vào key status
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCreateProject = async (e: React.FormEvent) => {
|
||||
@@ -62,17 +105,39 @@ export default function ProjectsPage() {
|
||||
toast.warning("Vui lòng nhập tên dự án!");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
// Bước 1: Luôn tạo project trước
|
||||
const created = await apiCreateProject(formData);
|
||||
const projectId = created?.data?.id;
|
||||
toast.success("Tạo dự án mới thành công!");
|
||||
|
||||
if (!projectId) {
|
||||
toast.error("Tạo dự án thất bại: không nhận được ID dự án.");
|
||||
setIsSubmitting(false); // Dừng sớm nếu không có ID
|
||||
return;
|
||||
}
|
||||
|
||||
// Bước 2: Nếu có snapshot, tạo commit ban đầu từ JSON
|
||||
if (importSnapshot) {
|
||||
const snapshot = toApiEditorSnapshot(importSnapshot);
|
||||
await apiCreateProjectCommit(projectId, {
|
||||
edit_summary: `Init project from ${importSnapshotName || "JSON"}`,
|
||||
snapshot_json: snapshot,
|
||||
});
|
||||
toast.success("Tạo dự án từ JSON thành công!");
|
||||
} else {
|
||||
toast.success("Tạo dự án mới thành công!");
|
||||
}
|
||||
|
||||
// Bước 3: Dọn dẹp state và chuyển hướng
|
||||
closeModal();
|
||||
setFormData({ title: "", description: "", project_status: "PRIVATE" });
|
||||
setFormData({ title: "", description: "", status: "PRIVATE" });
|
||||
setImportSnapshot(null);
|
||||
setImportSnapshotName(null);
|
||||
fetchProjects();
|
||||
if (projectId) router.push(`/editor/${projectId}`);
|
||||
if (importJsonInputRef.current) importJsonInputRef.current.value = "";
|
||||
fetchProjects();
|
||||
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.");
|
||||
@@ -81,6 +146,47 @@ export default function ProjectsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
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 = await apiGetProjectCommits(projectId);
|
||||
const commits = extractProjectCommitList(res);
|
||||
const head =
|
||||
commits.find((c) => 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 handlePickImportJson = () => {
|
||||
importJsonInputRef.current?.click();
|
||||
};
|
||||
@@ -97,88 +203,13 @@ export default function ProjectsPage() {
|
||||
}
|
||||
setImportSnapshot(normalized);
|
||||
setImportSnapshotName(file.name);
|
||||
toast.success("Đã nạp JSON snapshot. Bấm 'Tạo với JSON' để khởi tạo dự án.");
|
||||
toast.success("Đã nạp JSON snapshot. Bấm 'Tạo dự án' để hoàn tất.");
|
||||
} catch (err) {
|
||||
console.error("Import JSON failed", err);
|
||||
toast.error("Không đọc được file JSON.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateProjectWithJson = async () => {
|
||||
if (!formData.title.trim()) {
|
||||
toast.warning("Vui lòng nhập tên dự án!");
|
||||
return;
|
||||
}
|
||||
if (!importSnapshot) {
|
||||
toast.warning("Chưa chọn JSON snapshot.");
|
||||
handlePickImportJson();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const created = await apiCreateProject(formData);
|
||||
const projectId = created?.data?.id;
|
||||
if (!projectId) {
|
||||
toast.error("Tạo dự án thất bại: thiếu project id.");
|
||||
return;
|
||||
}
|
||||
await apiCreateProjectCommit(projectId, {
|
||||
edit_summary: "Init project from JSON",
|
||||
snapshot_json: importSnapshot as any,
|
||||
} as any);
|
||||
toast.success("Tạo dự án (kèm JSON) thành công!");
|
||||
closeModal();
|
||||
setFormData({ title: "", description: "", project_status: "PRIVATE" });
|
||||
setImportSnapshot(null);
|
||||
setImportSnapshotName(null);
|
||||
if (importJsonInputRef.current) importJsonInputRef.current.value = "";
|
||||
fetchProjects();
|
||||
router.push(`/editor/${projectId}`);
|
||||
} catch (error) {
|
||||
console.error("Lỗi tạo dự án với JSON:", error);
|
||||
toast.error("Có lỗi xảy ra khi tạo dự án với JSON.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportHeadSnapshot = async (project: Project) => {
|
||||
const projectId = String(project.id || "").trim();
|
||||
if (!projectId) return;
|
||||
const headCommitId = project.latest_commit_id ? String(project.latest_commit_id) : "";
|
||||
if (!headCommitId) {
|
||||
toast.warning("Dự án chưa có head commit để export.");
|
||||
return;
|
||||
}
|
||||
setIsExportingProjectId(projectId);
|
||||
try {
|
||||
const res: any = await apiGetProjectCommits(projectId);
|
||||
const rawList = res?.data?.items ?? res?.data ?? res?.items ?? [];
|
||||
const commits = Array.isArray(rawList) ? rawList : [];
|
||||
const head = commits.find((c: any) => String(c?.id || "") === headCommitId) || null;
|
||||
const snapshot = head?.snapshot_json ?? null;
|
||||
if (!snapshot) {
|
||||
toast.error("Không tìm thấy snapshot_json của head commit.");
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(snapshot, null, 2)], { type: "application/json;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `project-${projectId}-head-${headCommitId}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("Đã export JSON snapshot.");
|
||||
} catch (err) {
|
||||
console.error("Export snapshot failed", err);
|
||||
toast.error("Export thất bại.");
|
||||
} finally {
|
||||
setIsExportingProjectId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSort = (column: ProjectSortColumn) => {
|
||||
if (sortBy === column) {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||
@@ -188,12 +219,9 @@ export default function ProjectsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const sortedProjects = [...projects].sort((a: any, b: any) => {
|
||||
let valA = a[sortBy];
|
||||
let valB = b[sortBy];
|
||||
|
||||
if (!valA) valA = "";
|
||||
if (!valB) valB = "";
|
||||
const sortedProjects = [...projects].sort((a, b) => {
|
||||
const valA = String(a[sortBy] || "");
|
||||
const valB = String(b[sortBy] || "");
|
||||
|
||||
if (valA < valB) return sortOrder === "asc" ? -1 : 1;
|
||||
if (valA > valB) return sortOrder === "asc" ? 1 : -1;
|
||||
@@ -216,23 +244,47 @@ export default function ProjectsPage() {
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case "PUBLIC":
|
||||
return <Badge size="sm" variant="light" color="success">PUBLIC</Badge>;
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="success">
|
||||
PUBLIC
|
||||
</Badge>
|
||||
);
|
||||
case "PRIVATE":
|
||||
return <Badge size="sm" variant="light" color="warning">PRIVATE</Badge>;
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="warning">
|
||||
PRIVATE
|
||||
</Badge>
|
||||
);
|
||||
case "ARCHIVE":
|
||||
return <Badge size="sm" variant="light" color="light">ARCHIVE</Badge>;
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="light">
|
||||
ARCHIVE
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return <Badge size="sm" variant="light" color="dark">{status}</Badge>;
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="dark">
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const SortButton = ({ column, label }: { column: ProjectSortColumn; label: string }) => {
|
||||
const SortButton = ({
|
||||
column,
|
||||
label,
|
||||
}: {
|
||||
column: ProjectSortColumn;
|
||||
label: string;
|
||||
}) => {
|
||||
const isActive = sortBy === column;
|
||||
return (
|
||||
<button
|
||||
onClick={() => handleSort(column)}
|
||||
className={`flex items-center gap-1 text-sm font-medium hover:text-blue-500 transition-colors ${
|
||||
isActive ? "text-blue-600 dark:text-blue-400" : "text-gray-500 dark:text-gray-400"
|
||||
isActive
|
||||
? "text-blue-600 dark:text-blue-400"
|
||||
: "text-gray-500 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
<span>{label}</span>
|
||||
@@ -246,15 +298,22 @@ export default function ProjectsPage() {
|
||||
return `JSON: ${importSnapshotName}`;
|
||||
}, [importSnapshotName]);
|
||||
|
||||
// const path =[
|
||||
// {name: "Thư viện", href:"/user/library/"}
|
||||
// ]
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto pb-10">
|
||||
<PageBreadcrumb pageTitle="Quản lý dự án" />
|
||||
|
||||
<div className="mx-auto pb-10">
|
||||
{/* <PageBreadcrumb pageTitle="Quản lý dự án" /> */}
|
||||
<StickyHeader header={`Quản lý dự án`} />
|
||||
<div className="mt-6">
|
||||
<ComponentCard
|
||||
title="Danh sách dự án"
|
||||
headerAction={
|
||||
<Button size="sm" onClick={openModal} className="bg-brand-500 hover:bg-brand-600 text-white">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={openModal}
|
||||
className="bg-brand-500 hover:bg-brand-600 text-white rounded-4xl! "
|
||||
>
|
||||
+ Tạo dự án mới
|
||||
</Button>
|
||||
}
|
||||
@@ -273,16 +332,22 @@ export default function ProjectsPage() {
|
||||
<div className="flex-1 pr-4">
|
||||
<SortButton column="title" label="Tên dự án" />
|
||||
</div>
|
||||
<div className="w-48 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">Trạng thái</div>
|
||||
<div className="w-48 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">Thành viên</div>
|
||||
<div className="w-48 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
Trạng thái
|
||||
</div>
|
||||
<div className="w-48 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
Thành viên
|
||||
</div>
|
||||
<div className="w-32 px-4">
|
||||
<SortButton column="updated_at" label="Cập nhật" />
|
||||
</div>
|
||||
<div className="w-48 px-4 text-sm font-medium text-gray-500 dark:text-gray-400 text-right">Thao tác</div>
|
||||
<div className="w-48 px-4 text-sm font-medium text-gray-500 dark:text-gray-400 text-right">
|
||||
Thao tác
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
|
||||
{sortedProjects.map((project: any) => (
|
||||
{sortedProjects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="group flex items-center p-5 hover:bg-gray-50 dark:hover:bg-[#161b22]/50 transition-colors"
|
||||
@@ -290,49 +355,83 @@ export default function ProjectsPage() {
|
||||
<div className="flex-1 pr-4 min-w-0">
|
||||
<div className="items-center gap-3 mb-1.5">
|
||||
<h3
|
||||
onClick={() => router.push(`/user/projects/${project.id}`)}
|
||||
onClick={() =>
|
||||
router.push(`/user/projects/${project.id}`)
|
||||
}
|
||||
className="font-semibold text-blue-600 dark:text-[#58a6ff] truncate cursor-pointer hover:underline"
|
||||
>
|
||||
{project.title}
|
||||
</h3>
|
||||
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-[#8b949e]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{project.user?.avatar_url ? (
|
||||
<Image src={project.user.avatar_url} alt="avatar" width={16} height={16} className="rounded-full object-cover" />
|
||||
<Image
|
||||
src={project.user.avatar_url}
|
||||
alt="avatar"
|
||||
width={16}
|
||||
height={16}
|
||||
className="rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-4 h-4 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<span className="text-[8px] font-bold text-gray-500 dark:text-gray-300">
|
||||
{project.user?.display_name?.charAt(0)?.toUpperCase() || "U"}
|
||||
{project.user?.display_name
|
||||
?.charAt(0)
|
||||
?.toUpperCase() || "U"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="truncate max-w-[150px]">{project.user?.display_name || "Unknown"}</span>
|
||||
<span className="truncate max-w-[150px]">
|
||||
{project.user?.display_name || "Unknown"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="w-48 px-4 shrink-0">
|
||||
{getStatusBadge(project.project_status)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="w-48 px-4 shrink-0">
|
||||
<div className="flex -space-x-2 overflow-hidden">
|
||||
{project.members && project.members.length > 0 ? (
|
||||
<>
|
||||
{project.members.slice(0, 4).map((m: any, index: number) =>
|
||||
m.avatar_url ? (
|
||||
<Image key={index} src={m.avatar_url} alt={m.display_name} width={32} height={32} title={m.display_name} className="inline-block w-8 h-8 rounded-full object-cover ring-2 ring-white dark:ring-[#0d1117]" />
|
||||
) : (
|
||||
<div key={index} title={m.display_name} className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 ring-2 ring-white dark:ring-[#0d1117]">
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-gray-300">{m.display_name?.charAt(0)?.toUpperCase() || "U"}</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{project.members
|
||||
.slice(0, 4)
|
||||
.map((m: ProjectMember, index: number) =>
|
||||
m.avatar_url ? (
|
||||
<Image
|
||||
key={index}
|
||||
src={m.avatar_url}
|
||||
alt={m.display_name}
|
||||
width={32}
|
||||
height={32}
|
||||
title={m.display_name}
|
||||
className="inline-block w-8 h-8 rounded-full object-cover ring-2 ring-white dark:ring-[#0d1117]"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
key={index}
|
||||
title={m.display_name}
|
||||
className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 ring-2 ring-white dark:ring-[#0d1117]"
|
||||
>
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-gray-300">
|
||||
{m.display_name
|
||||
?.charAt(0)
|
||||
?.toUpperCase() || "U"}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
{project.members.length > 4 && (
|
||||
<div title="Những người khác" className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-800 ring-2 ring-white dark:ring-[#0d1117] z-10">
|
||||
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">+{project.members.length - 4}</span>
|
||||
<div
|
||||
title="Những người khác"
|
||||
className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-800 ring-2 ring-white dark:ring-[#0d1117] z-10"
|
||||
>
|
||||
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
+{project.members.length - 4}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -346,16 +445,29 @@ export default function ProjectsPage() {
|
||||
{formatDate(project.updated_at)}
|
||||
</div>
|
||||
|
||||
<div className="w-48 px-4 shrink-0 flex justify-end gap-2">
|
||||
<div className="w-48 px-4 shrink-0 flex justify-end gap-2">
|
||||
<div className="relative group/btn1 inline-flex">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="!p-0 w-9 h-9 flex items-center justify-center"
|
||||
onClick={() => router.push(`/editor/${project.id}`)}
|
||||
onClick={() =>
|
||||
router.push(`/editor/${project.id}`)
|
||||
}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-4 h-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
<span className="absolute -top-8 left-1/2 -translate-x-1/2 scale-0 rounded bg-gray-900 px-2 py-1 text-[11px] font-medium text-white opacity-0 transition-all group-hover/btn1:scale-100 group-hover/btn1:opacity-100 z-50 pointer-events-none whitespace-nowrap shadow-sm dark:bg-gray-700">
|
||||
@@ -368,46 +480,51 @@ export default function ProjectsPage() {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="!p-0 w-9 h-9 flex items-center justify-center"
|
||||
disabled={isExportingProjectId === String(project.id)}
|
||||
disabled={
|
||||
isExportingProjectId === String(project.id)
|
||||
}
|
||||
onClick={() => handleExportHeadSnapshot(project)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-4 h-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m.75 12l3 3m0 0l3-3m-3 3v-6m-1.5-9H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m.75 12l3 3m0 0l3-3m-3 3v-6m-1.5-9H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
<span className="absolute -top-8 left-1/2 -translate-x-1/2 scale-0 rounded bg-gray-900 px-2 py-1 text-[11px] font-medium text-white opacity-0 transition-all group-hover/btn2:scale-100 group-hover/btn2:opacity-100 z-50 pointer-events-none whitespace-nowrap shadow-sm dark:bg-gray-700">
|
||||
Export JSON
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative group/btn3 inline-flex">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="!p-0 w-9 h-9 flex items-center justify-center"
|
||||
onClick={() => router.push(`/editor/${project.id}?only=wiki`)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-4 h-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
</Button>
|
||||
<span className="absolute -top-8 left-1/2 -translate-x-1/2 scale-0 rounded bg-gray-900 px-2 py-1 text-[11px] font-medium text-white opacity-0 transition-all group-hover/btn3:scale-100 group-hover/btn3:opacity-100 z-50 pointer-events-none whitespace-nowrap shadow-sm dark:bg-gray-700">
|
||||
Wiki Editor
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : !isLoading && (
|
||||
<div className="py-20 text-center">
|
||||
<p className="text-gray-500 dark:text-gray-400">Bạn chưa có dự án nào.</p>
|
||||
<Button size="sm" onClick={openModal} className="mt-4 bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 hover:bg-gray-200">
|
||||
Tạo dự án đầu tiên
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
!isLoading && (
|
||||
<div className="py-20 text-center">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Bạn chưa có dự án nào.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={openModal}
|
||||
className="mt-4 bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 hover:bg-gray-200"
|
||||
>
|
||||
Tạo dự án đầu tiên
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
@@ -415,10 +532,14 @@ export default function ProjectsPage() {
|
||||
|
||||
<Modal isOpen={isOpen} onClose={closeModal} className="max-w-[500px] m-4">
|
||||
<div className="p-6 bg-white rounded-3xl dark:bg-gray-900">
|
||||
<h3 className="mb-5 text-xl font-bold text-gray-800 dark:text-white/90">Tạo dự án mới</h3>
|
||||
<h3 className="mb-5 text-xl font-bold text-gray-800 dark:text-white/90">
|
||||
Tạo dự án mới
|
||||
</h3>
|
||||
<form onSubmit={handleCreateProject} className="flex flex-col gap-5">
|
||||
<div>
|
||||
<Label>Tên dự án <span className="text-red-500">*</span></Label>
|
||||
<Label>
|
||||
Tên dự án <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<input
|
||||
type="text"
|
||||
name="title"
|
||||
@@ -432,14 +553,29 @@ export default function ProjectsPage() {
|
||||
<div>
|
||||
<Label>Trạng thái</Label>
|
||||
<select
|
||||
name="project_status"
|
||||
value={formData.project_status}
|
||||
name="status"
|
||||
value={formData.status || "PRIVATE"} // Fallback về PRIVATE nếu undefined
|
||||
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"
|
||||
className="h-11 w-full rounded-xl border border-gray-200 bg-white 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:bg-gray-900 dark:focus:border-brand-800"
|
||||
>
|
||||
<option value="PRIVATE">Riêng tư (Private)</option>
|
||||
<option value="PUBLIC">Công khai (Public)</option>
|
||||
<option value="ARCHIVE">Lưu trữ (Archive)</option>
|
||||
<option
|
||||
value="PRIVATE"
|
||||
className="text-gray-800 dark:text-white bg-white dark:bg-gray-900"
|
||||
>
|
||||
Riêng tư (Private)
|
||||
</option>
|
||||
<option
|
||||
value="PUBLIC"
|
||||
className="text-gray-800 dark:text-white bg-white dark:bg-gray-900"
|
||||
>
|
||||
Công khai (Public)
|
||||
</option>
|
||||
<option
|
||||
value="ARCHIVE"
|
||||
className="text-gray-800 dark:text-white bg-white dark:bg-gray-900"
|
||||
>
|
||||
Lưu trữ (Archive)
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -456,7 +592,12 @@ export default function ProjectsPage() {
|
||||
<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}>
|
||||
<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">
|
||||
@@ -468,22 +609,31 @@ export default function ProjectsPage() {
|
||||
type="file"
|
||||
accept="application/json"
|
||||
className="hidden"
|
||||
onChange={(e) => handleImportJsonFile(e.target.files?.[0] || null)}
|
||||
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
|
||||
size="sm"
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
>
|
||||
Hủy
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="bg-gray-900 hover:bg-gray-800 text-white"
|
||||
onClick={handleCreateProjectWithJson}
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white"
|
||||
>
|
||||
Tạo với JSON
|
||||
{isSubmitting
|
||||
? "Đang xử lý..."
|
||||
: importSnapshot
|
||||
? "Tạo với JSON"
|
||||
: "Tạo dự án"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -491,4 +641,4 @@ export default function ProjectsPage() {
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { toast } from "sonner";
|
||||
import { newId } from "@/uhm/lib/utils/id";
|
||||
import Swal from "sweetalert2";
|
||||
import { PresignedUrlResponse } from "@/interface/media";
|
||||
import StickyHeader from "@/components/ui/StickyHeader";
|
||||
|
||||
type PendingFile = {
|
||||
id: string;
|
||||
@@ -197,42 +198,41 @@ export default function RoleUpgrade() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto pb-20">
|
||||
<PageBreadcrumb pageTitle="Đăng ký trở thành Nhà sử học" />
|
||||
|
||||
<div className="flex items-center justify-between bg-white dark:bg-gray-900 p-2 rounded-xl border border-gray-200 dark:border-gray-800 mb-6">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPreview(false)}
|
||||
className={`px-4 py-2 text-sm font-bold rounded-lg transition-all ${!showPreview ? "bg-blue-600 text-white" : "text-gray-500 hover:bg-gray-100"}`}
|
||||
>
|
||||
Soạn thảo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPreview(true)}
|
||||
className={`px-4 py-2 text-sm font-bold rounded-lg transition-all ${showPreview ? "bg-blue-600 text-white" : "text-gray-500 hover:bg-gray-100"}`}
|
||||
>
|
||||
Xem trước giao diện
|
||||
</button>
|
||||
<div className="mx-auto">
|
||||
<StickyHeader header="Đăng ký trở thành Nhà sử học" />
|
||||
<div className="max-w-4xl mx-auto pb-20">
|
||||
<div className="flex items-center justify-between bg-white dark:bg-gray-900 p-2 rounded-xl border border-gray-200 dark:border-gray-800 mb-6">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPreview(false)}
|
||||
className={`px-4 py-2 text-sm font-bold rounded-lg transition-all ${!showPreview ? "bg-blue-600 text-white" : "text-gray-500 hover:bg-gray-100"}`}
|
||||
>
|
||||
Soạn thảo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPreview(true)}
|
||||
className={`px-4 py-2 text-sm font-bold rounded-lg transition-all ${showPreview ? "bg-blue-600 text-white" : "text-gray-500 hover:bg-gray-100"}`}
|
||||
>
|
||||
Xem trước giao diện
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-[10px] font-black uppercase text-gray-400 mr-2 tracking-widest">
|
||||
{showPreview ? "Preview Mode" : "Edit Mode"}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-black uppercase text-gray-400 mr-2 tracking-widest">
|
||||
{showPreview ? "Preview Mode" : "Edit Mode"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="relative min-h-[400px]">
|
||||
{!showPreview ? (
|
||||
<RichTextEditor value={content} onChange={handleContentChange} />
|
||||
) : (
|
||||
<div className="bg-white border border-zinc-200 rounded-xl shadow-inner overflow-hidden min-h-[400px]">
|
||||
{content ? (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
onLoad={handleIframeLoad}
|
||||
srcDoc={`
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="relative min-h-[400px]">
|
||||
{!showPreview ? (
|
||||
<RichTextEditor value={content} onChange={handleContentChange} />
|
||||
) : (
|
||||
<div className="bg-white border border-zinc-200 rounded-xl shadow-inner overflow-hidden min-h-[400px]">
|
||||
{content ? (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
onLoad={handleIframeLoad}
|
||||
srcDoc={`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -253,115 +253,118 @@ export default function RoleUpgrade() {
|
||||
</body>
|
||||
</html>
|
||||
`}
|
||||
title="Preview"
|
||||
className="w-full border-none flex-1 transition-all duration-300"
|
||||
sandbox="allow-same-origin"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-[400px] text-zinc-400 italic bg-gray-50 dark:bg-gray-900">
|
||||
<p>Chưa có nội dung để xem trước</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-5 bg-white border border-gray-200 rounded-2xl dark:border-gray-800 dark:bg-gray-900">
|
||||
<label className="block mb-3 text-sm font-semibold text-gray-700 dark:text-white">
|
||||
Loại hồ sơ xác minh <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={verifyType}
|
||||
onChange={(e) => setVerifyType(e.target.value)}
|
||||
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:border-blue-500 dark:bg-gray-800 dark:border-gray-700"
|
||||
>
|
||||
<option value="OTHER">Tài liệu khác (Other)</option>
|
||||
<option value="ID_CARD">Thẻ nhận dạng (ID Card)</option>
|
||||
<option value="EDUCATION">Bằng cấp giáo dục (Education)</option>
|
||||
<option value="EXPERT">Chứng nhận chuyên gia (Expert)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Upload Files */}
|
||||
<div className="p-5 bg-white border border-gray-200 rounded-2xl dark:border-gray-800 dark:bg-gray-900">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">Tài liệu đính kèm</h3>
|
||||
<label className="cursor-pointer px-4 py-2 rounded-lg border border-dashed border-blue-500 text-blue-500 hover:bg-blue-50 transition">
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
accept="image/*,.pdf,.doc,.docx"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<span>+ Thêm tệp</span>
|
||||
</label>
|
||||
title="Preview"
|
||||
className="w-full border-none flex-1 transition-all duration-300"
|
||||
sandbox="allow-same-origin"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-[400px] text-zinc-400 italic bg-gray-50 dark:bg-gray-900">
|
||||
<p>Chưa có nội dung để xem trước</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pendingFiles.length > 0 && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-5 gap-4">
|
||||
{pendingFiles.map((item) => {
|
||||
const docStyle = getDocumentStyle(item.extension);
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="relative group aspect-square border rounded-xl overflow-hidden bg-gray-50"
|
||||
>
|
||||
{item.type === "image" ? (
|
||||
<Image
|
||||
src={item.previewUrl}
|
||||
alt="preview"
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center h-full ${docStyle.bg} p-2`}
|
||||
>
|
||||
<span className={`text-xs font-bold ${docStyle.color}`}>
|
||||
{item.extension.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-[10px] truncate w-full text-center mt-1">
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => removePendingFile(item.id, e)}
|
||||
className="absolute top-1 right-1 bg-red-500 text-white rounded-full p-1 opacity-0 group-hover:opacity-100 transition"
|
||||
>
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="p-5 bg-white border border-gray-200 rounded-2xl dark:border-gray-800 dark:bg-gray-900">
|
||||
<label className="block mb-3 text-sm font-semibold text-gray-700 dark:text-white">
|
||||
Loại hồ sơ xác minh <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={verifyType}
|
||||
onChange={(e) => setVerifyType(e.target.value)}
|
||||
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:border-blue-500 dark:bg-gray-800 dark:border-gray-700"
|
||||
>
|
||||
<option value="OTHER">Tài liệu khác (Other)</option>
|
||||
<option value="ID_CARD">Thẻ nhận dạng (ID Card)</option>
|
||||
<option value="EDUCATION">Bằng cấp giáo dục (Education)</option>
|
||||
<option value="EXPERT">Chứng nhận chuyên gia (Expert)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Upload Files */}
|
||||
<div className="p-5 bg-white border border-gray-200 rounded-2xl dark:border-gray-800 dark:bg-gray-900">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">Tài liệu đính kèm</h3>
|
||||
<label className="cursor-pointer px-4 py-2 rounded-lg border border-dashed border-blue-500 text-blue-500 hover:bg-blue-50 transition">
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
accept="image/*,.pdf,.doc,.docx"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<span>+ Thêm tệp</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingFiles.length > 0 && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-5 gap-4">
|
||||
{pendingFiles.map((item) => {
|
||||
const docStyle = getDocumentStyle(item.extension);
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="relative group aspect-square border rounded-xl overflow-hidden bg-gray-50"
|
||||
>
|
||||
{item.type === "image" ? (
|
||||
<Image
|
||||
src={item.previewUrl}
|
||||
alt="preview"
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center h-full ${docStyle.bg} p-2`}
|
||||
>
|
||||
<span
|
||||
className={`text-xs font-bold ${docStyle.color}`}
|
||||
>
|
||||
{item.extension.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-[10px] truncate w-full text-center mt-1">
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => removePendingFile(item.id, e)}
|
||||
className="absolute top-1 right-1 bg-red-500 text-white rounded-full p-1 opacity-0 group-hover:opacity-100 transition"
|
||||
>
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || isPreparingFiles}
|
||||
className={`w-full py-4 text-white font-bold rounded-2xl shadow-lg transition-all ${isSubmitting || isPreparingFiles ? "bg-gray-400" : "bg-blue-600 hover:bg-blue-700"}`}
|
||||
>
|
||||
{isSubmitting ? "Đang xử lý..." : "Gửi yêu cầu nâng cấp"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || isPreparingFiles}
|
||||
className={`w-full py-4 text-white font-bold rounded-2xl shadow-lg transition-all ${isSubmitting || isPreparingFiles ? "bg-gray-400" : "bg-blue-600 hover:bg-blue-700"}`}
|
||||
>
|
||||
{isSubmitting ? "Đang xử lý..." : "Gửi yêu cầu nâng cấp"}
|
||||
</button>
|
||||
<Lightbox
|
||||
index={lightboxIndex}
|
||||
open={lightboxIndex >= 0}
|
||||
close={() => setLightboxIndex(-1)}
|
||||
slides={slides}
|
||||
plugins={[Zoom, Captions]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Lightbox
|
||||
index={lightboxIndex}
|
||||
open={lightboxIndex >= 0}
|
||||
close={() => setLightboxIndex(-1)}
|
||||
slides={slides}
|
||||
plugins={[Zoom, Captions]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,554 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
||||
import ComponentCard from "@/components/common/ComponentCard";
|
||||
import Button from "@/components/ui/button/Button";
|
||||
import Badge from "@/components/ui/badge/Badge";
|
||||
import Label from "@/components/form/Label";
|
||||
|
||||
import { EditorContent, useEditor, type JSONContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import TiptapLink from "@tiptap/extension-link";
|
||||
|
||||
const STORAGE_KEY = "uhm_wiki_draft_v1";
|
||||
|
||||
type TocItem = {
|
||||
level: number;
|
||||
text: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
type WikiDraft = {
|
||||
schema_version: 1;
|
||||
title: string;
|
||||
doc: JSONContent;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
function slugify(input: string) {
|
||||
return input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
function textFromNode(node: any): string {
|
||||
if (!node) return "";
|
||||
if (node.type === "text") return node.text || "";
|
||||
if (Array.isArray(node.content)) return node.content.map(textFromNode).join("");
|
||||
return "";
|
||||
}
|
||||
|
||||
function buildToc(doc: JSONContent | null): TocItem[] {
|
||||
if (!doc) return [];
|
||||
const out: TocItem[] = [];
|
||||
const seen = new Map<string, number>();
|
||||
|
||||
const walk = (node: any) => {
|
||||
if (!node) return;
|
||||
if (node.type === "heading") {
|
||||
const level = Number(node.attrs?.level || 1);
|
||||
const text = textFromNode(node).trim();
|
||||
if (text) {
|
||||
const base = slugify(text) || "heading";
|
||||
const n = (seen.get(base) || 0) + 1;
|
||||
seen.set(base, n);
|
||||
const slug = n === 1 ? base : `${base}-${n}`;
|
||||
out.push({ level, text, slug });
|
||||
}
|
||||
}
|
||||
if (Array.isArray(node.content)) node.content.forEach(walk);
|
||||
};
|
||||
|
||||
walk(doc);
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderInlineText(node: any, key: string) {
|
||||
if (node.type !== "text") return null;
|
||||
const marks: any[] = Array.isArray(node.marks) ? node.marks : [];
|
||||
let el: React.ReactNode = node.text || "";
|
||||
|
||||
for (const m of marks) {
|
||||
if (m.type === "bold") el = <strong key={`${key}-b`}>{el}</strong>;
|
||||
else if (m.type === "italic") el = <em key={`${key}-i`}>{el}</em>;
|
||||
else if (m.type === "link") {
|
||||
const href = String(m.attrs?.href || "#");
|
||||
el = (
|
||||
<a
|
||||
key={`${key}-a`}
|
||||
href={href}
|
||||
target={m.attrs?.target || "_blank"}
|
||||
rel="noreferrer"
|
||||
className="text-brand-600 dark:text-brand-400 underline underline-offset-2"
|
||||
>
|
||||
{el}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <span key={key}>{el}</span>;
|
||||
}
|
||||
|
||||
function renderDoc(node: any, keyPrefix = "n", toc: TocItem[] = []) : React.ReactNode {
|
||||
if (!node) return null;
|
||||
const type = node.type;
|
||||
const content: any[] = Array.isArray(node.content) ? node.content : [];
|
||||
|
||||
if (type === "doc") {
|
||||
return <>{content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))}</>;
|
||||
}
|
||||
|
||||
if (type === "paragraph") {
|
||||
return (
|
||||
<p key={keyPrefix} className="text-sm leading-6 text-gray-800 dark:text-gray-200">
|
||||
{content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "heading") {
|
||||
const level = Number(node.attrs?.level || 1);
|
||||
const text = textFromNode(node).trim();
|
||||
const slug = toc.find((t) => t.text === text)?.slug || slugify(text);
|
||||
const cls =
|
||||
level === 1
|
||||
? "text-2xl font-bold"
|
||||
: level === 2
|
||||
? "text-xl font-semibold"
|
||||
: "text-lg font-semibold";
|
||||
return (
|
||||
<div key={keyPrefix} className="mt-5">
|
||||
<div id={slug} className={`${cls} text-gray-900 dark:text-gray-100`}>
|
||||
{content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "bulletList") {
|
||||
return (
|
||||
<ul key={keyPrefix} className="list-disc pl-5 text-sm text-gray-800 dark:text-gray-200">
|
||||
{content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "orderedList") {
|
||||
return (
|
||||
<ol key={keyPrefix} className="list-decimal pl-5 text-sm text-gray-800 dark:text-gray-200">
|
||||
{content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "listItem") {
|
||||
return <li key={keyPrefix}>{content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))}</li>;
|
||||
}
|
||||
|
||||
if (type === "blockquote") {
|
||||
return (
|
||||
<blockquote
|
||||
key={keyPrefix}
|
||||
className="border-l-4 border-gray-200 dark:border-gray-800 pl-4 text-sm text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))}
|
||||
</blockquote>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "codeBlock") {
|
||||
const code = content.map(textFromNode).join("");
|
||||
return (
|
||||
<pre
|
||||
key={keyPrefix}
|
||||
className="rounded-xl border border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-[#0d1117] p-4 overflow-auto text-xs"
|
||||
>
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "hardBreak") return <br key={keyPrefix} />;
|
||||
|
||||
if (type === "text") return renderInlineText(node, keyPrefix);
|
||||
|
||||
// fallback: render children
|
||||
return <span key={keyPrefix}>{content.map((c, i) => renderDoc(c, `${keyPrefix}.${i}`, toc))}</span>;
|
||||
}
|
||||
|
||||
type ViewMode = "edit" | "split" | "preview";
|
||||
|
||||
export default function WikiEditorPage() {
|
||||
const [view, setView] = useState<ViewMode>("split");
|
||||
const [showJson, setShowJson] = useState(false);
|
||||
const [title, setTitle] = useState("Untitled wiki");
|
||||
const [docJson, setDocJson] = useState<JSONContent | null>(null);
|
||||
const [savedAt, setSavedAt] = useState<string | null>(null);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const saveTimerRef = useRef<number | null>(null);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
}),
|
||||
TiptapLink.configure({
|
||||
openOnClick: false,
|
||||
autolink: true,
|
||||
linkOnPaste: true,
|
||||
}),
|
||||
],
|
||||
content: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "Write your wiki content here." }] },
|
||||
{ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Project" }] },
|
||||
{ type: "paragraph", content: [{ type: "text", text: "Use H1/H2/H3 and the TOC will follow." }] },
|
||||
],
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
setDocJson(editor.getJSON());
|
||||
setIsDirty(true);
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
// Keep editor styling independent from whatever global typography the app uses.
|
||||
class:
|
||||
"tiptap-editor focus:outline-none min-h-[360px] px-4 py-3",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Load draft
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw) as WikiDraft;
|
||||
if (parsed && typeof parsed === "object" && parsed.schema_version === 1 && parsed.doc) {
|
||||
setTitle(parsed.title || "Untitled wiki");
|
||||
editor.commands.setContent(parsed.doc as JSONContent);
|
||||
setDocJson(parsed.doc as JSONContent);
|
||||
setSavedAt(parsed.updated_at || "loaded");
|
||||
setIsDirty(false);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [editor]);
|
||||
|
||||
const toc = useMemo(() => buildToc(docJson), [docJson]);
|
||||
|
||||
const doSaveDraft = () => {
|
||||
if (!editor) return;
|
||||
const payload: WikiDraft = {
|
||||
schema_version: 1,
|
||||
title: title.trim() || "Untitled wiki",
|
||||
doc: editor.getJSON(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
|
||||
setSavedAt(new Date().toLocaleString("vi-VN"));
|
||||
setIsDirty(false);
|
||||
};
|
||||
|
||||
// Debounced autosave
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
if (!isDirty) return;
|
||||
|
||||
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = window.setTimeout(() => {
|
||||
doSaveDraft();
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editor, isDirty, title, docJson]);
|
||||
|
||||
const can = (cmd: () => boolean) => {
|
||||
try {
|
||||
return Boolean(editor && cmd());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const setLink = () => {
|
||||
if (!editor) return;
|
||||
const prev = editor.getAttributes("link")?.href as string | undefined;
|
||||
const href = window.prompt("Link URL", prev || "https://");
|
||||
if (href == null) return;
|
||||
const next = href.trim();
|
||||
if (!next.length) {
|
||||
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().extendMarkRange("link").setLink({ href: next }).run();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto pb-10">
|
||||
<PageBreadcrumb pageTitle="Wiki editor" paths={[{ name: "User", href: "/user" }]} />
|
||||
|
||||
<style jsx global>{`
|
||||
.tiptap-editor {
|
||||
color: inherit;
|
||||
}
|
||||
.tiptap-editor p {
|
||||
margin: 0.5rem 0;
|
||||
line-height: 1.65;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.tiptap-editor h1 {
|
||||
margin: 1rem 0 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.tiptap-editor h2 {
|
||||
margin: 0.9rem 0 0.4rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.tiptap-editor h3 {
|
||||
margin: 0.8rem 0 0.35rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.tiptap-editor ul,
|
||||
.tiptap-editor ol {
|
||||
margin: 0.6rem 0;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
.tiptap-editor li {
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
.tiptap-editor blockquote {
|
||||
margin: 0.75rem 0;
|
||||
padding-left: 0.75rem;
|
||||
border-left: 4px solid rgba(148, 163, 184, 0.55);
|
||||
color: rgba(100, 116, 139, 1);
|
||||
}
|
||||
.dark .tiptap-editor blockquote {
|
||||
border-left-color: rgba(71, 85, 105, 1);
|
||||
color: rgba(148, 163, 184, 1);
|
||||
}
|
||||
.tiptap-editor code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
"Liberation Mono", "Courier New", monospace;
|
||||
font-size: 0.85em;
|
||||
padding: 0.1rem 0.25rem;
|
||||
border-radius: 0.35rem;
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
}
|
||||
.tiptap-editor pre {
|
||||
margin: 0.8rem 0;
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(226, 232, 240, 1);
|
||||
background: rgba(248, 250, 252, 1);
|
||||
overflow: auto;
|
||||
}
|
||||
.dark .tiptap-editor pre {
|
||||
border-color: rgba(30, 41, 59, 1);
|
||||
background: rgba(13, 17, 23, 1);
|
||||
}
|
||||
.tiptap-editor pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
.tiptap-editor a {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.tiptap-editor hr {
|
||||
margin: 1rem 0;
|
||||
border: none;
|
||||
border-top: 1px solid rgba(226, 232, 240, 1);
|
||||
}
|
||||
.dark .tiptap-editor hr {
|
||||
border-top-color: rgba(30, 41, 59, 1);
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
<ComponentCard title="Wiki">
|
||||
<div className="p-4 flex flex-col gap-4">
|
||||
<div>
|
||||
<Label>Title</Label>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => {
|
||||
setTitle(e.target.value);
|
||||
setIsDirty(true);
|
||||
}}
|
||||
className="h-11 w-full rounded-xl border border-gray-200 bg-transparent px-4 py-2.5 text-sm text-gray-800 outline-none focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:text-white/90 dark:focus:border-brand-800"
|
||||
placeholder="Wiki title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge size="sm" variant="light" color="info">
|
||||
TipTap
|
||||
</Badge>
|
||||
{isDirty ? (
|
||||
<Badge size="sm" variant="light" color="warning">
|
||||
Unsaved
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" variant="light" color="success">
|
||||
Saved
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => setShowJson((v) => !v)}>
|
||||
{showJson ? "Hide JSON" : "Show JSON"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Last save: {savedAt || "-"}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-gray-600 dark:text-gray-300 mb-2">TOC</div>
|
||||
{toc.length === 0 ? (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">No headings</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{toc.map((t) => (
|
||||
<Link
|
||||
key={t.slug}
|
||||
href={`#${t.slug}`}
|
||||
className={`text-xs hover:underline text-gray-700 dark:text-gray-300 ${
|
||||
t.level === 1 ? "font-semibold" : t.level === 2 ? "pl-3" : "pl-6"
|
||||
}`}
|
||||
title={t.text}
|
||||
>
|
||||
{t.text}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-1 flex gap-2">
|
||||
<Button size="sm" className="bg-brand-500 hover:bg-brand-600 text-white" onClick={doSaveDraft} disabled={!editor}>
|
||||
Save now
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
setSavedAt(null);
|
||||
setIsDirty(false);
|
||||
}}
|
||||
>
|
||||
Clear draft
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
|
||||
<div className="lg:col-span-3 flex flex-col gap-6">
|
||||
<ComponentCard title="Editor">
|
||||
<div className="p-4">
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
<Button size="sm" variant="outline" onClick={() => setView("edit")}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setView("split")}>
|
||||
Split
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setView("preview")}>
|
||||
Preview
|
||||
</Button>
|
||||
|
||||
<div className="w-px h-7 bg-gray-200 dark:bg-gray-800 mx-1" />
|
||||
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleBold().run()} disabled={!can(() => editor!.can().toggleBold())}>
|
||||
B
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleItalic().run()} disabled={!can(() => editor!.can().toggleItalic())}>
|
||||
I
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleHeading({ level: 1 }).run()}>
|
||||
H1
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()}>
|
||||
H2
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()}>
|
||||
H3
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleBulletList().run()}>
|
||||
Bullets
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleOrderedList().run()}>
|
||||
Numbers
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleBlockquote().run()}>
|
||||
Quote
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleCodeBlock().run()}>
|
||||
Code
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={setLink} disabled={!editor}>
|
||||
Link
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().undo().run()} disabled={!can(() => editor!.can().undo())}>
|
||||
Undo
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().redo().run()} disabled={!can(() => editor!.can().redo())}>
|
||||
Redo
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={view === "split" ? "grid grid-cols-1 lg:grid-cols-2 gap-4" : ""}>
|
||||
{view !== "preview" ? (
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-[#0d1117]">
|
||||
{editor ? <EditorContent editor={editor} /> : <div className="p-4 text-sm text-gray-500">Loading editor...</div>}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{view !== "edit" ? (
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-[#0d1117] p-4">
|
||||
<div className="text-xs font-semibold text-gray-600 dark:text-gray-300 mb-2">
|
||||
Preview
|
||||
</div>
|
||||
{renderDoc(docJson, "p", toc)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
|
||||
{showJson ? (
|
||||
<ComponentCard title="Document JSON">
|
||||
<div className="p-4">
|
||||
<pre className="text-xs whitespace-pre-wrap break-words rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-[#0d1117] p-4 overflow-auto max-h-[520px]">
|
||||
{JSON.stringify({ title: title.trim() || "Untitled wiki", doc: docJson }, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,23 +13,13 @@ type TocItem = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function tiptapJsonToPlainText(node: unknown): string {
|
||||
if (node == null) return "";
|
||||
if (typeof node === "string") return node;
|
||||
if (Array.isArray(node)) return node.map(tiptapJsonToPlainText).join("");
|
||||
|
||||
if (isRecord(node)) {
|
||||
if (node.type === "text" && typeof node.text === "string") return node.text;
|
||||
if (node.type === "hardBreak") return "\n";
|
||||
if ("content" in node) return tiptapJsonToPlainText(node.content);
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
type WikiVersionRow = {
|
||||
id: string;
|
||||
title?: string;
|
||||
created_at?: string;
|
||||
content?: string;
|
||||
isCurrent: boolean;
|
||||
};
|
||||
|
||||
function escapeHtml(input: string): string {
|
||||
return input
|
||||
@@ -44,22 +34,7 @@ function normalizeWikiContentToHtml(raw: string | null | undefined): string {
|
||||
const value = String(raw || "").trim();
|
||||
if (!value.length) return "";
|
||||
|
||||
// New format: HTML string.
|
||||
if (value[0] === "<") return value;
|
||||
|
||||
// Legacy format: Tiptap JSON string.
|
||||
if (value[0] === "{") {
|
||||
try {
|
||||
const json: unknown = JSON.parse(value);
|
||||
const text = tiptapJsonToPlainText(json).trim();
|
||||
if (!text.length) return "";
|
||||
return `<p>${escapeHtml(text).replace(/\n/g, "<br/>")}</p>`;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown plaintext: treat as plain text.
|
||||
return `<p>${escapeHtml(value).replace(/\n/g, "<br/>")}</p>`;
|
||||
}
|
||||
|
||||
@@ -191,15 +166,15 @@ export default function WikiBySlugClient({ slug }: { slug: string }) {
|
||||
const hidePreviewTimerRef = useRef<number | null>(null);
|
||||
const previewCacheRef = useRef<Map<string, { title: string; quote: string | null }>>(new Map());
|
||||
|
||||
const allVersions = useMemo(() => {
|
||||
const allVersions = useMemo<WikiVersionRow[]>(() => {
|
||||
if (!wiki) return [];
|
||||
const current = {
|
||||
const current: WikiVersionRow = {
|
||||
id: wiki.id,
|
||||
created_at: wiki.updated_at,
|
||||
content: wiki.content,
|
||||
isCurrent: true,
|
||||
};
|
||||
const history = (wiki.content_sample || []).map(s => ({ ...s, isCurrent: false }));
|
||||
const history: WikiVersionRow[] = (wiki.content_sample || []).map((s) => ({ ...s, isCurrent: false }));
|
||||
const uniqueHistory = history.filter(h => h.id !== current.id);
|
||||
const combined = [current, ...uniqueHistory];
|
||||
return combined
|
||||
@@ -498,8 +473,10 @@ export default function WikiBySlugClient({ slug }: { slug: string }) {
|
||||
title: `Phiên bản lúc ${formatDate(sample?.created_at)}`
|
||||
};
|
||||
if (sample?.isCurrent) {
|
||||
return { ...versionInfo, content: sample.content || '' };
|
||||
return { ...versionInfo, content: sample.content || "" };
|
||||
}
|
||||
|
||||
|
||||
const contentResp = await getContentByVersionWikiId(versionId);
|
||||
return { ...versionInfo, content: contentResp?.data?.content || "" };
|
||||
});
|
||||
@@ -561,7 +538,7 @@ export default function WikiBySlugClient({ slug }: { slug: string }) {
|
||||
|
||||
{viewMode === 'history' && (
|
||||
<div className="p-4">
|
||||
<h2 className="text-xl mb-4 font-normal">Lịch sử phiên bản của "{wiki.title}"</h2>
|
||||
<h2 className="text-xl mb-4 font-normal">Lịch sử phiên bản của "{wiki.title}"</h2>
|
||||
<div className="flex gap-4 items-center mb-4">
|
||||
<button onClick={handleCompareVersions} disabled={isComparing || selectedVersionsForCompare.size === 0} className="px-4 py-2 text-sm bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-gray-300">
|
||||
{isComparing ? 'Đang tải...' : `So sánh ${selectedVersionsForCompare.size} phiên bản đã chọn`}
|
||||
|
||||
@@ -73,7 +73,16 @@ const Calendar: React.FC = () => {
|
||||
|
||||
const handleEventClick = (clickInfo: EventClickArg) => {
|
||||
const event = clickInfo.event;
|
||||
setSelectedEvent(event as unknown as CalendarEvent);
|
||||
setSelectedEvent({
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
start: event.startStr,
|
||||
end: event.endStr,
|
||||
extendedProps: {
|
||||
calendar: event.extendedProps.calendar,
|
||||
},
|
||||
} as CalendarEvent);
|
||||
|
||||
setEventTitle(event.title);
|
||||
setEventStartDate(event.start?.toISOString().split("T")[0] || "");
|
||||
setEventEndDate(event.end?.toISOString().split("T")[0] || "");
|
||||
|
||||
@@ -13,7 +13,7 @@ interface BreadcrumbProps {
|
||||
|
||||
const PageBreadcrumb: React.FC<BreadcrumbProps> = ({ pageTitle, paths }) => {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-6 sticky top-0 z-40 bg-linear-to-b from-gray-50 via-gray-50/60 to-transparent backdrop-blur-xs pb-8 pt-8">
|
||||
<h2
|
||||
className="text-xl font-semibold text-gray-800 dark:text-white/90"
|
||||
x-text="pageName"
|
||||
|
||||
@@ -7,9 +7,10 @@ import "react-quill-new/dist/quill.snow.css";
|
||||
const ReactQuillEditor = dynamic(
|
||||
async () => {
|
||||
const { default: RQ } = await import("react-quill-new");
|
||||
return ({ forwardedRef, ...props }: any) => (
|
||||
return ({ forwardedRef, ...props }: { forwardedRef: React.Ref<any>; [key: string]: any }) => (
|
||||
<RQ ref={forwardedRef} {...props} />
|
||||
);
|
||||
|
||||
},
|
||||
{
|
||||
ssr: false,
|
||||
|
||||
@@ -15,10 +15,13 @@ import { IsolatedContent } from "@/components/ui/IsolatedContent";
|
||||
import { apiDeleteHistorianCV } from "@/service/historianService";
|
||||
import { statusConfig } from "@/service/handler";
|
||||
|
||||
import { Application } from "@/interface/historian";
|
||||
import { MediaItem } from "@/components/tables/MediaTable";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
application: any;
|
||||
application: Application | null;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
@@ -42,7 +45,7 @@ export default function ApplicationDetailModal({
|
||||
}
|
||||
}, [isOpen, application]);
|
||||
|
||||
const isImageFile = (file: any) => {
|
||||
const isImageFile = (file: MediaItem) => {
|
||||
const isImageMime = file.mime_type?.startsWith("image/");
|
||||
const isImageExt = /\.(jpg|jpeg|png|webp|gif)$/i.test(file.storage_key);
|
||||
return isImageMime || isImageExt;
|
||||
@@ -51,17 +54,17 @@ export default function ApplicationDetailModal({
|
||||
const mediaList = application?.media || [];
|
||||
const imageMediaOnly = mediaList.filter(isImageFile);
|
||||
|
||||
const imageSlides = imageMediaOnly.map((item: any) => ({
|
||||
const imageSlides = imageMediaOnly.map((item: MediaItem) => ({
|
||||
src: `${URL_MEDIA}${item.storage_key}`,
|
||||
title: item.original_name,
|
||||
description: `Dung lượng: ${(item.size / 1024).toFixed(2)} KB`,
|
||||
}));
|
||||
|
||||
const handleMediaClick = (item: any) => {
|
||||
const handleMediaClick = (item: MediaItem) => {
|
||||
const fileUrl = `${URL_MEDIA}${item.storage_key}`;
|
||||
if (isImageFile(item)) {
|
||||
const photoIndex = imageMediaOnly.findIndex(
|
||||
(img: any) => img.id === item.id,
|
||||
(img: MediaItem) => img.id === item.id,
|
||||
);
|
||||
setIndex(photoIndex);
|
||||
} else {
|
||||
@@ -93,7 +96,9 @@ export default function ApplicationDetailModal({
|
||||
};
|
||||
|
||||
const handleDeleteApplication = async () => {
|
||||
if (!application) return;
|
||||
await apiDeleteHistorianCV(application.id);
|
||||
|
||||
Swal.fire("Thành công!", "Hồ sơ đã được xóa.", "success");
|
||||
onRefresh();
|
||||
onClose();
|
||||
@@ -186,7 +191,7 @@ export default function ApplicationDetailModal({
|
||||
</h4>
|
||||
{mediaList.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
|
||||
{mediaList.map((media: any, idx: number) => {
|
||||
{mediaList.map((media: MediaItem, idx: number) => {
|
||||
const isImg = isImageFile(media);
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -23,7 +23,8 @@ export interface MediaItem {
|
||||
original_name: string;
|
||||
mime_type: string;
|
||||
size: number;
|
||||
file_metadata: any;
|
||||
file_metadata: Record<string, unknown> | null;
|
||||
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { Modal } from "../ui/modal";
|
||||
import UserMetaCard from "@/components/user-profile/UserMetaCard";
|
||||
import UserInfoCard from "@/components/user-profile/UserInfoCard";
|
||||
import { UserMetaCardProps } from "@/interface/user";
|
||||
|
||||
import { fullDataUser } from "@/interface/admin";
|
||||
import { useEffect, useState } from "react";
|
||||
import { MediaDto } from "@/interface/media";
|
||||
@@ -28,7 +30,17 @@ export default function UserDetailModal({
|
||||
const [mediaData, setMediaData] = useState<MediaDto | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const formattedData = { data: user };
|
||||
const formattedData: UserMetaCardProps = {
|
||||
data: user
|
||||
? {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
profile: user.profile,
|
||||
roles: user.roles as any, // UserRole and Role are slightly different, need a better fix or small cast
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.id && isOpen) {
|
||||
@@ -68,8 +80,9 @@ export default function UserDetailModal({
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 custom-scrollbar max-h-[65vh] overflow-y-auto pr-2">
|
||||
<UserMetaCard data={formattedData as any} />
|
||||
<UserInfoCard data={formattedData as any} />
|
||||
<UserMetaCard data={formattedData} />
|
||||
<UserInfoCard data={formattedData} />
|
||||
|
||||
|
||||
<div className="min-h-[150px] relative">
|
||||
{loading ? (
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
|
||||
interface BreadcrumbPath {
|
||||
name: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
interface StickyHeaderProps {
|
||||
header: string;
|
||||
paths?: BreadcrumbPath[];
|
||||
}
|
||||
|
||||
export default function StickyHeader({ header, paths }: StickyHeaderProps) {
|
||||
return (
|
||||
<div className="sticky top-0 z-40 bg-gradient-to-b from-gray-50 via-gray-50/60 to-transparent pb-8 pt-8 backdrop-blur-xs dark:from-zinc-950 dark:via-zinc-950/60">
|
||||
<div className="flex justify-between items-end">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-gray-900 dark:text-white">
|
||||
{header}
|
||||
</h1>
|
||||
{paths && paths.length > 0 && (
|
||||
<nav className="mb-3">
|
||||
<ol className="flex flex-wrap items-center gap-1.5 text-sm">
|
||||
<li>
|
||||
<Link
|
||||
className="inline-flex items-center gap-1.5 text-gray-500 transition-colors hover:text-gray-800 dark:text-gray-400 dark:hover:text-white/90"
|
||||
href="/"
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
<span className="text-gray-400 dark:text-zinc-600">
|
||||
<svg width="12" height="12" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg" className="stroke-current" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6.0765 12.667L10.2432 8.50033L6.0765 4.33366" />
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
{paths.map((path, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<li>
|
||||
<Link
|
||||
className="inline-flex items-center gap-1.5 text-gray-500 transition-colors hover:text-gray-800 dark:text-gray-400 dark:hover:text-white/90"
|
||||
href={path.href}
|
||||
>
|
||||
{path.name}
|
||||
</Link>
|
||||
</li>
|
||||
<span className="text-gray-400 dark:text-zinc-600">
|
||||
<svg width="12" height="12" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg" className="stroke-current" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6.0765 12.667L10.2432 8.50033L6.0765 4.33366" />
|
||||
</svg>
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
<li className="font-medium text-gray-800 dark:text-white/90 truncate max-w-[200px]">
|
||||
{header}
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { ChatbotPayload } from "@/interface/chatbot";
|
||||
import { apiChatbot } from "@/service/chatbotService";
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
|
||||
type Message = {
|
||||
id: string;
|
||||
@@ -67,16 +69,18 @@ export default function ChatbotWidget({
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, botMessage]);
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ message: string }>;
|
||||
const errorMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
sender: "bot",
|
||||
text:
|
||||
error?.response?.data?.message ||
|
||||
axiosError.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);
|
||||
}
|
||||
};
|
||||
@@ -91,6 +95,7 @@ export default function ChatbotWidget({
|
||||
<div className="fixed bottom-8 right-8 z-50">
|
||||
{!isOpen && (
|
||||
<button
|
||||
name="AI chat"
|
||||
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"
|
||||
>
|
||||
|
||||
@@ -7,7 +7,7 @@ import Input from "../form/input/InputField";
|
||||
import Label from "../form/Label";
|
||||
import { UserMetaCardProps } from "@/interface/user";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { EyeCloseIcon, EyeIcon } from "@/icons";
|
||||
import { EyeCloseIcon, EyeIcon, LockIcon, MailIcon } from "@/icons";
|
||||
import { apiChangePassword } from "@/service/auth";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -89,26 +89,19 @@ export default function AccountDetails({ data }: { data: UserMetaCardProps }) {
|
||||
formValues.new_password !== formValues.confirm_password;
|
||||
return (
|
||||
<>
|
||||
<div className="p-5 border border-red-200 rounded-2xl dark:border-gray-800 lg:p-6">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="border-t border-red-200 py-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-gray-800 dark:text-white/90 lg:mb-6">
|
||||
Account Details
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-7 2xl:gap-x-32">
|
||||
<div>
|
||||
<p className="mb-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Email Address
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
|
||||
<div className="">
|
||||
<div className="flex items-center gap-2">
|
||||
<MailIcon className="leading-normal text-gray-500"/>
|
||||
<p className="text-sm font-medium text-gray-600 dark:text-white/90">
|
||||
{data?.data?.email || "example@mail.com"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Password
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<LockIcon className="leading-normal text-gray-500"/>
|
||||
<p className="text-sm font-medium text-gray-600 dark:text-white/90">
|
||||
••••••••
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,10 @@ const formatFullDateTime = (dateString: string) => {
|
||||
return `${time} ${day}`;
|
||||
};
|
||||
|
||||
const processMedia = (mediaArray: any[]) => {
|
||||
import { Application } from "@/interface/historian";
|
||||
import { MediaItem } from "../tables/MediaTable";
|
||||
|
||||
const processMedia = (mediaArray: MediaItem[]) => {
|
||||
if (!mediaArray || mediaArray.length === 0) return { type: "empty" };
|
||||
const imageFiles = mediaArray.filter((file) => {
|
||||
const isImageMime = file.mime_type?.startsWith("image/");
|
||||
@@ -49,15 +52,16 @@ const processMedia = (mediaArray: any[]) => {
|
||||
export default function ApplicationList({
|
||||
applications,
|
||||
}: {
|
||||
applications: any[];
|
||||
applications: Application[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleViewDetail = (app: any) => {
|
||||
const handleViewDetail = (app: Application) => {
|
||||
dispatch(setSelectedApplication(app));
|
||||
router.push(`/user/account/applications`);
|
||||
};
|
||||
|
||||
|
||||
const StatusIcons: Record<string, React.ReactNode> = {
|
||||
APPROVED: (
|
||||
@@ -113,15 +117,12 @@ export default function ApplicationList({
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-900/50">
|
||||
<div className="mb-8 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between border-b border-gray-100 pb-5 dark:border-zinc-800">
|
||||
<h3 className="text-xl font-bold text-gray-800 dark:text-white/90">
|
||||
Hồ sơ{" "}
|
||||
<h3 className="text-xl font-bold text-gray-800 dark:text-white/90 items-center flex">
|
||||
Hồ sơ
|
||||
<span className="ml-2 text-sm font-normal text-gray-500">
|
||||
({applications.length} tệp)
|
||||
</span>
|
||||
</h3>
|
||||
{/* <div className="text-sm text-gray-500">
|
||||
Cập nhật lần cuối: {new Date().toLocaleDateString("vi-VN")}
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
{/* CHỈ SỬA DÒNG NÀY: Tăng số lượng cột (grid-cols) để các thẻ nhỏ lại */}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { URL_MEDIA } from "../../../api";
|
||||
|
||||
import { deleteMedia } from "@/service/mediaService";
|
||||
import { INITIAL_LIMIT } from "../../../constant";
|
||||
import { MediaItem } from "../tables/MediaTable";
|
||||
|
||||
export default function MediaLibrary({
|
||||
data,
|
||||
@@ -32,7 +33,7 @@ export default function MediaLibrary({
|
||||
setLocalMedia(data?.data || []);
|
||||
}, [data]);
|
||||
|
||||
const isImageFile = (file: any) => {
|
||||
const isImageFile = (file: MediaItem) => {
|
||||
const isImageMime = file.mime_type?.startsWith("image/");
|
||||
const isImageExt = /\.(jpg|jpeg|png|webp|gif)$/i.test(file.storage_key);
|
||||
return isImageMime || isImageExt;
|
||||
@@ -77,7 +78,7 @@ export default function MediaLibrary({
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemClick = (item: any, idx: number, isImage: boolean) => {
|
||||
const handleItemClick = (item: MediaItem, idx: number, isImage: boolean) => {
|
||||
if (isSelectionMode) {
|
||||
toggleItemSelection(item.id);
|
||||
} else {
|
||||
@@ -166,7 +167,7 @@ export default function MediaLibrary({
|
||||
}
|
||||
};
|
||||
|
||||
const renderItemCard = (item: any, isImage: boolean, idx: number) => {
|
||||
const renderItemCard = (item: MediaItem, isImage: boolean, idx: number) => {
|
||||
const isSelected = selectedIds.includes(item.id);
|
||||
|
||||
return (
|
||||
@@ -268,7 +269,7 @@ export default function MediaLibrary({
|
||||
{selectedIds.length > 0 && (
|
||||
<button
|
||||
onClick={handleDeleteSelected}
|
||||
className="flex items-center gap-1 rounded-lg bg-red-50 px-4 py-2 text-sm font-medium text-red-600 transition-colors hover:bg-red-100 dark:bg-red-500/10 dark:hover:bg-red-500/20"
|
||||
className="flex items-center gap-1 h-10 rounded-4xl bg-red-50 px-4 py-2 text-sm font-medium text-red-600 transition-colors hover:bg-red-100 dark:bg-red-500/10 dark:hover:bg-red-500/20"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
@@ -283,13 +284,13 @@ export default function MediaLibrary({
|
||||
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>
|
||||
Xóa ({selectedIds.length})
|
||||
Xóa {selectedIds.length}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={toggleSelectionMode}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium transition-all w-17.5 h-10 ${
|
||||
className={`rounded-4xl px-4 py-2 text-sm font-medium transition-all w-17.5 h-10 ${
|
||||
isSelectionMode
|
||||
? "bg-blue-500 text-white shadow-md hover:bg-blue-600"
|
||||
: "border border-gray-200 bg-gray-50 text-gray-700 hover:bg-gray-100 dark:border-zinc-700 dark:bg-zinc-800 dark:text-gray-200 dark:hover:bg-zinc-700"
|
||||
@@ -316,7 +317,7 @@ export default function MediaLibrary({
|
||||
<div className="mt-6 flex justify-center">
|
||||
<button
|
||||
onClick={() => setShowAllImages(!showAllImages)}
|
||||
className="rounded-lg border border-gray-200 bg-gray-50 px-5 py-2 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:border-zinc-700 dark:bg-zinc-800 dark:text-gray-300"
|
||||
className="rounded-4xl border border-gray-200 bg-gray-50 px-5 py-2 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:border-zinc-700 dark:bg-zinc-800 dark:text-gray-300"
|
||||
>
|
||||
{showAllImages
|
||||
? "Thu gọn"
|
||||
@@ -333,7 +334,6 @@ export default function MediaLibrary({
|
||||
Tài liệu ({documentFiles.length})
|
||||
</h4>
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8">
|
||||
|
||||
{displayedDocs.map((item, idx) =>
|
||||
renderItemCard(item, false, idx),
|
||||
)}
|
||||
@@ -342,7 +342,7 @@ export default function MediaLibrary({
|
||||
<div className="mt-6 flex justify-center">
|
||||
<button
|
||||
onClick={() => setShowAllDocs(!showAllDocs)}
|
||||
className="rounded-lg border border-gray-200 bg-gray-50 px-5 py-2 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:border-zinc-700 dark:bg-zinc-800 dark:text-gray-300"
|
||||
className="rounded-4xl border border-gray-200 bg-gray-50 px-5 py-2 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:border-zinc-700 dark:bg-zinc-800 dark:text-gray-300"
|
||||
>
|
||||
{showAllDocs
|
||||
? "Thu gọn"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useModal } from "../../hooks/useModal";
|
||||
import { Modal } from "../ui/modal";
|
||||
import Button from "../ui/button/Button";
|
||||
@@ -10,9 +9,10 @@ import { Profile, UserMetaCardProps } from "@/interface/user";
|
||||
import { apiUpdateUser } from "@/service/userService";
|
||||
import { toast } from "sonner";
|
||||
import Link from "next/link";
|
||||
import { AxiosError } from "axios";
|
||||
import { LinkIcon, LocationIcon, MailIcon, PhoneIcon } from "@/icons";
|
||||
|
||||
export default function UserInfoCard({ data }: { data: UserMetaCardProps }) {
|
||||
const router = useRouter();
|
||||
const { isOpen, openModal, closeModal } = useModal();
|
||||
const [formData, setFormData] = useState<Profile>({
|
||||
display_name: "",
|
||||
@@ -62,8 +62,12 @@ export default function UserInfoCard({ data }: { data: UserMetaCardProps }) {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} catch (error: any) {
|
||||
const serverResponse = error.response?.data;
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{
|
||||
status: boolean;
|
||||
message: string;
|
||||
}>;
|
||||
const serverResponse = axiosError.response?.data;
|
||||
|
||||
if (serverResponse && serverResponse.status === false) {
|
||||
const msg = serverResponse.message || "";
|
||||
@@ -77,89 +81,17 @@ export default function UserInfoCard({ data }: { data: UserMetaCardProps }) {
|
||||
toast.error("Không thể kết nối đến máy chủ hoặc lỗi hệ thống.");
|
||||
}
|
||||
|
||||
console.error("Lỗi chi tiết:", error);
|
||||
console.error("Lỗi chi tiết:", axiosError);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-5 border border-gray-200 rounded-2xl dark:border-gray-800 lg:p-6">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-gray-800 dark:text-white/90 lg:mb-6">
|
||||
Personal Information
|
||||
</h4>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-7 2xl:gap-x-32">
|
||||
<div>
|
||||
<p className="mb-2 text-xs leading-normal text-gray-500 dark:text-gray-400">
|
||||
Full Name
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
|
||||
{data.data?.profile?.display_name || "Full Name"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-xs leading-normal text-gray-500 dark:text-gray-400">
|
||||
Email address
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
|
||||
{data.data?.email || "Email address"}
|
||||
</p>
|
||||
</div>
|
||||
{data.data?.profile?.phone && (
|
||||
<div>
|
||||
<p className="mb-2 text-xs leading-normal text-gray-500 dark:text-gray-400">
|
||||
Phone
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
|
||||
{data.data?.profile?.phone || "+XXX XXX XXX"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.data?.profile?.bio && (
|
||||
<div>
|
||||
<p className="mb-2 text-xs leading-normal text-gray-500 dark:text-gray-400">
|
||||
Bio
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
|
||||
{data.data?.profile?.bio || "No bio available"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.data?.profile?.location && (
|
||||
<div>
|
||||
<p className="mb-2 text-xs leading-normal text-gray-500 dark:text-gray-400">
|
||||
Address
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
|
||||
{data.data?.profile?.location || "No location available"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{data.data?.profile?.website && (
|
||||
<div>
|
||||
<p className="mb-2 text-xs leading-normal text-gray-500 dark:text-gray-400">
|
||||
Website
|
||||
</p>
|
||||
<Link
|
||||
href={data.data?.profile?.website || "#"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium text-gray-800 dark:text-white/90 hover:text-blue-500 dark:hover:text-blue-400"
|
||||
>
|
||||
{data.data?.profile?.website || "No website"}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="py-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
{data.openEdit && (
|
||||
<button
|
||||
onClick={openModal}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-full border border-gray-300 bg-white px-4 py-3 text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 hover:text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-gray-200 lg:inline-flex lg:w-auto"
|
||||
className="flex w-full items-center justify-center gap-2 rounded-full border border-gray-300 bg-white px-4 py-3 text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 hover:text-gray-600 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-gray-200 lg:inline-flex lg:w-auto"
|
||||
>
|
||||
<svg
|
||||
className="fill-current"
|
||||
@@ -177,19 +109,65 @@ export default function UserInfoCard({ data }: { data: UserMetaCardProps }) {
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
<div>
|
||||
<div className=" gap-4 lg:grid-cols-2 lg:gap-7 2xl:gap-x-32">
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<MailIcon className="leading-normal text-gray-500" />
|
||||
|
||||
<p className="text-sm font-medium text-gray-600 ">
|
||||
{data.data?.email || "Email address"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{data.data?.profile?.phone && (
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<PhoneIcon className="w-5 h-5 opacity-60" />
|
||||
|
||||
<p className="text-sm font-medium text-gray-600 ">
|
||||
{data.data?.profile?.phone || "+XXX XXX XXX"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.data?.profile?.location && (
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<LocationIcon className="w-5 h-5 opacity-60" />
|
||||
|
||||
<p className="text-sm font-medium text-gray-600 ">
|
||||
{data.data?.profile?.location || "No location available"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.data?.profile?.website && (
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<LinkIcon className="w-5 h-5 opacity-60" />
|
||||
|
||||
<Link
|
||||
href={data.data?.profile?.website || "#"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium text-gray-600 hover:text-blue-500 dark:hover:text-blue-400"
|
||||
>
|
||||
{data.data?.profile?.website || "No website"}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal isOpen={isOpen} onClose={closeModal} className="max-w-[700px] m-4">
|
||||
<div className="no-scrollbar relative w-full max-w-[700px] overflow-y-auto rounded-3xl bg-white p-4 dark:bg-gray-900 lg:p-11">
|
||||
<div className="px-2 pr-14">
|
||||
<h4 className="mb-2 text-2xl font-semibold text-gray-800 dark:text-white/90">
|
||||
<h4 className="mb-2 text-2xl font-semibold text-gray-600 ">
|
||||
Edit Personal Information
|
||||
</h4>
|
||||
</div>
|
||||
<form className="flex flex-col" onSubmit={handleSave}>
|
||||
<div className="custom-scrollbar h-[450px] overflow-y-auto px-2 pb-3">
|
||||
<div className="mt-7">
|
||||
<h5 className="mb-5 text-lg font-medium text-gray-800 dark:text-white/90 lg:mb-6">
|
||||
<h5 className="mb-5 text-lg font-medium text-gray-600 lg:mb-6">
|
||||
Personal Information
|
||||
</h5>
|
||||
|
||||
|
||||
@@ -72,16 +72,16 @@ export default function UserMetaCard({ data }: { data: UserMetaCardProps }) {
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="p-5 border border-gray-200 rounded-2xl dark:border-gray-800 lg:p-6">
|
||||
<div className="flex flex-col gap-5 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div className="flex flex-col items-center w-full gap-6 xl:flex-row">
|
||||
<div className="">
|
||||
<div className="flex flex-col gap-5 ">
|
||||
<div className="flex flex-col items-center w-full gap-6">
|
||||
<div
|
||||
onClick={handleAvatarClick}
|
||||
className="relative w-20 h-20 overflow-hidden border border-gray-200 rounded-full cursor-pointer dark:border-gray-800 group shrink-0"
|
||||
className="relative w-[296px] h-[296px] overflow-hidden border border-gray-200 rounded-full cursor-pointer dark:border-gray-800 group shrink-0"
|
||||
>
|
||||
<Image
|
||||
width={80}
|
||||
height={80}
|
||||
width={296}
|
||||
height={296}
|
||||
src={previewImage}
|
||||
alt="avatar"
|
||||
className="object-cover w-full h-full"
|
||||
@@ -142,26 +142,23 @@ export default function UserMetaCard({ data }: { data: UserMetaCardProps }) {
|
||||
</div>
|
||||
|
||||
<div className="order-3 xl:order-2">
|
||||
<h4 className="mb-2 text-lg font-semibold text-center text-gray-800 dark:text-white/90 xl:text-left">
|
||||
{data.data?.profile?.display_name || "Full Name"}
|
||||
</h4>
|
||||
<div className="flex flex-col items-center gap-1 text-center xl:flex-row xl:gap-3 xl:text-left">
|
||||
<p className="text-sm text-blue-500 dark:text-gray-400">
|
||||
{data.data?.roles?.map((role) => role.name).join(", ") ||
|
||||
"No roles available"}
|
||||
</p>
|
||||
{data.data?.profile?.bio && (
|
||||
<>
|
||||
<div className="hidden h-3.5 w-px bg-gray-300 dark:bg-gray-700 xl:block"></div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-[450px] truncate">
|
||||
{data.data.profile.bio}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
ID: {data.data?.id}
|
||||
</span>
|
||||
<h4 className="mb-2 text-2xl font-semibold text-gray-700 text-left">
|
||||
{data.data?.profile?.display_name || "Full Name"}
|
||||
</h4>
|
||||
{data.data?.profile?.bio && (
|
||||
<p className="text-sm text-gray-500">
|
||||
{data.data.profile.bio}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
ID: <span>{data.data?.id}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
setStoredTokens,
|
||||
} from "@/auth/tokenStore"
|
||||
|
||||
const baseURL = API_URL_ROOT || "https://history-api.kain.id.vn"
|
||||
const baseURL = API_URL_ROOT
|
||||
|
||||
const api = axios.create({
|
||||
baseURL,
|
||||
@@ -24,6 +24,10 @@ const refreshApi = axios.create({
|
||||
let isRefreshing = false
|
||||
let queue: any[] = []
|
||||
|
||||
function shouldRedirectToSigninOnRefreshFailure(status?: number): boolean {
|
||||
return status === 401 || status === 404
|
||||
}
|
||||
|
||||
const processQueue = (error?: any) => {
|
||||
queue.forEach((p) => {
|
||||
if (error) p.reject(error)
|
||||
@@ -121,15 +125,15 @@ async function performRefreshAndRetry(originalRequest: any): Promise<AxiosRespon
|
||||
return refreshApi.post("/auth/refresh", {})
|
||||
}
|
||||
|
||||
let refreshRes: any = await tryCookieRefresh()
|
||||
const refreshRes: any = await tryCookieRefresh()
|
||||
|
||||
const nextTokens = extractTokensFromResponsePayload(refreshRes?.data)
|
||||
if (nextTokens) setStoredTokens(nextTokens)
|
||||
// Some backends may return only a new access token; keep refresh token.
|
||||
// Some backends may return only a new access token.
|
||||
else {
|
||||
const maybeAccess = (refreshRes?.data?.data?.access_token ?? refreshRes?.data?.access_token) as unknown
|
||||
if (typeof maybeAccess === "string" && maybeAccess.trim()) {
|
||||
if (refreshToken) setStoredTokens({ access_token: maybeAccess, refresh_token: refreshToken })
|
||||
setStoredTokens({ access_token: maybeAccess })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,9 +141,8 @@ async function performRefreshAndRetry(originalRequest: any): Promise<AxiosRespon
|
||||
return api(originalRequest)
|
||||
} catch (refreshErr: any) {
|
||||
processQueue(refreshErr)
|
||||
// Only force logout when refresh token/session is truly invalid (401).
|
||||
// CRITICAL: We only redirect if it's a 401, which means the HttpOnly cookie is missing or invalid.
|
||||
if (refreshErr?.response?.status === 401) {
|
||||
// Treat missing/invalid refresh endpoints or sessions as logged-out state.
|
||||
if (shouldRedirectToSigninOnRefreshFailure(refreshErr?.response?.status)) {
|
||||
clearStoredTokens()
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = "/signin"
|
||||
|
||||
@@ -51,7 +51,9 @@ import HorizontaLDots from "./horizontal-dots.svg";
|
||||
import ChatIcon from "./chat.svg";
|
||||
import MoreDotIcon from "./more-dot.svg";
|
||||
import BellIcon from "./bell.svg";
|
||||
|
||||
import PhoneIcon from "./phone.svg";
|
||||
import LocationIcon from "./location.svg";
|
||||
import LinkIcon from "./link.svg";
|
||||
export {
|
||||
DownloadIcon,
|
||||
BellIcon,
|
||||
@@ -106,4 +108,7 @@ export {
|
||||
HorizontaLDots,
|
||||
ChevronUpIcon,
|
||||
ChatIcon,
|
||||
PhoneIcon,
|
||||
LocationIcon,
|
||||
LinkIcon,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||
<svg width="64px" height="64px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<g id="SVGRepo_iconCarrier"> <path d="M9.16488 17.6505C8.92513 17.8743 8.73958 18.0241 8.54996 18.1336C7.62175 18.6695 6.47816 18.6695 5.54996 18.1336C5.20791 17.9361 4.87912 17.6073 4.22153 16.9498C3.56394 16.2922 3.23514 15.9634 3.03767 15.6213C2.50177 14.6931 2.50177 13.5495 3.03767 12.6213C3.23514 12.2793 3.56394 11.9505 4.22153 11.2929L7.04996 8.46448C7.70755 7.80689 8.03634 7.47809 8.37838 7.28062C9.30659 6.74472 10.4502 6.74472 11.3784 7.28061C11.7204 7.47809 12.0492 7.80689 12.7068 8.46448C13.3644 9.12207 13.6932 9.45086 13.8907 9.7929C14.4266 10.7211 14.4266 11.8647 13.8907 12.7929C13.7812 12.9825 13.6314 13.1681 13.4075 13.4078M10.5919 10.5922C10.368 10.8319 10.2182 11.0175 10.1087 11.2071C9.57284 12.1353 9.57284 13.2789 10.1087 14.2071C10.3062 14.5492 10.635 14.878 11.2926 15.5355C11.9502 16.1931 12.279 16.5219 12.621 16.7194C13.5492 17.2553 14.6928 17.2553 15.621 16.7194C15.9631 16.5219 16.2919 16.1931 16.9495 15.5355L19.7779 12.7071C20.4355 12.0495 20.7643 11.7207 20.9617 11.3787C21.4976 10.4505 21.4976 9.30689 20.9617 8.37869C20.7643 8.03665 20.4355 7.70785 19.7779 7.05026C19.1203 6.39267 18.7915 6.06388 18.4495 5.8664C17.5212 5.3305 16.3777 5.3305 15.4495 5.8664C15.2598 5.97588 15.0743 6.12571 14.8345 6.34955" stroke="#000000" stroke-width="2" stroke-linecap="round"/> </g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,7 @@
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||
<svg width="64px" height="64px" viewBox="0 0 32.00 32.00" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns" fill="#000000" transform="matrix(-1, 0, 0, 1, 0, 0)rotate(0)" stroke="#000000">
|
||||
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round" stroke="#CCCCCC" stroke-width="0.128"/>
|
||||
<g id="SVGRepo_iconCarrier"> <title>location</title> <desc>Created with Sketch Beta.</desc> <defs> </defs> <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage"> <g id="Icon-Set" sketch:type="MSLayerGroup" transform="translate(-104.000000, -411.000000)" fill="#000000"> <path d="M116,426 C114.343,426 113,424.657 113,423 C113,421.343 114.343,420 116,420 C117.657,420 119,421.343 119,423 C119,424.657 117.657,426 116,426 L116,426 Z M116,418 C113.239,418 111,420.238 111,423 C111,425.762 113.239,428 116,428 C118.761,428 121,425.762 121,423 C121,420.238 118.761,418 116,418 L116,418 Z M116,440 C114.337,440.009 106,427.181 106,423 C106,417.478 110.477,413 116,413 C121.523,413 126,417.478 126,423 C126,427.125 117.637,440.009 116,440 L116,440 Z M116,411 C109.373,411 104,416.373 104,423 C104,428.018 114.005,443.011 116,443 C117.964,443.011 128,427.95 128,423 C128,416.373 122.627,411 116,411 L116,411 Z" id="location" sketch:type="MSShapeGroup"> </path> </g> </g> </g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.05 6C15.0268 6.19057 15.9244 6.66826 16.6281 7.37194C17.3318 8.07561 17.8095 8.97326 18 9.95M14.05 2C16.0793 2.22544 17.9716 3.13417 19.4163 4.57701C20.8609 6.01984 21.7721 7.91101 22 9.94M18.5 21C9.93959 21 3 14.0604 3 5.5C3 5.11378 3.01413 4.73086 3.04189 4.35173C3.07375 3.91662 3.08968 3.69907 3.2037 3.50103C3.29814 3.33701 3.4655 3.18146 3.63598 3.09925C3.84181 3 4.08188 3 4.56201 3H7.37932C7.78308 3 7.98496 3 8.15802 3.06645C8.31089 3.12515 8.44701 3.22049 8.55442 3.3441C8.67601 3.48403 8.745 3.67376 8.88299 4.05321L10.0491 7.26005C10.2096 7.70153 10.2899 7.92227 10.2763 8.1317C10.2643 8.31637 10.2012 8.49408 10.0942 8.64506C9.97286 8.81628 9.77145 8.93713 9.36863 9.17882L8 10C9.2019 12.6489 11.3501 14.7999 14 16L14.8212 14.6314C15.0629 14.2285 15.1837 14.0271 15.3549 13.9058C15.5059 13.7988 15.6836 13.7357 15.8683 13.7237C16.0777 13.7101 16.2985 13.7904 16.74 13.9509L19.9468 15.117C20.3262 15.255 20.516 15.324 20.6559 15.4456C20.7795 15.553 20.8749 15.6891 20.9335 15.842C21 16.015 21 16.2169 21 16.6207V19.438C21 19.9181 21 20.1582 20.9007 20.364C20.8185 20.5345 20.663 20.7019 20.499 20.7963C20.3009 20.9103 20.0834 20.9262 19.6483 20.9581C19.2691 20.9859 18.8862 21 18.5 21Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -1,3 +1,5 @@
|
||||
import type { EditorSnapshot } from "@/uhm/types/projects";
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -14,9 +16,9 @@ export interface Project {
|
||||
display_name: string;
|
||||
avatar_url: string;
|
||||
};
|
||||
commits?: any[];
|
||||
commits?: unknown[];
|
||||
// Legacy (old BE): submission_ids
|
||||
submission_ids?: any[];
|
||||
submission_ids?: string[];
|
||||
// New BE: lightweight submissions list on project response
|
||||
submissions?: Array<{ id: string; status: string }>;
|
||||
members?: ProjectMember[];
|
||||
@@ -63,7 +65,7 @@ export interface GetProjectsParams {
|
||||
}
|
||||
export interface CreateCommitPayload {
|
||||
edit_summary: string;
|
||||
snapshot_json: number[];
|
||||
snapshot_json: EditorSnapshot;
|
||||
}
|
||||
export interface RestoreCommitPayload {
|
||||
commit_id: string;
|
||||
@@ -72,7 +74,7 @@ export interface RestoreCommitPayload {
|
||||
export interface CreateProjectPayload
|
||||
{
|
||||
description: string,
|
||||
project_status: "PRIVATE" | "PUBLIC" | "ARCHIVE",
|
||||
status: "PRIVATE" | "PUBLIC" | "ARCHIVE",
|
||||
title: string
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
@@ -6,20 +7,17 @@ import { usePathname } from "next/navigation";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import {
|
||||
BoxCubeIcon,
|
||||
CalenderIcon,
|
||||
ChevronDownIcon,
|
||||
FileIcon,
|
||||
GridIcon,
|
||||
HorizontaLDots,
|
||||
ListIcon,
|
||||
PageIcon,
|
||||
PieChartIcon,
|
||||
PlugInIcon,
|
||||
ShootingStarIcon,
|
||||
TableIcon,
|
||||
UserCircleIcon,
|
||||
} from "../icons/index";
|
||||
|
||||
import { apiGetCurrentUser, apiLogout } from "@/service/auth";
|
||||
import { UserMetaCardProps } from "@/interface/user";
|
||||
|
||||
type NavItem = {
|
||||
name: string;
|
||||
icon: React.ReactNode;
|
||||
@@ -33,85 +31,76 @@ type NavItem = {
|
||||
};
|
||||
|
||||
const ALL_NAV_ITEMS: NavItem[] = [
|
||||
{
|
||||
icon: <GridIcon />,
|
||||
name: "Trang Chủ",
|
||||
// subItems: [{ name: "Ecommerce", path: "/", pro: false }],
|
||||
path: "/",
|
||||
},
|
||||
{
|
||||
icon: <BoxCubeIcon />,
|
||||
name: "Dự Án",
|
||||
path: "/user/projects",
|
||||
},
|
||||
{
|
||||
icon: <FileIcon />,
|
||||
name: "Thư Viện",
|
||||
path: "/user/library",
|
||||
},
|
||||
{
|
||||
icon: <UserCircleIcon />,
|
||||
name: "Tài Khoản",
|
||||
path: "/user/account",
|
||||
},
|
||||
|
||||
{ icon: <GridIcon />, name: "Trang Chủ", path: "/" },
|
||||
{ icon: <BoxCubeIcon />, name: "Dự Án", path: "/user/projects" },
|
||||
{ icon: <FileIcon />, name: "Thư Viện", path: "/user/library" },
|
||||
];
|
||||
|
||||
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: <ShootingStarIcon />,
|
||||
name: "Về Chúng Tôi",
|
||||
path: "/user/about-us",
|
||||
},
|
||||
{
|
||||
icon: <ShootingStarIcon />,
|
||||
name: "Hỗ trợ",
|
||||
path: "/user/quick-qa",
|
||||
},
|
||||
{ icon: <ShootingStarIcon />, name: "Hỗ trợ", path: "/user/quick-qa" },
|
||||
];
|
||||
|
||||
const AppSidebar: React.FC = () => {
|
||||
const { isExpanded, isMobileOpen, isHovered, setIsHovered } = useSidebar();
|
||||
const { isExpanded, isMobileOpen, toggleSidebar, toggleMobileSidebar } = useSidebar();
|
||||
const pathname = usePathname();
|
||||
const [user, setUser] = useState<UserMetaCardProps | null>(null);
|
||||
|
||||
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 isSidebarVisible = isExpanded || isMobileOpen;
|
||||
|
||||
const handleToggle = () => {
|
||||
if (window.innerWidth >= 1024) {
|
||||
toggleSidebar();
|
||||
} else {
|
||||
toggleMobileSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const userData = await apiGetCurrentUser();
|
||||
if (isMounted) setUser(userData);
|
||||
} catch (err) {
|
||||
console.error("Lỗi fetch user:", err);
|
||||
}
|
||||
};
|
||||
fetchUser();
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const clearAllCookies = () => {
|
||||
document.cookie.split(";").forEach((c) => {
|
||||
document.cookie = c
|
||||
.replace(/^ +/, "")
|
||||
.replace(/=.*/, "=;expires=" + new Date().toUTCString() + ";path=/");
|
||||
});
|
||||
};
|
||||
|
||||
const handleLogout = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await apiLogout();
|
||||
} catch (error) {
|
||||
console.error("Logout failed", error);
|
||||
} finally {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
clearAllCookies();
|
||||
window.location.href = "/signin";
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmenuToggle = (index: number, menuType: "main" | "others") => {
|
||||
setOpenSubmenu((prev) =>
|
||||
@@ -121,18 +110,21 @@ const AppSidebar: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Tự động mở submenu nếu route hiện tại nằm trong subItems
|
||||
useEffect(() => {
|
||||
let submenuMatched = false;
|
||||
[
|
||||
{ items: ALL_NAV_ITEMS, type: "main" },
|
||||
{ items: OTHERS_ITEMS, type: "others" },
|
||||
].forEach(({ items, type }) => {
|
||||
const menuGroups = [
|
||||
{ items: ALL_NAV_ITEMS, type: "main" as const },
|
||||
{ items: OTHERS_ITEMS, type: "others" as const },
|
||||
];
|
||||
|
||||
menuGroups.forEach(({ items, type }) => {
|
||||
items.forEach((nav, index) => {
|
||||
nav.subItems?.forEach((sub) => {
|
||||
if (isActive(sub.path)) {
|
||||
setOpenSubmenu((prev) => {
|
||||
if (prev?.type === type && prev?.index === index) return prev;
|
||||
return { type: type as "main" | "others", index };
|
||||
return { type, index };
|
||||
});
|
||||
submenuMatched = true;
|
||||
}
|
||||
@@ -145,163 +137,257 @@ const AppSidebar: React.FC = () => {
|
||||
}
|
||||
}, [pathname, isActive]);
|
||||
|
||||
// Tính toán chiều cao cho hiệu ứng mượt mà của submenu
|
||||
useEffect(() => {
|
||||
if (openSubmenu !== null) {
|
||||
const key = `${openSubmenu.type}-${openSubmenu.index}`;
|
||||
if (subMenuRefs.current[key]) {
|
||||
setSubMenuHeight((prev) => ({
|
||||
...prev,
|
||||
[key]: subMenuRefs.current[key]?.scrollHeight || 0,
|
||||
}));
|
||||
requestAnimationFrame(() => {
|
||||
setSubMenuHeight((prev) => ({
|
||||
...prev,
|
||||
[key]: subMenuRefs.current[key]?.scrollHeight || 0,
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [openSubmenu]);
|
||||
|
||||
const renderMenuItems = (items: NavItem[], menuType: "main" | "others") => (
|
||||
<ul className="flex flex-col gap-4">
|
||||
{items.map((nav, index) => (
|
||||
<li key={nav.name}>
|
||||
{nav.subItems ? (
|
||||
<button
|
||||
onClick={() => handleSubmenuToggle(index, menuType)}
|
||||
className={`menu-item group uppercase ${openSubmenu?.type === menuType && openSubmenu?.index === index
|
||||
? "menu-item-active"
|
||||
: "menu-item-inactive"
|
||||
} cursor-pointer ${!isExpanded && !isHovered ? "lg:justify-center" : "lg:justify-start"}`}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
openSubmenu?.type === menuType && openSubmenu?.index === index
|
||||
? "menu-item-icon-active"
|
||||
: "menu-item-icon-inactive"
|
||||
}
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{items.map((nav, index) => {
|
||||
const isOpen = openSubmenu?.type === menuType && openSubmenu?.index === index;
|
||||
const refKey = `${menuType}-${index}`;
|
||||
|
||||
return (
|
||||
<li key={nav.name}>
|
||||
{nav.subItems ? (
|
||||
<button
|
||||
onClick={() => handleSubmenuToggle(index, menuType)}
|
||||
className={`menu-item group capitalize ${
|
||||
isOpen ? "menu-item-active" : "menu-item-icon-inactive"
|
||||
} ${!isExpanded ? "lg:justify-center px-2" : "lg:justify-start"}`}
|
||||
>
|
||||
{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" : ""}`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</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"
|
||||
}
|
||||
>
|
||||
<span className={`flex h-5 w-5 shrink-0 items-center justify-center ${isOpen ? "text-gray-900" : "text-gray-400"}`}>
|
||||
{nav.icon}
|
||||
</span>
|
||||
{(isExpanded || isHovered || isMobileOpen) && (
|
||||
<span className={`menu-item-text`}>{nav.name}</span>
|
||||
{isSidebarVisible && (
|
||||
<>
|
||||
<span className="block truncate text-[14px] font-medium">
|
||||
{nav.name}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={`ml-auto h-4 w-4 transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
{nav.subItems && (isExpanded || isHovered || isMobileOpen) && (
|
||||
<div
|
||||
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",
|
||||
}}
|
||||
>
|
||||
<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"}`}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</button>
|
||||
) : (
|
||||
nav.path && (
|
||||
<Link
|
||||
href={nav.path}
|
||||
className={`menu-item group ${
|
||||
isActive(nav.path) ? "menu-item-active" : "menu-item-icon-inactive"
|
||||
} ${!isExpanded ? "lg:justify-center px-2" : "lg:justify-start"}`}
|
||||
>
|
||||
<span className={`menu-item-icon ${isActive(nav.path) ? "text-gray-950" : "text-gray-400"}`}>
|
||||
{nav.icon}
|
||||
</span>
|
||||
{isSidebarVisible && (
|
||||
<span className="block truncate text-[14px]">{nav.name}</span>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Submenu Area */}
|
||||
{nav.subItems && isSidebarVisible && (
|
||||
<div
|
||||
ref={(el) => {
|
||||
subMenuRefs.current[refKey] = el;
|
||||
}}
|
||||
className="overflow-hidden ease-in-out transition-all duration-300"
|
||||
style={{
|
||||
height: isOpen ? `${subMenuHeight[refKey] || 0}px` : "0px",
|
||||
}}
|
||||
>
|
||||
<ul className="ml-4 mt-1 space-y-1 border-l border-gray-200 pl-4">
|
||||
{nav.subItems.map((subItem) => (
|
||||
<li key={subItem.name}>
|
||||
<Link
|
||||
href={subItem.path}
|
||||
className={`block rounded-xl py-2 text-[13px] transition-colors ${
|
||||
isActive(subItem.path)
|
||||
? "font-medium text-gray-950"
|
||||
: "text-gray-400 hover:text-gray-900"
|
||||
}`}
|
||||
>
|
||||
{subItem.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
|
||||
return (
|
||||
<aside
|
||||
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)}
|
||||
className={`fixed left-0 top-0 z-50 flex h-screen flex-col border-r border-gray-200 bg-white text-gray-900 will-change-[width]
|
||||
${isSidebarVisible ? "w-[290px] px-5" : "w-0 border-none px-0 overflow-hidden sm:w-[88px] sm:border-solid sm:px-4"}
|
||||
${isMobileOpen ? "translate-x-0" : "-translate-x-full"} lg:translate-x-0 transition-all duration-300`}
|
||||
>
|
||||
{/* HEADER LOGO & TOGGLE */}
|
||||
<div
|
||||
className={`py-8 flex ${!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"}`}
|
||||
className={`mb-2 flex w-full shrink-0 items-center ${
|
||||
!isSidebarVisible
|
||||
? "h-auto flex-col justify-center gap-3 py-4"
|
||||
: "h-24 flex-row justify-between py-8"
|
||||
}`}
|
||||
>
|
||||
<Link href="/">
|
||||
<Link href="/" className="flex shrink-0 items-center justify-center" aria-label="Go to homepage">
|
||||
<Image
|
||||
src="/images/logo/logo.svg"
|
||||
alt="Logo"
|
||||
width={isExpanded || isHovered || isMobileOpen ? 80 : 32}
|
||||
height={isExpanded || isHovered || isMobileOpen ? 50 : 32}
|
||||
width={isSidebarVisible ? 80 : 36}
|
||||
height={isSidebarVisible ? 45 : 36}
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{isSidebarVisible && (
|
||||
<button
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-300 bg-white/80 text-gray-400 hover:bg-white"
|
||||
onClick={handleToggle}
|
||||
aria-label="Collapse Sidebar"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 16 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M0.583252 1C0.583252 0.585788 0.919038 0.25 1.33325 0.25H14.6666C15.0808 0.25 15.4166 0.585786 15.4166 1C15.4166 1.41421 15.0808 1.75 14.6666 1.75L1.33325 1.75C0.919038 1.75 0.583252 1.41422 0.583252 1ZM0.583252 11C0.583252 10.5858 0.919038 10.25 1.33325 10.25L14.6666 10.25C15.0808 10.25 15.4166 10.5858 15.4166 11C15.4166 11.4142 15.0808 11.75 14.6666 11.75L1.33325 11.75C0.919038 11.75 0.583252 11.4142 0.583252 11ZM1.33325 5.25C0.919038 5.25 0.583252 5.58579 0.583252 6C0.583252 6.41421 0.919038 6.75 1.33325 6.75L7.99992 6.75C8.41413 6.75 8.74992 6.41421 8.74992 6C8.74992 5.58579 8.41413 5.25 7.99992 5.25L1.33325 5.25Z" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isSidebarVisible && (
|
||||
<button
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-300 bg-white/80 text-gray-400 hover:bg-white"
|
||||
onClick={handleToggle}
|
||||
aria-label="Expand Sidebar"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 16 12" fill="none" xmlns="http://www.w3.org/2000/svg" className="rotate-180">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M0.583252 1C0.583252 0.585788 0.919038 0.25 1.33325 0.25H14.6666C15.0808 0.25 15.4166 0.585786 15.4166 1C15.4166 1.41421 15.0808 1.75 14.6666 1.75L1.33325 1.75C0.919038 1.75 0.583252 1.41422 0.583252 1ZM0.583252 11C0.583252 10.5858 0.919038 10.25 1.33325 10.25L14.6666 10.25C15.0808 10.25 15.4166 10.5858 15.4166 11C15.4166 11.4142 15.0808 11.75 14.6666 11.75L1.33325 11.75C0.919038 11.75 0.583252 11.4142 0.583252 11ZM1.33325 5.25C0.919038 5.25 0.583252 5.58579 0.583252 6C0.583252 6.41421 0.919038 6.75 1.33325 6.75L7.99992 6.75C8.41413 6.75 8.74992 6.41421 8.74992 6C8.74992 5.58579 8.41413 5.25 7.99992 5.25L1.33325 5.25Z" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col overflow-y-auto duration-300 ease-linear no-scrollbar">
|
||||
<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>
|
||||
{renderMenuItems(ALL_NAV_ITEMS, "main")}
|
||||
</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")}
|
||||
|
||||
{/* USER PROFILE & NAVIGATION */}
|
||||
<div className="no-scrollbar flex flex-1 flex-col overflow-y-auto duration-200">
|
||||
<div className={`my-4 flex items-center gap-3 p-2 ${!isExpanded ? "justify-center px-2" : "rounded-4xl border border-gray-200"}`}>
|
||||
<div className="h-10 w-10 shrink-0 overflow-hidden rounded-full border-2 border-white bg-gray-100 shadow-sm">
|
||||
<Image
|
||||
width={40}
|
||||
height={40}
|
||||
src={user?.data?.profile?.avatar_url || "/images/no-images.jpg"}
|
||||
alt="User"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
{isSidebarVisible && (
|
||||
<div className="overflow-hidden">
|
||||
<span className="block truncate text-[14px] font-semibold text-gray-950">
|
||||
{user?.data?.profile?.display_name || "User"}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[11px] font-medium text-gray-400">
|
||||
{user?.data?.email || "email"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-col gap-5">
|
||||
<div>
|
||||
<h2 className={`mb-3 flex text-[11px] font-bold capitalize tracking-wider text-gray-400/80 ${!isExpanded ? "lg:justify-center" : "justify-start pl-2"}`}>
|
||||
{isSidebarVisible ? "Menu" : <HorizontaLDots />}
|
||||
</h2>
|
||||
{renderMenuItems(ALL_NAV_ITEMS, "main")}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className={`mb-3 flex text-[11px] font-bold capitalize tracking-wider text-gray-400/80 ${!isExpanded ? "lg:justify-center" : "justify-start pl-2"}`}>
|
||||
{isSidebarVisible ? "Others" : <HorizontaLDots />}
|
||||
</h2>
|
||||
{renderMenuItems(OTHERS_ITEMS, "others")}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* FOOTER ACTIONS - Được đồng bộ style */}
|
||||
<div className="mt-auto shrink-0 border-t border-gray-200/50 py-4">
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
<li>
|
||||
<Link
|
||||
href="/user/account"
|
||||
className={`menu-item group ${
|
||||
isActive("/user/account") ? "menu-item-active" : "menu-item-icon-inactive"
|
||||
} ${!isExpanded ? "lg:justify-center px-2" : "lg:justify-start"}`}
|
||||
>
|
||||
<span className={`menu-item-icon ${isActive("/user/account") ? "text-gray-950" : "text-gray-400"}`}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M12 3.5C7.30558 3.5 3.5 7.30558 3.5 12C3.5 14.1526 4.3002 16.1184 5.61936 17.616C6.17279 15.3096 8.24852 13.5955 10.7246 13.5955H13.2746C15.7509 13.5955 17.8268 15.31 18.38 17.6167C19.6996 16.119 20.5 14.153 20.5 12C20.5 7.30558 16.6944 3.5 12 3.5ZM17.0246 18.8566V18.8455C17.0246 16.7744 15.3457 15.0955 13.2746 15.0955H10.7246C8.65354 15.0955 6.97461 16.7744 6.97461 18.8455V18.856C8.38223 19.8895 10.1198 20.5 12 20.5C13.8798 20.5 15.6171 19.8898 17.0246 18.8566ZM2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM11.9991 7.25C10.8847 7.25 9.98126 8.15342 9.98126 9.26784C9.98126 10.3823 10.8847 11.2857 11.9991 11.2857C13.1135 11.2857 14.0169 10.3823 14.0169 9.26784C14.0169 8.15342 13.1135 7.25 11.9991 7.25ZM8.48126 9.26784C8.48126 7.32499 10.0563 5.75 11.9991 5.75C13.9419 5.75 15.5169 7.32499 15.5169 9.26784C15.5169 11.2107 13.9419 12.7857 11.9991 12.7857C10.0563 12.7857 8.48126 11.2107 8.48126 9.26784Z" fill="currentColor" />
|
||||
</svg>
|
||||
</span>
|
||||
{isSidebarVisible && <span className="block truncate text-[14px]">Tài Khoản</span>}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/user/role-upgrade"
|
||||
className={`menu-item group ${
|
||||
isActive("/user/role-upgrade") ? "menu-item-active" : "menu-item-icon-inactive"
|
||||
} ${!isExpanded ? "lg:justify-center px-2" : "lg:justify-start"}`}
|
||||
>
|
||||
<span className={`menu-item-icon ${isActive("/user/role-upgrade") ? "text-gray-950" : "text-gray-400"}`}>
|
||||
<ListIcon />
|
||||
</span>
|
||||
{isSidebarVisible && <span className="block truncate text-[14px]">Nhà Sử Học</span>}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/user/about-us"
|
||||
className={`menu-item group ${
|
||||
isActive("/user/about-us") ? "menu-item-active" : "menu-item-icon-inactive"
|
||||
} ${!isExpanded ? "lg:justify-center px-2" : "lg:justify-start"}`}
|
||||
>
|
||||
<span className={`menu-item-icon ${isActive("/user/about-us") ? "text-gray-950" : "text-gray-400"}`}>
|
||||
<ShootingStarIcon />
|
||||
</span>
|
||||
{isSidebarVisible && <span className="block truncate text-[14px]">Về chúng tôi</span>}
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
<li className="mt-2 border-t border-gray-200/40 pt-2">
|
||||
<button
|
||||
name="logout"
|
||||
onClick={handleLogout}
|
||||
className={`menu-item group w-full transition-all duration-200 hover:bg-red-50/50 ${
|
||||
!isExpanded ? "lg:justify-center px-2" : "lg:justify-start"
|
||||
}`}
|
||||
>
|
||||
<span className="menu-item-icon text-red-500">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M15.1007 19.247C14.6865 19.247 14.3507 18.9112 14.3507 18.497L14.3507 14.245H12.8507V18.497C12.8507 19.7396 13.8581 20.747 15.1007 20.747H18.5007C19.7434 20.747 20.7507 19.7396 20.7507 18.497L20.7507 5.49609C20.7507 4.25345 19.7433 3.24609 18.5007 3.24609H15.1007C13.8581 3.24609 12.8507 4.25345 12.8507 5.49609V9.74501L14.3507 9.74501V5.49609C14.3507 5.08188 14.6865 4.74609 15.1007 4.74609L18.5007 4.74609C18.9149 4.74609 19.2507 5.08188 19.2507 5.49609L19.2507 18.497C19.2507 18.9112 18.9149 19.247 18.5007 19.247H15.1007ZM3.25073 11.9984C3.25073 12.2144 3.34204 12.4091 3.48817 12.546L8.09483 17.1556C8.38763 17.4485 8.86251 17.4487 9.15549 17.1559C9.44848 16.8631 9.44863 16.3882 9.15583 16.0952L5.81116 12.7484L16.0007 12.7484C16.4149 12.7484 16.7507 12.4127 16.7507 11.9984C16.7507 11.5842 16.4149 11.2484 16.0007 11.2484L5.81528 11.2484L9.15585 7.90554C9.44864 7.61255 9.44847 7.13767 9.15547 6.84488C8.86248 6.55209 8.3876 6.55226 8.09481 6.84525L3.52309 11.4202C3.35673 11.5577 3.25073 11.7657 3.25073 11.9984Z" fill="currentColor" />
|
||||
</svg>
|
||||
</span>
|
||||
{isSidebarVisible && <span className="block truncate text-[14px] font-medium text-red-500">Đăng xuất</span>}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppSidebar;
|
||||
export default AppSidebar;
|
||||
@@ -8,7 +8,7 @@ const Backdrop: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-gray-900/50 lg:hidden"
|
||||
className="fixed inset-0 z-40 bg-gray-900/30 backdrop-blur-sm dark:bg-black/50 lg:hidden"
|
||||
onClick={toggleMobileSidebar}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ export default function SidebarWidget() {
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
mx-auto mb-10 w-full max-w-60 rounded-2xl bg-gray-50 px-4 py-5 text-center dark:bg-white/[0.03]`}
|
||||
mx-auto mb-10 w-full max-w-60 rounded-2xl bg-gray-100 px-4 py-5 text-center dark:bg-gray-800`}
|
||||
>
|
||||
<h3 className="mb-2 font-semibold text-gray-900 dark:text-white">
|
||||
#1 Tailwind CSS Dashboard
|
||||
@@ -16,7 +16,7 @@ export default function SidebarWidget() {
|
||||
href="https://tailadmin.com/pricing"
|
||||
target="_blank"
|
||||
rel="nofollow"
|
||||
className="flex items-center justify-center p-3 font-medium text-white rounded-lg bg-brand-500 text-theme-sm hover:bg-brand-600"
|
||||
className="flex items-center justify-center p-3 font-medium text-white rounded-lg bg-indigo-600 text-theme-sm hover:bg-indigo-700"
|
||||
>
|
||||
Upgrade To Pro
|
||||
</a>
|
||||
|
||||
@@ -9,7 +9,17 @@ export const apiGetListUser = async (payload: getUserDto) => {
|
||||
return response?.data;
|
||||
};
|
||||
|
||||
export const apiChangeRole = async (id: string, payload: any) => {
|
||||
export interface ChangeRolePayload {
|
||||
role_ids: string[];
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
export interface UpdateApplicationStatusPayload {
|
||||
status: "APPROVED" | "REJECTED";
|
||||
review_note: string;
|
||||
}
|
||||
|
||||
export const apiChangeRole = async (id: string, payload: ChangeRolePayload) => {
|
||||
const response = await api.patch(API.Admin.CHANGE_ROLE(id), payload);
|
||||
return response?.data;
|
||||
};
|
||||
@@ -32,11 +42,12 @@ export const apiGetUserMedia = async (id: string) => {
|
||||
return response?.data;
|
||||
};
|
||||
|
||||
export const apiUpdateApplicationStatus = async (id: string, payload: any) => {
|
||||
export const apiUpdateApplicationStatus = async (id: string, payload: UpdateApplicationStatusPayload) => {
|
||||
const response = await api.put(API.Admin.UPDATE_APPLICATION_STATUS(id), payload);
|
||||
return response?.data;
|
||||
};
|
||||
|
||||
|
||||
export const apiGetUserById = async (userId: string) => {
|
||||
const response = await api.get(API.Admin.GET_USER_BY_ID(userId));
|
||||
return response?.data;
|
||||
|
||||
@@ -2,6 +2,29 @@ import api from "@/config/config";
|
||||
import { API } from "../../api";
|
||||
import { clearStoredTokens, extractTokensFromResponsePayload, setStoredTokens } from "@/auth/tokenStore";
|
||||
|
||||
export interface SignUpPayload {
|
||||
display_name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
token_id: string;
|
||||
}
|
||||
|
||||
export interface SignInPayload {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface ResetPasswordPayload {
|
||||
email: string;
|
||||
new_password: string;
|
||||
token_id: string;
|
||||
}
|
||||
|
||||
export interface ChangePasswordPayload {
|
||||
old_password: string;
|
||||
new_password: string;
|
||||
}
|
||||
|
||||
export const apiCreateOTP = async (email: string, token_type: number = 2) => {
|
||||
const response = await api.post(API.Auth.CREATEOTP, {
|
||||
email,
|
||||
@@ -16,7 +39,7 @@ export const apiVerifyOTP = async (email: string, token: string, token_type: num
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const apiSignUp = async (payload: any) => {
|
||||
export const apiSignUp = async (payload: SignUpPayload) => {
|
||||
const response = await api.post(API.Auth.SIGNUP, payload);
|
||||
return response.data;
|
||||
};
|
||||
@@ -27,14 +50,14 @@ export const apiLogout = async () => {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const apiSignIn = async (payload: any) => {
|
||||
export const apiSignIn = async (payload: SignInPayload) => {
|
||||
const response = await api.post(API.Auth.SIGNIN, payload);
|
||||
const tokens = extractTokensFromResponsePayload(response?.data);
|
||||
if (tokens) setStoredTokens(tokens);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const apiResetPassword = async (payload: any) => {
|
||||
export const apiResetPassword = async (payload: ResetPasswordPayload) => {
|
||||
const response = await api.post(API.Auth.FORGOT_PASSWORD, payload);
|
||||
return response.data;
|
||||
};
|
||||
@@ -44,7 +67,8 @@ export const apiGetCurrentUser = async () => {
|
||||
return response?.data;
|
||||
};
|
||||
|
||||
export const apiChangePassword = async (payload: any) => {
|
||||
export const apiChangePassword = async (payload: ChangePasswordPayload) => {
|
||||
const response = await api.patch(API.User.CHANGE_PASSWORD, payload);
|
||||
return response?.data;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
import api from "@/config/config";
|
||||
import { API } from "../../api";
|
||||
|
||||
export const createHistorianCV = async (payload: any) => {
|
||||
export interface CreateHistorianCVPayload {
|
||||
content: string;
|
||||
media_ids: (string | number)[];
|
||||
verify_type: string;
|
||||
}
|
||||
|
||||
export interface GetUserApplicationsPayload {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
status?: string;
|
||||
user_id?: string;
|
||||
}
|
||||
|
||||
export const createHistorianCV = async (payload: CreateHistorianCVPayload) => {
|
||||
const response = await api.post(API.Historian.CREATE_CV, payload);
|
||||
return response?.data;
|
||||
};
|
||||
|
||||
export const apiGetUserApplications = async (payload :any) => {
|
||||
export const apiGetUserApplications = async (payload : GetUserApplicationsPayload) => {
|
||||
const response = await api.get(API.Historian.APPLICATION, { params: payload });
|
||||
return response?.data;
|
||||
};
|
||||
|
||||
|
||||
export const apiDeleteHistorianCV = async (id: number | string) => {
|
||||
const response = await api.delete(API.Historian.DELETE_CV(id));
|
||||
return response?.data;
|
||||
|
||||
@@ -121,7 +121,16 @@ export const deleteMediaById = async (mediaId: string) => {
|
||||
return response?.data;
|
||||
}
|
||||
|
||||
export const getMedia = async (payload: any) => {
|
||||
export interface GetMediaPayload {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: "asc" | "desc";
|
||||
user_id?: string;
|
||||
}
|
||||
|
||||
export const getMedia = async (payload: GetMediaPayload) => {
|
||||
const response = await api.get(API.Media.GET_MEDIA, {
|
||||
params: payload,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
.panel {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 16px;
|
||||
z-index: 20;
|
||||
width: 280px;
|
||||
max-width: calc(100vw - 2rem);
|
||||
background: rgba(15, 23, 42, 0.88);
|
||||
border-radius: 14px;
|
||||
backdrop-filter: blur(10px);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding: 12px 16px;
|
||||
transition: border-bottom-color 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.headerCollapsed {
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.collapseButton {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
color: #f8fafc;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.collapseButton:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.collapseIcon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.iconRotated {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
max-height: 1000px;
|
||||
opacity: 1;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1), padding 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.2s ease, gap 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.contentCollapsed {
|
||||
max-height: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #94a3b8;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #94a3b8;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
transition: color 0.15s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.actionButton:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.listContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for premium look */
|
||||
.listContainer::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.listContainer::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.listContainer::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.listContainer::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.listItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.15s ease;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.listItem:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.listItemLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.itemHashIcon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: #94a3b8;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.itemName {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-transform: capitalize;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.eyeIcon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: #fff;
|
||||
opacity: 0.7;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.listItem:hover .eyeIcon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Active status styling */
|
||||
.listItemActiveSky .itemHashIcon {
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
.listItemActiveEmerald .itemHashIcon {
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
/* Inactive status styling */
|
||||
.listItemInactive {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.listItemInactive .itemName {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.listItemInactive:hover {
|
||||
opacity: 0.8;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.eyeIconInactive {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: #94a3b8;
|
||||
opacity: 0.5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
.container {
|
||||
position: absolute;
|
||||
left: 18px;
|
||||
right: 18px;
|
||||
bottom: 16px;
|
||||
z-index: 10;
|
||||
background: linear-gradient(135deg, rgba(30, 30, 30, 0.72) 0%, rgba(20, 20, 20, 0.85) 100%);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50px;
|
||||
padding: 10px 16px;
|
||||
color: #f8fafc;
|
||||
box-shadow:
|
||||
0 10px 30px -10px rgba(0, 0, 0, 0.5),
|
||||
inset 0 1px 1px 0 rgba(255, 255, 255, 0.05);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@keyframes borderPulse {
|
||||
0% {
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
box-shadow:
|
||||
0 10px 30px -10px rgba(0, 0, 0, 0.15),
|
||||
inset 0 1px 1px 0 rgba(255, 255, 255, 0.7),
|
||||
inset 0 -1px 1px 0 rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
50% {
|
||||
border-color: rgba(16, 185, 129, 0.55);
|
||||
box-shadow:
|
||||
0 10px 30px -10px rgba(0, 0, 0, 0.15),
|
||||
0 0 12px rgba(16, 185, 129, 0.25),
|
||||
inset 0 1px 1px 0 rgba(255, 255, 255, 0.75),
|
||||
inset 0 -1px 1px 0 rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
100% {
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
box-shadow:
|
||||
0 10px 30px -10px rgba(0, 0, 0, 0.15),
|
||||
inset 0 1px 1px 0 rgba(255, 255, 255, 0.7),
|
||||
inset 0 -1px 1px 0 rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.containerLoading {
|
||||
animation: borderPulse 2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.flexWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 8px;
|
||||
column-gap: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.labelBounds {
|
||||
color: #94a3b8;
|
||||
font-weight: 600;
|
||||
min-width: 44px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.labelBoundsRight {
|
||||
composes: labelBounds;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Custom range slider styling */
|
||||
.slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
height: 24px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-runnable-track {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.slider:hover::-webkit-slider-runnable-track {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
margin-top: -6px;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 30% 30%, #34d399 0%, #059669 100%);
|
||||
border: 1.5px solid #ffffff;
|
||||
box-shadow:
|
||||
0 0 10px rgba(16, 185, 129, 0.4),
|
||||
0 3px 6px rgba(0, 0, 0, 0.15),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.4);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.slider:hover::-webkit-slider-thumb {
|
||||
transform: scale(1.2);
|
||||
box-shadow:
|
||||
0 0 15px rgba(16, 185, 129, 0.6),
|
||||
0 5px 10px rgba(0, 0, 0, 0.18),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.slider:active::-webkit-slider-thumb {
|
||||
transform: scale(1.05);
|
||||
box-shadow:
|
||||
0 0 8px rgba(16, 185, 129, 0.5),
|
||||
0 2px 4px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Firefox slider styling */
|
||||
.slider::-moz-range-track {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.slider:hover::-moz-range-track {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
height: 15px;
|
||||
width: 15px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 30% 30%, #34d399 0%, #059669 100%);
|
||||
border: 1.5px solid #ffffff;
|
||||
box-shadow:
|
||||
0 0 10px rgba(16, 185, 129, 0.4),
|
||||
0 3px 6px rgba(0, 0, 0, 0.15),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.4);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.slider:hover::-moz-range-thumb {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
/* Custom inputs styling */
|
||||
.numberInput {
|
||||
width: 128px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
outline: none;
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(4px);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.numberInput:hover {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.numberInput:focus {
|
||||
border-color: #10b981;
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(16, 185, 129, 0.25),
|
||||
inset 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.rangeInput {
|
||||
composes: numberInput;
|
||||
width: 84px;
|
||||
}
|
||||
|
||||
.rangeLabel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #94a3b8;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.rangeLabel:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Custom switch toggle styling */
|
||||
.toggleContainer {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.toggleContainer:focus-visible {
|
||||
outline: 2px solid rgba(52, 211, 153, 0.8);
|
||||
outline-offset: 3px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.toggleTrack {
|
||||
width: 38px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.toggleTrackActive {
|
||||
background: rgba(16, 185, 129, 0.35);
|
||||
border-color: rgba(16, 185, 129, 0.6);
|
||||
box-shadow:
|
||||
0 0 8px rgba(16, 185, 129, 0.35),
|
||||
inset 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.toggleThumb {
|
||||
position: absolute;
|
||||
top: 1.5px;
|
||||
left: 2px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 50%;
|
||||
background: #94a3b8;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25);
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.toggleThumbActive {
|
||||
left: 19px;
|
||||
background: #34d399;
|
||||
box-shadow:
|
||||
0 0 10px rgba(52, 211, 153, 0.6),
|
||||
0 2px 4px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.toggleContainer:hover .toggleTrack {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.toggleContainer:hover .toggleTrackActive {
|
||||
border-color: rgba(16, 185, 129, 0.7);
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: 0.5 !important;
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
.disabled .slider,
|
||||
.disabled .numberInput,
|
||||
.disabled .toggleContainer,
|
||||
.disabled .toggleTrack {
|
||||
cursor: not-allowed !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
@@ -1,9 +1,42 @@
|
||||
// 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";
|
||||
import { API_URL_ROOT } from "../../../api";
|
||||
|
||||
export const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL || FALLBACK_API_BASE_URL;
|
||||
const GOONG_TILES_BASE_URL = "https://tiles.goong.io";
|
||||
|
||||
export const API_BASE_URL = normalizeApiBaseUrl(API_URL_ROOT);
|
||||
const GOONG_PROXY_BASE_PATH = `${API_BASE_URL}/proxy`;
|
||||
|
||||
export const GOONG_SATELLITE_STYLE_UPSTREAM_URL = `${GOONG_TILES_BASE_URL}/assets/goong_satellite.json`;
|
||||
export const GOONG_VECTOR_OVERLAY_STYLE_UPSTREAM_URL = `${GOONG_TILES_BASE_URL}/assets/goong_map_web.json`;
|
||||
export const GOONG_GLYPHS_UPSTREAM_URL = `${GOONG_TILES_BASE_URL}/fonts/{fontstack}/{range}.pbf`;
|
||||
|
||||
export const USE_EXTERNAL_BACKGROUND_RASTER = API_BASE_URL.length > 0;
|
||||
|
||||
function normalizeApiBaseUrl(rawUrl: string): string {
|
||||
return rawUrl.trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function stripGoongApiKeyFromUrl(rawUrl: string): string {
|
||||
const [basePart, hashPart = ""] = rawUrl.split("#", 2);
|
||||
const [pathPart, queryString = ""] = basePart.split("?", 2);
|
||||
const sanitizedQuery = queryString
|
||||
.split("&")
|
||||
.filter((segment) => segment && !segment.toLowerCase().startsWith("api_key="))
|
||||
.join("&");
|
||||
|
||||
return `${pathPart}${sanitizedQuery ? `?${sanitizedQuery}` : ""}${hashPart ? `#${hashPart}` : ""}`;
|
||||
}
|
||||
|
||||
export function buildGoongProxyUrl(rawUrl: string): string {
|
||||
const sanitizedUrl = stripGoongApiKeyFromUrl(rawUrl);
|
||||
const proxyTarget = sanitizedUrl
|
||||
.trim()
|
||||
.replace(/^https?:\/\//i, "")
|
||||
.replace(/^\/+/, "");
|
||||
|
||||
return `${GOONG_PROXY_BASE_PATH}/${proxyTarget}`;
|
||||
}
|
||||
|
||||
export const GOONG_GLYPHS_PROXY_URL = buildGoongProxyUrl(GOONG_GLYPHS_UPSTREAM_URL);
|
||||
|
||||
export const API_ENDPOINTS = {
|
||||
geometries: `${API_BASE_URL}/geometries`,
|
||||
@@ -18,8 +51,4 @@ export const API_ENDPOINTS = {
|
||||
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;
|
||||
|
||||
@@ -9,8 +9,9 @@ export type { GeometriesBBoxQuery } from "@/uhm/types/api";
|
||||
export type EntityGeometrySearchGeo = {
|
||||
id: string;
|
||||
type: string | null;
|
||||
draw_geometry: unknown;
|
||||
binding?: unknown;
|
||||
draw_geometry: Geometry;
|
||||
binding?: string[];
|
||||
|
||||
time_start?: number | null;
|
||||
time_end?: number | null;
|
||||
};
|
||||
@@ -106,8 +107,9 @@ export async function searchGeometriesByEntityName(
|
||||
type GeometryRow = {
|
||||
id: string;
|
||||
geo_type: number;
|
||||
draw_geometry: unknown;
|
||||
binding?: unknown;
|
||||
draw_geometry: Geometry;
|
||||
binding?: string[];
|
||||
|
||||
time_start?: number;
|
||||
time_end?: number;
|
||||
bbox?: {
|
||||
|
||||
@@ -1,20 +1,584 @@
|
||||
import { API_ENDPOINTS } from "@/uhm/api/config";
|
||||
import { requestJson } from "@/uhm/api/http";
|
||||
import {
|
||||
buildGoongProxyUrl,
|
||||
GOONG_SATELLITE_STYLE_UPSTREAM_URL,
|
||||
GOONG_VECTOR_OVERLAY_STYLE_UPSTREAM_URL,
|
||||
USE_EXTERNAL_BACKGROUND_RASTER,
|
||||
} from "@/uhm/api/config";
|
||||
import { GOONG_LABEL_FALLBACK_FONT_STACK } from "@/uhm/lib/map/styles/shared/textFonts";
|
||||
import maplibregl from "maplibre-gl";
|
||||
|
||||
export type TileMetadata = Record<string, string>;
|
||||
export type GoongBackgroundGroupId =
|
||||
| "bg-country-borders-line"
|
||||
| "bg-province-borders-line"
|
||||
| "bg-district-borders-line"
|
||||
| "country-labels"
|
||||
| "rivers-line";
|
||||
|
||||
export function getVectorTileTemplateUrl(): string {
|
||||
return API_ENDPOINTS.vectorTiles;
|
||||
type GoongStyleSource = {
|
||||
type?: string;
|
||||
url?: string;
|
||||
tiles?: string[];
|
||||
tileSize?: number;
|
||||
attribution?: string;
|
||||
bounds?: number[];
|
||||
scheme?: "xyz" | "tms";
|
||||
minzoom?: number;
|
||||
maxzoom?: number;
|
||||
};
|
||||
|
||||
type GoongSourceManifest = {
|
||||
tiles?: string[];
|
||||
tileSize?: number;
|
||||
pixel_scale?: number | string;
|
||||
attribution?: string;
|
||||
bounds?: number[];
|
||||
scheme?: "xyz" | "tms";
|
||||
minzoom?: number;
|
||||
maxzoom?: number;
|
||||
};
|
||||
|
||||
type GoongStyleDocument = {
|
||||
glyphs?: string;
|
||||
sprite?: string;
|
||||
sources?: Record<string, GoongStyleSource>;
|
||||
layers?: maplibregl.LayerSpecification[];
|
||||
};
|
||||
|
||||
let externalRasterSourcePromise: Promise<maplibregl.RasterSourceSpecification> | null = null;
|
||||
let goongOverlayBundlePromise: Promise<GoongBackgroundOverlayBundle | null> | null = null;
|
||||
const goongStyleDocumentPromises = new Map<string, Promise<GoongStyleDocument>>();
|
||||
const goongSourceSpecificationPromises = new Map<string, Promise<maplibregl.SourceSpecification>>();
|
||||
|
||||
type GoongBackgroundOverlayBundle = {
|
||||
sources: Record<string, maplibregl.SourceSpecification>;
|
||||
layers: maplibregl.LayerSpecification[];
|
||||
};
|
||||
|
||||
export async function getBackgroundRasterSourceSpecification(): Promise<maplibregl.RasterSourceSpecification> {
|
||||
if (!USE_EXTERNAL_BACKGROUND_RASTER) {
|
||||
throw new Error("NEXT_PUBLIC_API_URL_ROOT is not configured.");
|
||||
}
|
||||
|
||||
if (!externalRasterSourcePromise) {
|
||||
externalRasterSourcePromise = loadGoongRasterSourceSpecification(
|
||||
GOONG_SATELLITE_STYLE_UPSTREAM_URL
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await externalRasterSourcePromise;
|
||||
} catch (error) {
|
||||
externalRasterSourcePromise = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function getRasterTileTemplateUrl(): string {
|
||||
return API_ENDPOINTS.rasterTiles;
|
||||
export async function getGoongBackgroundOverlayBundle(): Promise<GoongBackgroundOverlayBundle | null> {
|
||||
if (!USE_EXTERNAL_BACKGROUND_RASTER) {
|
||||
throw new Error("NEXT_PUBLIC_API_URL_ROOT is not configured.");
|
||||
}
|
||||
|
||||
if (!goongOverlayBundlePromise) {
|
||||
goongOverlayBundlePromise = loadGoongBackgroundOverlayBundle(
|
||||
GOONG_VECTOR_OVERLAY_STYLE_UPSTREAM_URL
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await goongOverlayBundlePromise;
|
||||
} catch (error) {
|
||||
goongOverlayBundlePromise = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchVectorTilesMetadata(): Promise<TileMetadata> {
|
||||
return requestJson<TileMetadata>(API_ENDPOINTS.vectorTilesMetadata);
|
||||
async function loadGoongRasterSourceSpecification(
|
||||
styleUpstreamUrl: string
|
||||
): Promise<maplibregl.RasterSourceSpecification> {
|
||||
const style = await loadGoongStyleDocument(styleUpstreamUrl);
|
||||
const sources = style.sources || {};
|
||||
|
||||
for (const source of Object.values(sources)) {
|
||||
if (source.type !== "raster") continue;
|
||||
|
||||
const spec = await normalizeGoongSourceSpecification(source, styleUpstreamUrl);
|
||||
if (spec.type === "raster" && (spec.tiles?.length || "url" in spec)) {
|
||||
return spec;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("No raster source found in Goong satellite style.");
|
||||
}
|
||||
|
||||
export async function fetchRasterTilesMetadata(): Promise<TileMetadata> {
|
||||
return requestJson<TileMetadata>(API_ENDPOINTS.rasterTilesMetadata);
|
||||
async function loadGoongBackgroundOverlayBundle(
|
||||
styleUpstreamUrl: string
|
||||
): Promise<GoongBackgroundOverlayBundle | null> {
|
||||
const style = await loadGoongStyleDocument(styleUpstreamUrl);
|
||||
const layers = style.layers || [];
|
||||
const sources = style.sources || {};
|
||||
const layerById = new Map(layers.map((layer) => [layer.id, layer]));
|
||||
const selectedLayersByGroup = new Map<GoongBackgroundGroupId, maplibregl.LayerSpecification[]>([
|
||||
["bg-country-borders-line", []],
|
||||
["bg-province-borders-line", []],
|
||||
["bg-district-borders-line", []],
|
||||
["rivers-line", []],
|
||||
["country-labels", []],
|
||||
]);
|
||||
|
||||
for (const rawLayer of layers) {
|
||||
const resolvedLayer = resolveLayerReference(rawLayer, layerById);
|
||||
const groupId = detectGoongBackgroundGroup(resolvedLayer);
|
||||
if (!groupId) continue;
|
||||
selectedLayersByGroup.get(groupId)?.push(resolvedLayer);
|
||||
}
|
||||
|
||||
const selectedSourceIds = new Set<string>();
|
||||
for (const groupLayers of selectedLayersByGroup.values()) {
|
||||
for (const layer of groupLayers) {
|
||||
if ("source" in layer && typeof layer.source === "string") {
|
||||
selectedSourceIds.add(layer.source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedSourceIds.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceIdMap = new Map<string, string>();
|
||||
const overlaySources: Record<string, maplibregl.SourceSpecification> = {};
|
||||
const overlaySourceEntries = await Promise.all(
|
||||
[...selectedSourceIds].map(async (sourceId) => {
|
||||
const source = sources[sourceId];
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prefixedId = `goong-overlay-${sourceId}`;
|
||||
const normalizedSource = await normalizeGoongSourceSpecification(
|
||||
source,
|
||||
styleUpstreamUrl
|
||||
);
|
||||
|
||||
return { sourceId, prefixedId, normalizedSource };
|
||||
})
|
||||
);
|
||||
|
||||
for (const entry of overlaySourceEntries) {
|
||||
if (!entry) continue;
|
||||
sourceIdMap.set(entry.sourceId, entry.prefixedId);
|
||||
overlaySources[entry.prefixedId] = entry.normalizedSource;
|
||||
}
|
||||
|
||||
const overlayLayers: maplibregl.LayerSpecification[] = [];
|
||||
for (const groupId of [
|
||||
"rivers-line",
|
||||
"bg-country-borders-line",
|
||||
"bg-province-borders-line",
|
||||
"bg-district-borders-line",
|
||||
"country-labels",
|
||||
] as const) {
|
||||
const groupLayers = [...(selectedLayersByGroup.get(groupId) || [])].sort(compareOverlayLayers);
|
||||
groupLayers.forEach((layer, index) => {
|
||||
overlayLayers.push(
|
||||
cloneOverlayLayer(layer, {
|
||||
id: `goong-${groupId}-${index}`,
|
||||
groupId,
|
||||
sourceIdMap,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
sources: overlaySources,
|
||||
layers: overlayLayers,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadGoongStyleDocument(styleUpstreamUrl: string): Promise<GoongStyleDocument> {
|
||||
const existingPromise = goongStyleDocumentPromises.get(styleUpstreamUrl);
|
||||
if (existingPromise) {
|
||||
return existingPromise;
|
||||
}
|
||||
|
||||
const styleProxyUrl = buildGoongProxyUrl(styleUpstreamUrl);
|
||||
const promise = fetch(styleProxyUrl, { cache: "force-cache" })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Goong style request failed with status ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as GoongStyleDocument;
|
||||
});
|
||||
goongStyleDocumentPromises.set(styleUpstreamUrl, promise);
|
||||
|
||||
try {
|
||||
return await promise;
|
||||
} catch (error) {
|
||||
goongStyleDocumentPromises.delete(styleUpstreamUrl);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGoongSourceSpecification(
|
||||
sourceUpstreamUrl: string,
|
||||
parentSource: GoongStyleSource
|
||||
): Promise<maplibregl.SourceSpecification> {
|
||||
const cacheKey = JSON.stringify({
|
||||
sourceUpstreamUrl,
|
||||
type: parentSource.type,
|
||||
tileSize: parentSource.tileSize,
|
||||
minzoom: parentSource.minzoom,
|
||||
maxzoom: parentSource.maxzoom,
|
||||
});
|
||||
const existingPromise = goongSourceSpecificationPromises.get(cacheKey);
|
||||
if (existingPromise) {
|
||||
return existingPromise;
|
||||
}
|
||||
|
||||
const sourceProxyUrl = buildGoongProxyUrl(sourceUpstreamUrl);
|
||||
const promise = fetch(sourceProxyUrl, { cache: "force-cache" })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Goong source request failed with status ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as GoongSourceManifest;
|
||||
})
|
||||
.then((sourceDocument) =>
|
||||
normalizeManifestBackedGoongSourceSpecification(parentSource, sourceDocument, sourceUpstreamUrl)
|
||||
);
|
||||
goongSourceSpecificationPromises.set(cacheKey, promise);
|
||||
|
||||
try {
|
||||
return await promise;
|
||||
} catch (error) {
|
||||
goongSourceSpecificationPromises.delete(cacheKey);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function normalizeGoongSourceSpecification(
|
||||
source: GoongStyleSource,
|
||||
parentDocumentUrl: string
|
||||
): Promise<maplibregl.SourceSpecification> {
|
||||
if (typeof source.url === "string" && source.url) {
|
||||
const sourceUpstreamUrl = resolveGoongResourceUrl(source.url, parentDocumentUrl);
|
||||
return loadGoongSourceSpecification(sourceUpstreamUrl, source);
|
||||
}
|
||||
|
||||
return normalizeInlineGoongSourceSpecification(source, parentDocumentUrl);
|
||||
}
|
||||
|
||||
function normalizeInlineGoongSourceSpecification(
|
||||
source: GoongStyleSource,
|
||||
parentDocumentUrl: string
|
||||
): maplibregl.SourceSpecification {
|
||||
return buildMapLibreSourceSpecification(source, parentDocumentUrl);
|
||||
}
|
||||
|
||||
function normalizeManifestBackedGoongSourceSpecification(
|
||||
parentSource: GoongStyleSource,
|
||||
sourceManifest: GoongSourceManifest,
|
||||
sourceUpstreamUrl: string
|
||||
): maplibregl.SourceSpecification {
|
||||
const mergedSource: GoongStyleSource = {
|
||||
...parentSource,
|
||||
attribution: sourceManifest.attribution ?? parentSource.attribution,
|
||||
bounds: sourceManifest.bounds ?? parentSource.bounds,
|
||||
maxzoom: sourceManifest.maxzoom ?? parentSource.maxzoom,
|
||||
minzoom: sourceManifest.minzoom ?? parentSource.minzoom,
|
||||
scheme: sourceManifest.scheme ?? parentSource.scheme,
|
||||
tileSize:
|
||||
sourceManifest.tileSize ??
|
||||
normalizeGoongTileSize(sourceManifest.pixel_scale) ??
|
||||
parentSource.tileSize,
|
||||
tiles: sourceManifest.tiles ?? parentSource.tiles,
|
||||
};
|
||||
|
||||
return buildMapLibreSourceSpecification(mergedSource, sourceUpstreamUrl);
|
||||
}
|
||||
|
||||
function buildMapLibreSourceSpecification(
|
||||
source: GoongStyleSource,
|
||||
parentDocumentUrl: string
|
||||
): maplibregl.SourceSpecification {
|
||||
const resolvedTiles = Array.isArray(source.tiles)
|
||||
? source.tiles.map((tileUrl) => {
|
||||
const upstreamTileUrl = resolveGoongResourceUrl(tileUrl, parentDocumentUrl);
|
||||
return buildGoongProxyUrl(upstreamTileUrl);
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (source.type === "raster") {
|
||||
const rasterSource: maplibregl.RasterSourceSpecification = {
|
||||
type: "raster",
|
||||
...(resolvedTiles?.length ? { tiles: resolvedTiles } : {}),
|
||||
...(typeof source.tileSize === "number" ? { tileSize: source.tileSize } : {}),
|
||||
...(typeof source.minzoom === "number" ? { minzoom: source.minzoom } : {}),
|
||||
...(typeof source.maxzoom === "number" ? { maxzoom: source.maxzoom } : {}),
|
||||
...(Array.isArray(source.bounds) ? { bounds: source.bounds as [number, number, number, number] } : {}),
|
||||
...(source.scheme ? { scheme: source.scheme } : {}),
|
||||
...(source.attribution ? { attribution: source.attribution } : {}),
|
||||
};
|
||||
|
||||
return rasterSource;
|
||||
}
|
||||
|
||||
if (source.type === "vector") {
|
||||
const vectorSource: maplibregl.VectorSourceSpecification = {
|
||||
type: "vector",
|
||||
...(resolvedTiles?.length ? { tiles: resolvedTiles } : {}),
|
||||
...(typeof source.minzoom === "number" ? { minzoom: source.minzoom } : {}),
|
||||
...(typeof source.maxzoom === "number" ? { maxzoom: source.maxzoom } : {}),
|
||||
...(Array.isArray(source.bounds) ? { bounds: source.bounds as [number, number, number, number] } : {}),
|
||||
...(source.scheme ? { scheme: source.scheme } : {}),
|
||||
...(source.attribution ? { attribution: source.attribution } : {}),
|
||||
};
|
||||
|
||||
return vectorSource;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported Goong source type: ${String(source.type || "unknown")}`);
|
||||
}
|
||||
|
||||
function normalizeGoongTileSize(value: number | string | undefined): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const parsedValue = Number.parseInt(value, 10);
|
||||
if (Number.isFinite(parsedValue)) {
|
||||
return parsedValue;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveLayerReference(
|
||||
layer: maplibregl.LayerSpecification,
|
||||
layerById: Map<string, maplibregl.LayerSpecification>
|
||||
): maplibregl.LayerSpecification {
|
||||
const withRef = layer as maplibregl.LayerSpecification & { ref?: string };
|
||||
if (!withRef.ref) {
|
||||
return deepClone(layer);
|
||||
}
|
||||
|
||||
const parent = layerById.get(withRef.ref);
|
||||
if (!parent) {
|
||||
return deepClone(layer);
|
||||
}
|
||||
|
||||
const resolvedParent = resolveLayerReference(parent, layerById);
|
||||
const merged = {
|
||||
...resolvedParent,
|
||||
...deepClone(layer),
|
||||
} as maplibregl.LayerSpecification & {
|
||||
ref?: string;
|
||||
layout?: Record<string, unknown>;
|
||||
paint?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
merged.layout = {
|
||||
...(resolvedParent as { layout?: Record<string, unknown> }).layout,
|
||||
...(withRef as { layout?: Record<string, unknown> }).layout,
|
||||
};
|
||||
merged.paint = {
|
||||
...(resolvedParent as { paint?: Record<string, unknown> }).paint,
|
||||
...(withRef as { paint?: Record<string, unknown> }).paint,
|
||||
};
|
||||
merged.metadata = {
|
||||
...(resolvedParent as { metadata?: Record<string, unknown> }).metadata,
|
||||
...(withRef as { metadata?: Record<string, unknown> }).metadata,
|
||||
};
|
||||
delete merged.ref;
|
||||
return merged;
|
||||
}
|
||||
|
||||
function detectGoongBackgroundGroup(
|
||||
layer: maplibregl.LayerSpecification
|
||||
): GoongBackgroundGroupId | null {
|
||||
const haystack = [
|
||||
layer.id,
|
||||
"source" in layer && typeof layer.source === "string" ? layer.source : "",
|
||||
"source-layer" in layer && typeof layer["source-layer"] === "string" ? layer["source-layer"] : "",
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
|
||||
if (layer.type === "symbol" && hasTextField(layer) && isPreferredPlaceLabelLayer(haystack)) {
|
||||
return "country-labels";
|
||||
}
|
||||
|
||||
if (layer.type === "line") {
|
||||
const boundaryGroup = detectBoundaryGroup(layer, haystack);
|
||||
if (boundaryGroup) {
|
||||
return boundaryGroup;
|
||||
}
|
||||
}
|
||||
|
||||
if (layer.type === "line" && /(water|waterway|river|stream|canal)/.test(haystack)) {
|
||||
return "rivers-line";
|
||||
}
|
||||
|
||||
if (layer.type === "fill" && /(water|lake|reservoir|sea|ocean)/.test(haystack)) {
|
||||
return "rivers-line";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasTextField(layer: maplibregl.LayerSpecification): boolean {
|
||||
const layout = (layer as { layout?: Record<string, unknown> }).layout;
|
||||
return Boolean(layout && "text-field" in layout && layout["text-field"]);
|
||||
}
|
||||
|
||||
function isPreferredPlaceLabelLayer(haystack: string): boolean {
|
||||
if (/(poi|airport|station|transit|rail|metro|bus|road|street|highway|path|route)/.test(haystack)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /(country|state|province|district|admin|place|city|town|village|settlement|capital|label)/.test(haystack);
|
||||
}
|
||||
|
||||
function detectBoundaryGroup(
|
||||
_layer: maplibregl.LayerSpecification,
|
||||
haystack: string
|
||||
): GoongBackgroundGroupId | null {
|
||||
if (/(road|street|highway|path|route|rail|transit|water|waterway|river|stream|canal)/.test(haystack)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!/(boundary|border|admin|country|state|province|district|ward|commune|county)/.test(haystack)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Goong's public styles expose the boundary hierarchy most clearly
|
||||
// through boundary-land-type-{0,1,2}. Prefer these exact matches over
|
||||
// keyword heuristics because the heuristic buckets were mixing levels.
|
||||
if (/boundary-land-type-0/.test(haystack)) {
|
||||
if (/boundary-land-type-0-bg/.test(haystack)) {
|
||||
return null;
|
||||
}
|
||||
return "bg-country-borders-line";
|
||||
}
|
||||
|
||||
if (/boundary-land-type-1/.test(haystack)) {
|
||||
if (/boundary-land-type-1-bg/.test(haystack)) {
|
||||
return null;
|
||||
}
|
||||
return "bg-province-borders-line";
|
||||
}
|
||||
|
||||
if (/boundary-land-type-2/.test(haystack)) {
|
||||
return "bg-district-borders-line";
|
||||
}
|
||||
|
||||
const adminLevels = extractAdminLevels(haystack);
|
||||
if (adminLevels.length > 0) {
|
||||
const minAdminLevel = Math.min(...adminLevels);
|
||||
if (minAdminLevel <= 2) return "bg-country-borders-line";
|
||||
if (minAdminLevel <= 5) return "bg-province-borders-line";
|
||||
return "bg-district-borders-line";
|
||||
}
|
||||
|
||||
if (/(district|ward|commune|subdistrict|neighbou?rhood)/.test(haystack)) {
|
||||
return "bg-district-borders-line";
|
||||
}
|
||||
|
||||
if (/(province|state|region)/.test(haystack)) {
|
||||
return "bg-province-borders-line";
|
||||
}
|
||||
|
||||
if (/(country|national|international)/.test(haystack)) {
|
||||
return "bg-country-borders-line";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractAdminLevels(haystack: string): number[] {
|
||||
const matches = Array.from(
|
||||
haystack.matchAll(/(?:admin[_ -]?level|adminlevel|admin|level)[_ -]?(\d{1,2})/g)
|
||||
);
|
||||
|
||||
return matches
|
||||
.map((match) => Number.parseInt(match[1] || "", 10))
|
||||
.filter((value) => Number.isFinite(value));
|
||||
}
|
||||
|
||||
function cloneOverlayLayer(
|
||||
layer: maplibregl.LayerSpecification,
|
||||
options: {
|
||||
id: string;
|
||||
groupId: GoongBackgroundGroupId;
|
||||
sourceIdMap: Map<string, string>;
|
||||
}
|
||||
): maplibregl.LayerSpecification {
|
||||
const cloned = deepClone(layer) as maplibregl.LayerSpecification & {
|
||||
source?: string;
|
||||
layout?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
cloned.id = options.id;
|
||||
if (typeof cloned.source === "string" && options.sourceIdMap.has(cloned.source)) {
|
||||
cloned.source = options.sourceIdMap.get(cloned.source);
|
||||
}
|
||||
|
||||
cloned.metadata = {
|
||||
...(cloned.metadata || {}),
|
||||
uhmBackgroundGroupId: options.groupId,
|
||||
uhmBackgroundProvider: "goong",
|
||||
};
|
||||
|
||||
if (options.groupId === "country-labels") {
|
||||
const layout = { ...(cloned.layout || {}) };
|
||||
delete layout["icon-image"];
|
||||
delete layout["icon-size"];
|
||||
delete layout["icon-allow-overlap"];
|
||||
delete layout["icon-ignore-placement"];
|
||||
if (!Array.isArray(layout["text-font"])) {
|
||||
layout["text-font"] = [...GOONG_LABEL_FALLBACK_FONT_STACK];
|
||||
}
|
||||
cloned.layout = layout;
|
||||
}
|
||||
|
||||
return cloned;
|
||||
}
|
||||
|
||||
function compareOverlayLayers(
|
||||
left: maplibregl.LayerSpecification,
|
||||
right: maplibregl.LayerSpecification
|
||||
): number {
|
||||
const leftMinzoom = "minzoom" in left && typeof left.minzoom === "number"
|
||||
? left.minzoom
|
||||
: -1;
|
||||
const rightMinzoom = "minzoom" in right && typeof right.minzoom === "number"
|
||||
? right.minzoom
|
||||
: -1;
|
||||
|
||||
if (leftMinzoom !== rightMinzoom) {
|
||||
return leftMinzoom - rightMinzoom;
|
||||
}
|
||||
|
||||
return left.id.localeCompare(right.id);
|
||||
}
|
||||
|
||||
function resolveGoongResourceUrl(value: string, parentDocumentUrl: string): string {
|
||||
if (/^[a-z]+:\/\//i.test(value) || value.startsWith("data:")) {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(value, parentDocumentUrl).toString();
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function deepClone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
@@ -4,20 +4,21 @@ import { ApiError, requestJson } from "@/uhm/api/http";
|
||||
|
||||
export type Wiki = {
|
||||
id: string;
|
||||
project_id?: string;
|
||||
project_id: string;
|
||||
title?: string;
|
||||
slug?: string | null;
|
||||
content?: string;
|
||||
is_deleted?: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
content_sample?:{
|
||||
created_at?: string;
|
||||
content?: string;
|
||||
id?: string;
|
||||
content_sample?: {
|
||||
id: string;
|
||||
title: string;
|
||||
created_at: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
|
||||
export async function searchWikisByTitle(title: string, options?: { limit?: number; cursor?: string; entityId?: string }): Promise<Wiki[]> {
|
||||
const keyword = title.trim();
|
||||
const params = new URLSearchParams({ title: keyword });
|
||||
|
||||
@@ -63,19 +63,24 @@ export default function Editor({
|
||||
undoStack,
|
||||
width = 280,
|
||||
}: Props) {
|
||||
// State đóng/mở modal submit project.
|
||||
const [isSubmitModalOpen, setIsSubmitModalOpen] = useState(false);
|
||||
// State nội dung submit gửi lên backend khi user xác nhận.
|
||||
const [submitContent, setSubmitContent] = useState("");
|
||||
|
||||
// Mở modal submit với nội dung sạch cho lần submit mới.
|
||||
const handleOpenSubmitModal = () => {
|
||||
setSubmitContent("");
|
||||
setIsSubmitModalOpen(true);
|
||||
};
|
||||
|
||||
// Xác nhận submit: đóng modal trước rồi chuyển content cho command cha.
|
||||
const handleConfirmSubmit = () => {
|
||||
setIsSubmitModalOpen(false);
|
||||
onSubmit(submitContent);
|
||||
};
|
||||
|
||||
// Hủy submit mà không thay đổi draft/commit.
|
||||
const handleCancelSubmit = () => {
|
||||
setIsSubmitModalOpen(false);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { type CSSProperties, useEffect, useRef } from "react";
|
||||
import { type CSSProperties, useEffect, useRef, forwardRef, useImperativeHandle } from "react";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
|
||||
import { Feature, FeatureCollection, Geometry } from "@/uhm/lib/editor/state/useEditorState";
|
||||
@@ -11,6 +11,7 @@ import { useMapInstance } from "./map/useMapInstance";
|
||||
import { setupMapLayers } from "./map/useMapLayers";
|
||||
import { useMapInteraction } from "./map/useMapInteraction";
|
||||
import { useMapSync } from "./map/useMapSync";
|
||||
import { bindImageOverlayInteractions, type MapImageOverlay } from "./map/imageOverlay";
|
||||
|
||||
export type MapHoverPayload = {
|
||||
featureId: string | number;
|
||||
@@ -19,20 +20,36 @@ export type MapHoverPayload = {
|
||||
lngLat: { lng: number; lat: number };
|
||||
};
|
||||
|
||||
export type MapHandle = {
|
||||
getViewState: () => {
|
||||
center: { lng: number; lat: number };
|
||||
zoom: number;
|
||||
pitch: number;
|
||||
bearing: number;
|
||||
projection: string;
|
||||
} | null;
|
||||
getMap: () => import("maplibre-gl").Map | null;
|
||||
};
|
||||
|
||||
type MapProps = {
|
||||
mode: EditorMode;
|
||||
draft: FeatureCollection;
|
||||
// FeatureCollection that should actually be rendered/interacted with on the map.
|
||||
// Callers should apply timeline/replay filters before passing it here.
|
||||
renderDraft: FeatureCollection;
|
||||
backgroundVisibility: BackgroundLayerVisibility;
|
||||
geometryVisibility?: Record<string, boolean>;
|
||||
selectedFeatureIds: (string | number)[];
|
||||
onSelectFeatureIds: (ids: (string | number)[]) => void;
|
||||
onSetMode?: (mode: EditorMode, featureId?: string | number) => void;
|
||||
// Label lookup context only. It may include non-rendered geometries for entity label resolution.
|
||||
labelContextDraft?: FeatureCollection;
|
||||
labelTimelineYear?: number | null;
|
||||
onCreateFeature?: (feature: FeatureCollection["features"][number]) => void;
|
||||
onDeleteFeature?: (id: string | number) => void;
|
||||
onDeleteFeature?: (id: string | number | (string | number)[]) => void;
|
||||
onHideFeature?: (id: string | number) => void;
|
||||
onUpdateFeature?: (id: string | number, geometry: Geometry) => void;
|
||||
allowGeometryEditing?: boolean;
|
||||
respectBindingFilter?: boolean;
|
||||
applyGeometryBindingFilter?: boolean;
|
||||
height?: CSSProperties["height"];
|
||||
fitToDraftBounds?: boolean;
|
||||
fitBoundsKey?: string | number | null;
|
||||
@@ -41,24 +58,27 @@ type MapProps = {
|
||||
focusFeatureCollection?: FeatureCollection | null;
|
||||
focusRequestKey?: string | number | null;
|
||||
focusPadding?: number | import("maplibre-gl").PaddingOptions;
|
||||
hideOutside?: boolean;
|
||||
onToggleHideOutside?: () => void;
|
||||
imageOverlay?: MapImageOverlay | null;
|
||||
onImageOverlayChange?: (overlay: MapImageOverlay) => void;
|
||||
onBindGeometries?: (targetId: string | number, sourceIds: (string | number)[]) => void;
|
||||
};
|
||||
|
||||
export default function Map({
|
||||
const Map = forwardRef<MapHandle, MapProps>(function Map({
|
||||
mode,
|
||||
onSetMode,
|
||||
draft,
|
||||
renderDraft,
|
||||
backgroundVisibility,
|
||||
geometryVisibility,
|
||||
selectedFeatureIds,
|
||||
onSelectFeatureIds,
|
||||
labelContextDraft,
|
||||
labelTimelineYear,
|
||||
onCreateFeature,
|
||||
onDeleteFeature,
|
||||
onHideFeature,
|
||||
onUpdateFeature,
|
||||
allowGeometryEditing = true,
|
||||
respectBindingFilter = true,
|
||||
applyGeometryBindingFilter = true,
|
||||
height = "100vh",
|
||||
fitToDraftBounds = false,
|
||||
fitBoundsKey = null,
|
||||
@@ -67,27 +87,49 @@ export default function Map({
|
||||
focusFeatureCollection = null,
|
||||
focusRequestKey = null,
|
||||
focusPadding,
|
||||
hideOutside = false,
|
||||
onToggleHideOutside,
|
||||
}: MapProps) {
|
||||
imageOverlay = null,
|
||||
onImageOverlayChange,
|
||||
onBindGeometries,
|
||||
}, ref) {
|
||||
// Ref giữ mode mới nhất cho MapLibre handlers được register một lần.
|
||||
const modeRef = useRef<MapProps["mode"]>(mode);
|
||||
const draftRef = useRef<FeatureCollection>(draft);
|
||||
// Ref giữ render draft mới nhất để map engines đọc không bị stale closure.
|
||||
const renderDraftRef = useRef<FeatureCollection>(renderDraft);
|
||||
// Ref callback select feature mới nhất cho event click trên map.
|
||||
const onSelectFeatureIdsRef = useRef(onSelectFeatureIds);
|
||||
// Ref callback đổi mode mới nhất, dùng khi map interaction chuyển sang replay/select.
|
||||
const onSetModeRef = useRef(onSetMode);
|
||||
// Ref callback hover mới nhất cho tooltip/panel ngoài map.
|
||||
const onHoverFeatureChangeRef = useRef<MapProps["onHoverFeatureChange"]>(onHoverFeatureChange);
|
||||
// Ref callback create mới nhất khi drawing engine tạo feature.
|
||||
const onCreateRef = useRef<MapProps["onCreateFeature"]>(onCreateFeature);
|
||||
// Ref callback delete mới nhất khi editing engine xóa feature.
|
||||
const onDeleteRef = useRef<MapProps["onDeleteFeature"]>(onDeleteFeature);
|
||||
// Ref callback hide local mới nhất khi context menu select ẩn feature khỏi map.
|
||||
const onHideRef = useRef<MapProps["onHideFeature"]>(onHideFeature);
|
||||
// Ref callback update mới nhất khi editing engine đổi geometry.
|
||||
const onUpdateRef = useRef<MapProps["onUpdateFeature"]>(onUpdateFeature);
|
||||
|
||||
// Ref giữ overlay mới nhất cho right-drag controls.
|
||||
const imageOverlayRef = useRef<MapImageOverlay | null>(imageOverlay);
|
||||
// Ref callback update overlay mới nhất để interaction không stale.
|
||||
const onImageOverlayChangeRef = useRef<MapProps["onImageOverlayChange"]>(onImageOverlayChange);
|
||||
// Ref callback bind geometry mới nhất để interaction không stale.
|
||||
const onBindGeometriesRef = useRef<MapProps["onBindGeometries"]>(onBindGeometries);
|
||||
|
||||
useEffect(() => { modeRef.current = mode; }, [mode]);
|
||||
useEffect(() => { draftRef.current = draft; }, [draft]);
|
||||
useEffect(() => { renderDraftRef.current = renderDraft; }, [renderDraft]);
|
||||
useEffect(() => { onSelectFeatureIdsRef.current = onSelectFeatureIds; }, [onSelectFeatureIds]);
|
||||
useEffect(() => { onSetModeRef.current = onSetMode; }, [onSetMode]);
|
||||
useEffect(() => { onHoverFeatureChangeRef.current = onHoverFeatureChange; }, [onHoverFeatureChange]);
|
||||
useEffect(() => { onCreateRef.current = onCreateFeature; }, [onCreateFeature]);
|
||||
useEffect(() => { onDeleteRef.current = onDeleteFeature; }, [onDeleteFeature]);
|
||||
useEffect(() => { onHideRef.current = onHideFeature; }, [onHideFeature]);
|
||||
useEffect(() => { onUpdateRef.current = onUpdateFeature; }, [onUpdateFeature]);
|
||||
useEffect(() => { imageOverlayRef.current = imageOverlay; }, [imageOverlay]);
|
||||
useEffect(() => { onImageOverlayChangeRef.current = onImageOverlayChange; }, [onImageOverlayChange]);
|
||||
useEffect(() => { onBindGeometriesRef.current = onBindGeometries; }, [onBindGeometries]);
|
||||
|
||||
// Hook sở hữu lifecycle MapLibre instance và các control camera/projection.
|
||||
const {
|
||||
mapRef,
|
||||
containerRef,
|
||||
@@ -100,8 +142,18 @@ export default function Map({
|
||||
geolocationCenteredRef,
|
||||
handleZoomByStep,
|
||||
handleZoomSliderChange,
|
||||
beginZoomSliderDrag,
|
||||
endZoomSliderDrag,
|
||||
getViewState,
|
||||
} = useMapInstance();
|
||||
|
||||
// Public API cho parent đọc map instance/view state mà không expose implementation nội bộ.
|
||||
useImperativeHandle(ref, () => ({
|
||||
getViewState,
|
||||
getMap: () => mapRef.current,
|
||||
}), [getViewState, mapRef]);
|
||||
|
||||
// Hook gắn/dọn các interaction vẽ, chọn, sửa geometry.
|
||||
const {
|
||||
editingEngineRef,
|
||||
setupMapInteractions,
|
||||
@@ -110,35 +162,41 @@ export default function Map({
|
||||
mapRef,
|
||||
mode,
|
||||
modeRef,
|
||||
draftRef,
|
||||
renderDraftRef,
|
||||
allowGeometryEditing,
|
||||
selectedFeatureIds,
|
||||
onSelectFeatureIdsRef,
|
||||
onSetModeRef,
|
||||
onCreateRef,
|
||||
onDeleteRef,
|
||||
onHideRef,
|
||||
onUpdateRef,
|
||||
onHoverFeatureChangeRef,
|
||||
onBindGeometriesRef,
|
||||
});
|
||||
|
||||
// Hook đồng bộ draft/layer/filter/highlight từ React state xuống MapLibre source/layer.
|
||||
const {
|
||||
applyDraftToMap,
|
||||
applyRenderDraftToMap,
|
||||
applyHighlightToMap,
|
||||
applyImageOverlayToMap,
|
||||
tryCenterToUserLocation,
|
||||
} = useMapSync({
|
||||
mapRef,
|
||||
draft,
|
||||
renderDraft,
|
||||
labelContextDraft,
|
||||
labelTimelineYear,
|
||||
backgroundVisibility,
|
||||
geometryVisibility,
|
||||
selectedFeatureIds,
|
||||
respectBindingFilter,
|
||||
applyGeometryBindingFilter,
|
||||
fitToDraftBounds,
|
||||
fitBoundsKey,
|
||||
highlightFeatures,
|
||||
focusFeatureCollection,
|
||||
focusRequestKey,
|
||||
focusPadding,
|
||||
imageOverlay,
|
||||
allowGeometryEditing,
|
||||
editingEngineRef,
|
||||
geolocationCenteredRef,
|
||||
@@ -149,8 +207,9 @@ export default function Map({
|
||||
if (!map || !isMapLoaded) return;
|
||||
|
||||
setupMapLayers(map, backgroundVisibility, highlightFeatures, applyHighlightToMap);
|
||||
applyImageOverlayToMap();
|
||||
setupMapInteractions(map);
|
||||
applyDraftToMap(draftRef.current);
|
||||
applyRenderDraftToMap(renderDraftRef.current);
|
||||
tryCenterToUserLocation();
|
||||
|
||||
return () => {
|
||||
@@ -165,7 +224,18 @@ export default function Map({
|
||||
// Trigger resize after a short delay to allow layout to settle
|
||||
setTimeout(() => map.resize(), 100);
|
||||
}
|
||||
}, [mode, isMapLoaded]);
|
||||
}, [mode, isMapLoaded, mapRef]);
|
||||
|
||||
const hasImageOverlay = Boolean(imageOverlay);
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !isMapLoaded || !hasImageOverlay) return;
|
||||
return bindImageOverlayInteractions(
|
||||
map,
|
||||
() => imageOverlayRef.current,
|
||||
(nextOverlay) => onImageOverlayChangeRef.current?.(nextOverlay)
|
||||
);
|
||||
}, [hasImageOverlay, isMapLoaded, mapRef]);
|
||||
|
||||
return (
|
||||
<div style={{ width: "100%", height, position: "relative" }}>
|
||||
@@ -229,72 +299,6 @@ export default function Map({
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
>
|
||||
{mode === "replay" && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSetMode?.("select")}
|
||||
style={{
|
||||
...zoomButtonStyle,
|
||||
width: "auto",
|
||||
padding: "0 12px",
|
||||
fontSize: "12px",
|
||||
fontWeight: 700,
|
||||
background: "#7f1d1d",
|
||||
color: "white",
|
||||
border: "1px solid #991b1b",
|
||||
borderRadius: "999px",
|
||||
cursor: "pointer",
|
||||
marginRight: "4px",
|
||||
}}
|
||||
>
|
||||
Thoát Replay Edit
|
||||
</button>
|
||||
|
||||
<div
|
||||
onClick={onToggleHideOutside}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
cursor: "pointer",
|
||||
marginRight: "8px",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "12px", fontWeight: 700, color: hideOutside ? "#fb7185" : "#94a3b8" }}>
|
||||
Hide Outside
|
||||
</span>
|
||||
<div
|
||||
style={{
|
||||
width: "32px",
|
||||
height: "18px",
|
||||
borderRadius: "10px",
|
||||
background: hideOutside ? "#e11d48" : "#334155",
|
||||
position: "relative",
|
||||
transition: "background 0.2s",
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "2px",
|
||||
left: hideOutside ? "16px" : "2px",
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
borderRadius: "50%",
|
||||
background: "white",
|
||||
transition: "left 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
boxShadow: "0 1px 2px rgba(0,0,0,0.3)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ width: "1px", height: "20px", background: "rgba(148, 163, 184, 0.3)", marginRight: "4px" }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<label
|
||||
title={
|
||||
isGlobeProjection
|
||||
@@ -373,6 +377,26 @@ export default function Map({
|
||||
max={zoomBounds.max}
|
||||
step={0.1}
|
||||
value={zoomLevel}
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
try {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// Browser may reject capture for non-primary pointers; drag lock still works.
|
||||
}
|
||||
beginZoomSliderDrag();
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
event.stopPropagation();
|
||||
try {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// Ignore if capture was already released.
|
||||
}
|
||||
endZoomSliderDrag();
|
||||
}}
|
||||
onPointerCancel={endZoomSliderDrag}
|
||||
onBlur={endZoomSliderDrag}
|
||||
onChange={(event) => handleZoomSliderChange(Number(event.target.value))}
|
||||
style={{
|
||||
flex: 1,
|
||||
@@ -406,7 +430,9 @@ export default function Map({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default Map;
|
||||
|
||||
const zoomButtonStyle: React.CSSProperties = {
|
||||
width: "28px",
|
||||
|
||||
@@ -1,34 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { persistBackgroundLayerVisibility } from "@/uhm/lib/editor/background/backgroundVisibilityStorage";
|
||||
import {
|
||||
BACKGROUND_LAYER_OPTIONS,
|
||||
BackgroundLayerId,
|
||||
BackgroundLayerVisibility,
|
||||
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
||||
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
||||
} from "@/uhm/lib/map/styles/backgroundLayers";
|
||||
import { GEO_TYPE_KEYS } from "@/uhm/lib/map/geo/geoTypeMap";
|
||||
import { useEditorStore } from "@/uhm/store/editorStore";
|
||||
|
||||
type Props = {
|
||||
visibility: BackgroundLayerVisibility;
|
||||
onToggleLayer: (id: BackgroundLayerId) => void;
|
||||
onShowAll: () => void;
|
||||
onHideAll: () => void;
|
||||
geometryVisibility?: Record<string, boolean>;
|
||||
onToggleGeometryType?: (typeKey: string) => void;
|
||||
topContent?: ReactNode;
|
||||
width?: number;
|
||||
};
|
||||
|
||||
export default function BackgroundLayersPanel({
|
||||
visibility,
|
||||
onToggleLayer,
|
||||
onShowAll,
|
||||
onHideAll,
|
||||
geometryVisibility,
|
||||
onToggleGeometryType,
|
||||
topContent,
|
||||
width = 240,
|
||||
}: Props) {
|
||||
const {
|
||||
visibility,
|
||||
setBackgroundVisibility,
|
||||
geometryVisibility,
|
||||
setGeometryVisibility,
|
||||
} = useEditorStore(
|
||||
useShallow((state) => ({
|
||||
visibility: state.backgroundVisibility,
|
||||
setBackgroundVisibility: state.setBackgroundVisibility,
|
||||
geometryVisibility: state.geometryVisibility,
|
||||
setGeometryVisibility: state.setGeometryVisibility,
|
||||
}))
|
||||
);
|
||||
|
||||
const updateBackgroundVisibility = (
|
||||
updater: (prev: typeof visibility) => typeof visibility
|
||||
) => {
|
||||
setBackgroundVisibility((prev) => {
|
||||
const next = updater(prev);
|
||||
persistBackgroundLayerVisibility(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleLayer = (id: BackgroundLayerId) => {
|
||||
updateBackgroundVisibility((prev) => ({
|
||||
...prev,
|
||||
[id]: !prev[id],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleShowAll = () => {
|
||||
updateBackgroundVisibility(() => ({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }));
|
||||
};
|
||||
|
||||
const handleHideAll = () => {
|
||||
updateBackgroundVisibility(() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }));
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
style={{
|
||||
@@ -52,7 +83,7 @@ export default function BackgroundLayersPanel({
|
||||
<button
|
||||
key={layer.id}
|
||||
type="button"
|
||||
onClick={() => onToggleLayer(layer.id)}
|
||||
onClick={() => handleToggleLayer(layer.id)}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
@@ -75,44 +106,75 @@ export default function BackgroundLayersPanel({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{geometryVisibility && onToggleGeometryType ? (
|
||||
<>
|
||||
<div style={{ height: 1, background: "#1f2937", margin: "12px 0" }} />
|
||||
<div style={{ margin: 0, marginBottom: 10, fontWeight: 800, fontSize: 13, color: "#e5e7eb" }}>
|
||||
Geometries
|
||||
</div>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "10px 12px" }}>
|
||||
{GEO_TYPE_KEYS.map((typeKey) => {
|
||||
const on = geometryVisibility[typeKey] !== false;
|
||||
return (
|
||||
<button
|
||||
key={typeKey}
|
||||
type="button"
|
||||
onClick={() => onToggleGeometryType(typeKey)}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
cursor: "pointer",
|
||||
color: on ? "#22c55e" : "#e5e7eb",
|
||||
textDecorationLine: on ? "none" : "line-through",
|
||||
textDecorationThickness: on ? undefined : "2px",
|
||||
textDecorationColor: on ? undefined : "rgba(148, 163, 184, 0.7)",
|
||||
fontSize: 13,
|
||||
fontWeight: 750,
|
||||
whiteSpace: "nowrap",
|
||||
textTransform: "capitalize",
|
||||
}}
|
||||
title={on ? "On" : "Off"}
|
||||
>
|
||||
{typeKey.replaceAll("_", " ")}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div style={{ marginTop: 10, display: "flex", gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleShowAll}
|
||||
style={secondaryButtonStyle}
|
||||
>
|
||||
Show all
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleHideAll}
|
||||
style={secondaryButtonStyle}
|
||||
>
|
||||
Hide all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<>
|
||||
<div style={{ height: 1, background: "#1f2937", margin: "12px 0" }} />
|
||||
<div style={{ margin: 0, marginBottom: 10, fontWeight: 800, fontSize: 13, color: "#e5e7eb" }}>
|
||||
Geometries
|
||||
</div>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "10px 12px" }}>
|
||||
{GEO_TYPE_KEYS.map((typeKey) => {
|
||||
const on = geometryVisibility[typeKey] !== false;
|
||||
return (
|
||||
<button
|
||||
key={typeKey}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setGeometryVisibility((prev) => ({
|
||||
...prev,
|
||||
[typeKey]: prev[typeKey] === false,
|
||||
}));
|
||||
}}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
cursor: "pointer",
|
||||
color: on ? "#22c55e" : "#e5e7eb",
|
||||
textDecorationLine: on ? "none" : "line-through",
|
||||
textDecorationThickness: on ? undefined : "2px",
|
||||
textDecorationColor: on ? undefined : "rgba(148, 163, 184, 0.7)",
|
||||
fontSize: 13,
|
||||
fontWeight: 750,
|
||||
whiteSpace: "nowrap",
|
||||
textTransform: "capitalize",
|
||||
}}
|
||||
title={on ? "On" : "Off"}
|
||||
>
|
||||
{typeKey.replaceAll("_", " ")}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
const secondaryButtonStyle = {
|
||||
border: "1px solid #334155",
|
||||
borderRadius: 6,
|
||||
background: "#0b1220",
|
||||
color: "#cbd5e1",
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
padding: "6px 8px",
|
||||
} as const;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import NewBadge from "@/uhm/components/editor/NewBadge";
|
||||
import { useEditorStore } from "@/uhm/store/editorStore";
|
||||
|
||||
type EntityChoice = { id: string; name: string; isNew?: boolean };
|
||||
type WikiChoice = { id: string; title: string; isNew?: boolean };
|
||||
@@ -18,9 +20,6 @@ type BindingRow = {
|
||||
};
|
||||
|
||||
type Props = {
|
||||
entities: EntityChoice[];
|
||||
wikis: WikiSnapshot[];
|
||||
links: EntityWikiLinkSnapshot[];
|
||||
setLinks: React.Dispatch<React.SetStateAction<EntityWikiLinkSnapshot[]>>;
|
||||
};
|
||||
|
||||
@@ -29,7 +28,20 @@ function wikiTitle(w: WikiSnapshot): string {
|
||||
return t.length ? t : "Untitled wiki";
|
||||
}
|
||||
|
||||
export default function EntityWikiBindingsPanel({ entities, wikis, links, setLinks }: Props) {
|
||||
export default function EntityWikiBindingsPanel({ setLinks }: Props) {
|
||||
const {
|
||||
entityCatalog,
|
||||
snapshotEntityRows,
|
||||
wikis,
|
||||
links,
|
||||
} = useEditorStore(
|
||||
useShallow((state) => ({
|
||||
entityCatalog: state.entityCatalog,
|
||||
snapshotEntityRows: state.snapshotEntityRows,
|
||||
wikis: state.snapshotWikis,
|
||||
links: state.snapshotEntityWikiLinks,
|
||||
}))
|
||||
);
|
||||
const [activeEntityId, setActiveEntityId] = useState<string>("");
|
||||
const [activeWikiId, setActiveWikiId] = useState<string>("");
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
@@ -46,11 +58,29 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
|
||||
[wikis]
|
||||
);
|
||||
|
||||
const entityChoices = useMemo(() => {
|
||||
const cleaned = (entities || []).filter((e) => e && typeof e.id === "string" && e.id.trim().length > 0);
|
||||
cleaned.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return cleaned;
|
||||
}, [entities]);
|
||||
const entityChoices = useMemo<EntityChoice[]>(() => {
|
||||
const visibleSnapshotEntityRows = new globalThis.Map<string, { id: string; name: string; isNew: boolean }>();
|
||||
for (const ref of snapshotEntityRows || []) {
|
||||
const id = String(ref?.id || "").trim();
|
||||
if (!id || ref?.operation === "delete" || visibleSnapshotEntityRows.has(id)) continue;
|
||||
visibleSnapshotEntityRows.set(id, {
|
||||
id,
|
||||
name: String(ref?.name || id),
|
||||
isNew: ref?.source === "inline" && ref?.operation === "create",
|
||||
});
|
||||
}
|
||||
|
||||
const rows = Array.from(visibleSnapshotEntityRows.values()).map((entity) => {
|
||||
const found = entityCatalog.find((item) => String(item.id) === entity.id) || null;
|
||||
return {
|
||||
id: entity.id,
|
||||
name: String(found?.name || entity.name || entity.id),
|
||||
isNew: entity.isNew,
|
||||
};
|
||||
});
|
||||
rows.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return rows;
|
||||
}, [entityCatalog, snapshotEntityRows]);
|
||||
|
||||
const activeLinks = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
|
||||
@@ -1,23 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, type CSSProperties, type KeyboardEvent } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import NewBadge from "@/uhm/components/editor/NewBadge";
|
||||
import { normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline";
|
||||
import { useEditorStore } from "@/uhm/store/editorStore";
|
||||
|
||||
type GeometryChoice = {
|
||||
id: string;
|
||||
label?: string;
|
||||
time_start?: unknown;
|
||||
time_end?: unknown;
|
||||
isTimelineVisible?: boolean;
|
||||
isOrphan?: boolean;
|
||||
timeStatus?: GeometryTimeStatus;
|
||||
timelineStatus?: GeometryTimelineStatus;
|
||||
isNew?: boolean;
|
||||
};
|
||||
|
||||
type GeometryTimeStatus = "missing" | "partial" | "complete";
|
||||
type GeometryTimelineStatus = "off" | "visible" | "filteredOut";
|
||||
type GeometryRow = Required<Pick<GeometryChoice, "id" | "label" | "isOrphan" | "timeStatus" | "timelineStatus" | "isNew">> & {
|
||||
time_start: number | null;
|
||||
time_end: number | null;
|
||||
isTimelineVisible: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
geometries: GeometryChoice[];
|
||||
selectedGeometryId: string | null;
|
||||
selectedGeometryId?: string | null;
|
||||
selectedGeometryBindingIds: string[];
|
||||
onToggleBindGeometryForSelectedGeometry?: (geometryId: string, nextChecked: boolean) => void;
|
||||
onFocusGeometry?: (geometryId: string) => void;
|
||||
statusText?: string | null;
|
||||
bindingFilterEnabled: boolean;
|
||||
onBindingFilterEnabledChange: (next: boolean) => void;
|
||||
};
|
||||
|
||||
export default function GeometryBindingPanel({
|
||||
@@ -26,12 +40,29 @@ export default function GeometryBindingPanel({
|
||||
selectedGeometryBindingIds,
|
||||
onToggleBindGeometryForSelectedGeometry,
|
||||
onFocusGeometry,
|
||||
statusText,
|
||||
bindingFilterEnabled,
|
||||
onBindingFilterEnabledChange,
|
||||
}: Props) {
|
||||
const {
|
||||
selectedFeatureIds,
|
||||
statusText,
|
||||
bindingFilterEnabled,
|
||||
setGeometryBindingFilterEnabled,
|
||||
geometryVisibility,
|
||||
setGeometryVisibility,
|
||||
} = useEditorStore(
|
||||
useShallow((state) => ({
|
||||
selectedFeatureIds: state.selectedFeatureIds,
|
||||
statusText: state.geoBindingStatus,
|
||||
bindingFilterEnabled: state.geometryBindingFilterEnabled,
|
||||
setGeometryBindingFilterEnabled: state.setGeometryBindingFilterEnabled,
|
||||
geometryVisibility: state.geometryVisibility,
|
||||
setGeometryVisibility: state.setGeometryVisibility,
|
||||
}))
|
||||
);
|
||||
const effectiveSelectedGeometryId =
|
||||
selectedGeometryId ??
|
||||
(selectedFeatureIds.length > 0 ? String(selectedFeatureIds[0]) : null);
|
||||
const canBindToggle =
|
||||
Boolean(selectedGeometryId) && typeof onToggleBindGeometryForSelectedGeometry === "function";
|
||||
Boolean(effectiveSelectedGeometryId) && typeof onToggleBindGeometryForSelectedGeometry === "function";
|
||||
const canFocusGeometry = typeof onFocusGeometry === "function";
|
||||
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
@@ -39,26 +70,61 @@ export default function GeometryBindingPanel({
|
||||
const rows = useMemo(() => {
|
||||
const cleaned = (geometries || [])
|
||||
.filter((g) => g && typeof g.id === "string" && g.id.trim().length > 0)
|
||||
.map((g) => ({ id: g.id.trim(), label: (g.label || "").trim(), isNew: Boolean(g.isNew) }));
|
||||
.map((g) => ({
|
||||
id: g.id.trim(),
|
||||
label: (g.label || "").trim(),
|
||||
time_start: normalizeTimelineYearValue(g.time_start),
|
||||
time_end: normalizeTimelineYearValue(g.time_end),
|
||||
isTimelineVisible: Boolean(g.isTimelineVisible),
|
||||
isOrphan: Boolean(g.isOrphan),
|
||||
timeStatus: resolveTimeStatus(g),
|
||||
timelineStatus: resolveTimelineStatus(g),
|
||||
isNew: Boolean(g.isNew),
|
||||
}));
|
||||
cleaned.sort((a, b) => a.id.localeCompare(b.id));
|
||||
return cleaned;
|
||||
}, [geometries]);
|
||||
|
||||
const bindingSet = useMemo(() => new Set(selectedGeometryBindingIds || []), [selectedGeometryBindingIds]);
|
||||
const selectedGeometry = useMemo(() => {
|
||||
if (!selectedGeometryId) return null;
|
||||
return rows.find((g) => g.id === selectedGeometryId) || null;
|
||||
}, [rows, selectedGeometryId]);
|
||||
if (!effectiveSelectedGeometryId) return null;
|
||||
return rows.find((g) => g.id === effectiveSelectedGeometryId) || null;
|
||||
}, [effectiveSelectedGeometryId, rows]);
|
||||
const visibleRows = useMemo(() => {
|
||||
return rows
|
||||
.filter((g) => g.id !== selectedGeometryId)
|
||||
.filter((g) => g.id !== effectiveSelectedGeometryId)
|
||||
.sort((a, b) => {
|
||||
const aBound = bindingSet.has(a.id);
|
||||
const bBound = bindingSet.has(b.id);
|
||||
if (aBound !== bBound) return aBound ? -1 : 1;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
}, [bindingSet, rows, selectedGeometryId]);
|
||||
}, [bindingSet, effectiveSelectedGeometryId, rows]);
|
||||
const summary = useMemo(() => {
|
||||
let orphan = 0;
|
||||
let missingTime = 0;
|
||||
let partialTime = 0;
|
||||
let filteredOut = 0;
|
||||
let hidden = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.isOrphan) orphan += 1;
|
||||
if (row.timeStatus === "missing") missingTime += 1;
|
||||
if (row.timeStatus === "partial") partialTime += 1;
|
||||
if (row.timelineStatus === "filteredOut") filteredOut += 1;
|
||||
if (geometryVisibility[row.id] === false) hidden += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
total: rows.length,
|
||||
orphan,
|
||||
missingTime,
|
||||
partialTime,
|
||||
timeIssues: missingTime + partialTime,
|
||||
filteredOut,
|
||||
hidden,
|
||||
};
|
||||
}, [geometryVisibility, rows]);
|
||||
|
||||
const handleFocusKeyDown = (event: KeyboardEvent<HTMLDivElement>, geometryId: string) => {
|
||||
if (!canFocusGeometry) return;
|
||||
@@ -67,6 +133,17 @@ export default function GeometryBindingPanel({
|
||||
onFocusGeometry?.(geometryId);
|
||||
};
|
||||
|
||||
const handleFocusGeometry = (geometryId: string) => {
|
||||
onFocusGeometry?.(geometryId);
|
||||
};
|
||||
|
||||
const toggleGeometryVisibility = (geometryId: string) => {
|
||||
setGeometryVisibility((prev) => ({
|
||||
...prev,
|
||||
[geometryId]: prev[geometryId] === false,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -77,29 +154,72 @@ export default function GeometryBindingPanel({
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
|
||||
<div style={{ display: "flex",flexDirection: "column", gap: 10, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: "14px", whiteSpace: "nowrap" }}>Geometry Binding</div>
|
||||
<label
|
||||
<div
|
||||
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>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={bindingFilterEnabled}
|
||||
aria-label="Toggle geometry binding filter"
|
||||
onClick={() => setGeometryBindingFilterEnabled(!bindingFilterEnabled)}
|
||||
style={{
|
||||
width: 32,
|
||||
height: 18,
|
||||
padding: 2,
|
||||
borderRadius: 999,
|
||||
border: bindingFilterEnabled ? "1px solid #38bdf8" : "1px solid #334155",
|
||||
background: bindingFilterEnabled ? "rgba(14, 165, 233, 0.32)" : "#111827",
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: bindingFilterEnabled ? "flex-end" : "flex-start",
|
||||
transition: "background 140ms ease, border-color 140ms ease",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 999,
|
||||
background: bindingFilterEnabled ? "#67e8f9" : "#94a3b8",
|
||||
boxShadow: bindingFilterEnabled ? "0 0 8px rgba(103, 232, 249, 0.45)" : "none",
|
||||
transition: "background 140ms ease, box-shadow 140ms ease",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
<span style={{ fontSize: 12, color: "#94a3b8", whiteSpace: "nowrap" }}>Filter binding</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{rows.length}</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
|
||||
<div style={summaryWrapStyle}>
|
||||
<span style={summaryBadgeStyle} title="Total geometry count">all {summary.total}</span>
|
||||
{summary.orphan > 0 ? (
|
||||
<span style={summaryDangerBadgeStyle} title="Geometry without any bound entity">entity {summary.orphan}</span>
|
||||
) : null}
|
||||
{summary.timeIssues > 0 ? (
|
||||
<span
|
||||
style={summaryWarningBadgeStyle}
|
||||
title={`Missing time: ${summary.missingTime}; partial time: ${summary.partialTime}`}
|
||||
>
|
||||
time {summary.timeIssues}
|
||||
</span>
|
||||
) : null}
|
||||
{summary.filteredOut > 0 ? (
|
||||
<span style={summaryMutedBadgeStyle} title="Geometry filtered out by timeline">out {summary.filteredOut}</span>
|
||||
) : null}
|
||||
{summary.hidden > 0 ? (
|
||||
<span style={summaryMutedBadgeStyle} title="Geometry hidden manually">hidden {summary.hidden}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
@@ -125,19 +245,27 @@ export default function GeometryBindingPanel({
|
||||
</div>
|
||||
|
||||
{collapsed ? null : selectedGeometry ? (
|
||||
(() => {
|
||||
const isHidden = geometryVisibility[selectedGeometry.id] === false;
|
||||
const isBound = bindingSet.has(selectedGeometry.id);
|
||||
const title = buildGeometryTitle(selectedGeometry, isHidden, isBound);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 10,
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid rgba(59, 130, 246, 0.45)",
|
||||
border:
|
||||
"1px solid rgba(59, 130, 246, 0.45)",
|
||||
background: "rgba(37, 99, 235, 0.12)",
|
||||
cursor: canFocusGeometry ? "pointer" : "default",
|
||||
opacity: isHidden ? 0.58 : 1,
|
||||
boxShadow: "none",
|
||||
}}
|
||||
title={selectedGeometry.id}
|
||||
title={title}
|
||||
role={canFocusGeometry ? "button" : undefined}
|
||||
tabIndex={canFocusGeometry ? 0 : undefined}
|
||||
onClick={() => onFocusGeometry?.(selectedGeometry.id)}
|
||||
onClick={() => handleFocusGeometry(selectedGeometry.id)}
|
||||
onKeyDown={(event) => handleFocusKeyDown(event, selectedGeometry.id)}
|
||||
>
|
||||
<div
|
||||
@@ -153,33 +281,25 @@ export default function GeometryBindingPanel({
|
||||
Selected
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "#e5e7eb",
|
||||
fontWeight: 700,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{selectedGeometry.label || selectedGeometry.id}
|
||||
</span>
|
||||
<GeometryLabel row={selectedGeometry} color="#dbeafe" />
|
||||
{selectedGeometry.isNew ? <NewBadge /> : null}
|
||||
<button
|
||||
type="button"
|
||||
title={isHidden ? "Show geometry on map" : "Hide geometry on map"}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleGeometryVisibility(selectedGeometry.id);
|
||||
}}
|
||||
style={iconButtonStyle}
|
||||
aria-label={isHidden ? `Show geometry ${selectedGeometry.id}` : `Hide geometry ${selectedGeometry.id}`}
|
||||
>
|
||||
{isHidden ? <EyeOffIcon /> : <EyeIcon />}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 3,
|
||||
fontSize: "11px",
|
||||
color: "#94a3b8",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{selectedGeometry.id}
|
||||
</div>
|
||||
<StatusChips row={selectedGeometry} isHidden={isHidden} isBound={isBound} />
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : null}
|
||||
|
||||
{collapsed ? null : rows.length ? (
|
||||
@@ -187,24 +307,31 @@ export default function GeometryBindingPanel({
|
||||
{visibleRows
|
||||
.map((g) => {
|
||||
const isBound = bindingSet.has(g.id);
|
||||
const isHidden = geometryVisibility[g.id] === false;
|
||||
const title = buildGeometryTitle(g, isHidden, isBound);
|
||||
return (
|
||||
<div
|
||||
key={g.id}
|
||||
style={{
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
border: isBound ? "1px solid rgba(20, 184, 166, 0.65)" : "1px solid #1f2937",
|
||||
background: isBound ? "rgba(20, 184, 166, 0.12)" : "transparent",
|
||||
border: isBound
|
||||
? "1px solid rgba(20, 184, 166, 0.65)"
|
||||
: "1px solid #1f2937",
|
||||
background: isBound
|
||||
? "rgba(20, 184, 166, 0.12)"
|
||||
: "transparent",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
cursor: canFocusGeometry ? "pointer" : "default",
|
||||
opacity: canBindToggle ? 1 : 0.75,
|
||||
opacity: isHidden ? 0.55 : canBindToggle ? 1 : 0.75,
|
||||
boxShadow: "none",
|
||||
}}
|
||||
title={g.id}
|
||||
title={title}
|
||||
role={canFocusGeometry ? "button" : undefined}
|
||||
tabIndex={canFocusGeometry ? 0 : undefined}
|
||||
onClick={() => onFocusGeometry?.(g.id)}
|
||||
onClick={() => handleFocusGeometry(g.id)}
|
||||
onKeyDown={(event) => handleFocusKeyDown(event, g.id)}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
@@ -216,34 +343,25 @@ export default function GeometryBindingPanel({
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "#e5e7eb",
|
||||
fontWeight: 700,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{g.label || g.id}
|
||||
</span>
|
||||
{isBound ? <span style={boundBadgeStyle}>bound</span> : null}
|
||||
<GeometryLabel row={g} />
|
||||
{g.isNew ? <NewBadge /> : null}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
color: "#94a3b8",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{g.id}
|
||||
</div>
|
||||
<StatusChips row={g} isHidden={isHidden} isBound={isBound} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
title={isHidden ? "Show geometry on map" : "Hide geometry on map"}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleGeometryVisibility(g.id);
|
||||
}}
|
||||
style={iconButtonStyle}
|
||||
aria-label={isHidden ? `Show geometry ${g.id}` : `Hide geometry ${g.id}`}
|
||||
>
|
||||
{isHidden ? <EyeOffIcon /> : <EyeIcon />}
|
||||
</button>
|
||||
|
||||
{canBindToggle ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -293,7 +411,86 @@ export default function GeometryBindingPanel({
|
||||
);
|
||||
}
|
||||
|
||||
const boundBadgeStyle: CSSProperties = {
|
||||
function GeometryLabel({ row, color = "#e5e7eb" }: { row: GeometryRow; color?: string }) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color,
|
||||
fontWeight: 700,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{row.label || "Geometry"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusChips({ row, isHidden, isBound }: { row: GeometryRow; isHidden: boolean; isBound: boolean }) {
|
||||
return (
|
||||
<div style={statusChipRowStyle}>
|
||||
{row.isOrphan ? <span style={dangerBadgeStyle}>no entity</span> : null}
|
||||
{row.timeStatus === "missing" ? <span style={dangerBadgeStyle}>no time</span> : null}
|
||||
{row.timeStatus === "partial" ? <span style={warningBadgeStyle}>partial time</span> : null}
|
||||
{row.timelineStatus === "visible" ? <span style={timelineBadgeStyle}>timeline</span> : null}
|
||||
{row.timelineStatus === "filteredOut" ? <span style={mutedBadgeStyle}>out timeline</span> : null}
|
||||
{isHidden ? <span style={hiddenBadgeStyle}>hidden</span> : null}
|
||||
{isBound ? <span style={boundBadgeStyle}>bound</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveTimeStatus(geometry: GeometryChoice): GeometryTimeStatus {
|
||||
if (geometry.timeStatus === "missing" || geometry.timeStatus === "partial" || geometry.timeStatus === "complete") {
|
||||
return geometry.timeStatus;
|
||||
}
|
||||
|
||||
const hasStart = normalizeTimelineYearValue(geometry.time_start) !== null;
|
||||
const hasEnd = normalizeTimelineYearValue(geometry.time_end) !== null;
|
||||
if (!hasStart && !hasEnd) return "missing";
|
||||
if (!hasStart || !hasEnd) return "partial";
|
||||
return "complete";
|
||||
}
|
||||
|
||||
function resolveTimelineStatus(geometry: GeometryChoice): GeometryTimelineStatus {
|
||||
if (
|
||||
geometry.timelineStatus === "off" ||
|
||||
geometry.timelineStatus === "visible" ||
|
||||
geometry.timelineStatus === "filteredOut"
|
||||
) {
|
||||
return geometry.timelineStatus;
|
||||
}
|
||||
|
||||
return geometry.isTimelineVisible ? "visible" : "off";
|
||||
}
|
||||
|
||||
function buildGeometryTitle(row: GeometryRow, isHidden: boolean, isBound: boolean): string {
|
||||
const parts = [`ID: ${row.id}`];
|
||||
|
||||
if (row.isOrphan) parts.push("Orphan");
|
||||
if (row.timeStatus === "missing") parts.push("Missing time");
|
||||
if (row.timeStatus === "partial") parts.push("Partial time");
|
||||
if (row.timelineStatus === "visible") parts.push("Timeline visible");
|
||||
if (row.timelineStatus === "filteredOut") parts.push("Filtered out by timeline");
|
||||
if (isHidden) parts.push("Hidden");
|
||||
if (isBound) parts.push("Bound");
|
||||
if (row.isNew) parts.push("New");
|
||||
|
||||
return parts.join(" | ");
|
||||
}
|
||||
|
||||
const summaryWrapStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
gap: 4,
|
||||
minWidth: 0,
|
||||
flexWrap: "wrap",
|
||||
};
|
||||
|
||||
const baseBadgeStyle: CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -301,16 +498,135 @@ const boundBadgeStyle: CSSProperties = {
|
||||
height: 17,
|
||||
padding: "0 6px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid rgba(45, 212, 191, 0.5)",
|
||||
background: "rgba(20, 184, 166, 0.18)",
|
||||
color: "#99f6e4",
|
||||
fontSize: 10,
|
||||
fontWeight: 900,
|
||||
lineHeight: 1,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0,
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
|
||||
const summaryBadgeStyle: CSSProperties = {
|
||||
...baseBadgeStyle,
|
||||
border: "1px solid rgba(148, 163, 184, 0.35)",
|
||||
background: "rgba(15, 23, 42, 0.9)",
|
||||
color: "#cbd5e1",
|
||||
};
|
||||
|
||||
const summaryDangerBadgeStyle: CSSProperties = {
|
||||
...baseBadgeStyle,
|
||||
border: "1px solid rgba(248, 113, 113, 0.5)",
|
||||
background: "rgba(127, 29, 29, 0.32)",
|
||||
color: "#fecaca",
|
||||
};
|
||||
|
||||
const summaryWarningBadgeStyle: CSSProperties = {
|
||||
...baseBadgeStyle,
|
||||
border: "1px solid rgba(250, 204, 21, 0.48)",
|
||||
background: "rgba(113, 63, 18, 0.3)",
|
||||
color: "#fde68a",
|
||||
};
|
||||
|
||||
const summaryMutedBadgeStyle: CSSProperties = {
|
||||
...baseBadgeStyle,
|
||||
border: "1px solid rgba(148, 163, 184, 0.4)",
|
||||
background: "rgba(51, 65, 85, 0.32)",
|
||||
color: "#cbd5e1",
|
||||
};
|
||||
|
||||
const statusChipRowStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
gap: 4,
|
||||
marginTop: 5,
|
||||
minHeight: 17,
|
||||
};
|
||||
|
||||
const dangerBadgeStyle: CSSProperties = {
|
||||
...baseBadgeStyle,
|
||||
border: "1px solid rgba(248, 113, 113, 0.5)",
|
||||
background: "rgba(127, 29, 29, 0.28)",
|
||||
color: "#fecaca",
|
||||
};
|
||||
|
||||
const warningBadgeStyle: CSSProperties = {
|
||||
...baseBadgeStyle,
|
||||
border: "1px solid rgba(250, 204, 21, 0.5)",
|
||||
background: "rgba(113, 63, 18, 0.28)",
|
||||
color: "#fde68a",
|
||||
};
|
||||
|
||||
const timelineBadgeStyle: CSSProperties = {
|
||||
...baseBadgeStyle,
|
||||
border: "1px solid rgba(34, 197, 94, 0.5)",
|
||||
background: "rgba(20, 83, 45, 0.3)",
|
||||
color: "#bbf7d0",
|
||||
};
|
||||
|
||||
const mutedBadgeStyle: CSSProperties = {
|
||||
...baseBadgeStyle,
|
||||
border: "1px solid rgba(148, 163, 184, 0.45)",
|
||||
background: "rgba(71, 85, 105, 0.28)",
|
||||
color: "#cbd5e1",
|
||||
};
|
||||
|
||||
const boundBadgeStyle: CSSProperties = {
|
||||
...baseBadgeStyle,
|
||||
border: "1px solid rgba(45, 212, 191, 0.5)",
|
||||
background: "rgba(20, 184, 166, 0.18)",
|
||||
color: "#99f6e4",
|
||||
};
|
||||
|
||||
const hiddenBadgeStyle: CSSProperties = {
|
||||
...baseBadgeStyle,
|
||||
border: "1px solid rgba(148, 163, 184, 0.45)",
|
||||
background: "rgba(71, 85, 105, 0.32)",
|
||||
color: "#cbd5e1",
|
||||
};
|
||||
|
||||
const iconButtonStyle: CSSProperties = {
|
||||
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",
|
||||
};
|
||||
|
||||
function EyeIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"
|
||||
stroke="#cbd5e1"
|
||||
strokeWidth="2"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="3" stroke="#cbd5e1" strokeWidth="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function EyeOffIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M3 3l18 18" stroke="#fca5a5" strokeWidth="2" strokeLinecap="round" />
|
||||
<path
|
||||
d="M10.6 6.2A10.5 10.5 0 0 1 12 6c6 0 9.5 6 9.5 6a17 17 0 0 1-2.1 2.8M6.2 8.1A17 17 0 0 0 2.5 12s3.5 6 9.5 6c1.3 0 2.5-.3 3.5-.7"
|
||||
stroke="#fca5a5"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LockIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import type { ChangeEvent } from "react";
|
||||
import type { MapImageOverlay } from "@/uhm/components/map/imageOverlay";
|
||||
|
||||
type Props = {
|
||||
overlay: MapImageOverlay | null;
|
||||
onPickImage: (file: File | null) => void;
|
||||
onPasteImage: () => void;
|
||||
keyboardEnabled: boolean;
|
||||
onKeyboardEnabledChange: (enabled: boolean) => void;
|
||||
onOpacityChange: (opacity: number) => void;
|
||||
onRemove: () => void;
|
||||
};
|
||||
|
||||
export default function ImageOverlayPanel({
|
||||
overlay,
|
||||
onPickImage,
|
||||
onPasteImage,
|
||||
keyboardEnabled,
|
||||
onKeyboardEnabledChange,
|
||||
onOpacityChange,
|
||||
onRemove,
|
||||
}: Props) {
|
||||
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0] || null;
|
||||
onPickImage(file);
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
return (
|
||||
<section style={{ padding: 10, background: "#0b1220", borderRadius: 8, border: "1px solid #1f2937" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 800, fontSize: 13, color: "white" }}>Trace Image</div>
|
||||
<div style={{ marginTop: 2, fontSize: 11, color: "#94a3b8" }}>
|
||||
Chuột phải kéo điểm vàng để di chuyển, điểm xanh để kéo dãn giữ ratio.
|
||||
{keyboardEnabled ? " WASD di chuyển, Q/E phóng to/thu nhỏ." : ""}
|
||||
</div>
|
||||
</div>
|
||||
{overlay ? (
|
||||
<button type="button" onClick={onRemove} style={dangerButtonStyle}>
|
||||
Remove
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 10, display: "flex", flexWrap: "wrap", gap: 8 }}>
|
||||
<label style={uploadButtonStyle}>
|
||||
{overlay ? "Đổi ảnh" : "Thêm ảnh"}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
</label>
|
||||
<button type="button" onClick={onPasteImage} style={uploadButtonStyle}>
|
||||
Paste ảnh
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onKeyboardEnabledChange(!keyboardEnabled)}
|
||||
disabled={!overlay}
|
||||
style={{
|
||||
...uploadButtonStyle,
|
||||
opacity: overlay ? 1 : 0.5,
|
||||
color: keyboardEnabled ? "#86efac" : "#93c5fd",
|
||||
cursor: overlay ? "pointer" : "not-allowed",
|
||||
}}
|
||||
title="Bật/tắt điều khiển ảnh bằng WASD và Q/E"
|
||||
>
|
||||
Keys: {keyboardEnabled ? "On" : "Off"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{overlay ? (
|
||||
<div style={{ marginTop: 10, display: "grid", gap: 8 }}>
|
||||
<div style={{ fontSize: 12, color: "#cbd5e1", overflowWrap: "anywhere" }}>
|
||||
{overlay.name}
|
||||
</div>
|
||||
<label style={{ display: "grid", gap: 6, fontSize: 12, color: "#cbd5e1" }}>
|
||||
<span>Opacity: {Math.round(overlay.opacity * 100)}%</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={overlay.opacity}
|
||||
onChange={(event) => onOpacityChange(Number(event.target.value))}
|
||||
style={{ width: "100%", accentColor: "#38bdf8" }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: "#94a3b8" }}>
|
||||
Chưa có ảnh overlay.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const uploadButtonStyle = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
border: "1px solid #334155",
|
||||
borderRadius: 6,
|
||||
background: "#111827",
|
||||
color: "#93c5fd",
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
fontWeight: 800,
|
||||
padding: "7px 10px",
|
||||
} as const;
|
||||
|
||||
const dangerButtonStyle = {
|
||||
border: "1px solid #7f1d1d",
|
||||
borderRadius: 6,
|
||||
background: "#1f1111",
|
||||
color: "#fecaca",
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
fontWeight: 800,
|
||||
padding: "6px 8px",
|
||||
} as const;
|
||||
@@ -43,5 +43,12 @@ export function ModeHint({ mode }: { mode: EditorMode }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (mode === "replay_preview") {
|
||||
return (
|
||||
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
|
||||
Đang xem preview replay trên session tách biệt.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,34 +2,42 @@
|
||||
|
||||
import { useMemo, useState, type CSSProperties } from "react";
|
||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||
import type { EntityFormState } from "@/uhm/lib/editor/session/sessionTypes";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import NewBadge from "@/uhm/components/editor/NewBadge";
|
||||
import { useEditorStore } from "@/uhm/store/editorStore";
|
||||
|
||||
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[];
|
||||
onUpdateEntity?: (entityId: string, payload: { name: string; description: string | null; time_start: string; time_end: string }) => void;
|
||||
hasSelectedGeometry?: boolean;
|
||||
selectedGeometryTime?: { time_start: number | null; time_end: number | null } | null;
|
||||
onToggleBindEntityForSelectedGeometry?: (entityId: string, nextChecked: boolean) => void;
|
||||
};
|
||||
|
||||
export default function ProjectEntityRefsPanel({
|
||||
entityRefs,
|
||||
entityForm,
|
||||
onEntityFormChange,
|
||||
isEntitySubmitting,
|
||||
onCreateEntityOnly,
|
||||
onUpdateEntity,
|
||||
entityFormStatus,
|
||||
selectedGeometryEntityIds,
|
||||
hasSelectedGeometry,
|
||||
selectedGeometryTime,
|
||||
onToggleBindEntityForSelectedGeometry,
|
||||
}: Props) {
|
||||
const {
|
||||
snapshotEntityRows,
|
||||
entityForm,
|
||||
setEntityForm,
|
||||
isEntitySubmitting,
|
||||
entityFormStatus,
|
||||
selectedGeometryEntityIds,
|
||||
} = useEditorStore(
|
||||
useShallow((state) => ({
|
||||
snapshotEntityRows: state.snapshotEntityRows,
|
||||
entityForm: state.entityForm,
|
||||
setEntityForm: state.setEntityForm,
|
||||
isEntitySubmitting: state.isEntitySubmitting,
|
||||
entityFormStatus: state.entityFormStatus,
|
||||
selectedGeometryEntityIds: state.selectedGeometryEntityIds,
|
||||
}))
|
||||
);
|
||||
const canBindToggle =
|
||||
Boolean(hasSelectedGeometry) &&
|
||||
Array.isArray(selectedGeometryEntityIds) &&
|
||||
@@ -43,6 +51,16 @@ export default function ProjectEntityRefsPanel({
|
||||
() => new Set((selectedGeometryEntityIds || []).map(String)),
|
||||
[selectedGeometryEntityIds]
|
||||
);
|
||||
const entityRefs = useMemo(() => {
|
||||
const byId = new globalThis.Map<string, EntitySnapshot>();
|
||||
for (const ref of snapshotEntityRows || []) {
|
||||
const id = String(ref?.id || "").trim();
|
||||
if (!id || byId.has(id)) continue;
|
||||
if (ref.operation === "delete") continue;
|
||||
byId.set(id, ref);
|
||||
}
|
||||
return Array.from(byId.values());
|
||||
}, [snapshotEntityRows]);
|
||||
const sortedEntityRefs = useMemo(() => {
|
||||
const rows = [...(entityRefs || [])];
|
||||
rows.sort((a, b) => {
|
||||
@@ -62,11 +80,34 @@ export default function ProjectEntityRefsPanel({
|
||||
);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editDescription, setEditDescription] = useState("");
|
||||
const [editTimeStart, setEditTimeStart] = useState("");
|
||||
const [editTimeEnd, setEditTimeEnd] = useState("");
|
||||
const canCopySelectedGeometryTime =
|
||||
selectedGeometryTime != null &&
|
||||
(selectedGeometryTime.time_start != null || selectedGeometryTime.time_end != null);
|
||||
|
||||
const openEntityEditor = (entity: EntitySnapshot) => {
|
||||
setActiveEntityId(String(entity.id));
|
||||
setEditName(typeof entity.name === "string" ? entity.name : "");
|
||||
setEditDescription(entity.description == null ? "" : String(entity.description));
|
||||
setEditTimeStart(entity.time_start != null ? String(entity.time_start) : "");
|
||||
setEditTimeEnd(entity.time_end != null ? String(entity.time_end) : "");
|
||||
};
|
||||
const handleEntityFormChange = (key: "name" | "description" | "time_start" | "time_end", value: string) => {
|
||||
setEntityForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
const copySelectedGeometryTimeToCreateForm = () => {
|
||||
if (!canCopySelectedGeometryTime || !selectedGeometryTime) return;
|
||||
setEntityForm((prev) => ({
|
||||
...prev,
|
||||
time_start: selectedGeometryTime.time_start != null ? String(selectedGeometryTime.time_start) : "",
|
||||
time_end: selectedGeometryTime.time_end != null ? String(selectedGeometryTime.time_end) : "",
|
||||
}));
|
||||
};
|
||||
const copySelectedGeometryTimeToEditForm = () => {
|
||||
if (!canCopySelectedGeometryTime || !selectedGeometryTime) return;
|
||||
setEditTimeStart(selectedGeometryTime.time_start != null ? String(selectedGeometryTime.time_start) : "");
|
||||
setEditTimeEnd(selectedGeometryTime.time_end != null ? String(selectedGeometryTime.time_end) : "");
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -218,27 +259,27 @@ export default function ProjectEntityRefsPanel({
|
||||
</span>
|
||||
{isNewEntityRef(activeEntity) ? <NewBadge /> : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveEntityId(null)}
|
||||
title="Dong"
|
||||
aria-label="Dong sua entity"
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 6,
|
||||
border: "1px solid #334155",
|
||||
background: "#0b1220",
|
||||
color: "#e2e8f0",
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copySelectedGeometryTimeToEditForm}
|
||||
disabled={!canCopySelectedGeometryTime || isEntitySubmitting}
|
||||
title="Lay nam cua GEO dang chon"
|
||||
aria-label="Lay nam cua GEO dang chon cho entity dang sua"
|
||||
style={iconButtonStyle(!canCopySelectedGeometryTime || isEntitySubmitting)}
|
||||
>
|
||||
<ClockIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveEntityId(null)}
|
||||
title="Dong"
|
||||
aria-label="Dong sua entity"
|
||||
style={iconButtonStyle(false)}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 11, color: "#94a3b8", overflowWrap: "anywhere" }}>
|
||||
@@ -258,10 +299,31 @@ export default function ProjectEntityRefsPanel({
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
<div style={timeInputGridStyle}>
|
||||
<input
|
||||
value={editTimeStart}
|
||||
onChange={(event) => setEditTimeStart(event.target.value)}
|
||||
placeholder="time_start"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
<input
|
||||
value={editTimeEnd}
|
||||
onChange={(event) => setEditTimeEnd(event.target.value)}
|
||||
placeholder="time_end"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdateEntity!(String(activeEntity.id), { name: editName, description: editDescription.trim().length ? editDescription : null })}
|
||||
onClick={() => onUpdateEntity!(String(activeEntity.id), {
|
||||
name: editName,
|
||||
description: editDescription.trim().length ? editDescription : null,
|
||||
time_start: editTimeStart,
|
||||
time_end: editTimeEnd,
|
||||
})}
|
||||
disabled={isEntitySubmitting}
|
||||
style={{
|
||||
border: "none",
|
||||
@@ -296,47 +358,64 @@ export default function ProjectEntityRefsPanel({
|
||||
<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 style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
{isCreateOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={copySelectedGeometryTimeToCreateForm}
|
||||
disabled={!canCopySelectedGeometryTime || isEntitySubmitting}
|
||||
title="Lay nam cua GEO dang chon"
|
||||
aria-label="Lay nam cua GEO dang chon cho entity moi"
|
||||
style={iconButtonStyle(!canCopySelectedGeometryTime || isEntitySubmitting)}
|
||||
>
|
||||
<ClockIcon />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCreateOpen((v) => !v)}
|
||||
disabled={isEntitySubmitting}
|
||||
title={isCreateOpen ? "Dong" : "Mo"}
|
||||
aria-label={isCreateOpen ? "Dong tao entity" : "Mo tao entity"}
|
||||
style={iconButtonStyle(isEntitySubmitting)}
|
||||
>
|
||||
{isCreateOpen ? <CloseIcon /> : <PlusIcon />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isCreateOpen ? (
|
||||
<>
|
||||
<input
|
||||
value={entityForm.name}
|
||||
onChange={(event) => onEntityFormChange("name", event.target.value)}
|
||||
onChange={(event) => handleEntityFormChange("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)}
|
||||
onChange={(event) => handleEntityFormChange("description", event.target.value)}
|
||||
placeholder="Description"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
<div style={timeInputGridStyle}>
|
||||
<input
|
||||
value={entityForm.time_start}
|
||||
onChange={(event) => handleEntityFormChange("time_start", event.target.value)}
|
||||
placeholder="time_start"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
<input
|
||||
value={entityForm.time_end}
|
||||
onChange={(event) => handleEntityFormChange("time_end", event.target.value)}
|
||||
placeholder="time_end"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@@ -384,6 +463,29 @@ const entityInputStyle: CSSProperties = {
|
||||
fontSize: "13px",
|
||||
};
|
||||
|
||||
const timeInputGridStyle: CSSProperties = {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: 8,
|
||||
};
|
||||
|
||||
function iconButtonStyle(disabled: boolean): CSSProperties {
|
||||
return {
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 6,
|
||||
border: "1px solid #334155",
|
||||
background: "#0b1220",
|
||||
color: "#e2e8f0",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
opacity: disabled ? 0.55 : 1,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flex: "0 0 auto",
|
||||
};
|
||||
}
|
||||
|
||||
const boundBadgeStyle: CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
@@ -469,3 +571,12 @@ function CloseIcon() {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ClockIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="8" stroke="#fbbf24" strokeWidth="2" />
|
||||
<path d="M12 7v5l3 2" stroke="#fbbf24" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import type {
|
||||
ReplayPreviewDialog,
|
||||
ReplayPreviewImage,
|
||||
ReplayPreviewToast,
|
||||
} from "@/uhm/lib/replay/useReplayPreview";
|
||||
|
||||
type Props = {
|
||||
isPreviewMode: boolean;
|
||||
isPlaying: boolean;
|
||||
title: string;
|
||||
descriptions: string;
|
||||
subtitle: string | null;
|
||||
dialog: ReplayPreviewDialog | null;
|
||||
image: ReplayPreviewImage | null;
|
||||
toasts: ReplayPreviewToast[];
|
||||
sidebarOpen: boolean;
|
||||
playbackSpeed: number;
|
||||
activeStepLabel: string | null;
|
||||
activeStepNumber: number | null;
|
||||
totalSteps: number;
|
||||
onPlayPreview: () => void;
|
||||
onStopPreview: () => void;
|
||||
onResetPreview: () => void;
|
||||
onExitPreview: () => void;
|
||||
};
|
||||
|
||||
export default function ReplayPreviewOverlay({
|
||||
isPreviewMode,
|
||||
isPlaying,
|
||||
title,
|
||||
descriptions,
|
||||
subtitle,
|
||||
dialog,
|
||||
image,
|
||||
toasts,
|
||||
sidebarOpen,
|
||||
playbackSpeed,
|
||||
activeStepLabel,
|
||||
activeStepNumber,
|
||||
totalSteps,
|
||||
onPlayPreview,
|
||||
onStopPreview,
|
||||
onResetPreview,
|
||||
onExitPreview,
|
||||
}: Props) {
|
||||
const hasNarrativeCard = title.trim().length > 0 || descriptions.trim().length > 0;
|
||||
const hasWikiPreview = sidebarOpen;
|
||||
const shouldRender =
|
||||
isPreviewMode ||
|
||||
isPlaying ||
|
||||
hasNarrativeCard ||
|
||||
Boolean(subtitle) ||
|
||||
Boolean(dialog) ||
|
||||
Boolean(image) ||
|
||||
Boolean(toasts.length);
|
||||
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 15,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{hasNarrativeCard ? (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 72,
|
||||
left: 18,
|
||||
maxWidth: 460,
|
||||
borderRadius: 18,
|
||||
border: "1px solid rgba(148, 163, 184, 0.26)",
|
||||
background: "linear-gradient(145deg, rgba(15, 23, 42, 0.94), rgba(30, 41, 59, 0.88))",
|
||||
boxShadow: "0 14px 42px rgba(2, 6, 23, 0.42)",
|
||||
padding: "18px 20px",
|
||||
}}
|
||||
>
|
||||
{title.trim().length ? (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 26,
|
||||
lineHeight: 1.1,
|
||||
fontWeight: 900,
|
||||
color: "#f8fafc",
|
||||
overflowWrap: "anywhere",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
) : null}
|
||||
{descriptions.trim().length ? (
|
||||
<div
|
||||
style={{
|
||||
marginTop: title.trim().length ? 12 : 0,
|
||||
fontSize: 14,
|
||||
lineHeight: 1.55,
|
||||
color: "#dbeafe",
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{descriptions}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{toasts.length ? (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 72,
|
||||
right: hasWikiPreview ? 454 : 18,
|
||||
display: "grid",
|
||||
gap: 8,
|
||||
width: 280,
|
||||
}}
|
||||
>
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: "1px solid rgba(56, 189, 248, 0.28)",
|
||||
background: "rgba(8, 47, 73, 0.9)",
|
||||
color: "#e0f2fe",
|
||||
padding: "12px 14px",
|
||||
fontSize: 13,
|
||||
lineHeight: 1.4,
|
||||
boxShadow: "0 10px 26px rgba(2, 6, 23, 0.32)",
|
||||
}}
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{image ? (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 18,
|
||||
bottom: 96,
|
||||
width: 320,
|
||||
borderRadius: 18,
|
||||
overflow: "hidden",
|
||||
border: "1px solid rgba(148, 163, 184, 0.22)",
|
||||
background: "rgba(15, 23, 42, 0.9)",
|
||||
boxShadow: "0 16px 44px rgba(2, 6, 23, 0.42)",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.caption || "Historical image"}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "block",
|
||||
maxHeight: 240,
|
||||
objectFit: "cover",
|
||||
background: "#020617",
|
||||
}}
|
||||
/>
|
||||
{image.caption?.trim() ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.45,
|
||||
color: "#cbd5e1",
|
||||
}}
|
||||
>
|
||||
{image.caption}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{dialog ? (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: dialog.side === "right" ? "auto" : 18,
|
||||
right: dialog.side === "right" ? 18 : "auto",
|
||||
bottom: subtitle ? 138 : 96,
|
||||
maxWidth: 420,
|
||||
display: "grid",
|
||||
gap: 10,
|
||||
gridTemplateColumns: dialog.avatar.trim().length ? "56px 1fr" : "1fr",
|
||||
alignItems: "start",
|
||||
}}
|
||||
>
|
||||
{dialog.avatar.trim().length ? (
|
||||
<img
|
||||
src={dialog.avatar}
|
||||
alt={dialog.speaker || "speaker"}
|
||||
style={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: "50%",
|
||||
objectFit: "cover",
|
||||
border: "2px solid rgba(125, 211, 252, 0.55)",
|
||||
background: "#0f172a",
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 18,
|
||||
border: "1px solid rgba(148, 163, 184, 0.24)",
|
||||
background: "rgba(15, 23, 42, 0.92)",
|
||||
padding: "14px 16px",
|
||||
color: "#f8fafc",
|
||||
boxShadow: "0 14px 36px rgba(2, 6, 23, 0.38)",
|
||||
}}
|
||||
>
|
||||
{dialog.speaker?.trim() ? (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 6,
|
||||
fontSize: 11,
|
||||
color: "#7dd3fc",
|
||||
fontWeight: 900,
|
||||
letterSpacing: 0.4,
|
||||
}}
|
||||
>
|
||||
{dialog.speaker}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 15,
|
||||
lineHeight: 1.5,
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{dialog.text}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{subtitle?.trim() ? (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
bottom: 90,
|
||||
transform: "translateX(-50%)",
|
||||
maxWidth: 720,
|
||||
borderRadius: 999,
|
||||
border: "1px solid rgba(148, 163, 184, 0.24)",
|
||||
background: "rgba(2, 6, 23, 0.84)",
|
||||
color: "#f8fafc",
|
||||
padding: "10px 18px",
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.45,
|
||||
textAlign: "center",
|
||||
boxShadow: "0 12px 32px rgba(2, 6, 23, 0.28)",
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isPreviewMode ? (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 64,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
width: "min(520px, calc(100% - 72px))",
|
||||
borderRadius: 18,
|
||||
border: "1px solid rgba(148, 163, 184, 0.24)",
|
||||
background: "rgba(15, 23, 42, 0.9)",
|
||||
color: "#e2e8f0",
|
||||
padding: "12px 14px",
|
||||
boxShadow: "0 12px 32px rgba(2, 6, 23, 0.3)",
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "grid", gap: 6, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
padding: "3px 8px",
|
||||
borderRadius: 999,
|
||||
background: "rgba(34, 197, 94, 0.2)",
|
||||
color: "#86efac",
|
||||
fontWeight: 900,
|
||||
fontSize: 11,
|
||||
letterSpacing: 0.3,
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
Preview
|
||||
</span>
|
||||
{activeStepLabel ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 800,
|
||||
color: "#f8fafc",
|
||||
overflowWrap: "anywhere",
|
||||
}}
|
||||
>
|
||||
{activeStepLabel}
|
||||
</span>
|
||||
) : null}
|
||||
<span style={{ fontSize: 12, color: "#94a3b8" }}>
|
||||
x{playbackSpeed.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
{totalSteps > 0 ? (
|
||||
<div style={{ display: "grid", gap: 6 }}>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 6,
|
||||
borderRadius: 999,
|
||||
background: "rgba(51, 65, 85, 0.8)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${Math.max(0, Math.min(100, ((activeStepNumber || 0) / totalSteps) * 100))}%`,
|
||||
height: "100%",
|
||||
borderRadius: 999,
|
||||
background: "linear-gradient(90deg, #22c55e, #38bdf8)",
|
||||
transition: "width 180ms ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "#94a3b8" }}>
|
||||
Step {activeStepNumber || 0}/{totalSteps}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, flex: "0 0 auto" }}>
|
||||
{isPlaying ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStopPreview}
|
||||
style={previewButtonStyle("#7f1d1d")}
|
||||
>
|
||||
Dừng
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onResetPreview}
|
||||
style={previewButtonStyle("#1e3a8a")}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPlayPreview}
|
||||
style={previewButtonStyle("#166534")}
|
||||
>
|
||||
Phát lại
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExitPreview}
|
||||
style={previewButtonStyle("#334155")}
|
||||
>
|
||||
Thoát preview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function previewButtonStyle(background: string): CSSProperties {
|
||||
return {
|
||||
border: "none",
|
||||
background,
|
||||
color: "white",
|
||||
borderRadius: 10,
|
||||
padding: "8px 12px",
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
fontWeight: 800,
|
||||
boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.08)",
|
||||
};
|
||||
}
|
||||
@@ -1,36 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { type CSSProperties, useMemo, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { Feature } from "@/uhm/lib/editor/state/useEditorState";
|
||||
import {
|
||||
GEOMETRY_TYPE_OPTIONS,
|
||||
GeometryPreset,
|
||||
GeometryTypeGroupId,
|
||||
GeometryTypeOption,
|
||||
findGeometryTypeOption,
|
||||
groupGeometryTypeOptions,
|
||||
} from "@/uhm/lib/map/geo/geometryTypeOptions";
|
||||
import { normalizeGeoTypeKey } from "@/uhm/lib/map/geo/geoTypeMap";
|
||||
import type { GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionTypes";
|
||||
import { useEditorStore } from "@/uhm/store/editorStore";
|
||||
|
||||
type Props = {
|
||||
selectedFeatures: Feature[];
|
||||
entityTypeOptions: GeometryTypeOption[];
|
||||
geometryMetaForm: GeometryMetaFormState;
|
||||
onGeometryMetaFormChange: (key: keyof GeometryMetaFormState, value: string) => void;
|
||||
isEntitySubmitting: boolean;
|
||||
onApplyGeometryMetadata: () => Promise<{ ok: boolean; error?: string }>;
|
||||
changeCount: number;
|
||||
onReplayEdit?: (id: string | number) => void;
|
||||
onDeleteFeatures?: (ids: (string | number)[]) => void;
|
||||
onDeselectAll?: () => void;
|
||||
};
|
||||
|
||||
export default function SelectedGeometryPanel({
|
||||
selectedFeatures,
|
||||
entityTypeOptions,
|
||||
geometryMetaForm,
|
||||
onGeometryMetaFormChange,
|
||||
isEntitySubmitting,
|
||||
onApplyGeometryMetadata,
|
||||
changeCount,
|
||||
onReplayEdit,
|
||||
onDeleteFeatures,
|
||||
onDeselectAll,
|
||||
}: Props) {
|
||||
const {
|
||||
geometryMetaForm,
|
||||
setGeometryMetaForm,
|
||||
isEntitySubmitting,
|
||||
} = useEditorStore(
|
||||
useShallow((state) => ({
|
||||
geometryMetaForm: state.geometryMetaForm,
|
||||
setGeometryMetaForm: state.setGeometryMetaForm,
|
||||
isEntitySubmitting: state.isEntitySubmitting,
|
||||
}))
|
||||
);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [geoApplyFeedback, setGeoApplyFeedback] = useState<
|
||||
| {
|
||||
@@ -68,10 +78,17 @@ export default function SelectedGeometryPanel({
|
||||
const visibleGeoApplyFeedback =
|
||||
geoApplyFeedback && geoApplyFeedback.signature === geoMetaSignature ? geoApplyFeedback : null;
|
||||
|
||||
const isBulkMode = selectedFeatures.length >= 2;
|
||||
const isMultiEditValid = useMemo(() => {
|
||||
if (selectedFeatures.length <= 1) return true;
|
||||
const firstShape = selectedFeatures[0].geometry.type;
|
||||
return selectedFeatures.every((f) => f.geometry.type === firstShape);
|
||||
}, [selectedFeatures]);
|
||||
|
||||
if (!selectedFeatures || selectedFeatures.length === 0) return null;
|
||||
const representativeFeature = selectedFeatures[0];
|
||||
|
||||
const groupedGeometryTypeOptions = groupGeometryTypeOptions(entityTypeOptions);
|
||||
const groupedGeometryTypeOptions = groupGeometryTypeOptions(GEOMETRY_TYPE_OPTIONS);
|
||||
const featureGeometryPreset = resolveFeatureGeometryPreset(representativeFeature);
|
||||
const allowedGroupIds = getAllowedGroupIdsForPreset(featureGeometryPreset);
|
||||
const groupedGeoTypeOptions = groupedGeometryTypeOptions.filter((group) =>
|
||||
@@ -93,7 +110,7 @@ export default function SelectedGeometryPanel({
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, marginBottom: "8px" }}>
|
||||
<div style={{ fontWeight: 700, fontSize: "14px" }}>
|
||||
Geometry property
|
||||
{isBulkMode ? `Đang chọn ${selectedFeatures.length} Geometries` : "Geometry property"}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -119,7 +136,78 @@ export default function SelectedGeometryPanel({
|
||||
</div>
|
||||
|
||||
{collapsed ? null : (
|
||||
<div style={{ display: "grid", gap: "8px", fontSize: "13px" }}>
|
||||
<div style={{ display: "grid", gap: "8px", fontSize: "13px" }}>
|
||||
{isBulkMode && (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: "8px",
|
||||
border: "1px solid #334155",
|
||||
borderRadius: "8px",
|
||||
padding: "8px",
|
||||
background: "#1e293b",
|
||||
}}
|
||||
>
|
||||
<div style={{ color: "#93c5fd", fontWeight: 700, fontSize: "12px" }}>
|
||||
HÀNH ĐỘNG NHANH
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "6px" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onReplayEdit?.(representativeFeature.properties.id)}
|
||||
style={{
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
padding: "8px 10px",
|
||||
cursor: "pointer",
|
||||
background: "#2563eb",
|
||||
color: "#ffffff",
|
||||
fontWeight: 700,
|
||||
fontSize: "13px",
|
||||
textAlign: "center",
|
||||
gridColumn: "span 2",
|
||||
}}
|
||||
>
|
||||
Vào Replay ({selectedFeatures.length} geo)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteFeatures?.(selectedFeatures.map(f => f.properties.id))}
|
||||
style={{
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
padding: "7px 10px",
|
||||
cursor: "pointer",
|
||||
background: "#dc2626",
|
||||
color: "#ffffff",
|
||||
fontWeight: 600,
|
||||
fontSize: "12px",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Xóa ({selectedFeatures.length} geo)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeselectAll?.()}
|
||||
style={{
|
||||
border: "1px solid #475569",
|
||||
borderRadius: "6px",
|
||||
padding: "7px 10px",
|
||||
cursor: "pointer",
|
||||
background: "transparent",
|
||||
color: "#cbd5e1",
|
||||
fontWeight: 600,
|
||||
fontSize: "12px",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Bỏ chọn tất cả
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
@@ -136,82 +224,113 @@ export default function SelectedGeometryPanel({
|
||||
<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})`}
|
||||
|
||||
{!isMultiEditValid ? (
|
||||
<div style={{ color: "#fca5a5", fontSize: "12px", padding: "8px", border: "1px solid #7f1d1d", borderRadius: "6px", background: "#450a0a", marginTop: "4px" }}>
|
||||
Không thể chỉnh sửa thuộc tính cho các geometry không cùng loại hình dạng (Point, Line, Polygon).
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ color: "#e2e8f0", fontWeight: 600, fontSize: "12px" }}>
|
||||
Loại GEO
|
||||
</div>
|
||||
<select
|
||||
value={geometryMetaForm.type_key}
|
||||
onChange={(event) =>
|
||||
setGeometryMetaForm((prev) => ({
|
||||
...prev,
|
||||
type_key: event.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
>
|
||||
{group.options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
{!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>
|
||||
))}
|
||||
</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}
|
||||
</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) =>
|
||||
setGeometryMetaForm((prev) => ({
|
||||
...prev,
|
||||
time_start: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="time_start"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
<input
|
||||
value={geometryMetaForm.time_end}
|
||||
onChange={(event) =>
|
||||
setGeometryMetaForm((prev) => ({
|
||||
...prev,
|
||||
time_end: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="time_end"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleApplyGeoMeta}
|
||||
disabled={isEntitySubmitting}
|
||||
style={primaryGeometryButtonStyle}
|
||||
>
|
||||
{isBulkMode ? `Apply cho ${selectedFeatures.length} geo` : "Apply"}
|
||||
</button>
|
||||
{onReplayEdit && !isBulkMode && selectedFeatures.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onReplayEdit(selectedFeatures[0].properties.id)}
|
||||
style={{
|
||||
...primaryGeometryButtonStyle,
|
||||
background: "#1e293b",
|
||||
border: "1px solid #334155",
|
||||
color: "#38bdf8",
|
||||
}}
|
||||
>
|
||||
Replay Edit
|
||||
</button>
|
||||
)}
|
||||
{visibleGeoApplyFeedback ? (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color:
|
||||
visibleGeoApplyFeedback.kind === "ok" ? "#22c55e" : "#fca5a5",
|
||||
}}
|
||||
>
|
||||
{visibleGeoApplyFeedback.text}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{changeCount > 0 ? (
|
||||
@@ -219,7 +338,7 @@ export default function SelectedGeometryPanel({
|
||||
Thay đổi sẽ vào lịch sử khi Commit.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||