Compare commits

...

22 Commits

Author SHA1 Message Date
BoKhongLo 7e025fb449 feat: implement resizable public wiki sidebar with improved content fetching, map zoom limits, and layout adjustments
Build and Release / release (push) Successful in 36s
2026-05-20 18:02:10 +07:00
taDuc 8c0bff8082 Remove .env from tracking 2026-05-20 14:31:45 +07:00
taDuc 2a3e908c44 chore: remove .env from tracking and enforce gitignore 2026-05-20 14:25:17 +07:00
taDuc 6599d8bf21 feat: configure mapbox access token for spatial data core engine 2026-05-20 14:14:46 +07:00
taDuc 194b3ad3c2 add somenew UI editor feature for more effêcncy 2026-05-20 02:17:46 +07:00
BoKhongLo 488eee1a25 responsive
Build and Release / release (push) Successful in 36s
2026-05-19 21:52:29 +07:00
BoKhongLo f5514b8fb5 big update new layout
Build and Release / release (push) Successful in 35s
2026-05-19 18:00:19 +07:00
taDuc e7c8322c63 fix: cors 2026-05-18 23:41:13 +07:00
taDuc 663347889b reafactor(important): change to new map backround base goong.io 2026-05-18 22:48:21 +07:00
taDuc 97d505dcc7 preview mode 2026-05-18 13:45:35 +07:00
taDuc c09928a2b2 add id to BattleReplay 2026-05-17 22:01:09 +07:00
taDuc 047f662736 complete replay editor v1 2026-05-17 21:45:33 +07:00
taDuc 3808086529 add README 2026-05-16 12:09:38 +07:00
taDuc 7424bc43b0 init replay mode draft 2026-05-16 12:08:21 +07:00
taDuc a8097c95d4 refactor(important): reduce editor drill state 2026-05-16 01:45:19 +07:00
taDuc 4c81862bb4 feat: implement replay system with action dispatchers and context switching between main and playback modes 2026-05-15 19:39:02 +07:00
taDuc 3682f25282 refactor: remove Tiptap editor dependency and legacy JSON format support from wiki components 2026-05-15 00:04:44 +07:00
taDuc 57e3d6b3e5 docs: add comprehensive project documentation and clean up snapshot-related files and components 2026-05-14 23:50:49 +07:00
taDuc b220798978 refactor: improve type safety by replacing any types with specific interfaces across API services and components. 2026-05-14 21:54:44 +07:00
taDuc dca3ca67ad refactor: update token refresh logic and extend map interaction mode callback to support feature context 2026-05-14 17:34:31 +07:00
taDuc 494195c532 Merge branch 'master' of github.com:Pregnant-Guild/FE_User_history_web 2026-05-14 17:24:46 +07:00
taDuc 2ad8f9793b feat: add resizable side panels for replay mode in editor page 2026-05-14 17:21:22 +07:00
150 changed files with 23193 additions and 4168 deletions
+206 -1
View File
@@ -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.
-248
View File
@@ -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**`FrontEndUser` (module UHM editor) tạo ra khi bấm **Commit** trong `/editor/[id]`, và gửi lên endpoint `POST /projects/{id}/commits`.
Nguồn tham chiếu trong code (FrontEndUser):
- Types:
- `src/uhm/types/sections.ts` (`EditorSnapshot`, `EntityWikiLinkSnapshot`)
- `src/uhm/types/geo.ts` (`FeatureCollection`, `GeometrySnapshot`, `GeometryEntitySnapshot`)
- `src/uhm/types/entities.ts` (`EntitySnapshot`)
- `src/uhm/types/wiki.ts` (`WikiSnapshot`)
- Build/normalize snapshot:
- `src/uhm/lib/editor/snapshot/editorSnapshot.ts` (`buildEditorSnapshot`, `normalizeEditorSnapshot`)
## 1) Root Shape
FE hiện tại không dùng `schema_version`. `snapshot_json` là một object có các phần sau:
```ts
export type EditorSnapshot = {
editor_feature_collection?: FeatureCollection;
entities?: EntitySnapshot[];
geometries?: GeometrySnapshot[];
geometry_entity?: GeometryEntitySnapshot[];
wikis?: WikiSnapshot[];
entity_wiki?: EntityWikiLinkSnapshot[];
};
```
Lưu ý:
- FE có thể **đọc** cả `entity_wiki` và legacy alias `entity_wikis` khi load snapshot (normalize), nhưng khi commit FE ghi `entity_wiki`.
- `editor_feature_collection` là nguồn để render editor/map. Các join table (`geometry_entity`, `entity_wiki`) là nguồn quan hệ.
## 2) Types (TypeScript) - Đúng Theo FE Hiện Tại
### 2.1 GeoJSON (editor_feature_collection)
```ts
export type Geometry =
| { type: "Point"; coordinates: [number, number] }
| { type: "MultiPoint"; coordinates: [number, number][] }
| { type: "LineString"; coordinates: [number, number][] }
| { type: "MultiLineString"; coordinates: [number, number][][] }
| { type: "Polygon"; coordinates: [number, number][][] }
| { type: "MultiPolygon"; coordinates: [number, number][][][] };
export type FeatureId = string | number;
export type FeatureProperties = {
id: FeatureId;
type?: string | null;
geometry_preset?: string | null;
time_start?: number | null;
time_end?: number | null;
binding?: string[];
// UI-only / legacy fields (FE sẽ strip khi persist snapshot):
entity_id?: string | null;
entity_ids?: string[];
entity_name?: string | null;
entity_names?: string[];
entity_type_id?: string | null;
};
export type Feature = {
type: "Feature";
properties: FeatureProperties;
geometry: Geometry;
};
export type FeatureCollection = {
type: "FeatureCollection";
features: Feature[];
};
```
### 2.2 Snapshot rows
```ts
export type SnapshotSource = "inline" | "ref";
export type EntitySnapshotOperation = "create" | "update" | "delete" | "reference";
export type GeometrySnapshotOperation = "create" | "update" | "delete" | "reference";
export type WikiSnapshotOperation = "create" | "update" | "delete" | "reference";
export type EntitySnapshot = {
id: string;
source: SnapshotSource;
operation?: EntitySnapshotOperation;
name?: string;
slug?: string | null;
description?: string | null;
status?: number | null;
base_updated_at?: string;
base_hash?: string;
};
export type GeometrySnapshot = {
id: string;
source: SnapshotSource;
operation?: GeometrySnapshotOperation;
type?: string | null;
draw_geometry?: Geometry;
geometry?: Geometry; // legacy
binding?: string[];
time_start?: number | null;
time_end?: number | null;
bbox?: {
min_lng: number;
min_lat: number;
max_lng: number;
max_lat: number;
} | null;
base_updated_at?: string;
base_hash?: string;
};
// FE stores wiki doc as a string (commonly HTML; in some flows it may be a JSON-stringified editor payload).
export type WikiDoc = string | null;
export type WikiSnapshot = {
id: string;
source: SnapshotSource;
operation?: WikiSnapshotOperation;
title: string;
slug?: string | null;
doc: WikiDoc;
updated_at?: string;
};
```
### 2.3 Join tables
```ts
export type GeometryEntitySnapshot = {
geometry_id: string;
entity_id: string;
base_links_hash?: string;
};
export type EntityWikiLinkSnapshot = {
entity_id: string;
wiki_id: string;
operation?: "reference" | "binding" | "delete";
};
```
## 3) Quy Ước FE Khi Build Snapshot (buildEditorSnapshot)
### 3.1 Feature.properties entity fields bị strip
Khi persist snapshot, FE chủ động xoá các field denormalize trên feature properties:
`entity_id`, `entity_ids`, `entity_name`, `entity_names`, `entity_type_id`.
Quan hệ geometry ↔ entity chỉ nằm ở `geometry_entity[]`.
### 3.2 entities[]
FE cố gắng đảm bảo mọi entity có `name` không rỗng (fallback sang `id`) và có `source`.
`operation` được dùng như "delta" trong commit:
- `"create"|"update"|"delete"`: thay đổi record entity
- `"reference"`: đưa entity vào context snapshot (pin/link) nhưng commit không sửa record entity
### 3.3 geometries[]
FE sinh 1 `GeometrySnapshot` cho mỗi feature đang tồn tại trong `editor_feature_collection.features[]`:
- `id = String(feature.properties.id)`
- `source:"inline"`
- `draw_geometry = feature.geometry`
- `binding`, `time_start`, `time_end`, `bbox` (nếu tính được)
- `type`: FE hiện gửi **string code** (geo_type smallint) dưới dạng string
- `operation`:
- `"create"` nếu geometry mới
- `"update"` nếu geometry thay đổi
- `undefined` nếu geometry không đổi
Nếu feature bị xoá khỏi draft, FE thêm 1 row:
```json
{ "id": "…", "source": "ref", "operation": "delete" }
```
### 3.4 geometry_entity[]
`geometry_entity` là danh sách quan hệ many-to-many geometry ↔ entity. Mỗi row là một cặp:
```ts
{ geometry_id: string; entity_id: string }
```
### 3.5 wikis[]
- Wiki `source:"ref"` (được add từ search): FE set `operation:"reference"``doc:null`.
- Wiki `source:"inline"` (được tạo/sửa trong editor):
- nếu UI set explicit `create|update|delete` thì giữ nguyên
- nếu không có operation:
- wiki mới: FE coi là `"create"`
- wiki cũ không đổi: FE gán `"reference"`
- wiki cũ có đổi nội dung: FE gán `"update"`
### 3.6 entity_wiki[]
Type trong FE cho UI state cho phép `"binding"``"delete"`.
Khi build snapshot để commit, FE map link “đang bật” về `"reference"` để tương thích với backend (một số backend chỉ chấp nhận `"reference"|"delete"`).
## 4) Ví Dụ snapshot_json (rút gọn)
```json
{
"editor_feature_collection": {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": { "id": "019e…", "type": "country", "time_start": 1000, "time_end": 1500 },
"geometry": { "type": "Polygon", "coordinates": [[[100, 10], [101, 10], [101, 11], [100, 10]]] }
}
]
},
"entities": [
{ "id": "019e…", "source": "inline", "operation": "reference", "name": "ent1", "description": null, "status": 1 }
],
"geometries": [
{ "id": "019e…", "source": "inline", "operation": "update", "type": "9", "draw_geometry": { "type": "Polygon", "coordinates": [] }, "binding": [], "time_start": 1000, "time_end": 1500, "bbox": null }
],
"geometry_entity": [
{ "geometry_id": "019e…", "entity_id": "019e…" }
],
"wikis": [
{ "id": "019e…", "source": "ref", "operation": "reference", "title": "Existing wiki", "doc": null, "updated_at": "2026-05-08T00:00:00.000Z" }
],
"entity_wiki": [
{ "entity_id": "019e…", "wiki_id": "019e…", "operation": "reference" }
]
}
```
## 5) Compat Notes (khi load snapshot cũ)
FE normalize khi load snapshot:
- Nếu thấy `entity_wikis` (plural) sẽ đọc như `entity_wiki`.
- Nếu join link có `operation:"reference"` thì FE coi như link active (UI biểu diễn như “binding”).
+66 -742
View File
File diff suppressed because it is too large Load Diff
+2 -4
View File
@@ -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",
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+281
View File
@@ -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,
};
+49
View File
@@ -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",
}}
/>
);
}
+141
View File
@@ -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;
}
+2 -6
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+128 -4
View File
@@ -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 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 project. Vào trang quản 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>
);
}
+33
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+3 -17
View File
@@ -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">
+10 -20
View File
@@ -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
</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></>
);
}
+4 -5
View File
@@ -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 ?? {}} />
+3 -9
View File
@@ -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>
+27 -27
View File
@@ -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 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 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>
);
}
+6 -6
View File
@@ -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>
+9 -9
View File
@@ -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
View File
@@ -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 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 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 (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 (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
View File
@@ -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 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ồ 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 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ồ 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>
);
}
-554
View File
@@ -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>
);
}
+14 -37
View File
@@ -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 &quot;{wiki.title}&quot;</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`}
+10 -1
View File
@@ -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] || "");
+1 -1
View File
@@ -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"
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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;
}
+16 -3
View File
@@ -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 ? (
+69
View File
@@ -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>
);
}
+7 -2
View File
@@ -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"
>
+10 -17
View File
@@ -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ồ {" "}
<h3 className="text-xl font-bold text-gray-800 dark:text-white/90 items-center flex">
Hồ
<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 */}
+9 -9
View File
@@ -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"
+60 -82
View File
@@ -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>
+17 -20
View File
@@ -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
View File
@@ -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
View File
@@ -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,
};
+7
View File
@@ -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

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

+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.05 6C15.0268 6.19057 15.9244 6.66826 16.6281 7.37194C17.3318 8.07561 17.8095 8.97326 18 9.95M14.05 2C16.0793 2.22544 17.9716 3.13417 19.4163 4.57701C20.8609 6.01984 21.7721 7.91101 22 9.94M18.5 21C9.93959 21 3 14.0604 3 5.5C3 5.11378 3.01413 4.73086 3.04189 4.35173C3.07375 3.91662 3.08968 3.69907 3.2037 3.50103C3.29814 3.33701 3.4655 3.18146 3.63598 3.09925C3.84181 3 4.08188 3 4.56201 3H7.37932C7.78308 3 7.98496 3 8.15802 3.06645C8.31089 3.12515 8.44701 3.22049 8.55442 3.3441C8.67601 3.48403 8.745 3.67376 8.88299 4.05321L10.0491 7.26005C10.2096 7.70153 10.2899 7.92227 10.2763 8.1317C10.2643 8.31637 10.2012 8.49408 10.0942 8.64506C9.97286 8.81628 9.77145 8.93713 9.36863 9.17882L8 10C9.2019 12.6489 11.3501 14.7999 14 16L14.8212 14.6314C15.0629 14.2285 15.1837 14.0271 15.3549 13.9058C15.5059 13.7988 15.6836 13.7357 15.8683 13.7237C16.0777 13.7101 16.2985 13.7904 16.74 13.9509L19.9468 15.117C20.3262 15.255 20.516 15.324 20.6559 15.4456C20.7795 15.553 20.8749 15.6891 20.9335 15.842C21 16.015 21 16.2169 21 16.6207V19.438C21 19.9181 21 20.1582 20.9007 20.364C20.8185 20.5345 20.663 20.7019 20.499 20.7963C20.3009 20.9103 20.0834 20.9262 19.6483 20.9581C19.2691 20.9859 18.8862 21 18.5 21Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+1 -1
View File
@@ -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
View File
@@ -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;
+1 -1
View File
@@ -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}
/>
);
+2 -2
View File
@@ -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>
+13 -2
View File
@@ -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
View File
@@ -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;
};
+16 -2
View File
@@ -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;
+10 -1
View File
@@ -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
View File
@@ -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;
+6 -4
View File
@@ -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
View File
@@ -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;
}
+6 -5
View File
@@ -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 });
+5
View File
@@ -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
View File
@@ -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 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;
+7
View File
@@ -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:
+465
View File
@@ -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;
}
+164 -34
View File
@@ -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[] {
+35 -2
View File
@@ -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,
};
}
+58 -6
View File
@@ -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;
}
+33 -171
View File
@@ -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);
}
+42 -6
View File
@@ -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);
}
}
}
+7 -2
View File
@@ -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,
+90 -34
View File
@@ -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("&", "&amp;")
@@ -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("&nbsp;", " ").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>
);
+34 -63
View File
@@ -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("&", "&amp;")
@@ -1130,15 +1104,12 @@ function escapeHtml(input: string): string {
.replaceAll("'", "&#39;");
}
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 {
+378
View File
@@ -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;
+190
View File
@@ -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[]``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)``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 đó
+296
View File
@@ -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``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"``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"``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"``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"``geometry_preset: "circle-area"`.
## 5. Chọn và sửa geometry
### Selection
- `Map` trả về danh sách `selectedFeatureIds`.
- `SelectedGeometryPanel`, `ProjectEntityRefsPanel``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``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
+223
View File
@@ -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`
-`BattleReplay` đang chỉnh
- chỉ chứa `geometry_id`, `target_geometry_ids`, `detail`
- `replayDraft`
-`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