Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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”).
|
||||
Generated
+66
-742
File diff suppressed because it is too large
Load Diff
+2
-4
@@ -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",
|
||||
|
||||
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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,141 @@
|
||||
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";
|
||||
|
||||
// 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 = feature.properties.time_start;
|
||||
const end = feature.properties.time_end;
|
||||
if (typeof start === "number" && Number.isFinite(start) && year < start) return false;
|
||||
if (typeof end === "number" && Number.isFinite(end) && 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: typeof e.time_start === "number" ? e.time_start : null,
|
||||
time_end: typeof e.time_end === "number" ? e.time_end : null,
|
||||
}))
|
||||
.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.");
|
||||
|
||||
+1060
-627
File diff suppressed because it is too large
Load Diff
+128
-4
@@ -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;
|
||||
}
|
||||
+11
-1
@@ -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;
|
||||
}
|
||||
+27
-7
@@ -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>
|
||||
|
||||
+114
-9
@@ -8,7 +8,7 @@ import TimelineBar from "@/uhm/components/ui/TimelineBar";
|
||||
import { fetchEntities, type Entity } from "@/uhm/api/entities";
|
||||
import { fetchGeometriesByBBox } from "@/uhm/api/geometries";
|
||||
import { ApiError } from "@/uhm/api/http";
|
||||
import { fetchWikiBySlug, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
|
||||
import { fetchWikiBySlug, getContentByVersionWikiId, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
|
||||
import {
|
||||
BACKGROUND_LAYER_OPTIONS,
|
||||
type BackgroundLayerId,
|
||||
@@ -88,6 +88,34 @@ export default function Page() {
|
||||
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);
|
||||
@@ -249,6 +277,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 +367,7 @@ export default function Page() {
|
||||
|
||||
selectEntity(onlyEntityId, {
|
||||
sourceFeatureId: selectedFeatureIds[0],
|
||||
focusMap: false,
|
||||
focusMap: true,
|
||||
selectGeometry: false,
|
||||
});
|
||||
}, [activeEntityId, relations.geometryEntityIds, selectEntity, selectedFeatureIds]);
|
||||
@@ -382,6 +414,8 @@ export default function Page() {
|
||||
};
|
||||
}, [linkEntityPopup]);
|
||||
|
||||
const cachedWiki = activeWikiSlug ? (wikiCache[activeWikiSlug] as Wiki & { __fetched?: boolean }) : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeWikiSlug) {
|
||||
setIsActiveWikiLoading(false);
|
||||
@@ -389,10 +423,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 +440,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 } as any,
|
||||
}));
|
||||
} 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 +477,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] || [];
|
||||
@@ -466,6 +524,8 @@ export default function Page() {
|
||||
<Map
|
||||
mode="select"
|
||||
draft={data}
|
||||
labelContextDraft={mapLabelContextDraft}
|
||||
labelTimelineYear={timelineDraftYear}
|
||||
selectedFeatureIds={selectedFeatureIds}
|
||||
onSelectFeatureIds={setSelectedFeatureIds}
|
||||
backgroundVisibility={backgroundVisibility}
|
||||
@@ -476,7 +536,7 @@ export default function Page() {
|
||||
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,6 +550,7 @@ 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">
|
||||
@@ -626,7 +687,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 +698,12 @@ export default function Page() {
|
||||
setActiveWikiSlug(null);
|
||||
setActiveWikiError(null);
|
||||
setLinkEntityPopup(null);
|
||||
setSelectedFeatureIds([]);
|
||||
}}
|
||||
onWikiLinkRequest={handleWikiLinkRequest}
|
||||
sidebarWidth={sidebarWidth}
|
||||
onSidebarWidthChange={setSidebarWidth}
|
||||
maxDragWidth={maxDragWidth}
|
||||
/>
|
||||
</aside>
|
||||
) : null}
|
||||
@@ -768,6 +833,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>
|
||||
|
||||
+301
-167
@@ -12,9 +12,15 @@ 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 {
|
||||
apiCreateProject,
|
||||
apiCreateProjectCommit,
|
||||
apiGetProjectCommits,
|
||||
getCurrentProject,
|
||||
} from "@/service/projectService";
|
||||
import { normalizeEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
||||
import type { EditorSnapshot } from "@/uhm/types/projects";
|
||||
import StickyHeader from "@/components/ui/StickyHeader";
|
||||
|
||||
export type ProjectSortColumn = "created_at" | "updated_at" | "title";
|
||||
|
||||
@@ -23,16 +29,26 @@ export default function ProjectsPage() {
|
||||
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 +67,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 +86,38 @@ 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) {
|
||||
await apiCreateProjectCommit(projectId, {
|
||||
edit_summary: `Init project from ${importSnapshotName || "JSON"}`,
|
||||
snapshot_json: importSnapshot as any,
|
||||
} as any);
|
||||
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 +126,48 @@ 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: 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 handlePickImportJson = () => {
|
||||
importJsonInputRef.current?.click();
|
||||
};
|
||||
@@ -97,88 +184,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");
|
||||
@@ -191,7 +203,7 @@ 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 = "";
|
||||
|
||||
@@ -216,23 +228,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 +282,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,12 +316,18 @@ 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">
|
||||
@@ -290,49 +339,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: any, index: number) =>
|
||||
m.avatar_url ? (
|
||||
<Image
|
||||
key={index}
|
||||
src={m.avatar_url}
|
||||
alt={m.display_name}
|
||||
width={32}
|
||||
height={32}
|
||||
title={m.display_name}
|
||||
className="inline-block w-8 h-8 rounded-full object-cover ring-2 ring-white dark:ring-[#0d1117]"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
key={index}
|
||||
title={m.display_name}
|
||||
className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 ring-2 ring-white dark:ring-[#0d1117]"
|
||||
>
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-gray-300">
|
||||
{m.display_name
|
||||
?.charAt(0)
|
||||
?.toUpperCase() || "U"}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
{project.members.length > 4 && (
|
||||
<div title="Những người khác" className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-800 ring-2 ring-white dark:ring-[#0d1117] z-10">
|
||||
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">+{project.members.length - 4}</span>
|
||||
<div
|
||||
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 +429,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 +464,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 +516,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 +537,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 +576,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 +593,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 +625,4 @@ export default function ProjectsPage() {
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+141
-138
@@ -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>
|
||||
|
||||
+10
-7
@@ -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"
|
||||
|
||||
+6
-1
@@ -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"/>
|
||||
|
||||
|
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"/>
|
||||
|
||||
|
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 |
@@ -72,7 +72,7 @@ export interface RestoreCommitPayload {
|
||||
export interface CreateProjectPayload
|
||||
{
|
||||
description: string,
|
||||
project_status: "PRIVATE" | "PUBLIC" | "ARCHIVE",
|
||||
status: "PRIVATE" | "PUBLIC" | "ARCHIVE",
|
||||
title: string
|
||||
}
|
||||
|
||||
|
||||
+286
-200
@@ -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;
|
||||
|
||||
+28
-4
@@ -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,
|
||||
});
|
||||
|
||||
+38
-9
@@ -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?: {
|
||||
|
||||
+575
-11
@@ -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);
|
||||
};
|
||||
|
||||
+92
-75
@@ -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,6 +20,17 @@ 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;
|
||||
@@ -28,8 +40,10 @@ type MapProps = {
|
||||
onSelectFeatureIds: (ids: (string | number)[]) => void;
|
||||
onSetMode?: (mode: EditorMode, featureId?: string | number) => void;
|
||||
labelContextDraft?: FeatureCollection;
|
||||
labelTimelineYear?: number | null;
|
||||
onCreateFeature?: (feature: FeatureCollection["features"][number]) => void;
|
||||
onDeleteFeature?: (id: string | number) => void;
|
||||
onHideFeature?: (id: string | number) => void;
|
||||
onUpdateFeature?: (id: string | number, geometry: Geometry) => void;
|
||||
allowGeometryEditing?: boolean;
|
||||
respectBindingFilter?: boolean;
|
||||
@@ -41,11 +55,11 @@ 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;
|
||||
};
|
||||
|
||||
export default function Map({
|
||||
const Map = forwardRef<MapHandle, MapProps>(function Map({
|
||||
mode,
|
||||
onSetMode,
|
||||
draft,
|
||||
@@ -54,8 +68,10 @@ export default function Map({
|
||||
selectedFeatureIds,
|
||||
onSelectFeatureIds,
|
||||
labelContextDraft,
|
||||
labelTimelineYear,
|
||||
onCreateFeature,
|
||||
onDeleteFeature,
|
||||
onHideFeature,
|
||||
onUpdateFeature,
|
||||
allowGeometryEditing = true,
|
||||
respectBindingFilter = true,
|
||||
@@ -67,17 +83,31 @@ export default function Map({
|
||||
focusFeatureCollection = null,
|
||||
focusRequestKey = null,
|
||||
focusPadding,
|
||||
hideOutside = false,
|
||||
onToggleHideOutside,
|
||||
}: MapProps) {
|
||||
imageOverlay = null,
|
||||
onImageOverlayChange,
|
||||
}, ref) {
|
||||
// Ref giữ mode mới nhất cho MapLibre handlers được register một lần.
|
||||
const modeRef = useRef<MapProps["mode"]>(mode);
|
||||
// Ref giữ draft mới nhất để engine đọc không bị stale closure.
|
||||
const draftRef = useRef<FeatureCollection>(draft);
|
||||
// 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);
|
||||
|
||||
useEffect(() => { modeRef.current = mode; }, [mode]);
|
||||
useEffect(() => { draftRef.current = draft; }, [draft]);
|
||||
@@ -86,8 +116,12 @@ export default function Map({
|
||||
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]);
|
||||
|
||||
// Hook sở hữu lifecycle MapLibre instance và các control camera/projection.
|
||||
const {
|
||||
mapRef,
|
||||
containerRef,
|
||||
@@ -100,8 +134,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,
|
||||
@@ -117,18 +161,22 @@ export default function Map({
|
||||
onSetModeRef,
|
||||
onCreateRef,
|
||||
onDeleteRef,
|
||||
onHideRef,
|
||||
onUpdateRef,
|
||||
onHoverFeatureChangeRef,
|
||||
});
|
||||
|
||||
// Hook đồng bộ draft/layer/filter/highlight từ React state xuống MapLibre source/layer.
|
||||
const {
|
||||
applyDraftToMap,
|
||||
applyHighlightToMap,
|
||||
applyImageOverlayToMap,
|
||||
tryCenterToUserLocation,
|
||||
} = useMapSync({
|
||||
mapRef,
|
||||
draft,
|
||||
labelContextDraft,
|
||||
labelTimelineYear,
|
||||
backgroundVisibility,
|
||||
geometryVisibility,
|
||||
selectedFeatureIds,
|
||||
@@ -139,6 +187,7 @@ export default function Map({
|
||||
focusFeatureCollection,
|
||||
focusRequestKey,
|
||||
focusPadding,
|
||||
imageOverlay,
|
||||
allowGeometryEditing,
|
||||
editingEngineRef,
|
||||
geolocationCenteredRef,
|
||||
@@ -149,6 +198,7 @@ export default function Map({
|
||||
if (!map || !isMapLoaded) return;
|
||||
|
||||
setupMapLayers(map, backgroundVisibility, highlightFeatures, applyHighlightToMap);
|
||||
applyImageOverlayToMap();
|
||||
setupMapInteractions(map);
|
||||
applyDraftToMap(draftRef.current);
|
||||
tryCenterToUserLocation();
|
||||
@@ -165,7 +215,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 +290,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 +368,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 +421,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,
|
||||
snapshotEntities,
|
||||
wikis,
|
||||
links,
|
||||
} = useEditorStore(
|
||||
useShallow((state) => ({
|
||||
entityCatalog: state.entityCatalog,
|
||||
snapshotEntities: state.snapshotEntities,
|
||||
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 visibleSnapshotEntities = new globalThis.Map<string, { id: string; name: string; isNew: boolean }>();
|
||||
for (const ref of snapshotEntities || []) {
|
||||
const id = String(ref?.id || "").trim();
|
||||
if (!id || ref?.operation === "delete" || visibleSnapshotEntities.has(id)) continue;
|
||||
visibleSnapshotEntities.set(id, {
|
||||
id,
|
||||
name: String(ref?.name || id),
|
||||
isNew: ref?.source === "inline" && ref?.operation === "create",
|
||||
});
|
||||
}
|
||||
|
||||
const rows = Array.from(visibleSnapshotEntities.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, snapshotEntities]);
|
||||
|
||||
const activeLinks = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
"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 { useEditorStore } from "@/uhm/store/editorStore";
|
||||
|
||||
type GeometryChoice = {
|
||||
id: string;
|
||||
label?: string;
|
||||
time_start?: number | null;
|
||||
time_end?: number | null;
|
||||
isTimelineVisible?: boolean;
|
||||
isNew?: 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 +28,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 +58,33 @@ 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: typeof g.time_start === "number" ? g.time_start : null,
|
||||
time_end: typeof g.time_end === "number" ? g.time_end : null,
|
||||
isTimelineVisible: Boolean(g.isTimelineVisible),
|
||||
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 handleFocusKeyDown = (event: KeyboardEvent<HTMLDivElement>, geometryId: string) => {
|
||||
if (!canFocusGeometry) return;
|
||||
@@ -67,6 +93,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={{
|
||||
@@ -92,7 +129,7 @@ export default function GeometryBindingPanel({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bindingFilterEnabled}
|
||||
onChange={(e) => onBindingFilterEnabledChange(e.target.checked)}
|
||||
onChange={(e) => setGeometryBindingFilterEnabled(e.target.checked)}
|
||||
style={{ width: 14, height: 14 }}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: "#94a3b8", whiteSpace: "nowrap" }}>Filter</span>
|
||||
@@ -125,19 +162,27 @@ export default function GeometryBindingPanel({
|
||||
</div>
|
||||
|
||||
{collapsed ? null : selectedGeometry ? (
|
||||
(() => {
|
||||
const isHidden = geometryVisibility[selectedGeometry.id] === false;
|
||||
const idColor = getGeometryIdColor(selectedGeometry);
|
||||
const labelColor = selectedGeometry.isTimelineVisible ? "#22c55e" : "#e5e7eb";
|
||||
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}
|
||||
role={canFocusGeometry ? "button" : undefined}
|
||||
tabIndex={canFocusGeometry ? 0 : undefined}
|
||||
onClick={() => onFocusGeometry?.(selectedGeometry.id)}
|
||||
onClick={() => handleFocusGeometry(selectedGeometry.id)}
|
||||
onKeyDown={(event) => handleFocusKeyDown(event, selectedGeometry.id)}
|
||||
>
|
||||
<div
|
||||
@@ -156,7 +201,7 @@ export default function GeometryBindingPanel({
|
||||
<span
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "#e5e7eb",
|
||||
color: labelColor,
|
||||
fontWeight: 700,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
@@ -165,13 +210,26 @@ export default function GeometryBindingPanel({
|
||||
>
|
||||
{selectedGeometry.label || selectedGeometry.id}
|
||||
</span>
|
||||
{isHidden ? <span style={hiddenBadgeStyle}>hidden</span> : null}
|
||||
{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",
|
||||
color: idColor,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
@@ -180,6 +238,8 @@ export default function GeometryBindingPanel({
|
||||
{selectedGeometry.id}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : null}
|
||||
|
||||
{collapsed ? null : rows.length ? (
|
||||
@@ -187,24 +247,32 @@ export default function GeometryBindingPanel({
|
||||
{visibleRows
|
||||
.map((g) => {
|
||||
const isBound = bindingSet.has(g.id);
|
||||
const isHidden = geometryVisibility[g.id] === false;
|
||||
const idColor = getGeometryIdColor(g);
|
||||
const labelColor = g.isTimelineVisible ? "#22c55e" : "#e5e7eb";
|
||||
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}
|
||||
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 }}>
|
||||
@@ -219,7 +287,7 @@ export default function GeometryBindingPanel({
|
||||
<span
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "#e5e7eb",
|
||||
color: labelColor,
|
||||
fontWeight: 700,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
@@ -228,13 +296,14 @@ export default function GeometryBindingPanel({
|
||||
>
|
||||
{g.label || g.id}
|
||||
</span>
|
||||
{isHidden ? <span style={hiddenBadgeStyle}>hidden</span> : null}
|
||||
{isBound ? <span style={boundBadgeStyle}>bound</span> : null}
|
||||
{g.isNew ? <NewBadge /> : null}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
color: "#94a3b8",
|
||||
color: idColor,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
@@ -244,6 +313,19 @@ export default function GeometryBindingPanel({
|
||||
</div>
|
||||
</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"
|
||||
@@ -311,6 +393,74 @@ const boundBadgeStyle: CSSProperties = {
|
||||
letterSpacing: 0,
|
||||
};
|
||||
|
||||
const hiddenBadgeStyle: CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flex: "0 0 auto",
|
||||
height: 17,
|
||||
padding: "0 6px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid rgba(148, 163, 184, 0.45)",
|
||||
background: "rgba(71, 85, 105, 0.32)",
|
||||
color: "#cbd5e1",
|
||||
fontSize: 10,
|
||||
fontWeight: 900,
|
||||
lineHeight: 1,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0,
|
||||
};
|
||||
|
||||
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 getGeometryIdColor(geometry: GeometryChoice): string {
|
||||
const hasStart = typeof geometry.time_start === "number";
|
||||
const hasEnd = typeof geometry.time_end === "number";
|
||||
if (!hasStart && !hasEnd) return "#f87171";
|
||||
if (!hasStart || !hasEnd) return "#facc15";
|
||||
return "#94a3b8";
|
||||
}
|
||||
|
||||
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 {
|
||||
snapshotEntities,
|
||||
entityForm,
|
||||
setEntityForm,
|
||||
isEntitySubmitting,
|
||||
entityFormStatus,
|
||||
selectedGeometryEntityIds,
|
||||
} = useEditorStore(
|
||||
useShallow((state) => ({
|
||||
snapshotEntities: state.snapshotEntities,
|
||||
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 snapshotEntities || []) {
|
||||
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());
|
||||
}, [snapshotEntities]);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)",
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,36 +1,42 @@
|
||||
"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;
|
||||
};
|
||||
|
||||
export default function SelectedGeometryPanel({
|
||||
selectedFeatures,
|
||||
entityTypeOptions,
|
||||
geometryMetaForm,
|
||||
onGeometryMetaFormChange,
|
||||
isEntitySubmitting,
|
||||
onApplyGeometryMetadata,
|
||||
changeCount,
|
||||
onReplayEdit,
|
||||
}: 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<
|
||||
| {
|
||||
@@ -71,7 +77,7 @@ export default function SelectedGeometryPanel({
|
||||
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) =>
|
||||
@@ -141,7 +147,12 @@ export default function SelectedGeometryPanel({
|
||||
</div>
|
||||
<select
|
||||
value={geometryMetaForm.type_key}
|
||||
onChange={(event) => onGeometryMetaFormChange("type_key", event.target.value)}
|
||||
onChange={(event) =>
|
||||
setGeometryMetaForm((prev) => ({
|
||||
...prev,
|
||||
type_key: event.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
>
|
||||
@@ -174,14 +185,24 @@ export default function SelectedGeometryPanel({
|
||||
) : null}
|
||||
<input
|
||||
value={geometryMetaForm.time_start}
|
||||
onChange={(event) => onGeometryMetaFormChange("time_start", event.target.value)}
|
||||
onChange={(event) =>
|
||||
setGeometryMetaForm((prev) => ({
|
||||
...prev,
|
||||
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)}
|
||||
onChange={(event) =>
|
||||
setGeometryMetaForm((prev) => ({
|
||||
...prev,
|
||||
time_end: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="time_end"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
@@ -201,6 +222,20 @@ export default function SelectedGeometryPanel({
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
{onReplayEdit && 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={{
|
||||
|
||||
@@ -48,6 +48,8 @@ export function formatUndoLabel(action: UndoAction) {
|
||||
case "snapshot_entities":
|
||||
case "snapshot_wikis":
|
||||
case "snapshot_entity_wiki":
|
||||
case "replay":
|
||||
case "replay_session":
|
||||
case "group":
|
||||
return action.label;
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
|
||||
export type MapImageOverlay = {
|
||||
url: string;
|
||||
name: string;
|
||||
opacity: number;
|
||||
aspectRatio: number;
|
||||
coordinates: maplibregl.Coordinates;
|
||||
};
|
||||
|
||||
const IMAGE_OVERLAY_SOURCE_ID = "uhm-image-overlay-source";
|
||||
const IMAGE_OVERLAY_LAYER_ID = "uhm-image-overlay-layer";
|
||||
const IMAGE_OVERLAY_CONTROL_SOURCE_ID = "uhm-image-overlay-control-source";
|
||||
const IMAGE_OVERLAY_HANDLE_LAYER_ID = "uhm-image-overlay-handles";
|
||||
const IMAGE_OVERLAY_CENTER_LAYER_ID = "uhm-image-overlay-center";
|
||||
|
||||
type OverlayControlAction = "move" | "resize";
|
||||
type OverlayResizeEdge = "top" | "right" | "bottom" | "left";
|
||||
|
||||
type OverlayControlFeature = GeoJSON.Feature<GeoJSON.Point, {
|
||||
action: OverlayControlAction;
|
||||
edge?: OverlayResizeEdge;
|
||||
}>;
|
||||
|
||||
export function applyImageOverlay(
|
||||
map: maplibregl.Map,
|
||||
overlay: MapImageOverlay | null | undefined
|
||||
) {
|
||||
if (!overlay) {
|
||||
removeImageOverlay(map);
|
||||
return;
|
||||
}
|
||||
|
||||
const existingSource = map.getSource(IMAGE_OVERLAY_SOURCE_ID) as maplibregl.ImageSource | undefined;
|
||||
if (existingSource) {
|
||||
if (existingSource.url === overlay.url) {
|
||||
existingSource.setCoordinates(overlay.coordinates);
|
||||
} else {
|
||||
existingSource.updateImage({
|
||||
url: overlay.url,
|
||||
coordinates: overlay.coordinates,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
map.addSource(IMAGE_OVERLAY_SOURCE_ID, {
|
||||
type: "image",
|
||||
url: overlay.url,
|
||||
coordinates: overlay.coordinates,
|
||||
});
|
||||
}
|
||||
|
||||
if (!map.getLayer(IMAGE_OVERLAY_LAYER_ID)) {
|
||||
map.addLayer({
|
||||
id: IMAGE_OVERLAY_LAYER_ID,
|
||||
type: "raster",
|
||||
source: IMAGE_OVERLAY_SOURCE_ID,
|
||||
paint: {
|
||||
"raster-opacity": clampOpacity(overlay.opacity),
|
||||
"raster-fade-duration": 0,
|
||||
"raster-resampling": "linear",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
map.setPaintProperty(IMAGE_OVERLAY_LAYER_ID, "raster-opacity", clampOpacity(overlay.opacity));
|
||||
map.setPaintProperty(IMAGE_OVERLAY_LAYER_ID, "raster-fade-duration", 0);
|
||||
}
|
||||
|
||||
// Không truyền beforeId để layer được đưa lên trên cùng, phục vụ trace khi vẽ.
|
||||
map.moveLayer(IMAGE_OVERLAY_LAYER_ID);
|
||||
applyImageOverlayControls(map, overlay);
|
||||
}
|
||||
|
||||
export function removeImageOverlay(map: maplibregl.Map) {
|
||||
removeImageOverlayControls(map);
|
||||
|
||||
if (map.getLayer(IMAGE_OVERLAY_LAYER_ID)) {
|
||||
map.removeLayer(IMAGE_OVERLAY_LAYER_ID);
|
||||
}
|
||||
|
||||
if (map.getSource(IMAGE_OVERLAY_SOURCE_ID)) {
|
||||
map.removeSource(IMAGE_OVERLAY_SOURCE_ID);
|
||||
}
|
||||
}
|
||||
|
||||
export function getViewportImageCoordinates(
|
||||
map: maplibregl.Map,
|
||||
aspectRatio: number
|
||||
): maplibregl.Coordinates {
|
||||
const canvas = map.getCanvas();
|
||||
const canvasWidth = Math.max(canvas.clientWidth || canvas.width || 800, 1);
|
||||
const canvasHeight = Math.max(canvas.clientHeight || canvas.height || 600, 1);
|
||||
const safeAspectRatio = normalizeAspectRatio(aspectRatio);
|
||||
|
||||
let width = canvasWidth * 0.72;
|
||||
let height = width / safeAspectRatio;
|
||||
const maxHeight = canvasHeight * 0.72;
|
||||
if (height > maxHeight) {
|
||||
height = maxHeight;
|
||||
width = height * safeAspectRatio;
|
||||
}
|
||||
|
||||
return buildCoordinatesFromScreenBox(
|
||||
map,
|
||||
{ x: canvasWidth / 2, y: canvasHeight / 2 },
|
||||
width,
|
||||
height
|
||||
);
|
||||
}
|
||||
|
||||
export function moveImageOverlayCoordinatesByPixels(
|
||||
map: maplibregl.Map,
|
||||
coordinates: maplibregl.Coordinates,
|
||||
deltaX: number,
|
||||
deltaY: number
|
||||
): maplibregl.Coordinates {
|
||||
return moveCoordinates(map, coordinates, new maplibregl.Point(deltaX, deltaY));
|
||||
}
|
||||
|
||||
export function scaleImageOverlayCoordinatesByFactor(
|
||||
map: maplibregl.Map,
|
||||
coordinates: maplibregl.Coordinates,
|
||||
factor: number,
|
||||
aspectRatio: number
|
||||
): maplibregl.Coordinates {
|
||||
const safeFactor = Number.isFinite(factor) && factor > 0 ? factor : 1;
|
||||
const screenBox = getScreenBox(map, coordinates);
|
||||
const minimumSize = 48;
|
||||
const width = Math.max(screenBox.width * safeFactor, minimumSize);
|
||||
const height = width / normalizeAspectRatio(aspectRatio);
|
||||
return buildCoordinatesFromScreenBox(map, screenBox.center, width, height);
|
||||
}
|
||||
|
||||
export function bindImageOverlayInteractions(
|
||||
map: maplibregl.Map,
|
||||
getOverlay: () => MapImageOverlay | null,
|
||||
onChange: (overlay: MapImageOverlay) => void
|
||||
) {
|
||||
let rafId: number | null = null;
|
||||
let pendingCoordinates: maplibregl.Coordinates | null = null;
|
||||
let latestOverlay: MapImageOverlay | null = null;
|
||||
let activeDrag: {
|
||||
action: OverlayControlAction;
|
||||
edge: OverlayResizeEdge | null;
|
||||
startPoint: maplibregl.Point;
|
||||
startCoordinates: maplibregl.Coordinates;
|
||||
startBox: ScreenBox;
|
||||
aspectRatio: number;
|
||||
wasDragPanEnabled: boolean;
|
||||
} | null = null;
|
||||
|
||||
const startDrag = (event: maplibregl.MapLayerMouseEvent) => {
|
||||
if ((event.originalEvent as MouseEvent | undefined)?.button !== 2) return;
|
||||
|
||||
const overlay = getOverlay();
|
||||
const feature = event.features?.[0] as OverlayControlFeature | undefined;
|
||||
if (!overlay || !feature?.properties?.action) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.originalEvent.preventDefault();
|
||||
event.originalEvent.stopPropagation();
|
||||
|
||||
activeDrag = {
|
||||
action: feature.properties.action,
|
||||
edge: feature.properties.edge || null,
|
||||
startPoint: event.point,
|
||||
startCoordinates: overlay.coordinates,
|
||||
startBox: getScreenBox(map, overlay.coordinates),
|
||||
aspectRatio: normalizeAspectRatio(overlay.aspectRatio),
|
||||
wasDragPanEnabled: map.dragPan.isEnabled(),
|
||||
};
|
||||
latestOverlay = overlay;
|
||||
map.dragPan.disable();
|
||||
map.getCanvas().style.cursor = activeDrag.action === "move" ? "grabbing" : "nwse-resize";
|
||||
};
|
||||
|
||||
const moveDrag = (event: maplibregl.MapMouseEvent) => {
|
||||
if (!activeDrag) return;
|
||||
const overlay = getOverlay();
|
||||
if (!overlay) return;
|
||||
|
||||
event.preventDefault();
|
||||
const nextCoordinates = activeDrag.action === "move"
|
||||
? moveCoordinates(map, activeDrag.startCoordinates, event.point.sub(activeDrag.startPoint))
|
||||
: resizeCoordinates(map, activeDrag.startBox, event.point, activeDrag.edge, activeDrag.aspectRatio);
|
||||
|
||||
latestOverlay = {
|
||||
...overlay,
|
||||
coordinates: nextCoordinates,
|
||||
};
|
||||
scheduleImageOverlayCoordinateUpdate(map, nextCoordinates);
|
||||
};
|
||||
|
||||
const endDrag = () => {
|
||||
if (!activeDrag) return;
|
||||
const finishedDrag = activeDrag;
|
||||
activeDrag = null;
|
||||
flushImageOverlayCoordinateUpdate(map);
|
||||
if (latestOverlay) {
|
||||
onChange(latestOverlay);
|
||||
latestOverlay = null;
|
||||
}
|
||||
if (finishedDrag.wasDragPanEnabled && !map.dragPan.isEnabled()) {
|
||||
map.dragPan.enable();
|
||||
}
|
||||
map.getCanvas().style.cursor = "";
|
||||
};
|
||||
|
||||
const preventContextMenu = (event: maplibregl.MapLayerMouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.originalEvent.preventDefault();
|
||||
event.originalEvent.stopPropagation();
|
||||
};
|
||||
|
||||
map.on("mousedown", IMAGE_OVERLAY_HANDLE_LAYER_ID, startDrag);
|
||||
map.on("mousedown", IMAGE_OVERLAY_CENTER_LAYER_ID, startDrag);
|
||||
map.on("mousemove", moveDrag);
|
||||
map.on("mouseup", endDrag);
|
||||
map.on("contextmenu", IMAGE_OVERLAY_HANDLE_LAYER_ID, preventContextMenu);
|
||||
map.on("contextmenu", IMAGE_OVERLAY_CENTER_LAYER_ID, preventContextMenu);
|
||||
|
||||
return () => {
|
||||
endDrag();
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
pendingCoordinates = null;
|
||||
map.off("mousedown", IMAGE_OVERLAY_HANDLE_LAYER_ID, startDrag);
|
||||
map.off("mousedown", IMAGE_OVERLAY_CENTER_LAYER_ID, startDrag);
|
||||
map.off("mousemove", moveDrag);
|
||||
map.off("mouseup", endDrag);
|
||||
map.off("contextmenu", IMAGE_OVERLAY_HANDLE_LAYER_ID, preventContextMenu);
|
||||
map.off("contextmenu", IMAGE_OVERLAY_CENTER_LAYER_ID, preventContextMenu);
|
||||
};
|
||||
|
||||
function scheduleImageOverlayCoordinateUpdate(
|
||||
targetMap: maplibregl.Map,
|
||||
coordinates: maplibregl.Coordinates
|
||||
) {
|
||||
pendingCoordinates = coordinates;
|
||||
if (rafId !== null) return;
|
||||
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null;
|
||||
flushImageOverlayCoordinateUpdate(targetMap);
|
||||
});
|
||||
}
|
||||
|
||||
function flushImageOverlayCoordinateUpdate(targetMap: maplibregl.Map) {
|
||||
if (!pendingCoordinates) return;
|
||||
updateImageOverlayCoordinates(targetMap, pendingCoordinates);
|
||||
pendingCoordinates = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getImageOverlayInteractiveLayerIds() {
|
||||
return [IMAGE_OVERLAY_HANDLE_LAYER_ID, IMAGE_OVERLAY_CENTER_LAYER_ID];
|
||||
}
|
||||
|
||||
function applyImageOverlayControls(map: maplibregl.Map, overlay: MapImageOverlay) {
|
||||
const data = buildControlFeatureCollection(overlay.coordinates);
|
||||
const existingSource = map.getSource(IMAGE_OVERLAY_CONTROL_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
|
||||
if (existingSource) {
|
||||
existingSource.setData(data);
|
||||
} else {
|
||||
map.addSource(IMAGE_OVERLAY_CONTROL_SOURCE_ID, {
|
||||
type: "geojson",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
if (!map.getLayer(IMAGE_OVERLAY_HANDLE_LAYER_ID)) {
|
||||
map.addLayer({
|
||||
id: IMAGE_OVERLAY_HANDLE_LAYER_ID,
|
||||
type: "circle",
|
||||
source: IMAGE_OVERLAY_CONTROL_SOURCE_ID,
|
||||
filter: ["==", ["get", "action"], "resize"],
|
||||
paint: {
|
||||
"circle-color": "#38bdf8",
|
||||
"circle-radius": 7,
|
||||
"circle-stroke-color": "#0f172a",
|
||||
"circle-stroke-width": 2,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!map.getLayer(IMAGE_OVERLAY_CENTER_LAYER_ID)) {
|
||||
map.addLayer({
|
||||
id: IMAGE_OVERLAY_CENTER_LAYER_ID,
|
||||
type: "circle",
|
||||
source: IMAGE_OVERLAY_CONTROL_SOURCE_ID,
|
||||
filter: ["==", ["get", "action"], "move"],
|
||||
paint: {
|
||||
"circle-color": "#fbbf24",
|
||||
"circle-radius": 8,
|
||||
"circle-stroke-color": "#0f172a",
|
||||
"circle-stroke-width": 2,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
map.moveLayer(IMAGE_OVERLAY_HANDLE_LAYER_ID);
|
||||
map.moveLayer(IMAGE_OVERLAY_CENTER_LAYER_ID);
|
||||
}
|
||||
|
||||
function updateImageOverlayCoordinates(
|
||||
map: maplibregl.Map,
|
||||
coordinates: maplibregl.Coordinates
|
||||
) {
|
||||
const imageSource = map.getSource(IMAGE_OVERLAY_SOURCE_ID) as maplibregl.ImageSource | undefined;
|
||||
imageSource?.setCoordinates(coordinates);
|
||||
|
||||
const controlSource = map.getSource(IMAGE_OVERLAY_CONTROL_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
|
||||
controlSource?.setData(buildControlFeatureCollection(coordinates));
|
||||
}
|
||||
|
||||
function removeImageOverlayControls(map: maplibregl.Map) {
|
||||
if (map.getLayer(IMAGE_OVERLAY_CENTER_LAYER_ID)) {
|
||||
map.removeLayer(IMAGE_OVERLAY_CENTER_LAYER_ID);
|
||||
}
|
||||
if (map.getLayer(IMAGE_OVERLAY_HANDLE_LAYER_ID)) {
|
||||
map.removeLayer(IMAGE_OVERLAY_HANDLE_LAYER_ID);
|
||||
}
|
||||
if (map.getSource(IMAGE_OVERLAY_CONTROL_SOURCE_ID)) {
|
||||
map.removeSource(IMAGE_OVERLAY_CONTROL_SOURCE_ID);
|
||||
}
|
||||
}
|
||||
|
||||
function buildControlFeatureCollection(
|
||||
coordinates: maplibregl.Coordinates
|
||||
): GeoJSON.FeatureCollection<GeoJSON.Point, OverlayControlFeature["properties"]> {
|
||||
const [topLeft, topRight, bottomRight, bottomLeft] = coordinates;
|
||||
const center = averageCoordinates(coordinates);
|
||||
|
||||
return [
|
||||
createControlFeature(center, { action: "move" }),
|
||||
createControlFeature(midpoint(topLeft, topRight), { action: "resize", edge: "top" }),
|
||||
createControlFeature(midpoint(topRight, bottomRight), { action: "resize", edge: "right" }),
|
||||
createControlFeature(midpoint(bottomRight, bottomLeft), { action: "resize", edge: "bottom" }),
|
||||
createControlFeature(midpoint(bottomLeft, topLeft), { action: "resize", edge: "left" }),
|
||||
].reduce<GeoJSON.FeatureCollection<GeoJSON.Point, OverlayControlFeature["properties"]>>(
|
||||
(collection, feature) => {
|
||||
collection.features.push(feature);
|
||||
return collection;
|
||||
},
|
||||
{ type: "FeatureCollection", features: [] }
|
||||
);
|
||||
}
|
||||
|
||||
function createControlFeature(
|
||||
coordinates: [number, number],
|
||||
properties: OverlayControlFeature["properties"]
|
||||
): OverlayControlFeature {
|
||||
return {
|
||||
type: "Feature",
|
||||
properties,
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type ScreenPoint = { x: number; y: number };
|
||||
type ScreenBox = {
|
||||
center: ScreenPoint;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
function getScreenBox(map: maplibregl.Map, coordinates: maplibregl.Coordinates): ScreenBox {
|
||||
const points = coordinates.map((coordinate) => map.project(coordinate));
|
||||
const minX = Math.min(...points.map((point) => point.x));
|
||||
const maxX = Math.max(...points.map((point) => point.x));
|
||||
const minY = Math.min(...points.map((point) => point.y));
|
||||
const maxY = Math.max(...points.map((point) => point.y));
|
||||
return {
|
||||
center: {
|
||||
x: (minX + maxX) / 2,
|
||||
y: (minY + maxY) / 2,
|
||||
},
|
||||
width: Math.max(maxX - minX, 40),
|
||||
height: Math.max(maxY - minY, 40),
|
||||
};
|
||||
}
|
||||
|
||||
function moveCoordinates(
|
||||
map: maplibregl.Map,
|
||||
coordinates: maplibregl.Coordinates,
|
||||
delta: maplibregl.Point
|
||||
): maplibregl.Coordinates {
|
||||
return coordinates.map((coordinate) => {
|
||||
const point = map.project(coordinate);
|
||||
return lngLatToCoordinate(map.unproject([point.x + delta.x, point.y + delta.y]));
|
||||
}) as maplibregl.Coordinates;
|
||||
}
|
||||
|
||||
function resizeCoordinates(
|
||||
map: maplibregl.Map,
|
||||
startBox: ScreenBox,
|
||||
currentPoint: maplibregl.Point,
|
||||
edge: OverlayResizeEdge | null,
|
||||
aspectRatio: number
|
||||
): maplibregl.Coordinates {
|
||||
const minimumSize = 48;
|
||||
let width = startBox.width;
|
||||
let height = startBox.height;
|
||||
|
||||
if (edge === "left" || edge === "right") {
|
||||
width = Math.max(Math.abs(currentPoint.x - startBox.center.x) * 2, minimumSize);
|
||||
height = width / aspectRatio;
|
||||
} else {
|
||||
height = Math.max(Math.abs(currentPoint.y - startBox.center.y) * 2, minimumSize);
|
||||
width = height * aspectRatio;
|
||||
}
|
||||
|
||||
return buildCoordinatesFromScreenBox(map, startBox.center, width, height);
|
||||
}
|
||||
|
||||
function buildCoordinatesFromScreenBox(
|
||||
map: maplibregl.Map,
|
||||
center: ScreenPoint,
|
||||
width: number,
|
||||
height: number
|
||||
): maplibregl.Coordinates {
|
||||
const halfWidth = width / 2;
|
||||
const halfHeight = height / 2;
|
||||
return [
|
||||
lngLatToCoordinate(map.unproject([center.x - halfWidth, center.y - halfHeight])),
|
||||
lngLatToCoordinate(map.unproject([center.x + halfWidth, center.y - halfHeight])),
|
||||
lngLatToCoordinate(map.unproject([center.x + halfWidth, center.y + halfHeight])),
|
||||
lngLatToCoordinate(map.unproject([center.x - halfWidth, center.y + halfHeight])),
|
||||
];
|
||||
}
|
||||
|
||||
function averageCoordinates(coordinates: maplibregl.Coordinates): [number, number] {
|
||||
const total = coordinates.reduce(
|
||||
(sum, coordinate) => ({
|
||||
lng: sum.lng + coordinate[0],
|
||||
lat: sum.lat + coordinate[1],
|
||||
}),
|
||||
{ lng: 0, lat: 0 }
|
||||
);
|
||||
return [total.lng / coordinates.length, total.lat / coordinates.length];
|
||||
}
|
||||
|
||||
function midpoint(a: [number, number], b: [number, number]): [number, number] {
|
||||
return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
||||
}
|
||||
|
||||
function lngLatToCoordinate(lngLat: maplibregl.LngLat): [number, number] {
|
||||
return [lngLat.lng, lngLat.lat];
|
||||
}
|
||||
|
||||
function normalizeAspectRatio(value: number) {
|
||||
if (!Number.isFinite(value) || value <= 0) return 1;
|
||||
return value;
|
||||
}
|
||||
|
||||
function clampOpacity(value: number) {
|
||||
if (!Number.isFinite(value)) return 0.55;
|
||||
if (value < 0) return 0;
|
||||
if (value > 1) return 1;
|
||||
return value;
|
||||
}
|
||||
@@ -5,21 +5,22 @@ import { Feature, FeatureCollection, Geometry } from "@/uhm/lib/editor/state/use
|
||||
import {
|
||||
FEATURE_STATE_SOURCE_IDS,
|
||||
PATH_ARROW_ICON_ID,
|
||||
RASTER_BASE_INSERT_BEFORE_LAYER_ID,
|
||||
RASTER_BASE_LAYER_ID,
|
||||
RASTER_BASE_SOURCE_ID,
|
||||
PATH_ARROW_SOURCE_ID
|
||||
} from "@/uhm/lib/map/constants";
|
||||
import { PATH_RENDER_BY_TYPE } from "@/uhm/lib/map/styles/style";
|
||||
import { getRasterTileTemplateUrl } from "@/uhm/api/tiles";
|
||||
import { getBackgroundRasterSourceSpecification } from "@/uhm/api/tiles";
|
||||
import { newId } from "@/uhm/lib/utils/id";
|
||||
import { normalizeGeoTypeKey } from "@/uhm/lib/map/geo/geoTypeMap";
|
||||
import type { EntityLabelCandidate } from "@/uhm/types/geo";
|
||||
|
||||
type Coordinate = [number, number];
|
||||
type PolygonCoordinates = Coordinate[][];
|
||||
type FeatureLabelInfo = {
|
||||
entityId: string;
|
||||
label: string;
|
||||
timeEnd: number | null;
|
||||
};
|
||||
|
||||
export function applyBackgroundLayerVisibility(
|
||||
@@ -30,33 +31,46 @@ export function applyBackgroundLayerVisibility(
|
||||
|
||||
for (const layer of BACKGROUND_LAYER_OPTIONS) {
|
||||
if (layer.id === RASTER_BASE_LAYER_ID) continue;
|
||||
if (!map.getLayer(layer.id)) continue;
|
||||
map.setLayoutProperty(
|
||||
layer.id,
|
||||
"visibility",
|
||||
visibility[layer.id] ? "visible" : "none"
|
||||
);
|
||||
const nextVisibility = visibility[layer.id] ? "visible" : "none";
|
||||
|
||||
if (map.getLayer(layer.id)) {
|
||||
map.setLayoutProperty(layer.id, "visibility", nextVisibility);
|
||||
}
|
||||
|
||||
const groupedLayerIds = getBackgroundGroupLayerIds(map, layer.id);
|
||||
for (const groupedLayerId of groupedLayerIds) {
|
||||
if (!map.getLayer(groupedLayerId)) continue;
|
||||
map.setLayoutProperty(groupedLayerId, "visibility", nextVisibility);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function syncRasterBaseVisibility(map: maplibregl.Map, shouldShow: boolean) {
|
||||
if (shouldShow) {
|
||||
ensureRasterBaseLayer(map);
|
||||
void ensureRasterBaseLayer(map).catch((error) => {
|
||||
console.error("Failed to load proxied raster background.", error);
|
||||
removeRasterBaseLayer(map);
|
||||
});
|
||||
return;
|
||||
}
|
||||
removeRasterBaseLayer(map);
|
||||
}
|
||||
|
||||
export function ensureRasterBaseLayer(map: maplibregl.Map) {
|
||||
export async function ensureRasterBaseLayer(map: maplibregl.Map) {
|
||||
if (!map.getSource(RASTER_BASE_SOURCE_ID)) {
|
||||
map.addSource(RASTER_BASE_SOURCE_ID, createRasterBaseSource());
|
||||
const source = await createRasterBaseSource();
|
||||
if (map.getSource(RASTER_BASE_SOURCE_ID)) {
|
||||
// Another caller already added the source while we were waiting.
|
||||
} else {
|
||||
map.addSource(RASTER_BASE_SOURCE_ID, source);
|
||||
}
|
||||
}
|
||||
|
||||
const beforeId = getRasterBaseInsertBeforeLayerId(map);
|
||||
if (!map.getLayer(RASTER_BASE_LAYER_ID)) {
|
||||
const beforeId = map.getLayer(RASTER_BASE_INSERT_BEFORE_LAYER_ID)
|
||||
? RASTER_BASE_INSERT_BEFORE_LAYER_ID
|
||||
: undefined;
|
||||
map.addLayer(createRasterBaseLayer(), beforeId);
|
||||
} else if (beforeId && beforeId !== RASTER_BASE_LAYER_ID) {
|
||||
map.moveLayer(RASTER_BASE_LAYER_ID, beforeId);
|
||||
}
|
||||
|
||||
map.setLayoutProperty(RASTER_BASE_LAYER_ID, "visibility", "visible");
|
||||
@@ -73,13 +87,7 @@ export function removeRasterBaseLayer(map: maplibregl.Map) {
|
||||
}
|
||||
|
||||
export function createRasterBaseSource() {
|
||||
return {
|
||||
type: "raster" as const,
|
||||
tiles: [getRasterTileTemplateUrl()],
|
||||
tileSize: 256,
|
||||
minzoom: 0,
|
||||
maxzoom: 6,
|
||||
};
|
||||
return getBackgroundRasterSourceSpecification();
|
||||
}
|
||||
|
||||
export function createRasterBaseLayer() {
|
||||
@@ -94,11 +102,35 @@ export function createRasterBaseLayer() {
|
||||
};
|
||||
}
|
||||
|
||||
function getRasterBaseInsertBeforeLayerId(map: maplibregl.Map): string | undefined {
|
||||
const style = map.getStyle();
|
||||
const layers = style?.layers || [];
|
||||
|
||||
return layers.find((layer) => {
|
||||
return layer.id !== "background" && layer.id !== RASTER_BASE_LAYER_ID;
|
||||
})?.id;
|
||||
}
|
||||
|
||||
function getBackgroundGroupLayerIds(
|
||||
map: maplibregl.Map,
|
||||
groupId: string
|
||||
): string[] {
|
||||
const style = map.getStyle();
|
||||
if (!style?.layers?.length) return [];
|
||||
|
||||
return style.layers
|
||||
.filter((layer) => {
|
||||
const metadata = (layer as { metadata?: Record<string, unknown> }).metadata;
|
||||
return metadata?.uhmBackgroundGroupId === groupId;
|
||||
})
|
||||
.map((layer) => layer.id);
|
||||
}
|
||||
|
||||
export function getSelectableLayers(map: maplibregl.Map): string[] {
|
||||
const selectableSources = ["countries", "places", PATH_ARROW_SOURCE_ID];
|
||||
const style = map.getStyle();
|
||||
if (!style || !style.layers) return [];
|
||||
|
||||
|
||||
return style.layers
|
||||
.filter((layer) => "source" in layer && selectableSources.includes(layer.source as string))
|
||||
.map((layer) => layer.id);
|
||||
@@ -200,8 +232,12 @@ export function splitDraftFeatures(fc: FeatureCollection) {
|
||||
return { polygons, points };
|
||||
}
|
||||
|
||||
export function decoratePointFeaturesWithLabels(fc: FeatureCollection, labelContext: FeatureCollection = fc): FeatureCollection {
|
||||
const getLabel = createFeatureLabelResolver(labelContext);
|
||||
export function decoratePointFeaturesWithLabels(
|
||||
fc: FeatureCollection,
|
||||
labelContext: FeatureCollection = fc,
|
||||
timelineYear?: number | null
|
||||
): FeatureCollection {
|
||||
const getLabel = createFeatureLabelResolver(labelContext, timelineYear);
|
||||
return {
|
||||
...fc,
|
||||
features: fc.features.map((feature) => ({
|
||||
@@ -214,8 +250,12 @@ export function decoratePointFeaturesWithLabels(fc: FeatureCollection, labelCont
|
||||
};
|
||||
}
|
||||
|
||||
export function decorateLineFeaturesWithLabels(fc: FeatureCollection, labelContext: FeatureCollection = fc): FeatureCollection {
|
||||
const getLabel = createFeatureLabelResolver(labelContext);
|
||||
export function decorateLineFeaturesWithLabels(
|
||||
fc: FeatureCollection,
|
||||
labelContext: FeatureCollection = fc,
|
||||
timelineYear?: number | null
|
||||
): FeatureCollection {
|
||||
const getLabel = createFeatureLabelResolver(labelContext, timelineYear);
|
||||
return {
|
||||
...fc,
|
||||
features: fc.features.map((feature) => ({
|
||||
@@ -228,8 +268,12 @@ export function decorateLineFeaturesWithLabels(fc: FeatureCollection, labelConte
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPolygonLabelFeatureCollection(fc: FeatureCollection, labelContext: FeatureCollection = fc): FeatureCollection {
|
||||
const getLabel = createFeatureLabelResolver(labelContext);
|
||||
export function buildPolygonLabelFeatureCollection(
|
||||
fc: FeatureCollection,
|
||||
labelContext: FeatureCollection = fc,
|
||||
timelineYear?: number | null
|
||||
): FeatureCollection {
|
||||
const getLabel = createFeatureLabelResolver(labelContext, timelineYear);
|
||||
const features: Feature[] = [];
|
||||
|
||||
for (const feature of fc.features) {
|
||||
@@ -402,8 +446,8 @@ export function buildPathArrowGeometry(coords: [number, number][]): Geometry | n
|
||||
|
||||
if (bodyPoints.length < 2) return null;
|
||||
|
||||
const tailWidth = clampNumber(totalLength * 0.005, 8000, 40000);
|
||||
const shoulderWidth = clampNumber(totalLength * 0.015, 18000, 100000);
|
||||
const tailWidth = clampNumber(totalLength * 0.02, 5, 40000);
|
||||
const shoulderWidth = clampNumber(totalLength * 0.1, 10, 100000);
|
||||
const headWidth = shoulderWidth * 2.0;
|
||||
|
||||
const leftBody: ProjectedPoint[] = [];
|
||||
@@ -625,12 +669,15 @@ export function roundZoom(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
function createFeatureLabelResolver(fc: FeatureCollection): (feature: Feature) => string | null {
|
||||
function createFeatureLabelResolver(
|
||||
fc: FeatureCollection,
|
||||
timelineYear?: number | null
|
||||
): (feature: Feature) => string | null {
|
||||
const directLabelsByFeatureId = new Map<string, FeatureLabelInfo>();
|
||||
const inheritedLabelsByChildId = new Map<string, FeatureLabelInfo | null>();
|
||||
|
||||
for (const feature of fc.features) {
|
||||
const labelInfo = getSingleEntityFeatureLabelInfo(feature);
|
||||
const labelInfo = getSingleEntityFeatureLabelInfo(feature, timelineYear);
|
||||
if (!labelInfo) continue;
|
||||
directLabelsByFeatureId.set(String(feature.properties.id), labelInfo);
|
||||
}
|
||||
@@ -680,14 +727,97 @@ function mergeInheritedFeatureLabel(
|
||||
}
|
||||
}
|
||||
|
||||
function getSingleEntityFeatureLabelInfo(feature: Feature): FeatureLabelInfo | null {
|
||||
function getSingleEntityFeatureLabelInfo(
|
||||
feature: Feature,
|
||||
timelineYear?: number | null
|
||||
): FeatureLabelInfo | null {
|
||||
const candidates = getFeatureEntityLabelCandidates(feature);
|
||||
if (candidates.length > 0) {
|
||||
const timelineCandidate = getLatestTimelineEntityCandidate(candidates, timelineYear);
|
||||
if (!timelineCandidate) return null;
|
||||
return {
|
||||
entityId: timelineCandidate.id,
|
||||
label: timelineCandidate.name,
|
||||
timeEnd: normalizeLabelYear(timelineCandidate.time_end),
|
||||
};
|
||||
}
|
||||
|
||||
const entityIds = getFeatureEntityIds(feature);
|
||||
if (entityIds.length !== 1) return null;
|
||||
|
||||
const label = getSingleEntityName(feature);
|
||||
if (!label) return null;
|
||||
|
||||
return { entityId: entityIds[0], label };
|
||||
return { entityId: entityIds[0], label, timeEnd: null };
|
||||
}
|
||||
|
||||
function getLatestTimelineEntityCandidate(
|
||||
candidates: EntityLabelCandidate[],
|
||||
timelineYear?: number | null
|
||||
): EntityLabelCandidate | null {
|
||||
if (!candidates.length) return null;
|
||||
|
||||
const activeCandidates = candidates.filter((candidate) =>
|
||||
isEntityCandidateVisibleAtYear(candidate, timelineYear)
|
||||
);
|
||||
if (!activeCandidates.length) return null;
|
||||
|
||||
return activeCandidates.sort(compareEntityLabelCandidates)[0] || null;
|
||||
}
|
||||
|
||||
function getFeatureEntityLabelCandidates(feature: Feature): EntityLabelCandidate[] {
|
||||
const rawCandidates = feature.properties.entity_label_candidates;
|
||||
if (!Array.isArray(rawCandidates)) return [];
|
||||
|
||||
const byId = new Map<string, EntityLabelCandidate>();
|
||||
for (const raw of rawCandidates) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const candidate = raw as EntityLabelCandidate;
|
||||
const id = String(candidate.id || "").trim();
|
||||
const name = String(candidate.name || "").trim();
|
||||
if (!id || !name) continue;
|
||||
byId.set(id, {
|
||||
id,
|
||||
name,
|
||||
time_start: normalizeLabelYear(candidate.time_start),
|
||||
time_end: normalizeLabelYear(candidate.time_end),
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(byId.values());
|
||||
}
|
||||
|
||||
function isEntityCandidateVisibleAtYear(
|
||||
candidate: EntityLabelCandidate,
|
||||
timelineYear?: number | null
|
||||
): boolean {
|
||||
if (typeof timelineYear !== "number" || !Number.isFinite(timelineYear)) return true;
|
||||
|
||||
const start = normalizeLabelYear(candidate.time_start);
|
||||
const end = normalizeLabelYear(candidate.time_end);
|
||||
if (start != null && timelineYear < start) return false;
|
||||
if (end != null && timelineYear > end) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function compareEntityLabelCandidates(a: EntityLabelCandidate, b: EntityLabelCandidate): number {
|
||||
const endA = normalizeLabelYear(a.time_end);
|
||||
const endB = normalizeLabelYear(b.time_end);
|
||||
const endScoreA = endA == null ? Number.NEGATIVE_INFINITY : endA;
|
||||
const endScoreB = endB == null ? Number.NEGATIVE_INFINITY : endB;
|
||||
if (endScoreA !== endScoreB) return endScoreB - endScoreA;
|
||||
|
||||
const startA = normalizeLabelYear(a.time_start);
|
||||
const startB = normalizeLabelYear(b.time_start);
|
||||
const startScoreA = startA == null ? Number.NEGATIVE_INFINITY : startA;
|
||||
const startScoreB = startB == null ? Number.NEGATIVE_INFINITY : startB;
|
||||
if (startScoreA !== startScoreB) return startScoreB - startScoreA;
|
||||
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
function normalizeLabelYear(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function getFeatureEntityIds(feature: Feature): string[] {
|
||||
|
||||
@@ -29,6 +29,8 @@ export function useMapInstance() {
|
||||
|
||||
const [isMapLoaded, setIsMapLoaded] = useState(false);
|
||||
const geolocationCenteredRef = useRef(false);
|
||||
// Ref khóa sync zoom từ MapLibre trong lúc user kéo slider để tránh value bị animate ghi ngược.
|
||||
const isZoomSliderDraggingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
@@ -60,6 +62,7 @@ export function useMapInstance() {
|
||||
mapRef.current = map;
|
||||
|
||||
const syncZoomLevel = () => {
|
||||
if (isZoomSliderDraggingRef.current) return;
|
||||
setZoomLevel(roundZoom(map.getZoom()));
|
||||
};
|
||||
|
||||
@@ -80,7 +83,8 @@ export function useMapInstance() {
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("Map initialization failed", err);
|
||||
setFatalInitError(err instanceof Error ? err.message : "Map initialization failed.");
|
||||
const message = err instanceof Error ? err.message : "Map initialization failed.";
|
||||
window.setTimeout(() => setFatalInitError(message), 0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -121,10 +125,36 @@ export function useMapInstance() {
|
||||
const map = mapRef.current;
|
||||
if (!map || !Number.isFinite(nextRaw)) return;
|
||||
const next = clampNumber(nextRaw, zoomBounds.min, zoomBounds.max);
|
||||
map.easeTo({ zoom: next, duration: 80 });
|
||||
// Slider cần phản hồi trực tiếp theo pointer; easeTo liên tục sẽ làm thumb bị nhảy ngược.
|
||||
map.jumpTo({ zoom: next });
|
||||
setZoomLevel(next);
|
||||
}, [zoomBounds]);
|
||||
|
||||
const beginZoomSliderDrag = useCallback(() => {
|
||||
isZoomSliderDraggingRef.current = true;
|
||||
}, []);
|
||||
|
||||
const endZoomSliderDrag = useCallback(() => {
|
||||
const map = mapRef.current;
|
||||
isZoomSliderDraggingRef.current = false;
|
||||
if (!map) return;
|
||||
setZoomLevel(roundZoom(map.getZoom()));
|
||||
}, []);
|
||||
|
||||
const getViewState = useCallback(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return null;
|
||||
const center = map.getCenter();
|
||||
const projection = map.getProjection();
|
||||
return {
|
||||
center: { lng: center.lng, lat: center.lat },
|
||||
zoom: map.getZoom(),
|
||||
pitch: map.getPitch(),
|
||||
bearing: map.getBearing(),
|
||||
projection: String(projection?.type || "mercator"),
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
mapRef,
|
||||
containerRef,
|
||||
@@ -138,5 +168,8 @@ export function useMapInstance() {
|
||||
geolocationCenteredRef,
|
||||
handleZoomByStep,
|
||||
handleZoomSliderChange,
|
||||
beginZoomSliderDrag,
|
||||
endZoomSliderDrag,
|
||||
getViewState,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { initLine } from "@/uhm/lib/map/engines/lineEngine";
|
||||
import { initPath } from "@/uhm/lib/map/engines/pathEngine";
|
||||
import { initCircle } from "@/uhm/lib/map/engines/circleEngine";
|
||||
import { createEditingEngine } from "@/uhm/lib/map/engines/editingEngine";
|
||||
import { Feature, FeatureCollection, Geometry } from "@/uhm/lib/editor/state/useEditorState";
|
||||
import { FeatureCollection, Geometry } from "@/uhm/lib/editor/state/useEditorState";
|
||||
import { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
|
||||
import { buildClientFeatureId, getSelectableLayers } from "./mapUtils";
|
||||
import { MapHoverPayload } from "../Map";
|
||||
@@ -15,7 +15,7 @@ import { MapHoverPayload } from "../Map";
|
||||
type EngineBinding = {
|
||||
cleanup: () => void;
|
||||
cancel?: () => void;
|
||||
clearSelection?: () => void;
|
||||
clearSelection?: (skipNotify?: boolean) => void;
|
||||
};
|
||||
|
||||
type UseMapInteractionProps = {
|
||||
@@ -26,9 +26,10 @@ type UseMapInteractionProps = {
|
||||
allowGeometryEditing: boolean;
|
||||
selectedFeatureIds: (string | number)[];
|
||||
onSelectFeatureIdsRef: React.MutableRefObject<(ids: (string | number)[]) => void>;
|
||||
onSetModeRef: React.MutableRefObject<((mode: EditorMode) => void) | undefined>;
|
||||
onSetModeRef: React.MutableRefObject<((mode: EditorMode, featureId?: string | number) => void) | undefined>;
|
||||
onCreateRef: React.MutableRefObject<((feature: FeatureCollection["features"][number]) => void) | undefined>;
|
||||
onDeleteRef: React.MutableRefObject<((id: string | number) => void) | undefined>;
|
||||
onHideRef: React.MutableRefObject<((id: string | number) => void) | undefined>;
|
||||
onUpdateRef: React.MutableRefObject<((id: string | number, geometry: Geometry) => void) | undefined>;
|
||||
onHoverFeatureChangeRef: React.MutableRefObject<((payload: MapHoverPayload | null) => void) | undefined>;
|
||||
};
|
||||
@@ -44,6 +45,7 @@ export function useMapInteraction({
|
||||
onSetModeRef,
|
||||
onCreateRef,
|
||||
onDeleteRef,
|
||||
onHideRef,
|
||||
onUpdateRef,
|
||||
onHoverFeatureChangeRef,
|
||||
}: UseMapInteractionProps) {
|
||||
@@ -62,8 +64,11 @@ export function useMapInteraction({
|
||||
}, [mapRef, onUpdateRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "select" || !selectedFeatureIds || selectedFeatureIds.length === 0) {
|
||||
const allowsSelectionMode = mode === "select" || mode === "replay";
|
||||
if (!allowsSelectionMode || !selectedFeatureIds || selectedFeatureIds.length === 0) {
|
||||
editingEngineRef.current?.clearEditing();
|
||||
// Clear the internal selection state of the select engine to stay in sync with React state
|
||||
engineBindingsRef.current.select?.clearSelection?.(false);
|
||||
}
|
||||
}, [mode, selectedFeatureIds]);
|
||||
|
||||
@@ -141,11 +146,31 @@ export function useMapInteraction({
|
||||
const originalFeature = draftRef.current.features.find(
|
||||
(item) => String(item.properties.id) === String(rawId)
|
||||
);
|
||||
editingEngineRef.current?.beginEditing((originalFeature || feature) as any);
|
||||
editingEngineRef.current?.beginEditing(
|
||||
(originalFeature || feature) as unknown as maplibregl.MapGeoJSONFeature
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
allowGeometryEditing
|
||||
? (id: string | number) => {
|
||||
const originalFeature = draftRef.current.features.find(
|
||||
(item) => String(item.properties.id) === String(id)
|
||||
);
|
||||
if (!originalFeature) return;
|
||||
|
||||
const nextFeature = buildDuplicatedFeatureShapeOnly(originalFeature);
|
||||
onCreateRef.current?.(nextFeature);
|
||||
}
|
||||
: undefined,
|
||||
allowGeometryEditing
|
||||
? (id: string | number) => {
|
||||
onHideRef.current?.(id);
|
||||
onSelectFeatureIdsRef.current?.([]);
|
||||
}
|
||||
: undefined,
|
||||
(ids) => onSelectFeatureIdsRef.current?.(ids),
|
||||
(id: string | number) => onSetModeRef.current?.("replay", id)
|
||||
(id: string | number) => onSetModeRef.current?.("replay", id),
|
||||
() => Boolean(editingEngineRef.current?.editingRef.current)
|
||||
);
|
||||
|
||||
const cleanupPoint = initPoint(
|
||||
@@ -319,3 +344,30 @@ export function useMapInteraction({
|
||||
cleanupMapInteractions,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDuplicatedFeatureShapeOnly(
|
||||
feature: FeatureCollection["features"][number]
|
||||
): FeatureCollection["features"][number] {
|
||||
const geometry = cloneGeometry(feature.geometry);
|
||||
return {
|
||||
type: "Feature",
|
||||
properties: {
|
||||
id: buildClientFeatureId(),
|
||||
type: feature.properties.type ?? null,
|
||||
geometry_preset: feature.properties.geometry_preset ?? null,
|
||||
entity_id: null,
|
||||
entity_ids: [],
|
||||
entity_name: null,
|
||||
entity_names: [],
|
||||
binding: [],
|
||||
},
|
||||
geometry,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneGeometry(geometry: Geometry): Geometry {
|
||||
if (typeof structuredClone === "function") {
|
||||
return structuredClone(geometry);
|
||||
}
|
||||
return JSON.parse(JSON.stringify(geometry)) as Geometry;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
import { useEffect } from "react";
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { getVectorTileTemplateUrl } from "@/uhm/api/tiles";
|
||||
import {
|
||||
COUNTRY_FILL_COLOR_EXPRESSION,
|
||||
LINE_COLOR_BY_TYPE,
|
||||
PATH_RENDER_BY_TYPE,
|
||||
POLYGON_FILL_BY_TYPE,
|
||||
POLYGON_OPACITY_BY_TYPE,
|
||||
POLYGON_STROKE_BY_TYPE,
|
||||
} from "@/uhm/lib/map/styles/style";
|
||||
import { GOONG_GLYPHS_PROXY_URL } from "@/uhm/api/config";
|
||||
import { getGoongBackgroundOverlayBundle } from "@/uhm/api/tiles";
|
||||
import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants";
|
||||
import { PATH_ARROW_ICON_ID, PATH_ARROW_SOURCE_ID, POLYGON_LABEL_SOURCE_ID } from "@/uhm/lib/map/constants";
|
||||
import { ensurePointGeotypeIcons, getAllGeotypeLabelLayers, getAllGeotypeLayers } from "@/uhm/lib/map/styles/geotypeLayers";
|
||||
import {
|
||||
applyBackgroundLayerVisibility,
|
||||
buildTypeMatchExpression,
|
||||
ensurePathArrowIcon,
|
||||
} from "./mapUtils";
|
||||
import { BackgroundLayerVisibility } from "@/uhm/lib/map/styles/backgroundLayers";
|
||||
@@ -23,15 +14,8 @@ import { FeatureCollection } from "@/uhm/lib/editor/state/useEditorState";
|
||||
export function getBaseMapStyle(): maplibregl.StyleSpecification {
|
||||
return {
|
||||
version: 8,
|
||||
glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
|
||||
sources: {
|
||||
base: {
|
||||
type: "vector",
|
||||
tiles: [getVectorTileTemplateUrl()],
|
||||
minzoom: 0,
|
||||
maxzoom: 6,
|
||||
},
|
||||
},
|
||||
glyphs: GOONG_GLYPHS_PROXY_URL,
|
||||
sources: {},
|
||||
layers: [
|
||||
{
|
||||
id: "background",
|
||||
@@ -40,157 +24,6 @@ export function getBaseMapStyle(): maplibregl.StyleSpecification {
|
||||
"background-color": "#0b1220",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "graticules-line",
|
||||
type: "line",
|
||||
source: "base",
|
||||
"source-layer": "ne_10m_graticules_10",
|
||||
paint: {
|
||||
"line-color": "#334155",
|
||||
"line-width": [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["zoom"],
|
||||
0, 0.3,
|
||||
4, 0.6,
|
||||
6, 0.8,
|
||||
],
|
||||
"line-opacity": 0.55,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "land",
|
||||
type: "fill",
|
||||
source: "base",
|
||||
"source-layer": "ne_10m_land",
|
||||
paint: {
|
||||
"fill-color": "#1e293b",
|
||||
"fill-opacity": 0.25,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "bg-countries-fill",
|
||||
type: "fill",
|
||||
source: "base",
|
||||
"source-layer": "ne_10m_admin_0_countries",
|
||||
paint: {
|
||||
"fill-color": COUNTRY_FILL_COLOR_EXPRESSION,
|
||||
"fill-opacity": 0.38,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "bg-country-borders-line",
|
||||
type: "line",
|
||||
source: "base",
|
||||
"source-layer": "ne_10m_admin_0_boundary_lines_land",
|
||||
paint: {
|
||||
"line-color": "#cbd5e1",
|
||||
"line-width": [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["zoom"],
|
||||
0, 0.2,
|
||||
4, 0.5,
|
||||
6, 1.1,
|
||||
],
|
||||
"line-opacity": 0.85,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "country-labels",
|
||||
type: "symbol",
|
||||
source: "base",
|
||||
"source-layer": "country_labels",
|
||||
minzoom: 0,
|
||||
layout: {
|
||||
"text-field": [
|
||||
"coalesce",
|
||||
["get", "NAME_EN"],
|
||||
["get", "NAME"],
|
||||
["get", "ADMIN"],
|
||||
["get", "name"],
|
||||
"",
|
||||
],
|
||||
"text-size": [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["zoom"],
|
||||
0, 15,
|
||||
1, 16,
|
||||
2, 17,
|
||||
4, 19,
|
||||
6, 23,
|
||||
],
|
||||
"text-padding": 0,
|
||||
"text-max-width": 10,
|
||||
"text-allow-overlap": true,
|
||||
"text-ignore-placement": true,
|
||||
"symbol-placement": "point",
|
||||
},
|
||||
paint: {
|
||||
"text-color": "#e2e8f0",
|
||||
"text-halo-color": "#0b1220",
|
||||
"text-halo-width": 1.2,
|
||||
"text-halo-blur": 0.5,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "regions-line",
|
||||
type: "line",
|
||||
source: "base",
|
||||
"source-layer": "ne_10m_geography_regions_polys",
|
||||
paint: {
|
||||
"line-color": "#475569",
|
||||
"line-width": [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["zoom"],
|
||||
0, 0.2,
|
||||
4, 0.6,
|
||||
6, 1,
|
||||
],
|
||||
"line-opacity": 0.6,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "lakes-fill",
|
||||
type: "fill",
|
||||
source: "base",
|
||||
"source-layer": "ne_10m_lakes",
|
||||
paint: {
|
||||
"fill-color": "#1d4ed8",
|
||||
"fill-opacity": 0.45,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "rivers-line",
|
||||
type: "line",
|
||||
source: "base",
|
||||
"source-layer": "ne_10m_rivers_lake_centerlines",
|
||||
paint: {
|
||||
"line-color": "#38bdf8",
|
||||
"line-width": [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["zoom"],
|
||||
0, 0.25,
|
||||
4, 0.8,
|
||||
6, 1.5,
|
||||
],
|
||||
"line-opacity": 0.85,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "geolines-line",
|
||||
type: "line",
|
||||
source: "base",
|
||||
"source-layer": "ne_10m_geographic_lines",
|
||||
paint: {
|
||||
"line-color": "#94a3b8",
|
||||
"line-width": 1.2,
|
||||
"line-opacity": 0.8,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -202,6 +35,9 @@ export function setupMapLayers(
|
||||
applyHighlightToMap: (fc: FeatureCollection) => void
|
||||
) {
|
||||
applyBackgroundLayerVisibility(map, backgroundVisibility);
|
||||
void replaceBackgroundLayersWithGoong(map, backgroundVisibility).catch((error) => {
|
||||
console.error("Failed to load proxied background overlay bundle.", error);
|
||||
});
|
||||
const hasPathArrowIcon = ensurePathArrowIcon(map);
|
||||
|
||||
// preview (drawing)
|
||||
@@ -432,3 +268,29 @@ export function setupMapLayers(
|
||||
});
|
||||
applyHighlightToMap(highlightFeatures || EMPTY_FEATURE_COLLECTION);
|
||||
}
|
||||
|
||||
async function replaceBackgroundLayersWithGoong(
|
||||
map: maplibregl.Map,
|
||||
backgroundVisibility: BackgroundLayerVisibility
|
||||
) {
|
||||
const bundle = await getGoongBackgroundOverlayBundle();
|
||||
if (!bundle || map.getLayer("goong-country-labels-0")) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [sourceId, source] of Object.entries(bundle.sources)) {
|
||||
if (!map.getSource(sourceId)) {
|
||||
map.addSource(sourceId, source);
|
||||
}
|
||||
}
|
||||
|
||||
const insertBeforeId = map.getLayer("draw-preview-fill")
|
||||
? "draw-preview-fill"
|
||||
: undefined;
|
||||
for (const layer of bundle.layers) {
|
||||
if (map.getLayer(layer.id)) continue;
|
||||
map.addLayer(layer, insertBeforeId);
|
||||
}
|
||||
|
||||
applyBackgroundLayerVisibility(map, backgroundVisibility);
|
||||
}
|
||||
|
||||
@@ -16,11 +16,13 @@ import {
|
||||
setSelectedFeatureState,
|
||||
splitDraftFeatures,
|
||||
} from "./mapUtils";
|
||||
import { applyImageOverlay, type MapImageOverlay } from "./imageOverlay";
|
||||
|
||||
type UseMapSyncProps = {
|
||||
mapRef: React.MutableRefObject<maplibregl.Map | null>;
|
||||
draft: FeatureCollection;
|
||||
labelContextDraft?: FeatureCollection;
|
||||
labelTimelineYear?: number | null;
|
||||
backgroundVisibility: BackgroundLayerVisibility;
|
||||
geometryVisibility?: Record<string, boolean>;
|
||||
selectedFeatureIds: (string | number)[];
|
||||
@@ -31,6 +33,7 @@ type UseMapSyncProps = {
|
||||
focusFeatureCollection?: FeatureCollection | null;
|
||||
focusRequestKey?: string | number | null;
|
||||
focusPadding?: number | maplibregl.PaddingOptions;
|
||||
imageOverlay?: MapImageOverlay | null;
|
||||
allowGeometryEditing: boolean;
|
||||
editingEngineRef: React.MutableRefObject<{
|
||||
editingRef: React.MutableRefObject<{ id: string | number } | null>;
|
||||
@@ -43,6 +46,7 @@ export function useMapSync({
|
||||
mapRef,
|
||||
draft,
|
||||
labelContextDraft,
|
||||
labelTimelineYear,
|
||||
backgroundVisibility,
|
||||
geometryVisibility,
|
||||
selectedFeatureIds,
|
||||
@@ -53,29 +57,38 @@ export function useMapSync({
|
||||
focusFeatureCollection,
|
||||
focusRequestKey,
|
||||
focusPadding,
|
||||
imageOverlay,
|
||||
allowGeometryEditing,
|
||||
editingEngineRef,
|
||||
geolocationCenteredRef,
|
||||
}: UseMapSyncProps) {
|
||||
const draftRef = useRef<FeatureCollection>(draft);
|
||||
const labelContextDraftRef = useRef<FeatureCollection | undefined>(labelContextDraft);
|
||||
const labelTimelineYearRef = useRef<number | null | undefined>(labelTimelineYear);
|
||||
const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility);
|
||||
const geometryVisibilityRef = useRef<Record<string, boolean> | undefined>(geometryVisibility);
|
||||
const selectedFeatureIdsRef = useRef<(string | number)[]>(selectedFeatureIds);
|
||||
const respectBindingFilterRef = useRef(respectBindingFilter);
|
||||
const fitToDraftBoundsRef = useRef(fitToDraftBounds);
|
||||
const highlightFeaturesRef = useRef<FeatureCollection | null>(highlightFeatures || null);
|
||||
const imageOverlayRef = useRef<MapImageOverlay | null>(imageOverlay || null);
|
||||
const focusFeatureCollectionRef = useRef<FeatureCollection | null | undefined>(focusFeatureCollection);
|
||||
const focusPaddingRef = useRef<number | maplibregl.PaddingOptions | undefined>(focusPadding);
|
||||
|
||||
const fitBoundsAppliedRef = useRef(false);
|
||||
|
||||
useEffect(() => { draftRef.current = draft; }, [draft]);
|
||||
useEffect(() => { labelContextDraftRef.current = labelContextDraft; }, [labelContextDraft]);
|
||||
useEffect(() => { labelTimelineYearRef.current = labelTimelineYear; }, [labelTimelineYear]);
|
||||
useEffect(() => { backgroundVisibilityRef.current = backgroundVisibility; }, [backgroundVisibility]);
|
||||
useEffect(() => { geometryVisibilityRef.current = geometryVisibility; }, [geometryVisibility]);
|
||||
useEffect(() => { selectedFeatureIdsRef.current = selectedFeatureIds; }, [selectedFeatureIds]);
|
||||
useEffect(() => { respectBindingFilterRef.current = respectBindingFilter; }, [respectBindingFilter]);
|
||||
useEffect(() => { fitToDraftBoundsRef.current = fitToDraftBounds; }, [fitToDraftBounds]);
|
||||
useEffect(() => { highlightFeaturesRef.current = highlightFeatures || null; }, [highlightFeatures]);
|
||||
useEffect(() => { imageOverlayRef.current = imageOverlay || null; }, [imageOverlay]);
|
||||
useEffect(() => { focusFeatureCollectionRef.current = focusFeatureCollection; }, [focusFeatureCollection]);
|
||||
useEffect(() => { focusPaddingRef.current = focusPadding; }, [focusPadding]);
|
||||
|
||||
useEffect(() => {
|
||||
fitBoundsAppliedRef.current = false;
|
||||
@@ -102,10 +115,11 @@ export function useMapSync({
|
||||
: fc;
|
||||
const visibleDraft = filterDraftByGeometryVisibility(visibleDraftRaw, geometryVisibilityRef.current);
|
||||
const labelContext = labelContextDraftRef.current || fc;
|
||||
const labelTimelineYear = labelTimelineYearRef.current;
|
||||
const { polygons, points } = splitDraftFeatures(visibleDraft);
|
||||
const labeledGeometries = decorateLineFeaturesWithLabels(polygons, labelContext);
|
||||
const labeledPoints = decoratePointFeaturesWithLabels(points, labelContext);
|
||||
const polygonLabels = buildPolygonLabelFeatureCollection(polygons, labelContext);
|
||||
const labeledGeometries = decorateLineFeaturesWithLabels(polygons, labelContext, labelTimelineYear);
|
||||
const labeledPoints = decoratePointFeaturesWithLabels(points, labelContext, labelTimelineYear);
|
||||
const polygonLabels = buildPolygonLabelFeatureCollection(polygons, labelContext, labelTimelineYear);
|
||||
const pathArrowShapes = buildPathArrowFeatureCollection(visibleDraft);
|
||||
|
||||
countriesSource.setData(labeledGeometries);
|
||||
@@ -135,6 +149,7 @@ export function useMapSync({
|
||||
const source = map.getSource("entity-focus") as maplibregl.GeoJSONSource | undefined;
|
||||
if (!source) return;
|
||||
source.setData(fc);
|
||||
moveHighlightLayersToTop(map);
|
||||
}, [mapRef]);
|
||||
|
||||
const tryCenterToUserLocation = useCallback(() => {
|
||||
@@ -175,6 +190,12 @@ export function useMapSync({
|
||||
source?.setData(highlightFeatures || EMPTY_FEATURE_COLLECTION);
|
||||
}, [highlightFeatures, mapRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !map.isStyleLoaded()) return;
|
||||
applyImageOverlay(map, imageOverlay);
|
||||
}, [imageOverlay, mapRef]);
|
||||
|
||||
useEffect(() => {
|
||||
applyDraftToMap(draft);
|
||||
const editingId = editingEngineRef.current?.editingRef?.current?.id;
|
||||
@@ -188,6 +209,7 @@ export function useMapSync({
|
||||
allowGeometryEditing,
|
||||
draft,
|
||||
labelContextDraft,
|
||||
labelTimelineYear,
|
||||
selectedFeatureIds,
|
||||
respectBindingFilter,
|
||||
geometryVisibility,
|
||||
@@ -199,7 +221,7 @@ export function useMapSync({
|
||||
useEffect(() => {
|
||||
if (focusRequestKey === null || focusRequestKey === undefined) return;
|
||||
const map = mapRef.current;
|
||||
const target = focusFeatureCollection;
|
||||
const target = focusFeatureCollectionRef.current;
|
||||
if (!target || !target.features.length) return;
|
||||
if (!map) return;
|
||||
|
||||
@@ -208,7 +230,7 @@ export function useMapSync({
|
||||
|
||||
const focus = () => {
|
||||
if (cancelled || mapRef.current !== map || !map.isStyleLoaded()) return;
|
||||
fitMapToFeatureCollection(map, target, focusPadding, {
|
||||
fitMapToFeatureCollection(map, target, focusPaddingRef.current, {
|
||||
duration: 550,
|
||||
maxZoom: 10,
|
||||
pointZoom: 9,
|
||||
@@ -225,11 +247,25 @@ export function useMapSync({
|
||||
cancelled = true;
|
||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [focusFeatureCollection, focusPadding, focusRequestKey, mapRef]);
|
||||
}, [focusRequestKey, mapRef]);
|
||||
|
||||
return {
|
||||
applyDraftToMap,
|
||||
applyHighlightToMap,
|
||||
tryCenterToUserLocation,
|
||||
applyImageOverlayToMap: () => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !map.isStyleLoaded()) return;
|
||||
applyImageOverlay(map, imageOverlayRef.current);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function moveHighlightLayersToTop(map: maplibregl.Map) {
|
||||
const layerIds = ["entity-focus-fill", "entity-focus-line", "entity-focus-points"];
|
||||
for (const layerId of layerIds) {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.moveLayer(layerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ type Props = {
|
||||
statusText?: string | null;
|
||||
filterEnabled?: boolean;
|
||||
onFilterEnabledChange?: (enabled: boolean) => void;
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
export default function TimelineBar({
|
||||
@@ -24,6 +25,7 @@ export default function TimelineBar({
|
||||
statusText,
|
||||
filterEnabled,
|
||||
onFilterEnabledChange,
|
||||
style,
|
||||
}: Props) {
|
||||
const lower = FIXED_TIMELINE_START_YEAR;
|
||||
const upper = FIXED_TIMELINE_END_YEAR;
|
||||
@@ -58,6 +60,7 @@ export default function TimelineBar({
|
||||
padding: "10px 12px",
|
||||
color: "#e2e8f0",
|
||||
backdropFilter: "blur(2px)",
|
||||
...style,
|
||||
}}
|
||||
title={helperText || undefined}
|
||||
>
|
||||
@@ -65,7 +68,9 @@ export default function TimelineBar({
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
flexWrap: "wrap",
|
||||
rowGap: "8px",
|
||||
columnGap: "10px",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
@@ -129,7 +134,7 @@ export default function TimelineBar({
|
||||
aria-label="Timeline year"
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minWidth: "120px",
|
||||
accentColor: "#22c55e",
|
||||
cursor: effectiveDisabled ? "not-allowed" : "pointer",
|
||||
opacity: effectiveDisabled ? 0.6 : 1,
|
||||
|
||||
@@ -18,26 +18,11 @@ type Props = {
|
||||
error?: string | null;
|
||||
onClose: () => void;
|
||||
onWikiLinkRequest: (request: { slug: string; rect: DOMRect }) => void;
|
||||
sidebarWidth?: number;
|
||||
onSidebarWidthChange?: (width: number) => void;
|
||||
maxDragWidth?: number;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function tiptapJsonToPlainText(node: unknown): string {
|
||||
if (node == null) return "";
|
||||
if (typeof node === "string") return node;
|
||||
if (Array.isArray(node)) return node.map(tiptapJsonToPlainText).join("");
|
||||
|
||||
if (isRecord(node)) {
|
||||
if (node.type === "text" && typeof node.text === "string") return node.text;
|
||||
if (node.type === "hardBreak") return "\n";
|
||||
if ("content" in node) return tiptapJsonToPlainText(node.content);
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function escapeHtml(input: string): string {
|
||||
return input
|
||||
.replaceAll("&", "&")
|
||||
@@ -48,21 +33,13 @@ function escapeHtml(input: string): string {
|
||||
}
|
||||
|
||||
function normalizeWikiContentToHtml(raw: string | null | undefined): string {
|
||||
const value = String(raw || "").trim();
|
||||
let value = String(raw || "").trim();
|
||||
if (!value.length) return "";
|
||||
|
||||
if (value[0] === "<") return value;
|
||||
// Replace non-breaking spaces to allow text wrap
|
||||
value = value.replaceAll(" ", " ").replaceAll("\u00a0", " ");
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
if (value[0] === "<") return value;
|
||||
|
||||
return `<p>${escapeHtml(value).replace(/\n/g, "<br/>")}</p>`;
|
||||
}
|
||||
@@ -151,8 +128,52 @@ export default function PublicWikiSidebar({
|
||||
error,
|
||||
onClose,
|
||||
onWikiLinkRequest,
|
||||
sidebarWidth,
|
||||
onSidebarWidthChange,
|
||||
maxDragWidth,
|
||||
}: Props) {
|
||||
const contentRootRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const [localWidth, setLocalWidth] = 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 width = sidebarWidth ?? localWidth;
|
||||
const setWidth = onSidebarWidthChange ?? setLocalWidth;
|
||||
const maxDragWidthLimit = maxDragWidth ?? 800;
|
||||
|
||||
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
const startX = event.clientX;
|
||||
const startWidth = width;
|
||||
|
||||
const onMove = (e: PointerEvent) => {
|
||||
const deltaX = e.clientX - startX;
|
||||
const nextWidth = Math.max(320, Math.min(maxDragWidthLimit, startWidth - deltaX));
|
||||
setWidth(nextWidth);
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("public-wiki-sidebar-width", String(nextWidth));
|
||||
}
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.removeEventListener("pointerup", onUp);
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", onMove);
|
||||
window.addEventListener("pointerup", onUp);
|
||||
};
|
||||
|
||||
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
|
||||
const processedWiki = useMemo(() => {
|
||||
if (!wiki) return { html: "", toc: [] as TocItem[] };
|
||||
@@ -216,7 +237,19 @@ export default function PublicWikiSidebar({
|
||||
}, [onWikiLinkRequest, renderHtml]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-800 dark:bg-gray-950">
|
||||
<div
|
||||
style={{ width: `${width}px` }}
|
||||
className="relative flex h-full min-h-0 flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-800 dark:bg-gray-950"
|
||||
>
|
||||
{/* Drag Handle on the left edge */}
|
||||
<div
|
||||
onPointerDown={handlePointerDown}
|
||||
className="absolute left-0 top-0 bottom-0 w-[6px] cursor-col-resize z-50 group select-none hover:bg-black/[0.03] dark:hover:bg-white/[0.02]"
|
||||
title="Kéo để chỉnh kích thước"
|
||||
>
|
||||
{/* Visual drag line overlay */}
|
||||
<div className="absolute left-[2px] top-0 bottom-0 w-[2px] bg-transparent group-hover:bg-brand-500/50 group-active:bg-brand-500 transition-colors" />
|
||||
</div>
|
||||
<div className="border-b border-gray-200 px-4 py-4 dark:border-gray-800">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
@@ -300,6 +333,11 @@ export default function PublicWikiSidebar({
|
||||
height: auto;
|
||||
overflow-y: visible;
|
||||
padding: 18px 18px 22px;
|
||||
line-height: 1.6;
|
||||
font-size: 14.5px;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.uhm-wiki-sidebar-view.ql-editor p {
|
||||
margin: 0 0 0.75em;
|
||||
@@ -346,16 +384,22 @@ export default function PublicWikiSidebar({
|
||||
border: 1px solid rgba(226, 232, 240, 1);
|
||||
border-radius: 10px;
|
||||
background: rgba(248, 250, 252, 1);
|
||||
overflow: auto;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor pre {
|
||||
border-color: rgba(51, 65, 85, 1);
|
||||
background: rgba(2, 6, 23, 0.4);
|
||||
}
|
||||
.uhm-wiki-sidebar-view.ql-editor img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block !important;
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
border-radius: 8px;
|
||||
float: none !important;
|
||||
margin: 1.25em auto !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.uhm-wiki-sidebar-view.ql-editor a {
|
||||
text-decoration: underline;
|
||||
@@ -381,6 +425,18 @@ export default function PublicWikiSidebar({
|
||||
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor a[href="__missing__"] {
|
||||
color: #f87171;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.uhm-wiki-sidebar-view.ql-editor {
|
||||
padding: 14px 14px 20px;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.uhm-wiki-sidebar-view.ql-editor h1 {
|
||||
font-size: 1.4em;
|
||||
}
|
||||
.uhm-wiki-sidebar-view.ql-editor h2 {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import "react-quill-new/dist/quill.snow.css";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import Button from "@/components/ui/button/Button";
|
||||
@@ -13,6 +14,7 @@ import { newId } from "@/uhm/lib/utils/id";
|
||||
import type ReactQuill from "react-quill-new";
|
||||
import { checkWikiSlugExists, fetchWikiBySlug, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
|
||||
import NewBadge from "@/uhm/components/editor/NewBadge";
|
||||
import { useEditorStore } from "@/uhm/store/editorStore";
|
||||
|
||||
type ReactQuillProps = ComponentProps<typeof ReactQuill>;
|
||||
type QuillRange = { index: number; length: number };
|
||||
@@ -36,6 +38,13 @@ type QuillLinkFormat = {
|
||||
__uhmAllowSlugHref?: boolean;
|
||||
__uhmOriginalSanitize?: unknown;
|
||||
};
|
||||
type QuillImageFormatCtor = {
|
||||
new (): {
|
||||
domNode: Element;
|
||||
format(name: string, value: string): void;
|
||||
};
|
||||
formats: (domNode: Element) => Record<string, string>;
|
||||
};
|
||||
|
||||
const ReactQuillEditor = dynamic<ReactQuillProps>(() => import("react-quill-new"), {
|
||||
ssr: false,
|
||||
@@ -46,10 +55,7 @@ let quillLinkSanitizePatched = false;
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
wikis: WikiSnapshot[];
|
||||
setWikis: React.Dispatch<React.SetStateAction<WikiSnapshot[]>>;
|
||||
autoOpen?: boolean;
|
||||
requestedActiveId?: string | null;
|
||||
};
|
||||
|
||||
function clampTitle(title: string) {
|
||||
@@ -57,7 +63,13 @@ function clampTitle(title: string) {
|
||||
return t.length ? t.slice(0, 120) : "Untitled wiki";
|
||||
}
|
||||
|
||||
export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen, requestedActiveId }: Props) {
|
||||
export default function WikiSidebarPanel({ projectId, setWikis }: Props) {
|
||||
const { wikis, requestedActiveId } = useEditorStore(
|
||||
useShallow((state) => ({
|
||||
wikis: state.snapshotWikis,
|
||||
requestedActiveId: state.requestedActiveWikiId,
|
||||
}))
|
||||
);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
@@ -93,12 +105,6 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
|
||||
const globalWikiSearchRequestRef = useRef(0);
|
||||
const importFileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoOpen) return;
|
||||
// open once on mount
|
||||
setOpen(true);
|
||||
}, [autoOpen]);
|
||||
|
||||
// Allow Quill to keep wiki links where href is a slug (no scheme).
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
@@ -120,15 +126,20 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
|
||||
console.error("Failed to load quill-blot-formatter", err);
|
||||
}
|
||||
|
||||
const ImageFormat = Quill.import?.("formats/image") as any;
|
||||
const ImageFormat = Quill.import?.("formats/image") as QuillImageFormatCtor | undefined;
|
||||
if (ImageFormat) {
|
||||
class CustomImage extends ImageFormat {
|
||||
const BaseImageFormat = ImageFormat;
|
||||
class CustomImage extends BaseImageFormat {
|
||||
static formats(domNode: Element) {
|
||||
const formats = ImageFormat.formats(domNode) || {};
|
||||
if (domNode.hasAttribute("style")) formats.style = domNode.getAttribute("style");
|
||||
if (domNode.hasAttribute("width")) formats.width = domNode.getAttribute("width");
|
||||
if (domNode.hasAttribute("height")) formats.height = domNode.getAttribute("height");
|
||||
if (domNode.hasAttribute("class")) formats.class = domNode.getAttribute("class");
|
||||
const formats = BaseImageFormat.formats(domNode) || {};
|
||||
const style = domNode.getAttribute("style");
|
||||
const width = domNode.getAttribute("width");
|
||||
const height = domNode.getAttribute("height");
|
||||
const className = domNode.getAttribute("class");
|
||||
if (style) formats.style = style;
|
||||
if (width) formats.width = width;
|
||||
if (height) formats.height = height;
|
||||
if (className) formats.class = className;
|
||||
return formats;
|
||||
}
|
||||
|
||||
@@ -205,7 +216,6 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
|
||||
title: seedTitle,
|
||||
slug: slug ?? null,
|
||||
doc: "",
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
setWikis((prev) => [seed, ...prev]);
|
||||
setActiveId(id);
|
||||
@@ -280,7 +290,6 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
|
||||
title: nextTitle,
|
||||
slug: nextSlug,
|
||||
doc: payload,
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -291,13 +300,10 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
|
||||
if (!activeId) return;
|
||||
|
||||
const fmt = detectWikiDocStorageFormat(wikiDocHtml);
|
||||
const label = fmt === "json" ? "json" : fmt === "text" ? "txt" : "html";
|
||||
const mime =
|
||||
fmt === "json"
|
||||
? "application/json;charset=utf-8"
|
||||
: fmt === "text"
|
||||
? "text/plain;charset=utf-8"
|
||||
: "text/html;charset=utf-8";
|
||||
const label = fmt === "text" ? "txt" : "html";
|
||||
const mime = fmt === "text"
|
||||
? "text/plain;charset=utf-8"
|
||||
: "text/html;charset=utf-8";
|
||||
|
||||
const base =
|
||||
normalizeWikiSlugInput(wikiSlug) ||
|
||||
@@ -1066,22 +1072,8 @@ function normalizeWikiDocForQuill(doc: string | null): string {
|
||||
const raw = (doc || "").trim();
|
||||
if (!raw.length) return "";
|
||||
|
||||
// New format (Quill): HTML string.
|
||||
if (raw[0] === "<") return raw;
|
||||
|
||||
// Legacy format (Tiptap): JSON string.
|
||||
if (raw[0] === "{") {
|
||||
try {
|
||||
const json: unknown = JSON.parse(raw);
|
||||
const text = tiptapJsonToPlainText(json).trim();
|
||||
if (!text.length) return "";
|
||||
return `<p>${escapeHtml(text).replace(/\n/g, "<br/>")}</p>`;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown plaintext: treat as plain text.
|
||||
return `<p>${escapeHtml(raw).replace(/\n/g, "<br/>")}</p>`;
|
||||
}
|
||||
|
||||
@@ -1103,24 +1095,6 @@ function slugifyWikiTitle(raw: string): string {
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function tiptapJsonToPlainText(node: unknown): string {
|
||||
if (node == null) return "";
|
||||
if (typeof node === "string") return node;
|
||||
if (Array.isArray(node)) return node.map(tiptapJsonToPlainText).join("");
|
||||
|
||||
if (isRecord(node)) {
|
||||
if (node.type === "text" && typeof node.text === "string") return node.text;
|
||||
if (node.type === "hardBreak") return "\n";
|
||||
if ("content" in node) return tiptapJsonToPlainText(node.content);
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function escapeHtml(input: string): string {
|
||||
return input
|
||||
.replaceAll("&", "&")
|
||||
@@ -1130,15 +1104,12 @@ function escapeHtml(input: string): string {
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
type WikiDocStorageFormat = "html" | "json" | "text";
|
||||
type WikiDocStorageFormat = "html" | "text";
|
||||
|
||||
function detectWikiDocStorageFormat(doc: string): WikiDocStorageFormat {
|
||||
const raw = String(doc || "").trim();
|
||||
if (!raw.length) return "html";
|
||||
const first = raw[0];
|
||||
if (first === "<") return "html";
|
||||
if (first === "{" || first === "[") return "json";
|
||||
return "text";
|
||||
return raw[0] === "<" ? "html" : "text";
|
||||
}
|
||||
|
||||
function downloadTextFile(filename: string, contents: string, mime: string): void {
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* Schema tham chiếu cho commit snapshot.
|
||||
*
|
||||
* Đây là file doc tự chứa, không import runtime types.
|
||||
* Mục tiêu là mô tả đúng shape dữ liệu hiện tại của editor/commit/replay
|
||||
* mà không phụ thuộc trực tiếp vào source code runtime.
|
||||
*
|
||||
* Ghi chú:
|
||||
* - Payload tạo commit hiện là `{ snapshot_json, edit_summary }`.
|
||||
* - `CommitSnapshot` hiện tương đương `EditorSnapshot`.
|
||||
* - Nhiều field root để optional vì frontend còn phải đọc snapshot cũ / partial.
|
||||
* - Replay actions trong dữ liệu thật dùng `params: unknown[]` theo positional tuple.
|
||||
* - Snapshot replay cũ còn `replay_features` sẽ được FE migrate sang `target_geometry_ids` khi load.
|
||||
* - Trước khi gửi API, frontend còn normalize thêm một số field, ví dụ `geometries[].type`.
|
||||
*/
|
||||
|
||||
// ---- Root request ----
|
||||
|
||||
export type CreateCommitRequest = {
|
||||
snapshot_json: CommitSnapshot;
|
||||
edit_summary: string;
|
||||
};
|
||||
|
||||
// ---- GeoJSON / FeatureCollection ----
|
||||
|
||||
export type GeometryPreset = "line" | "polygon" | "circle-area" | "point";
|
||||
|
||||
export type Geometry =
|
||||
| ({ type: "Point"; coordinates: [number, number] } & CircleGeometryMetadata)
|
||||
| ({ type: "MultiPoint"; coordinates: [number, number][] } & CircleGeometryMetadata)
|
||||
| ({ type: "LineString"; coordinates: [number, number][] } & CircleGeometryMetadata)
|
||||
| ({ type: "MultiLineString"; coordinates: [number, number][][] } & CircleGeometryMetadata)
|
||||
| ({ type: "Polygon"; coordinates: [number, number][][] } & CircleGeometryMetadata)
|
||||
| ({ type: "MultiPolygon"; coordinates: [number, number][][][] } & CircleGeometryMetadata);
|
||||
|
||||
export type CircleGeometryMetadata = {
|
||||
circle_center?: [number, number];
|
||||
circle_radius?: number;
|
||||
};
|
||||
|
||||
export type FeatureId = string | number;
|
||||
|
||||
export type FeatureProperties = {
|
||||
id: FeatureId;
|
||||
type?: string | null;
|
||||
geometry_preset?: GeometryPreset | null;
|
||||
time_start?: number | null;
|
||||
time_end?: number | null;
|
||||
binding?: string[];
|
||||
|
||||
// UI/editor-only denormalized fields.
|
||||
entity_id?: string | null;
|
||||
entity_ids?: string[];
|
||||
entity_name?: string | null;
|
||||
entity_names?: string[];
|
||||
entity_type_id?: string | null;
|
||||
point_label?: string | null;
|
||||
line_label?: string | null;
|
||||
polygon_label?: string | null;
|
||||
};
|
||||
|
||||
export type Feature = {
|
||||
type: "Feature";
|
||||
properties: FeatureProperties;
|
||||
geometry: Geometry;
|
||||
};
|
||||
|
||||
export type FeatureCollection = {
|
||||
type: "FeatureCollection";
|
||||
features: Feature[];
|
||||
};
|
||||
|
||||
// ---- Snapshot rows ----
|
||||
|
||||
export type SnapshotSource = "inline" | "ref";
|
||||
export type SnapshotOperation = "create" | "update" | "delete" | "reference";
|
||||
|
||||
export type EntitySnapshotOperation = SnapshotOperation;
|
||||
export type GeometrySnapshotOperation = SnapshotOperation;
|
||||
export type WikiSnapshotOperation = SnapshotOperation;
|
||||
|
||||
export type EntitySnapshot = {
|
||||
id: string;
|
||||
source: SnapshotSource;
|
||||
operation?: EntitySnapshotOperation;
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
export type GeometrySnapshot = {
|
||||
id: string;
|
||||
source: SnapshotSource;
|
||||
operation?: GeometrySnapshotOperation;
|
||||
type?: string | null;
|
||||
draw_geometry?: Geometry;
|
||||
geometry?: Geometry;
|
||||
binding?: string[];
|
||||
time_start?: number | null;
|
||||
time_end?: number | null;
|
||||
bbox?: {
|
||||
min_lng: number;
|
||||
min_lat: number;
|
||||
max_lng: number;
|
||||
max_lat: number;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type GeometryEntitySnapshot = {
|
||||
geometry_id: string;
|
||||
entity_id: string;
|
||||
operation?: "reference" | "binding" | "delete";
|
||||
};
|
||||
|
||||
export type WikiDoc = string | null;
|
||||
|
||||
export type WikiSnapshot = {
|
||||
id: string;
|
||||
source: SnapshotSource;
|
||||
operation?: WikiSnapshotOperation;
|
||||
title: string;
|
||||
slug?: string | null;
|
||||
doc: WikiDoc;
|
||||
};
|
||||
|
||||
export type EntityWikiLinkSnapshot = {
|
||||
entity_id: string;
|
||||
wiki_id: string;
|
||||
operation?: "reference" | "binding" | "delete";
|
||||
};
|
||||
|
||||
// ---- Replay / Scripting System (runtime shape) ----
|
||||
|
||||
/**
|
||||
* Canonical UI action names trong snapshot hiện tại.
|
||||
* Không còn wrapper `function_name: "UI"` trong shape mới.
|
||||
*/
|
||||
export type UIOptionName =
|
||||
| "timeline"
|
||||
| "layer_panel"
|
||||
| "wiki_panel"
|
||||
| "close_wiki_panel"
|
||||
| "zoom_panel"
|
||||
| "wiki"
|
||||
| "toast"
|
||||
| "wiki_header"
|
||||
| "playback_speed";
|
||||
|
||||
export type MapFunctionName =
|
||||
| "set_camera_view"
|
||||
| "set_time_filter"
|
||||
| "enable_timeline_filter"
|
||||
| "disable_timeline_filter"
|
||||
| "toggle_labels"
|
||||
| "show_labels"
|
||||
| "hide_labels"
|
||||
| "show_all_geometries"
|
||||
| "reset_camera_north";
|
||||
|
||||
export type GeoFunctionName =
|
||||
| "fly_to_geometry"
|
||||
| "fly_to_geometries"
|
||||
| "set_geometry_visibility"
|
||||
| "show_geometries"
|
||||
| "hide_geometries"
|
||||
| "fit_to_geometries"
|
||||
| "orbit_camera_around_geometry"
|
||||
| "pulse_geometry"
|
||||
| "animate_dashed_border"
|
||||
| "set_geometry_style"
|
||||
| "show_geometry_label"
|
||||
| "follow_geometry_path"
|
||||
| "follow_geometries_path"
|
||||
| "dim_other_geometries";
|
||||
|
||||
export type NarrativeFunctionName =
|
||||
| "set_title"
|
||||
| "clear_title"
|
||||
| "set_descriptions"
|
||||
| "clear_descriptions"
|
||||
| "show_dialog_box"
|
||||
| "clear_dialog_box"
|
||||
| "display_historical_image"
|
||||
| "clear_historical_image"
|
||||
| "set_step_subtitle"
|
||||
| "clear_step_subtitle";
|
||||
|
||||
/**
|
||||
* Runtime thật hiện dùng positional array cho params.
|
||||
* File doc này giữ đúng shape đó.
|
||||
*/
|
||||
export type ReplayAction<T> = {
|
||||
function_name: T;
|
||||
params: unknown[];
|
||||
};
|
||||
|
||||
export type ReplayStep = {
|
||||
duration: number;
|
||||
use_UI_function: ReplayAction<UIOptionName>[];
|
||||
use_map_function: ReplayAction<MapFunctionName>[];
|
||||
use_geo_function: ReplayAction<GeoFunctionName>[];
|
||||
use_narrow_function: ReplayAction<NarrativeFunctionName>[];
|
||||
};
|
||||
|
||||
export type ReplayStage = {
|
||||
id: number;
|
||||
title?: string;
|
||||
detail_time_start: string;
|
||||
detail_time_stop: string;
|
||||
steps: ReplayStep[];
|
||||
};
|
||||
|
||||
export type BattleReplay = {
|
||||
id: string;
|
||||
geometry_id: string;
|
||||
target_geometry_ids: string[];
|
||||
detail: ReplayStage[];
|
||||
};
|
||||
|
||||
// ---- Replay tuple docs ----
|
||||
|
||||
/**
|
||||
* Doc-only helper để giải thích meaning của từng vị trí trong `params`.
|
||||
* Runtime không ép các tuple này; chúng chỉ là tài liệu tham chiếu.
|
||||
*/
|
||||
|
||||
export type ReplayCameraViewStateDoc = {
|
||||
center?: [number, number] | { lng: number; lat: number };
|
||||
zoom?: number;
|
||||
pitch?: number;
|
||||
bearing?: number;
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
export type ReplayUiParamTupleDocs = {
|
||||
timeline: [visible: boolean];
|
||||
layer_panel: [visible: boolean];
|
||||
wiki_panel: [visible: boolean];
|
||||
close_wiki_panel: [];
|
||||
zoom_panel: [visible: boolean];
|
||||
wiki: [wiki_id: string];
|
||||
toast: [message: string];
|
||||
wiki_header: [header_id: string];
|
||||
playback_speed: [speed: number];
|
||||
};
|
||||
|
||||
/**
|
||||
* Snapshot cũ kiểu `function_name: "UI"` chỉ còn là legacy input.
|
||||
* Frontend hiện normalize chúng sang `function_name: UIOptionName` khi load.
|
||||
*/
|
||||
|
||||
export type ReplayMapFunctionParamTupleDocs = {
|
||||
set_camera_view: [state: ReplayCameraViewStateDoc];
|
||||
set_time_filter: [year: number];
|
||||
enable_timeline_filter: [];
|
||||
disable_timeline_filter: [];
|
||||
toggle_labels: [visible: boolean];
|
||||
show_labels: [];
|
||||
hide_labels: [];
|
||||
show_all_geometries: [];
|
||||
reset_camera_north: [];
|
||||
};
|
||||
|
||||
export type ReplayGeoFunctionParamTupleDocs = {
|
||||
fly_to_geometry: [
|
||||
geometry_id: string,
|
||||
zoom?: number,
|
||||
padding?: number,
|
||||
duration?: number,
|
||||
];
|
||||
fly_to_geometries: [geometry_ids: string[]];
|
||||
set_geometry_visibility: [geometry_ids: string[], visible: boolean];
|
||||
show_geometries: [geometry_ids: string[]];
|
||||
hide_geometries: [geometry_ids: string[]];
|
||||
fit_to_geometries: [
|
||||
geometry_ids: string[],
|
||||
padding?: number,
|
||||
duration?: number,
|
||||
];
|
||||
orbit_camera_around_geometry: [
|
||||
geometry_id: string,
|
||||
zoom?: number,
|
||||
pitch?: number,
|
||||
revolutions?: number,
|
||||
duration?: number,
|
||||
];
|
||||
pulse_geometry: [
|
||||
geometry_id: string,
|
||||
color?: string,
|
||||
repeat?: number,
|
||||
duration?: number,
|
||||
];
|
||||
animate_dashed_border: [
|
||||
geometry_id: string,
|
||||
color?: string,
|
||||
width?: number,
|
||||
speed?: number,
|
||||
duration?: number,
|
||||
];
|
||||
set_geometry_style: [
|
||||
geometry_ids: string[],
|
||||
fill_color?: string,
|
||||
fill_opacity?: number,
|
||||
line_color?: string,
|
||||
line_width?: number,
|
||||
];
|
||||
show_geometry_label: [
|
||||
geometry_id: string,
|
||||
text?: string,
|
||||
color?: string,
|
||||
size?: number,
|
||||
];
|
||||
follow_geometry_path: [
|
||||
geometry_id: string,
|
||||
duration?: number,
|
||||
zoom?: number,
|
||||
pitch?: number,
|
||||
];
|
||||
follow_geometries_path: [
|
||||
geometry_ids: string[],
|
||||
duration?: number,
|
||||
zoom?: number,
|
||||
pitch?: number,
|
||||
];
|
||||
dim_other_geometries: [
|
||||
geometry_ids: string[],
|
||||
];
|
||||
};
|
||||
|
||||
export type ReplayNarrativeParamTupleDocs = {
|
||||
set_title: [title: string];
|
||||
clear_title: [];
|
||||
set_descriptions: [text: string];
|
||||
clear_descriptions: [];
|
||||
show_dialog_box: [
|
||||
avatar: string,
|
||||
text: string,
|
||||
side?: "left" | "right",
|
||||
speaker?: string,
|
||||
];
|
||||
clear_dialog_box: [];
|
||||
display_historical_image: [
|
||||
url: string,
|
||||
caption?: string,
|
||||
];
|
||||
clear_historical_image: [];
|
||||
set_step_subtitle: [subtitle: string | null];
|
||||
clear_step_subtitle: [];
|
||||
};
|
||||
|
||||
export type ReplayParamTupleDocs =
|
||||
& ReplayUiParamTupleDocs
|
||||
& ReplayMapFunctionParamTupleDocs
|
||||
& ReplayGeoFunctionParamTupleDocs
|
||||
& ReplayNarrativeParamTupleDocs;
|
||||
|
||||
export type ReplayActionTupleDoc<T extends keyof ReplayParamTupleDocs> = {
|
||||
function_name: T;
|
||||
params: ReplayParamTupleDocs[T];
|
||||
};
|
||||
|
||||
// ---- Snapshot root ----
|
||||
|
||||
export type EditorSnapshot = {
|
||||
// Legacy snapshots có thể còn field project embedded.
|
||||
project?: {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
editor_feature_collection?: FeatureCollection;
|
||||
entities?: EntitySnapshot[];
|
||||
geometries?: GeometrySnapshot[];
|
||||
geometry_entity?: GeometryEntitySnapshot[];
|
||||
wikis?: WikiSnapshot[];
|
||||
entity_wiki?: EntityWikiLinkSnapshot[];
|
||||
replays?: BattleReplay[];
|
||||
};
|
||||
|
||||
export type CommitSnapshot = EditorSnapshot;
|
||||
@@ -0,0 +1,190 @@
|
||||
# UHM Editor - developer guide thực dụng
|
||||
|
||||
Tài liệu này dành cho người sửa editor hiện tại, không phải mô tả kiến trúc lý tưởng.
|
||||
|
||||
## 1. Entry points quan trọng
|
||||
|
||||
- `src/app/editor/[id]/page.tsx`
|
||||
- orchestration chính của project editor
|
||||
- `src/uhm/components/Map.tsx`
|
||||
- container cho map và các hook map
|
||||
- `src/uhm/lib/editor/state/useEditorState.ts`
|
||||
- draft geometry + diff + undo
|
||||
- `src/uhm/lib/editor/state/useEditorSessionState.ts`
|
||||
- session/UI/project/wiki/entity state
|
||||
- `src/uhm/lib/editor/snapshot/editorSnapshot.ts`
|
||||
- normalize snapshot từ backend và build snapshot gửi ngược lại backend
|
||||
|
||||
Nếu chưa đọc 5 file này, chưa nên sửa behavior lớn của editor.
|
||||
|
||||
## 2. Cấu trúc thư mục nên ưu tiên hiểu
|
||||
|
||||
- `src/uhm/components/editor/`
|
||||
- panel UI bên trái/phải
|
||||
- `src/uhm/components/wiki/`
|
||||
- wiki editor và wiki viewer/sidebar
|
||||
- `src/uhm/components/map/`
|
||||
- hooks tích hợp MapLibre
|
||||
- `src/uhm/lib/map/engines/`
|
||||
- logic interaction theo mode
|
||||
- `src/uhm/lib/editor/session/`
|
||||
- các nhóm session state
|
||||
- `src/uhm/lib/editor/draft/`
|
||||
- draft diff và undo
|
||||
- `src/uhm/lib/editor/snapshot/`
|
||||
- schema conversion / snapshot semantics
|
||||
|
||||
## 3. Cách editor thật sự vận hành
|
||||
|
||||
Editor có 3 tầng dữ liệu:
|
||||
|
||||
1. `baselineSnapshot`
|
||||
- snapshot gốc của session
|
||||
2. `initialData`
|
||||
- `FeatureCollection` rehydrate từ snapshot đó
|
||||
3. `draft`
|
||||
- working copy để user sửa trên map
|
||||
|
||||
Khi commit:
|
||||
|
||||
- geometry đi từ `draft`
|
||||
- entity/wiki/link đi từ snapshot collections
|
||||
- `buildEditorSnapshot()` quyết định operation nào là `reference`, `binding`, `update`, `delete`
|
||||
|
||||
Đừng tự build payload ở component nếu chưa hiểu file `editorSnapshot.ts`.
|
||||
|
||||
## 4. Khi thêm mode/tool mới
|
||||
|
||||
Checklist an toàn:
|
||||
|
||||
1. Thêm mode vào `sessionTypes.ts`.
|
||||
2. Thêm button vào `ToolsPanel.tsx`.
|
||||
3. Nếu mode cần preview source/layer mới, thêm vào `setupMapLayers()`.
|
||||
4. Nối mode với engine trong `useMapInteraction.ts`.
|
||||
5. Nếu tool tạo geometry mới, chọn default:
|
||||
- `type`
|
||||
- `geometry_preset`
|
||||
- `entity_ids`
|
||||
- `binding`
|
||||
6. Kiểm tra interaction cleanup khi chuyển mode.
|
||||
|
||||
Nếu mode chưa được cleanup đúng, map rất dễ giữ preview cũ hoặc event listener cũ.
|
||||
|
||||
## 5. Khi thêm geotype mới
|
||||
|
||||
Checklist ngắn:
|
||||
|
||||
1. Cập nhật `geoTypeMap` nếu cần mapping backend code <-> key.
|
||||
2. Cập nhật `geometryTypeOptions.ts`.
|
||||
3. Tạo style file trong `styles/geotypes/`.
|
||||
4. Register ở `geotypeLayers.ts`.
|
||||
5. Kiểm tra point icon hoặc label pipeline nếu type mới là point/route/polygon label.
|
||||
|
||||
Nếu chỉ sửa `geometryTypeOptions.ts` mà quên style registry, UI sẽ cho chọn type nhưng map không render đúng.
|
||||
|
||||
## 6. Khi sửa snapshot semantics
|
||||
|
||||
File quan trọng nhất là `editorSnapshot.ts`.
|
||||
|
||||
Ở đó đang có hai hướng xử lý khác nhau:
|
||||
|
||||
- `normalizeEditorSnapshot(raw)`
|
||||
- đọc payload từ backend
|
||||
- rehydrate fields UI như `entity_ids`, `entity_name`, `binding`, `time_start`, `time_end`
|
||||
- `buildEditorSnapshot(options)`
|
||||
- strip các field generate-only khỏi `editor_feature_collection`
|
||||
- build `geometry_entity[]` và `entity_wiki[]`
|
||||
- tính operation phù hợp
|
||||
|
||||
Nguyên tắc:
|
||||
|
||||
- feature trong editor có thể mang field denormalized để UI dễ dùng
|
||||
- payload gửi backend thì không nên mang những field denormalized đó
|
||||
|
||||
## 7. Khi sửa wiki editor
|
||||
|
||||
Wiki project editor hiện là Quill, không phải Tiptap.
|
||||
|
||||
Các file nên đọc trước:
|
||||
|
||||
- `WikiSidebarPanel.tsx`
|
||||
- `PublicWikiSidebar.tsx`
|
||||
|
||||
Các điểm dễ làm hỏng:
|
||||
|
||||
- sanitize link của Quill
|
||||
- compatibility với doc dạng HTML/plain text
|
||||
- slug links nội bộ
|
||||
- sentinel `__missing__`
|
||||
|
||||
Nếu thay storage format, phải sửa cả editor lẫn viewer compatibility path.
|
||||
|
||||
## 8. Những key localStorage thật sự đang dùng
|
||||
|
||||
- `uhm.backgroundLayerVisibility.v1`
|
||||
- `uhm:mapProjection`
|
||||
|
||||
Hiện không có local draft autosave toàn editor.
|
||||
Đừng dựa vào doc cũ hoặc giả định rằng F5 sẽ hồi lại draft geometry/wiki/entity.
|
||||
|
||||
## 9. Restore commit hiện là FE-only
|
||||
|
||||
`CommitHistoryPanel -> Restore`:
|
||||
|
||||
- load snapshot từ commit cũ
|
||||
- reset editor state ở frontend
|
||||
- không đổi head commit trên backend
|
||||
|
||||
Nếu muốn restore server-side thật, cần dùng endpoint backend riêng và sửa cả UI wording.
|
||||
|
||||
## 10. Pending submission lock là rule thật
|
||||
|
||||
`openSectionEditor()` chủ động chặn project có `PENDING` submission.
|
||||
|
||||
Nghĩa là:
|
||||
|
||||
- không nên "lách" UI để cho sửa tiếp
|
||||
- nếu đổi behavior này, phải thống nhất với backend contract
|
||||
|
||||
## 11. Performance và state hygiene
|
||||
|
||||
Một số nguyên tắc nên giữ:
|
||||
|
||||
- dùng `draftRef`/refs trong map engines để tránh rebind handler vô ích
|
||||
- giữ component panel càng dumb càng tốt, logic patch state đặt ở page/hooks
|
||||
- khi cần undo cho entity/wiki/link, đi qua `editor.setSnapshot*()` để undo stack biết
|
||||
- hạn chế thêm `JSON.stringify` compare ở chỗ nóng nếu chưa đo hiệu năng
|
||||
|
||||
## 12. Chỗ dễ gây hiểu nhầm khi debug
|
||||
|
||||
### Geometry biến mất
|
||||
|
||||
Có thể do:
|
||||
|
||||
- timeline filter
|
||||
- geometry visibility theo type
|
||||
- binding filter
|
||||
|
||||
Không phải lúc nào cũng là bug render layer.
|
||||
|
||||
### Commit count lạ
|
||||
|
||||
`Commit (N)` là `pendingSaveCount`, không phải số mutation backend.
|
||||
|
||||
### Selection mất
|
||||
|
||||
Khi timeline filter làm geometry đang chọn không còn visible, page sẽ tự cắt `selectedFeatureIds`.
|
||||
|
||||
## 13. Nên test gì sau khi sửa
|
||||
|
||||
Ít nhất nên test thủ công:
|
||||
|
||||
1. mở project có commit cũ
|
||||
2. tạo geometry mới bằng mode liên quan
|
||||
3. sửa metadata geometry
|
||||
4. bind entity và geometry
|
||||
5. tạo/sửa wiki
|
||||
6. link entity-wiki
|
||||
7. commit
|
||||
8. restore từ commit cũ
|
||||
9. mở project có pending submission nếu đang debug flow đó
|
||||
@@ -0,0 +1,296 @@
|
||||
# UHM Editor - tính năng hiện có
|
||||
|
||||
Tài liệu này mô tả editor đang chạy tại `src/app/editor/[id]/page.tsx` và các panel liên quan trong `src/uhm/components/`.
|
||||
Mục tiêu của tài liệu là phản ánh đúng implementation hiện tại, không mô tả các tính năng chưa được nối dây.
|
||||
|
||||
## 1. Cách mở editor
|
||||
|
||||
- `GET /editor/[id]`: mở editor đầy đủ với map, panel trái và panel phải.
|
||||
|
||||
## 2. Bố cục giao diện
|
||||
|
||||
- Cột trái (`Editor.tsx`)
|
||||
- `ProjectPanel`
|
||||
- `ToolsPanel`
|
||||
- `CommitPanel`
|
||||
- `CommitHistoryPanel`
|
||||
- `UndoListPanel`
|
||||
- Khu vực giữa
|
||||
- `Map`
|
||||
- `TimelineBar` khi không ở `replay`
|
||||
- Cột phải (`BackgroundLayersPanel`)
|
||||
- Search hợp nhất
|
||||
- Geometry Binding
|
||||
- Entities
|
||||
- Wiki
|
||||
- Entity ↔ Wiki
|
||||
- Selected Geometry
|
||||
|
||||
Hai cột hai bên đều resize được bằng drag handle.
|
||||
|
||||
## 3. Editor modes
|
||||
|
||||
`EditorMode` hiện có:
|
||||
|
||||
- `idle`
|
||||
- `select`
|
||||
- `draw`
|
||||
- `add-point`
|
||||
- `add-line`
|
||||
- `add-path`
|
||||
- `add-circle`
|
||||
- `replay`
|
||||
|
||||
Ý nghĩa thực tế:
|
||||
|
||||
- `select`: chọn geometry, xóa geometry, mở vertex editing cho polygon/circle, vào replay.
|
||||
- `draw`: vẽ polygon.
|
||||
- `add-point`: tạo point.
|
||||
- `add-line`: vẽ `LineString`.
|
||||
- `add-path`: vẽ `LineString` có render arrow layer cho route.
|
||||
- `add-circle`: kéo chuột để tạo polygon hình tròn, có `circle_center` và `circle_radius`.
|
||||
- `replay`: hiện là chế độ tập trung vào một geometry và các geometry trong `binding`; chưa có hệ thống script replay UI/map như file schema tham chiếu.
|
||||
|
||||
## 4. Công cụ vẽ và phím điều khiển
|
||||
|
||||
### Polygon (`draw`)
|
||||
|
||||
- Click để thêm đỉnh.
|
||||
- `Shift` hoặc `Alt` khi click/move để snap vào geometry gần nhất.
|
||||
- `Enter` để hoàn tất polygon.
|
||||
- `Escape` để hủy.
|
||||
- `Backspace` để bỏ đỉnh cuối.
|
||||
|
||||
Geometry mới mặc định có:
|
||||
|
||||
- `type: "country"`
|
||||
- `geometry_preset: "polygon"`
|
||||
- `entity_ids: []`
|
||||
- `binding: []`
|
||||
|
||||
### Point (`add-point`)
|
||||
|
||||
- Click một lần để tạo point.
|
||||
- Geometry mới mặc định có `type: "city"` và `geometry_preset: "point"`.
|
||||
|
||||
### Line (`add-line`)
|
||||
|
||||
- Click để thêm đỉnh.
|
||||
- `Enter` để hoàn tất.
|
||||
- `Escape` để hủy.
|
||||
- `Backspace` để bỏ đỉnh cuối.
|
||||
|
||||
Geometry mới mặc định có `type: "defense_line"` và `geometry_preset: "line"`.
|
||||
|
||||
### Path (`add-path`)
|
||||
|
||||
- Tương tự `add-line`, nhưng render preview và layer theo route/path.
|
||||
- Geometry mới mặc định có `type: "attack_route"` và `geometry_preset: "line"`.
|
||||
|
||||
### Circle (`add-circle`)
|
||||
|
||||
- `mousedown` để đặt tâm.
|
||||
- Kéo chuột để thay đổi bán kính.
|
||||
- `mouseup` để hoàn tất.
|
||||
- `Escape` để hủy.
|
||||
|
||||
Geometry trả về vẫn là `Polygon`, nhưng có thêm:
|
||||
|
||||
- `circle_center`
|
||||
- `circle_radius`
|
||||
|
||||
Mặc định `type: "war"` và `geometry_preset: "circle-area"`.
|
||||
|
||||
## 5. Chọn và sửa geometry
|
||||
|
||||
### Selection
|
||||
|
||||
- `Map` trả về danh sách `selectedFeatureIds`.
|
||||
- `SelectedGeometryPanel`, `ProjectEntityRefsPanel` và `GeometryBindingPanel` đều đọc từ selection này.
|
||||
- Multi-select có tồn tại ở level state, nhưng một số thao tác chỉ hợp lệ khi các geometry cùng shape.
|
||||
|
||||
### Vertex editing
|
||||
|
||||
Khi đang ở `select`, editor có thể sửa polygon/circle qua `editingEngine`.
|
||||
|
||||
- Kéo handle để đổi vị trí đỉnh.
|
||||
- Với circle:
|
||||
- handle `0`: dời tâm
|
||||
- handle `1`: đổi bán kính
|
||||
- `Ctrl` hoặc `Cmd` + click lên đường edit để chèn thêm đỉnh mới cho polygon.
|
||||
- `Enter` để áp dụng chỉnh sửa.
|
||||
- `Escape` để hủy chỉnh sửa.
|
||||
|
||||
### Xóa geometry
|
||||
|
||||
- Hành động xóa được đi qua `onDeleteFeature`.
|
||||
- Undo có thể khôi phục lại geometry vừa xóa.
|
||||
|
||||
## 6. Metadata geometry
|
||||
|
||||
`SelectedGeometryPanel` hiện cho phép sửa:
|
||||
|
||||
- `type_key`
|
||||
- `time_start`
|
||||
- `time_end`
|
||||
|
||||
`binding` đang được hiển thị trong state form nhưng không có input edit trực tiếp trong panel; việc bind/unbind geometry hiện đi qua `GeometryBindingPanel`.
|
||||
|
||||
Các ràng buộc đang có:
|
||||
|
||||
- `time_start` và `time_end` phải parse được thành số hoặc để trống.
|
||||
- Nếu cả hai đều có giá trị thì `time_start <= time_end`.
|
||||
|
||||
Khi apply, editor patch trực tiếp `feature.properties` của geometry đang chọn.
|
||||
|
||||
## 7. Timeline
|
||||
|
||||
`TimelineBar` hiện dùng dải năm cố định từ util timeline.
|
||||
|
||||
- Slider + numeric input cùng điều khiển `timelineDraftYear`.
|
||||
- Có toggle `filterEnabled`.
|
||||
- Khi bật filter:
|
||||
- geometry đã có trong baseline chỉ hiện nếu năm hiện tại nằm trong `[time_start, time_end]`
|
||||
- geometry mới tạo trong session vẫn được giữ visible
|
||||
|
||||
Timeline hiện là filter phía client, không fetch lại dữ liệu project theo năm.
|
||||
|
||||
## 8. Search hợp nhất và import
|
||||
|
||||
Panel phải có `UnifiedSearchBar` với 3 loại search:
|
||||
|
||||
- `entity`
|
||||
- tìm local + backend theo tên/mô tả
|
||||
- nút `Add` sẽ thêm entity vào `snapshotEntities` dưới dạng `reference`
|
||||
- `wiki`
|
||||
- tìm backend theo title
|
||||
- nút `Add` sẽ thêm wiki vào `snapshotWikis` dưới dạng `reference`
|
||||
- `geo`
|
||||
- tìm geometry theo tên entity
|
||||
- nút `Import` sẽ import geometry vào draft hiện tại
|
||||
- đồng thời thêm entity tương ứng vào `snapshotEntities` nếu chưa có
|
||||
- import sẽ tự tắt timeline filter để geometry mới import không bị ẩn
|
||||
|
||||
## 9. Entity và binding
|
||||
|
||||
### Project entities
|
||||
|
||||
`ProjectEntityRefsPanel` hỗ trợ:
|
||||
|
||||
- tạo entity local (`source: "inline"`, `operation: "create"`)
|
||||
- sửa entity đã có trong snapshot
|
||||
- bind/unbind entity vào geometry đang chọn
|
||||
|
||||
Editor không gọi API create entity riêng ở bước này. Entity mới chỉ sống trong snapshot cho tới khi commit project.
|
||||
|
||||
### Geometry ↔ Entity
|
||||
|
||||
Liên kết nhiều-nhiều được thể hiện bằng:
|
||||
|
||||
- field UI trên feature: `entity_id`, `entity_ids`, `entity_name`, `entity_names`
|
||||
- payload snapshot: `geometry_entity[]`
|
||||
|
||||
Panel `ProjectEntityRefsPanel` là nơi bind/unbind entity theo geometry đang chọn.
|
||||
|
||||
### Geometry ↔ Geometry
|
||||
|
||||
`GeometryBindingPanel` thao tác trên `feature.properties.binding`.
|
||||
|
||||
- Chọn một geometry làm gốc.
|
||||
- Bind/unbind với geometry khác trong project.
|
||||
- Có nút focus để zoom vào geometry trong list binding.
|
||||
- Có toggle `Filter`: map chỉ hiển thị geometry liên quan tới selection nếu filter binding đang bật.
|
||||
|
||||
## 10. Wiki và entity-wiki
|
||||
|
||||
### Wiki panel
|
||||
|
||||
`WikiSidebarPanel` dùng `react-quill-new`.
|
||||
|
||||
Các khả năng đang có:
|
||||
|
||||
- tạo wiki local
|
||||
- sửa title/slug/doc
|
||||
- import HTML file
|
||||
- export nội dung hiện tại theo định dạng suy ra từ `doc`
|
||||
- lưu wiki vào `snapshotWikis`
|
||||
|
||||
Storage thực tế của `doc`:
|
||||
|
||||
- format mới: HTML string
|
||||
- plaintext fallback
|
||||
|
||||
### Internal wiki link
|
||||
|
||||
Toolbar `link` mở modal custom:
|
||||
|
||||
- tìm wiki local theo title/slug
|
||||
- tìm wiki global từ server
|
||||
- chèn link bằng `slug`, không bắt buộc scheme URL
|
||||
- có thể tạo `__missing__` link để đánh dấu liên kết chưa map được
|
||||
|
||||
### Entity ↔ Wiki
|
||||
|
||||
`EntityWikiBindingsPanel` quản lý `snapshotEntityWikiLinks`.
|
||||
|
||||
- link mới dùng `operation: "binding"`
|
||||
- unlink bằng cách remove row khỏi editor state
|
||||
- khi build snapshot, editor tự sinh delta `binding` hoặc `delete` so với baseline
|
||||
|
||||
## 11. Commit, submit và restore
|
||||
|
||||
### Pending change count
|
||||
|
||||
Số trong nút `Commit` không chỉ là geometry diff. Nó gồm:
|
||||
|
||||
- `editor.changeCount`
|
||||
- `+1` nếu danh sách wiki dirty
|
||||
- `+1` nếu danh sách entity dirty
|
||||
- `+1` nếu danh sách entity-wiki dirty
|
||||
|
||||
### Commit
|
||||
|
||||
`commitSection()`:
|
||||
|
||||
- build snapshot từ `draft` + `snapshotEntities` + `snapshotWikis` + `snapshotEntityWikiLinks`
|
||||
- gửi `snapshot_json` lên API tạo commit
|
||||
- nếu thành công:
|
||||
- reset baseline sang snapshot vừa commit
|
||||
- clear undo stack
|
||||
- clear geometry changes
|
||||
|
||||
### Submit
|
||||
|
||||
- chỉ submit được khi project có `head_commit_id`
|
||||
- không submit nếu còn thay đổi chưa commit
|
||||
|
||||
### Restore
|
||||
|
||||
`CommitHistoryPanel` có nút `Restore`, nhưng restore hiện là:
|
||||
|
||||
- load snapshot từ commit cũ vào FE
|
||||
- không đổi head commit trên backend
|
||||
|
||||
Đây là FE-only restore để tiếp tục chỉnh sửa từ snapshot cũ.
|
||||
|
||||
## 12. Pending submission lock
|
||||
|
||||
Khi `openSectionEditor()` thấy project có submission `PENDING`, editor bị chặn mở.
|
||||
|
||||
UI hiện tại:
|
||||
|
||||
- hiển thị màn hình lock
|
||||
- cho phép xóa pending submission để unlock
|
||||
|
||||
Luồng này bám sát rule backend mới, không phải readonly mode giả lập ở FE.
|
||||
|
||||
## 13. Những thứ doc cũ từng nhắc nhưng code hiện chưa có
|
||||
|
||||
Các mục sau không nên xem là tính năng hiện hành của editor:
|
||||
|
||||
- autosave toàn bộ draft editor vào `localStorage`
|
||||
- restore head commit trên backend từ UI editor
|
||||
- import/export wiki JSON chuyên biệt như một workflow riêng
|
||||
- bộ shortcut toàn cục kiểu `Ctrl+S`, `Ctrl+Z`, `Ctrl+Y`
|
||||
- workflow duyệt `Approved/Rejected` được render đầy đủ trong editor page
|
||||
- hệ thống replay script theo `replays[]` trong schema snapshot
|
||||
@@ -0,0 +1,223 @@
|
||||
# UHM Editor - state replay hiện tại
|
||||
|
||||
Tài liệu này mô tả đúng flow replay mode hiện tại của `/editor/[id]`.
|
||||
|
||||
Nguồn thật:
|
||||
|
||||
- `src/app/editor/[id]/page.tsx`
|
||||
- `src/uhm/lib/editor/state/useEditorState.ts`
|
||||
- `src/uhm/lib/editor/project/useProjectCommands.ts`
|
||||
- `src/uhm/lib/editor/snapshot/editorSnapshot.ts`
|
||||
|
||||
## 1. Kết luận ngắn
|
||||
|
||||
Replay mode hiện tại có 2 lớp state:
|
||||
|
||||
- `activeReplayDraft`
|
||||
- là `BattleReplay` đang chỉnh
|
||||
- chỉ chứa `geometry_id`, `target_geometry_ids`, `detail`
|
||||
- `replayDraft`
|
||||
- là `FeatureCollection` local, được FE hydrate lại từ `mainDraft + target_geometry_ids`
|
||||
- chỉ dùng để map/render/select trong replay mode
|
||||
|
||||
Điểm quan trọng:
|
||||
|
||||
- `replayDraft` không còn được persist vào commit/API
|
||||
- commit chỉ lưu `replays[]` với `target_geometry_ids`
|
||||
- snapshot cũ còn `replay_features` sẽ được FE migrate sang `target_geometry_ids` khi load
|
||||
|
||||
## 2. Shape replay hiện tại
|
||||
|
||||
```ts
|
||||
type BattleReplay = {
|
||||
id: string;
|
||||
geometry_id: string;
|
||||
target_geometry_ids: string[];
|
||||
detail: ReplayStage[];
|
||||
};
|
||||
```
|
||||
|
||||
Ý nghĩa:
|
||||
|
||||
- `geometry_id`
|
||||
- MAIN geo của replay
|
||||
- cũng là key để tìm replay tương ứng
|
||||
- `id`
|
||||
- hiện luôn bằng `geometry_id`
|
||||
- thêm để schema replay có id riêng rõ ràng hơn
|
||||
- `target_geometry_ids`
|
||||
- toàn bộ geo được đưa vào replay
|
||||
- phần tử đầu nên luôn là MAIN geo
|
||||
- `detail`
|
||||
- stage/step/actions của kịch bản
|
||||
|
||||
## 3. Replay được mở như thế nào
|
||||
|
||||
Khi vào replay từ UI:
|
||||
|
||||
1. editor lấy `triggerId`
|
||||
- ưu tiên `selectedFeatureIds[0]`
|
||||
- nếu chưa có selection thì dùng `featureId` vừa click
|
||||
2. gọi `editor.switchReplayContext(triggerId, selectedFeatureIds)`
|
||||
3. `switchReplayContext()` sẽ:
|
||||
- flush replay cũ nếu đang mở replay khác
|
||||
- tìm replay đã tồn tại theo `geometry_id`
|
||||
- nếu chưa có thì tạo seed mới
|
||||
|
||||
## 4. Seed replay được tạo ra sao
|
||||
|
||||
Replay seed mới có dạng:
|
||||
|
||||
```ts
|
||||
{
|
||||
id: triggerId,
|
||||
geometry_id: triggerId,
|
||||
target_geometry_ids: [...],
|
||||
detail: []
|
||||
}
|
||||
```
|
||||
|
||||
`target_geometry_ids` được build từ:
|
||||
|
||||
- MAIN geo
|
||||
- toàn bộ bulk selection hiện tại
|
||||
- toàn bộ `binding` của MAIN geo trong `mainDraft`
|
||||
|
||||
Rule hiện tại:
|
||||
|
||||
- MAIN geo luôn đứng đầu
|
||||
- geo trùng sẽ được dedupe
|
||||
- nếu replay đã tồn tại sẵn, FE giữ `detail` cũ và chỉ append thêm geo mới còn thiếu vào `target_geometry_ids`
|
||||
|
||||
## 5. `replayDraft` được hydrate thế nào
|
||||
|
||||
`replayDraft` không còn nằm trong snapshot.
|
||||
|
||||
Mỗi lần:
|
||||
|
||||
- mở replay
|
||||
- undo replay session
|
||||
- restore `activeReplayDraft`
|
||||
|
||||
FE sẽ hydrate lại:
|
||||
|
||||
```ts
|
||||
replayDraft = hydrate(mainDraft, activeReplayDraft.target_geometry_ids)
|
||||
```
|
||||
|
||||
Hydrate hiện tại:
|
||||
|
||||
- lấy feature từ `mainDraft` theo đúng thứ tự `target_geometry_ids`
|
||||
- clone ra `FeatureCollection` mới
|
||||
- flatten `binding` thành `[]` để các geo trong replay bình đẳng với nhau
|
||||
|
||||
## 6. Trong replay mode map đang đọc gì
|
||||
|
||||
`useEditorState()` vẫn switch active draft như cũ:
|
||||
|
||||
```ts
|
||||
const activeDraft = mode === "replay" ? replayDraft : mainDraft;
|
||||
```
|
||||
|
||||
Nên khi `mode === "replay"`:
|
||||
|
||||
- `editor.draft` trỏ vào `replayDraft`
|
||||
- `editor.draftRef` trỏ vào `replayDraftRef`
|
||||
- map chỉ render tập geo đang nằm trong `target_geometry_ids`
|
||||
|
||||
## 7. Replay mode còn sửa geometry không
|
||||
|
||||
Không.
|
||||
|
||||
Hiện tại state layer đã chặn toàn bộ nhánh mutate geometry trong replay mode:
|
||||
|
||||
- `createFeature`
|
||||
- `createFeatureWithSnapshotEntities`
|
||||
- `patchFeatureProperties`
|
||||
- `patchFeaturePropertiesBatch`
|
||||
- `updateFeature`
|
||||
- `deleteFeature`
|
||||
|
||||
Nghĩa là:
|
||||
|
||||
- replay mode chỉ còn là nơi viết script replay
|
||||
- không còn persist hay commit geometry edit riêng của replay
|
||||
|
||||
## 8. Cái gì vẫn được sửa trong replay mode
|
||||
|
||||
Replay sidebar vẫn sửa:
|
||||
|
||||
- `detail[]`
|
||||
- `stage`
|
||||
- `step`
|
||||
- các action `UI / map / geo / narrative`
|
||||
|
||||
Các thay đổi đó đi qua:
|
||||
|
||||
- `editor.mutateActiveReplay`
|
||||
- `applyReplaySessionMutation()`
|
||||
|
||||
Undo replay vẫn riêng ở:
|
||||
|
||||
- `replayUndoStack`
|
||||
|
||||
## 9. Khi nào replay được flush về `replays[]`
|
||||
|
||||
`activeReplayDraft` chỉ là session đang mở.
|
||||
|
||||
Nó được flush về `replays[]` khi:
|
||||
|
||||
- thoát replay mode
|
||||
- chuyển sang replay khác
|
||||
|
||||
Hàm chịu trách nhiệm là:
|
||||
|
||||
- `finalizeActiveReplaySession()`
|
||||
|
||||
## 10. Commit lấy replay từ đâu
|
||||
|
||||
Commit không lấy `activeReplayDraft` trực tiếp.
|
||||
|
||||
Nó lấy:
|
||||
|
||||
- `editor.effectiveReplays`
|
||||
|
||||
`effectiveReplays` là:
|
||||
|
||||
- `replays`
|
||||
- cộng thêm overlay của `activeReplayDraft` nếu session hiện tại đã thay đổi nhưng chưa flush
|
||||
|
||||
Vì vậy:
|
||||
|
||||
- đang còn ở replay mode vẫn commit được replay mới nhất
|
||||
- không cần thoát replay mode mới lưu được script
|
||||
|
||||
## 11. Replay đi qua API ra sao
|
||||
|
||||
Payload commit hiện tại chỉ gửi:
|
||||
|
||||
- `geometry_id`
|
||||
- `target_geometry_ids`
|
||||
- `detail`
|
||||
|
||||
Không gửi:
|
||||
|
||||
- `replayDraft`
|
||||
- `replay_features`
|
||||
- `FeatureCollection` local của replay mode
|
||||
|
||||
## 12. Migrate dữ liệu cũ
|
||||
|
||||
Snapshot cũ nếu còn:
|
||||
|
||||
```ts
|
||||
replay_features?: FeatureCollection
|
||||
```
|
||||
|
||||
thì FE sẽ:
|
||||
|
||||
- đọc `replay_features.features[].properties.id`
|
||||
- chuyển chúng thành `target_geometry_ids`
|
||||
- bỏ `replay_features` khỏi runtime replay mới
|
||||
|
||||
Nên dữ liệu cũ vẫn mở được, nhưng commit mới sẽ ra schema mới.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user