Compare commits

..

16 Commits

Author SHA1 Message Date
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
127 changed files with 20452 additions and 3594 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", "@reduxjs/toolkit": "^2.11.2",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.1.17", "@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", "apexcharts": "^4.7.0",
"autoprefixer": "^10.4.22", "autoprefixer": "^10.4.22",
"axios": "^1.14.0", "axios": "^1.14.0",
@@ -44,7 +41,8 @@
"swiper": "^11.2.10", "swiper": "^11.2.10",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"yet-another-react-lightbox": "^3.30.1" "yet-another-react-lightbox": "^3.30.1",
"zustand": "^5.0.13"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.1", "@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.
File diff suppressed because it is too large Load Diff
+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;
}
+3 -1
View File
@@ -1,11 +1,13 @@
@import 'tailwindcss'; @import 'tailwindcss';
@import './fonts.css';
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@theme { @theme {
--font-*: initial; --font-*: initial;
--font-outfit: Outfit, sans-serif; --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-*: initial;
--breakpoint-2xsm: 375px; --breakpoint-2xsm: 375px;
+26 -6
View File
@@ -1,4 +1,4 @@
import { Inter } from 'next/font/google'; import localFont from 'next/font/local';
import './globals.css'; import './globals.css';
import "flatpickr/dist/flatpickr.css"; import "flatpickr/dist/flatpickr.css";
import { SidebarProvider } from '@/context/SidebarContext'; import { SidebarProvider } from '@/context/SidebarContext';
@@ -6,10 +6,30 @@ import { ThemeProvider } from '@/context/ThemeContext';
import { Toaster } from 'sonner'; import { Toaster } from 'sonner';
import StoreProvider from '@/store/StoreProvider'; import StoreProvider from '@/store/StoreProvider';
const inter = Inter({ const sfPro = localFont({
subsets: ["latin"], 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({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
@@ -17,7 +37,7 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html lang="en"> <html lang="en">
<body className={`${inter.className} dark:bg-gray-900`}> <body className={`${sfPro.className} dark:bg-gray-900`}>
<StoreProvider> <StoreProvider>
<ThemeProvider> <ThemeProvider>
<SidebarProvider>{children} <Toaster closeButton richColors position="top-right" /> </SidebarProvider> <SidebarProvider>{children} <Toaster closeButton richColors position="top-right" /> </SidebarProvider>
+10 -20
View File
@@ -17,6 +17,7 @@ import "yet-another-react-lightbox/plugins/captions.css";
import Image from "next/image"; import Image from "next/image";
import { URL_MEDIA } from "../../../../../api"; import { URL_MEDIA } from "../../../../../api";
import { MediaItem } from "@/components/tables/MediaTable"; import { MediaItem } from "@/components/tables/MediaTable";
import StickyHeader from "@/components/ui/StickyHeader";
export default function ApplicationDetailPage() { export default function ApplicationDetailPage() {
const application = useSelector( 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 ( 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"> <StickyHeader header={`Chi tiết đơn đăng ký`} paths={path} />
<div> <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">
<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>
<div className="space-y-10"> <div className="space-y-10">
<section> <section>
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest mb-4 block"> <label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest mb-4 block">
@@ -219,7 +208,7 @@ export default function ApplicationDetailPage() {
<button <button
onClick={handleDelete} onClick={handleDelete}
disabled={isDeleting} 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"} {isDeleting ? "ĐANG XỬ LÝ..." : "XÓA"}
</button> </button>
@@ -291,6 +280,7 @@ export default function ApplicationDetailPage() {
}, },
}} }}
/> />
</div> </div></>
); );
} }
+4 -5
View File
@@ -1,5 +1,6 @@
"use client"; "use client";
import StickyHeader from "@/components/ui/StickyHeader";
import AccountDetails from "@/components/user-profile/AccountDetails"; import AccountDetails from "@/components/user-profile/AccountDetails";
import UserInfoCard from "@/components/user-profile/UserInfoCard"; import UserInfoCard from "@/components/user-profile/UserInfoCard";
import UserMetaCard from "@/components/user-profile/UserMetaCard"; import UserMetaCard from "@/components/user-profile/UserMetaCard";
@@ -31,11 +32,9 @@ export default function Profile() {
return ( return (
<div> <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"> <StickyHeader header={`Thông tin tài khoản`} />
<h3 className="mb-5 text-lg font-semibold text-gray-800 dark:text-white/90 lg:mb-7"> <div className="p-6 my-8 flex mx-auto ">
Profile <div className="max-w-82 pr-4 border-r border-gray-300">
</h3>
<div className="space-y-6">
<UserMetaCard data={user ?? {}} /> <UserMetaCard data={user ?? {}} />
<UserInfoCard data={{ ...user, openEdit: true }} /> <UserInfoCard data={{ ...user, openEdit: true }} />
<AccountDetails data={user ?? {}} /> <AccountDetails data={user ?? {}} />
+2 -8
View File
@@ -9,7 +9,6 @@ import { setUserData } from "@/store/features/userSlice";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { useDispatch } from "react-redux"; import { useDispatch } from "react-redux";
import ChatbotWidget from "@/components/ui/chat/ChatbotWidget"; import ChatbotWidget from "@/components/ui/chat/ChatbotWidget";
export default function AdminLayout({ export default function AdminLayout({
children, children,
}: { }: {
@@ -31,7 +30,6 @@ export default function AdminLayout({
}, []) }, [])
// Dynamic class for main content margin based on sidebar state
const mainContentMargin = isMobileOpen const mainContentMargin = isMobileOpen
? "ml-0" ? "ml-0"
: isExpanded || isHovered : isExpanded || isHovered
@@ -40,17 +38,13 @@ export default function AdminLayout({
return ( return (
<div className="min-h-screen xl:flex"> <div className="min-h-screen xl:flex">
{/* Sidebar and Backdrop */}
<AppSidebar /> <AppSidebar />
<Backdrop /> <Backdrop />
{/* Main Content Area */}
<div <div
className={`flex-1 transition-all duration-300 ease-in-out ${mainContentMargin}`} className={`flex-1 transition-all duration-300 ease-in-out ${mainContentMargin}`}
> >
{/* Header */} {/* <AppHeader /> */}
<AppHeader /> <div className="mx-auto max-w-(--breakpoint-2xl)">{children}</div>
{/* Page Content */}
<div className="p-4 mx-auto max-w-(--breakpoint-2xl) md:p-6">{children}</div>
</div> </div>
<ChatbotWidget /> <ChatbotWidget />
</div> </div>
+8 -8
View File
@@ -7,6 +7,7 @@ import { apiGetCurrentUserApplications, apiGetCurrentUserMedia } from "@/service
import MediaLibrary from "@/components/user-profile/Media"; import MediaLibrary from "@/components/user-profile/Media";
import { Application } from "@/interface/historian"; import { Application } from "@/interface/historian";
import Loading from "@/app/loading"; import Loading from "@/app/loading";
import StickyHeader from "@/components/ui/StickyHeader";
export default function LibraryPage() { export default function LibraryPage() {
const [mediaData, setMediaData] = useState<MediaDto | null>(null); const [mediaData, setMediaData] = useState<MediaDto | null>(null);
@@ -37,16 +38,14 @@ export default function LibraryPage() {
const hasNoData = (!mediaData?.data || mediaData.data.length === 0) && applications.length === 0; const hasNoData = (!mediaData?.data || mediaData.data.length === 0) && applications.length === 0;
return ( return (
<div className="min-h-screen bg-gray-50/50 p-4 dark:bg-zinc-950 lg:p-8"> <div className="min-h-screen bg-gray-50/50 dark:bg-zinc-950">
<div className="mb-8">
<h1 className="text-3xl font-bold tracking-tight text-gray-900 dark:text-white">
Thư viện
</h1>
</div>
<StickyHeader header="Thư viện của tôi" />
<div className="px-4 pb-4 lg:px-8 lg:pb-8">
{hasNoData ? ( {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"> <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 dark:text-gray-400"> <p className="text-lg font-medium text-gray-500">
Chưa nội dung nào trong thư viện Chưa nội dung nào trong thư viện
</p> </p>
</div> </div>
@@ -66,5 +65,6 @@ export default function LibraryPage() {
</div> </div>
)} )}
</div> </div>
</div>
); );
} }
+1 -1
View File
@@ -9,7 +9,7 @@ import DemographicCard from "@/components/ecommerce/DemographicCard";
export const metadata: Metadata = { export const metadata: Metadata = {
title: title:
"Admin Dashboard", "Home Page",
description: "This is Dashboard Home for History Web", description: "This is Dashboard Home for History Web",
}; };
+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 { apiAddProjectMember, apiChangeProjectOwner, apiDeleteProject, apiGetProjectDetail, apiRemoveProjectMember, apiUpdateProject, apiUpdateProjectMemberRole } from "@/service/projectService";
import Loading from "@/app/loading"; import Loading from "@/app/loading";
import Button from "@/components/ui/button/Button"; import Button from "@/components/ui/button/Button";
import StickyHeader from "@/components/ui/StickyHeader";
import { PencilIcon } from "@/icons";
type TabType = "overview" | "members" | "settings"; type TabType = "overview" | "members" | "settings";
@@ -216,12 +218,12 @@ export default function ProjectDetailsPage() {
); );
// console.log(project) // console.log(project)
const path =[
{name: "Quản lý dự án", href:"/user/projects/"}
]
return ( return (
<div className="min-h-screen dark:bg-[#0d1117] text-gray-900 dark:text-[#c9d1d9] font-sans"> <div className="min-h-screen dark:bg-[#0d1117] text-gray-900 dark:text-[#c9d1d9] font-sans">
<PageBreadcrumb <StickyHeader header={`Chi tiết dự án`} paths={path} />
pageTitle="Chi tiết dự án"
paths={[{ name: "Quản lý dự án", href: "/user/projects" }]}
/>
<div className="pt-8 border-b border-gray-200 dark:border-[#30363d] bg-gray-50 dark:bg-[#0d1117]"> <div className="pt-8 border-b border-gray-200 dark:border-[#30363d] bg-gray-50 dark:bg-[#0d1117]">
<div className="px-6"> <div className="px-6">
<div className="flex items-center gap-2 text-xl mb-6"> <div className="flex items-center gap-2 text-xl mb-6">
@@ -308,11 +310,9 @@ export default function ProjectDetailsPage() {
))} ))}
<div className="flex-1" /> <div className="flex-1" />
<Button size="sm" variant="outline" onClick={() => router.push(`/editor/${id}`)}> <Button size="sm" variant="primary" className="rounded-4xl!" onClick={() => router.push(`/editor/${id}`)}>
Mo editor <PencilIcon />
</Button> Editor
<Button size="sm" variant="outline" onClick={() => router.push(`/editor/${id}?only=wiki`)}>
Editor only wiki
</Button> </Button>
</div> </div>
</div> </div>
+286 -152
View File
@@ -12,9 +12,15 @@ import Button from "@/components/ui/button/Button";
import Label from "@/components/form/Label"; import Label from "@/components/form/Label";
import Badge from "@/components/ui/badge/Badge"; import Badge from "@/components/ui/badge/Badge";
import { CreateProjectPayload, Project } from "@/interface/project"; 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 { normalizeEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
import type { EditorSnapshot } from "@/uhm/types/projects"; import type { EditorSnapshot } from "@/uhm/types/projects";
import StickyHeader from "@/components/ui/StickyHeader";
export type ProjectSortColumn = "created_at" | "updated_at" | "title"; export type ProjectSortColumn = "created_at" | "updated_at" | "title";
@@ -23,16 +29,26 @@ export default function ProjectsPage() {
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false); 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 [sortBy, setSortBy] = useState<ProjectSortColumn>("updated_at");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
const { isOpen, openModal, closeModal } = useModal(); 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 importJsonInputRef = useRef<HTMLInputElement | null>(null);
const [importSnapshot, setImportSnapshot] = useState<EditorSnapshot | null>(null); const [importSnapshot, setImportSnapshot] = useState<EditorSnapshot | null>(
const [importSnapshotName, setImportSnapshotName] = useState<string | null>(null); null,
);
const [importSnapshotName, setImportSnapshotName] = useState<string | null>(
null,
);
const fetchProjects = async () => { const fetchProjects = async () => {
try { try {
@@ -51,9 +67,17 @@ export default function ProjectsPage() {
fetchProjects(); 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; 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) => { 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!"); toast.warning("Vui lòng nhập tên dự án!");
return; return;
} }
try { try {
setIsSubmitting(true); setIsSubmitting(true);
// Bước 1: Luôn tạo project trước
const created = await apiCreateProject(formData); const created = await apiCreateProject(formData);
const projectId = created?.data?.id; const projectId = created?.data?.id;
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!"); 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(); closeModal();
setFormData({ title: "", description: "", project_status: "PRIVATE" }); setFormData({ title: "", description: "", status: "PRIVATE" });
setImportSnapshot(null); setImportSnapshot(null);
setImportSnapshotName(null); setImportSnapshotName(null);
if (importJsonInputRef.current) importJsonInputRef.current.value = "";
fetchProjects(); fetchProjects();
if (projectId) router.push(`/editor/${projectId}`); router.push(`/editor/${projectId}`);
} catch (error) { } catch (error) {
console.error("Lỗi tạo dự án:", error); console.error("Lỗi tạo dự án:", error);
toast.error("Có lỗi xảy ra khi tạo dự án."); 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 = () => { const handlePickImportJson = () => {
importJsonInputRef.current?.click(); importJsonInputRef.current?.click();
}; };
@@ -97,88 +184,13 @@ export default function ProjectsPage() {
} }
setImportSnapshot(normalized); setImportSnapshot(normalized);
setImportSnapshotName(file.name); 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) { } catch (err) {
console.error("Import JSON failed", err); console.error("Import JSON failed", err);
toast.error("Không đọc được file JSON."); 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) => { const handleSort = (column: ProjectSortColumn) => {
if (sortBy === column) { if (sortBy === column) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc"); setSortOrder(sortOrder === "asc" ? "desc" : "asc");
@@ -216,23 +228,47 @@ export default function ProjectsPage() {
const getStatusBadge = (status: string) => { const getStatusBadge = (status: string) => {
switch (status) { switch (status) {
case "PUBLIC": case "PUBLIC":
return <Badge size="sm" variant="light" color="success">PUBLIC</Badge>; return (
<Badge size="sm" variant="light" color="success">
PUBLIC
</Badge>
);
case "PRIVATE": case "PRIVATE":
return <Badge size="sm" variant="light" color="warning">PRIVATE</Badge>; return (
<Badge size="sm" variant="light" color="warning">
PRIVATE
</Badge>
);
case "ARCHIVE": case "ARCHIVE":
return <Badge size="sm" variant="light" color="light">ARCHIVE</Badge>; return (
<Badge size="sm" variant="light" color="light">
ARCHIVE
</Badge>
);
default: 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; const isActive = sortBy === column;
return ( return (
<button <button
onClick={() => handleSort(column)} onClick={() => handleSort(column)}
className={`flex items-center gap-1 text-sm font-medium hover:text-blue-500 transition-colors ${ 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> <span>{label}</span>
@@ -246,15 +282,22 @@ export default function ProjectsPage() {
return `JSON: ${importSnapshotName}`; return `JSON: ${importSnapshotName}`;
}, [importSnapshotName]); }, [importSnapshotName]);
// const path =[
// {name: "Thư viện", href:"/user/library/"}
// ]
return ( return (
<div className="max-w-7xl mx-auto pb-10"> <div className="mx-auto pb-10">
<PageBreadcrumb pageTitle="Quản lý dự án" /> {/* <PageBreadcrumb pageTitle="Quản lý dự án" /> */}
<StickyHeader header={`Quản lý dự án`} />
<div className="mt-6"> <div className="mt-6">
<ComponentCard <ComponentCard
title="Danh sách dự án" title="Danh sách dự án"
headerAction={ 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 + Tạo dự án mới
</Button> </Button>
} }
@@ -273,12 +316,18 @@ export default function ProjectsPage() {
<div className="flex-1 pr-4"> <div className="flex-1 pr-4">
<SortButton column="title" label="Tên dự án" /> <SortButton column="title" label="Tên dự án" />
</div> </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">
<div className="w-48 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">Thành viên</div> 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"> <div className="w-32 px-4">
<SortButton column="updated_at" label="Cập nhật" /> <SortButton column="updated_at" label="Cập nhật" />
</div> </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>
<div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800"> <div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
@@ -290,25 +339,36 @@ export default function ProjectsPage() {
<div className="flex-1 pr-4 min-w-0"> <div className="flex-1 pr-4 min-w-0">
<div className="items-center gap-3 mb-1.5"> <div className="items-center gap-3 mb-1.5">
<h3 <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" className="font-semibold text-blue-600 dark:text-[#58a6ff] truncate cursor-pointer hover:underline"
> >
{project.title} {project.title}
</h3> </h3>
</div> </div>
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-[#8b949e]"> <div className="flex items-center gap-2 text-xs text-gray-500 dark:text-[#8b949e]">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{project.user?.avatar_url ? ( {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"> <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"> <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> </span>
</div> </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>
</div> </div>
@@ -321,18 +381,41 @@ export default function ProjectsPage() {
<div className="flex -space-x-2 overflow-hidden"> <div className="flex -space-x-2 overflow-hidden">
{project.members && project.members.length > 0 ? ( {project.members && project.members.length > 0 ? (
<> <>
{project.members.slice(0, 4).map((m: any, index: number) => {project.members
.slice(0, 4)
.map((m: any, index: number) =>
m.avatar_url ? ( 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]" /> <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]"> <div
<span className="text-xs font-medium text-gray-500 dark:text-gray-300">{m.display_name?.charAt(0)?.toUpperCase() || "U"}</span> 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> </div>
) ),
)} )}
{project.members.length > 4 && ( {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"> <div
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">+{project.members.length - 4}</span> 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> </div>
)} )}
</> </>
@@ -352,10 +435,23 @@ export default function ProjectsPage() {
size="sm" size="sm"
variant="outline" variant="outline"
className="!p-0 w-9 h-9 flex items-center justify-center" 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"> <svg
<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" /> 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> </svg>
</Button> </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"> <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" size="sm"
variant="outline" variant="outline"
className="!p-0 w-9 h-9 flex items-center justify-center" 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)} 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"> <svg
<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" /> 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> </svg>
</Button> </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"> <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 Export JSON
</span> </span>
</div> </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> </div>
</div> </div>
</div> </div>
) : !isLoading && ( ) : (
!isLoading && (
<div className="py-20 text-center"> <div className="py-20 text-center">
<p className="text-gray-500 dark:text-gray-400">Bạn chưa dự án nào.</p> <p className="text-gray-500 dark:text-gray-400">
<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"> 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 Tạo dự án đu tiên
</Button> </Button>
</div> </div>
)
)} )}
</div> </div>
</ComponentCard> </ComponentCard>
@@ -415,10 +516,14 @@ export default function ProjectsPage() {
<Modal isOpen={isOpen} onClose={closeModal} className="max-w-[500px] m-4"> <Modal isOpen={isOpen} onClose={closeModal} className="max-w-[500px] m-4">
<div className="p-6 bg-white rounded-3xl dark:bg-gray-900"> <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"> <form onSubmit={handleCreateProject} className="flex flex-col gap-5">
<div> <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 <input
type="text" type="text"
name="title" name="title"
@@ -432,14 +537,29 @@ export default function ProjectsPage() {
<div> <div>
<Label>Trạng thái</Label> <Label>Trạng thái</Label>
<select <select
name="project_status" name="status"
value={formData.project_status} value={formData.status || "PRIVATE"} // Fallback về PRIVATE nếu undefined
onChange={handleChange} 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
<option value="PUBLIC">Công khai (Public)</option> value="PRIVATE"
<option value="ARCHIVE">Lưu trữ (Archive)</option> 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> </select>
</div> </div>
<div> <div>
@@ -456,7 +576,12 @@ export default function ProjectsPage() {
<div> <div>
<Label>Khởi tạo từ JSON</Label> <Label>Khởi tạo từ JSON</Label>
<div className="flex items-center gap-2"> <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 Chọn JSON
</Button> </Button>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate"> <div className="text-xs text-gray-500 dark:text-gray-400 truncate">
@@ -468,22 +593,31 @@ export default function ProjectsPage() {
type="file" type="file"
accept="application/json" accept="application/json"
className="hidden" className="hidden"
onChange={(e) => handleImportJsonFile(e.target.files?.[0] || null)} onChange={(e) =>
handleImportJsonFile(e.target.files?.[0] || null)
}
/> />
</div> </div>
<div className="flex items-center justify-end gap-3 mt-4"> <div className="flex items-center justify-end gap-3 mt-4">
<Button size="sm" variant="outline" type="button" onClick={closeModal}>Hủy</Button> <Button
<Button size="sm" type="submit" disabled={isSubmitting} className="bg-brand-500 hover:bg-brand-600 text-white"> size="sm"
{isSubmitting ? "Đang tạo..." : "Khởi tạo"} variant="outline"
type="button"
onClick={closeModal}
>
Hủy
</Button> </Button>
<Button <Button
size="sm" size="sm"
type="button" type="submit"
disabled={isSubmitting} disabled={isSubmitting}
className="bg-gray-900 hover:bg-gray-800 text-white" className="bg-indigo-600 hover:bg-indigo-700 text-white"
onClick={handleCreateProjectWithJson}
> >
Tạo với JSON {isSubmitting
? "Đang xử lý..."
: importSnapshot
? "Tạo với JSON"
: "Tạo dự án"}
</Button> </Button>
</div> </div>
</form> </form>
+7 -4
View File
@@ -19,6 +19,7 @@ import { toast } from "sonner";
import { newId } from "@/uhm/lib/utils/id"; import { newId } from "@/uhm/lib/utils/id";
import Swal from "sweetalert2"; import Swal from "sweetalert2";
import { PresignedUrlResponse } from "@/interface/media"; import { PresignedUrlResponse } from "@/interface/media";
import StickyHeader from "@/components/ui/StickyHeader";
type PendingFile = { type PendingFile = {
id: string; id: string;
@@ -197,9 +198,9 @@ export default function RoleUpgrade() {
}; };
return ( return (
<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="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 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"> <div className="flex gap-2">
<button <button
@@ -221,7 +222,6 @@ export default function RoleUpgrade() {
{showPreview ? "Preview Mode" : "Edit Mode"} {showPreview ? "Preview Mode" : "Edit Mode"}
</span> </span>
</div> </div>
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="relative min-h-[400px]"> <div className="relative min-h-[400px]">
{!showPreview ? ( {!showPreview ? (
@@ -318,7 +318,9 @@ export default function RoleUpgrade() {
<div <div
className={`flex flex-col items-center justify-center h-full ${docStyle.bg} p-2`} className={`flex flex-col items-center justify-center h-full ${docStyle.bg} p-2`}
> >
<span className={`text-xs font-bold ${docStyle.color}`}> <span
className={`text-xs font-bold ${docStyle.color}`}
>
{item.extension.toUpperCase()} {item.extension.toUpperCase()}
</span> </span>
<span className="text-[10px] truncate w-full text-center mt-1"> <span className="text-[10px] truncate w-full text-center mt-1">
@@ -363,5 +365,6 @@ export default function RoleUpgrade() {
plugins={[Zoom, Captions]} plugins={[Zoom, Captions]}
/> />
</div> </div>
</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; text: string;
}; };
function isRecord(value: unknown): value is Record<string, unknown> { type WikiVersionRow = {
return Boolean(value) && typeof value === "object" && !Array.isArray(value); id: string;
} title?: string;
created_at?: string;
function tiptapJsonToPlainText(node: unknown): string { content?: string;
if (node == null) return ""; isCurrent: boolean;
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 { function escapeHtml(input: string): string {
return input return input
@@ -44,22 +34,7 @@ function normalizeWikiContentToHtml(raw: string | null | undefined): string {
const value = String(raw || "").trim(); const value = String(raw || "").trim();
if (!value.length) return ""; if (!value.length) return "";
// New format: HTML string.
if (value[0] === "<") return value; 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>`; 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 hidePreviewTimerRef = useRef<number | null>(null);
const previewCacheRef = useRef<Map<string, { title: string; quote: string | null }>>(new Map()); const previewCacheRef = useRef<Map<string, { title: string; quote: string | null }>>(new Map());
const allVersions = useMemo(() => { const allVersions = useMemo<WikiVersionRow[]>(() => {
if (!wiki) return []; if (!wiki) return [];
const current = { const current: WikiVersionRow = {
id: wiki.id, id: wiki.id,
created_at: wiki.updated_at, created_at: wiki.updated_at,
content: wiki.content, content: wiki.content,
isCurrent: true, 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 uniqueHistory = history.filter(h => h.id !== current.id);
const combined = [current, ...uniqueHistory]; const combined = [current, ...uniqueHistory];
return combined return combined
@@ -498,8 +473,10 @@ export default function WikiBySlugClient({ slug }: { slug: string }) {
title: `Phiên bản lúc ${formatDate(sample?.created_at)}` title: `Phiên bản lúc ${formatDate(sample?.created_at)}`
}; };
if (sample?.isCurrent) { if (sample?.isCurrent) {
return { ...versionInfo, content: sample.content || '' }; return { ...versionInfo, content: sample.content || "" };
} }
const contentResp = await getContentByVersionWikiId(versionId); const contentResp = await getContentByVersionWikiId(versionId);
return { ...versionInfo, content: contentResp?.data?.content || "" }; return { ...versionInfo, content: contentResp?.data?.content || "" };
}); });
@@ -561,7 +538,7 @@ export default function WikiBySlugClient({ slug }: { slug: string }) {
{viewMode === 'history' && ( {viewMode === 'history' && (
<div className="p-4"> <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"> <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"> <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`} {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 handleEventClick = (clickInfo: EventClickArg) => {
const event = clickInfo.event; 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); setEventTitle(event.title);
setEventStartDate(event.start?.toISOString().split("T")[0] || ""); setEventStartDate(event.start?.toISOString().split("T")[0] || "");
setEventEndDate(event.end?.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 }) => { const PageBreadcrumb: React.FC<BreadcrumbProps> = ({ pageTitle, paths }) => {
return ( 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 <h2
className="text-xl font-semibold text-gray-800 dark:text-white/90" className="text-xl font-semibold text-gray-800 dark:text-white/90"
x-text="pageName" x-text="pageName"
+2 -1
View File
@@ -7,9 +7,10 @@ import "react-quill-new/dist/quill.snow.css";
const ReactQuillEditor = dynamic( const ReactQuillEditor = dynamic(
async () => { async () => {
const { default: RQ } = await import("react-quill-new"); 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} /> <RQ ref={forwardedRef} {...props} />
); );
}, },
{ {
ssr: false, ssr: false,
@@ -15,10 +15,13 @@ import { IsolatedContent } from "@/components/ui/IsolatedContent";
import { apiDeleteHistorianCV } from "@/service/historianService"; import { apiDeleteHistorianCV } from "@/service/historianService";
import { statusConfig } from "@/service/handler"; import { statusConfig } from "@/service/handler";
import { Application } from "@/interface/historian";
import { MediaItem } from "@/components/tables/MediaTable";
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
application: any; application: Application | null;
onRefresh: () => void; onRefresh: () => void;
} }
@@ -42,7 +45,7 @@ export default function ApplicationDetailModal({
} }
}, [isOpen, application]); }, [isOpen, application]);
const isImageFile = (file: any) => { const isImageFile = (file: MediaItem) => {
const isImageMime = file.mime_type?.startsWith("image/"); const isImageMime = file.mime_type?.startsWith("image/");
const isImageExt = /\.(jpg|jpeg|png|webp|gif)$/i.test(file.storage_key); const isImageExt = /\.(jpg|jpeg|png|webp|gif)$/i.test(file.storage_key);
return isImageMime || isImageExt; return isImageMime || isImageExt;
@@ -51,17 +54,17 @@ export default function ApplicationDetailModal({
const mediaList = application?.media || []; const mediaList = application?.media || [];
const imageMediaOnly = mediaList.filter(isImageFile); const imageMediaOnly = mediaList.filter(isImageFile);
const imageSlides = imageMediaOnly.map((item: any) => ({ const imageSlides = imageMediaOnly.map((item: MediaItem) => ({
src: `${URL_MEDIA}${item.storage_key}`, src: `${URL_MEDIA}${item.storage_key}`,
title: item.original_name, title: item.original_name,
description: `Dung lượng: ${(item.size / 1024).toFixed(2)} KB`, 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}`; const fileUrl = `${URL_MEDIA}${item.storage_key}`;
if (isImageFile(item)) { if (isImageFile(item)) {
const photoIndex = imageMediaOnly.findIndex( const photoIndex = imageMediaOnly.findIndex(
(img: any) => img.id === item.id, (img: MediaItem) => img.id === item.id,
); );
setIndex(photoIndex); setIndex(photoIndex);
} else { } else {
@@ -93,7 +96,9 @@ export default function ApplicationDetailModal({
}; };
const handleDeleteApplication = async () => { const handleDeleteApplication = async () => {
if (!application) return;
await apiDeleteHistorianCV(application.id); await apiDeleteHistorianCV(application.id);
Swal.fire("Thành công!", "Hồ sơ đã được xóa.", "success"); Swal.fire("Thành công!", "Hồ sơ đã được xóa.", "success");
onRefresh(); onRefresh();
onClose(); onClose();
@@ -186,7 +191,7 @@ export default function ApplicationDetailModal({
</h4> </h4>
{mediaList.length > 0 ? ( {mediaList.length > 0 ? (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4"> <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); const isImg = isImageFile(media);
return ( return (
<div <div
+2 -1
View File
@@ -23,7 +23,8 @@ export interface MediaItem {
original_name: string; original_name: string;
mime_type: string; mime_type: string;
size: number; size: number;
file_metadata: any; file_metadata: Record<string, unknown> | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
+16 -3
View File
@@ -2,6 +2,8 @@
import { Modal } from "../ui/modal"; import { Modal } from "../ui/modal";
import UserMetaCard from "@/components/user-profile/UserMetaCard"; import UserMetaCard from "@/components/user-profile/UserMetaCard";
import UserInfoCard from "@/components/user-profile/UserInfoCard"; import UserInfoCard from "@/components/user-profile/UserInfoCard";
import { UserMetaCardProps } from "@/interface/user";
import { fullDataUser } from "@/interface/admin"; import { fullDataUser } from "@/interface/admin";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { MediaDto } from "@/interface/media"; import { MediaDto } from "@/interface/media";
@@ -28,7 +30,17 @@ export default function UserDetailModal({
const [mediaData, setMediaData] = useState<MediaDto | null>(null); const [mediaData, setMediaData] = useState<MediaDto | null>(null);
const [loading, setLoading] = useState(true); 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(() => { useEffect(() => {
if (user?.id && isOpen) { if (user?.id && isOpen) {
@@ -68,8 +80,9 @@ export default function UserDetailModal({
</div> </div>
<div className="space-y-6 custom-scrollbar max-h-[65vh] overflow-y-auto pr-2"> <div className="space-y-6 custom-scrollbar max-h-[65vh] overflow-y-auto pr-2">
<UserMetaCard data={formattedData as any} /> <UserMetaCard data={formattedData} />
<UserInfoCard data={formattedData as any} /> <UserInfoCard data={formattedData} />
<div className="min-h-[150px] relative"> <div className="min-h-[150px] relative">
{loading ? ( {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 React, { useState, useRef, useEffect } from "react";
import { ChatbotPayload } from "@/interface/chatbot"; import { ChatbotPayload } from "@/interface/chatbot";
import { apiChatbot } from "@/service/chatbotService"; import { apiChatbot } from "@/service/chatbotService";
import { AxiosError } from "axios";
type Message = { type Message = {
id: string; id: string;
@@ -67,16 +69,18 @@ export default function ChatbotWidget({
}; };
setMessages((prev) => [...prev, botMessage]); setMessages((prev) => [...prev, botMessage]);
} catch (error: any) { } catch (error) {
const axiosError = error as AxiosError<{ message: string }>;
const errorMessage: Message = { const errorMessage: Message = {
id: (Date.now() + 1).toString(), id: (Date.now() + 1).toString(),
sender: "bot", sender: "bot",
text: 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.", "Có lỗi xảy ra khi kết nối. Vui lòng thử lại sau.",
}; };
setMessages((prev) => [...prev, errorMessage]); setMessages((prev) => [...prev, errorMessage]);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
@@ -91,6 +95,7 @@ export default function ChatbotWidget({
<div className="fixed bottom-8 right-8 z-50"> <div className="fixed bottom-8 right-8 z-50">
{!isOpen && ( {!isOpen && (
<button <button
name="AI chat"
onClick={() => setIsOpen(true)} 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" 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 Label from "../form/Label";
import { UserMetaCardProps } from "@/interface/user"; import { UserMetaCardProps } from "@/interface/user";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { EyeCloseIcon, EyeIcon } from "@/icons"; import { EyeCloseIcon, EyeIcon, LockIcon, MailIcon } from "@/icons";
import { apiChangePassword } from "@/service/auth"; import { apiChangePassword } from "@/service/auth";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -89,26 +89,19 @@ export default function AccountDetails({ data }: { data: UserMetaCardProps }) {
formValues.new_password !== formValues.confirm_password; formValues.new_password !== formValues.confirm_password;
return ( return (
<> <>
<div className="p-5 border border-red-200 rounded-2xl dark:border-gray-800 lg:p-6"> <div className="border-t border-red-200 py-6">
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-6">
<div> <div>
<h4 className="text-lg font-semibold text-gray-800 dark:text-white/90 lg:mb-6"> <div className="">
Account Details <div className="flex items-center gap-2">
</h4> <MailIcon className="leading-normal text-gray-500"/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-7 2xl:gap-x-32"> <p className="text-sm font-medium text-gray-600 dark:text-white/90">
<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">
{data?.data?.email || "example@mail.com"} {data?.data?.email || "example@mail.com"}
</p> </p>
</div> </div>
<div> <div className="flex items-center gap-3 mt-2">
<p className="mb-2 text-xs text-gray-500 dark:text-gray-400"> <LockIcon className="leading-normal text-gray-500"/>
Password <p className="text-sm font-medium text-gray-600 dark:text-white/90">
</p>
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
</p> </p>
</div> </div>
@@ -20,7 +20,10 @@ const formatFullDateTime = (dateString: string) => {
return `${time} ${day}`; 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" }; if (!mediaArray || mediaArray.length === 0) return { type: "empty" };
const imageFiles = mediaArray.filter((file) => { const imageFiles = mediaArray.filter((file) => {
const isImageMime = file.mime_type?.startsWith("image/"); const isImageMime = file.mime_type?.startsWith("image/");
@@ -49,16 +52,17 @@ const processMedia = (mediaArray: any[]) => {
export default function ApplicationList({ export default function ApplicationList({
applications, applications,
}: { }: {
applications: any[]; applications: Application[];
}) { }) {
const router = useRouter(); const router = useRouter();
const dispatch = useDispatch(); const dispatch = useDispatch();
const handleViewDetail = (app: any) => { const handleViewDetail = (app: Application) => {
dispatch(setSelectedApplication(app)); dispatch(setSelectedApplication(app));
router.push(`/user/account/applications`); router.push(`/user/account/applications`);
}; };
const StatusIcons: Record<string, React.ReactNode> = { const StatusIcons: Record<string, React.ReactNode> = {
APPROVED: ( APPROVED: (
<svg <svg
@@ -113,15 +117,12 @@ export default function ApplicationList({
return ( return (
<div className="rounded-2xl border border-gray-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-900/50"> <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"> <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"> <h3 className="text-xl font-bold text-gray-800 dark:text-white/90 items-center flex">
Hồ {" "} Hồ
<span className="ml-2 text-sm font-normal text-gray-500"> <span className="ml-2 text-sm font-normal text-gray-500">
({applications.length} tệp) ({applications.length} tệp)
</span> </span>
</h3> </h3>
{/* <div className="text-sm text-gray-500">
Cập nhật lần cuối: {new Date().toLocaleDateString("vi-VN")}
</div> */}
</div> </div>
{/* CHỈ SỬA DÒNG NÀY: Tăng số lượng cột (grid-cols) để các thẻ nhỏ lại */} {/* 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 { deleteMedia } from "@/service/mediaService";
import { INITIAL_LIMIT } from "../../../constant"; import { INITIAL_LIMIT } from "../../../constant";
import { MediaItem } from "../tables/MediaTable";
export default function MediaLibrary({ export default function MediaLibrary({
data, data,
@@ -32,7 +33,7 @@ export default function MediaLibrary({
setLocalMedia(data?.data || []); setLocalMedia(data?.data || []);
}, [data]); }, [data]);
const isImageFile = (file: any) => { const isImageFile = (file: MediaItem) => {
const isImageMime = file.mime_type?.startsWith("image/"); const isImageMime = file.mime_type?.startsWith("image/");
const isImageExt = /\.(jpg|jpeg|png|webp|gif)$/i.test(file.storage_key); const isImageExt = /\.(jpg|jpeg|png|webp|gif)$/i.test(file.storage_key);
return isImageMime || isImageExt; 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) { if (isSelectionMode) {
toggleItemSelection(item.id); toggleItemSelection(item.id);
} else { } 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); const isSelected = selectedIds.includes(item.id);
return ( return (
@@ -268,7 +269,7 @@ export default function MediaLibrary({
{selectedIds.length > 0 && ( {selectedIds.length > 0 && (
<button <button
onClick={handleDeleteSelected} 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 <svg
className="h-4 w-4" 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" 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> </svg>
Xóa ({selectedIds.length}) Xóa {selectedIds.length}
</button> </button>
)} )}
<button <button
onClick={toggleSelectionMode} 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 isSelectionMode
? "bg-blue-500 text-white shadow-md hover:bg-blue-600" ? "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" : "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"> <div className="mt-6 flex justify-center">
<button <button
onClick={() => setShowAllImages(!showAllImages)} 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 {showAllImages
? "Thu gọn" ? "Thu gọn"
@@ -333,7 +334,6 @@ export default function MediaLibrary({
Tài liệu ({documentFiles.length}) Tài liệu ({documentFiles.length})
</h4> </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"> <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) => {displayedDocs.map((item, idx) =>
renderItemCard(item, false, idx), renderItemCard(item, false, idx),
)} )}
@@ -342,7 +342,7 @@ export default function MediaLibrary({
<div className="mt-6 flex justify-center"> <div className="mt-6 flex justify-center">
<button <button
onClick={() => setShowAllDocs(!showAllDocs)} 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 {showAllDocs
? "Thu gọn" ? "Thu gọn"
+60 -82
View File
@@ -1,6 +1,5 @@
"use client"; "use client";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useModal } from "../../hooks/useModal"; import { useModal } from "../../hooks/useModal";
import { Modal } from "../ui/modal"; import { Modal } from "../ui/modal";
import Button from "../ui/button/Button"; import Button from "../ui/button/Button";
@@ -10,9 +9,10 @@ import { Profile, UserMetaCardProps } from "@/interface/user";
import { apiUpdateUser } from "@/service/userService"; import { apiUpdateUser } from "@/service/userService";
import { toast } from "sonner"; import { toast } from "sonner";
import Link from "next/link"; import Link from "next/link";
import { AxiosError } from "axios";
import { LinkIcon, LocationIcon, MailIcon, PhoneIcon } from "@/icons";
export default function UserInfoCard({ data }: { data: UserMetaCardProps }) { export default function UserInfoCard({ data }: { data: UserMetaCardProps }) {
const router = useRouter();
const { isOpen, openModal, closeModal } = useModal(); const { isOpen, openModal, closeModal } = useModal();
const [formData, setFormData] = useState<Profile>({ const [formData, setFormData] = useState<Profile>({
display_name: "", display_name: "",
@@ -62,8 +62,12 @@ export default function UserInfoCard({ data }: { data: UserMetaCardProps }) {
setTimeout(() => { setTimeout(() => {
window.location.reload(); window.location.reload();
}, 1000); }, 1000);
} catch (error: any) { } catch (error) {
const serverResponse = error.response?.data; const axiosError = error as AxiosError<{
status: boolean;
message: string;
}>;
const serverResponse = axiosError.response?.data;
if (serverResponse && serverResponse.status === false) { if (serverResponse && serverResponse.status === false) {
const msg = serverResponse.message || ""; 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."); 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 ( return (
<div className="p-5 border border-gray-200 rounded-2xl dark:border-gray-800 lg:p-6"> <div className="py-6">
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-6">
<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>
{data.openEdit && ( {data.openEdit && (
<button <button
onClick={openModal} 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 <svg
className="fill-current" className="fill-current"
@@ -177,19 +109,65 @@ export default function UserInfoCard({ data }: { data: UserMetaCardProps }) {
Edit Edit
</button> </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> </div>
<Modal isOpen={isOpen} onClose={closeModal} className="max-w-[700px] m-4"> <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="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"> <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 Edit Personal Information
</h4> </h4>
</div> </div>
<form className="flex flex-col" onSubmit={handleSave}> <form className="flex flex-col" onSubmit={handleSave}>
<div className="custom-scrollbar h-[450px] overflow-y-auto px-2 pb-3"> <div className="custom-scrollbar h-[450px] overflow-y-auto px-2 pb-3">
<div className="mt-7"> <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 Personal Information
</h5> </h5>
+14 -17
View File
@@ -72,16 +72,16 @@ export default function UserMetaCard({ data }: { data: UserMetaCardProps }) {
} }
}; };
return ( return (
<div className="p-5 border border-gray-200 rounded-2xl dark:border-gray-800 lg:p-6"> <div className="">
<div className="flex flex-col gap-5 xl:flex-row xl:items-center xl:justify-between"> <div className="flex flex-col gap-5 ">
<div className="flex flex-col items-center w-full gap-6 xl:flex-row"> <div className="flex flex-col items-center w-full gap-6">
<div <div
onClick={handleAvatarClick} 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 <Image
width={80} width={296}
height={80} height={296}
src={previewImage} src={previewImage}
alt="avatar" alt="avatar"
className="object-cover w-full h-full" className="object-cover w-full h-full"
@@ -142,26 +142,23 @@ export default function UserMetaCard({ data }: { data: UserMetaCardProps }) {
</div> </div>
<div className="order-3 xl:order-2"> <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"> <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"> <p className="text-sm text-blue-500 dark:text-gray-400">
{data.data?.roles?.map((role) => role.name).join(", ") || {data.data?.roles?.map((role) => role.name).join(", ") ||
"No roles available"} "No roles available"}
</p> </p>
</div>
<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 && ( {data.data?.profile?.bio && (
<> <p className="text-sm text-gray-500">
<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} {data.data.profile.bio}
</p> </p>
</>
)} )}
</div> <p className="text-sm text-gray-500 dark:text-gray-400">
<span className="text-sm text-gray-500 dark:text-gray-400"> ID: <span>{data.data?.id}</span>
ID: {data.data?.id} </p>
</span>
</div> </div>
</div> </div>
</div> </div>
+10 -7
View File
@@ -7,7 +7,7 @@ import {
setStoredTokens, setStoredTokens,
} from "@/auth/tokenStore" } from "@/auth/tokenStore"
const baseURL = API_URL_ROOT || "https://history-api.kain.id.vn" const baseURL = API_URL_ROOT
const api = axios.create({ const api = axios.create({
baseURL, baseURL,
@@ -24,6 +24,10 @@ const refreshApi = axios.create({
let isRefreshing = false let isRefreshing = false
let queue: any[] = [] let queue: any[] = []
function shouldRedirectToSigninOnRefreshFailure(status?: number): boolean {
return status === 401 || status === 404
}
const processQueue = (error?: any) => { const processQueue = (error?: any) => {
queue.forEach((p) => { queue.forEach((p) => {
if (error) p.reject(error) if (error) p.reject(error)
@@ -121,15 +125,15 @@ async function performRefreshAndRetry(originalRequest: any): Promise<AxiosRespon
return refreshApi.post("/auth/refresh", {}) return refreshApi.post("/auth/refresh", {})
} }
let refreshRes: any = await tryCookieRefresh() const refreshRes: any = await tryCookieRefresh()
const nextTokens = extractTokensFromResponsePayload(refreshRes?.data) const nextTokens = extractTokensFromResponsePayload(refreshRes?.data)
if (nextTokens) setStoredTokens(nextTokens) 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 { else {
const maybeAccess = (refreshRes?.data?.data?.access_token ?? refreshRes?.data?.access_token) as unknown const maybeAccess = (refreshRes?.data?.data?.access_token ?? refreshRes?.data?.access_token) as unknown
if (typeof maybeAccess === "string" && maybeAccess.trim()) { 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) return api(originalRequest)
} catch (refreshErr: any) { } catch (refreshErr: any) {
processQueue(refreshErr) processQueue(refreshErr)
// Only force logout when refresh token/session is truly invalid (401). // Treat missing/invalid refresh endpoints or sessions as logged-out state.
// CRITICAL: We only redirect if it's a 401, which means the HttpOnly cookie is missing or invalid. if (shouldRedirectToSigninOnRefreshFailure(refreshErr?.response?.status)) {
if (refreshErr?.response?.status === 401) {
clearStoredTokens() clearStoredTokens()
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.location.href = "/signin" window.location.href = "/signin"
+6 -1
View File
@@ -51,7 +51,9 @@ import HorizontaLDots from "./horizontal-dots.svg";
import ChatIcon from "./chat.svg"; import ChatIcon from "./chat.svg";
import MoreDotIcon from "./more-dot.svg"; import MoreDotIcon from "./more-dot.svg";
import BellIcon from "./bell.svg"; import BellIcon from "./bell.svg";
import PhoneIcon from "./phone.svg";
import LocationIcon from "./location.svg";
import LinkIcon from "./link.svg";
export { export {
DownloadIcon, DownloadIcon,
BellIcon, BellIcon,
@@ -106,4 +108,7 @@ export {
HorizontaLDots, HorizontaLDots,
ChevronUpIcon, ChevronUpIcon,
ChatIcon, 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 export interface CreateProjectPayload
{ {
description: string, description: string,
project_status: "PRIVATE" | "PUBLIC" | "ARCHIVE", status: "PRIVATE" | "PUBLIC" | "ARCHIVE",
title: string title: string
} }
+236 -150
View File
@@ -1,4 +1,5 @@
"use client"; "use client";
import React, { useEffect, useRef, useState, useCallback } from "react"; import React, { useEffect, useRef, useState, useCallback } from "react";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
@@ -6,20 +7,17 @@ import { usePathname } from "next/navigation";
import { useSidebar } from "../context/SidebarContext"; import { useSidebar } from "../context/SidebarContext";
import { import {
BoxCubeIcon, BoxCubeIcon,
CalenderIcon,
ChevronDownIcon, ChevronDownIcon,
FileIcon, FileIcon,
GridIcon, GridIcon,
HorizontaLDots, HorizontaLDots,
ListIcon, ListIcon,
PageIcon,
PieChartIcon,
PlugInIcon,
ShootingStarIcon, ShootingStarIcon,
TableIcon,
UserCircleIcon,
} from "../icons/index"; } from "../icons/index";
import { apiGetCurrentUser, apiLogout } from "@/service/auth";
import { UserMetaCardProps } from "@/interface/user";
type NavItem = { type NavItem = {
name: string; name: string;
icon: React.ReactNode; icon: React.ReactNode;
@@ -33,85 +31,76 @@ type NavItem = {
}; };
const ALL_NAV_ITEMS: NavItem[] = [ const ALL_NAV_ITEMS: NavItem[] = [
{ { icon: <GridIcon />, name: "Trang Chủ", path: "/" },
icon: <GridIcon />, { icon: <BoxCubeIcon />, name: "Dự Án", path: "/user/projects" },
name: "Trang Chủ", { icon: <FileIcon />, name: "Thư Viện", path: "/user/library" },
// 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",
},
]; ];
const OTHERS_ITEMS: NavItem[] = [ const OTHERS_ITEMS: NavItem[] = [
// { { icon: <ShootingStarIcon />, name: "Hỗ trợ", path: "/user/quick-qa" },
// 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",
},
]; ];
const AppSidebar: React.FC = () => { const AppSidebar: React.FC = () => {
const { isExpanded, isMobileOpen, isHovered, setIsHovered } = useSidebar(); const { isExpanded, isMobileOpen, toggleSidebar, toggleMobileSidebar } = useSidebar();
const pathname = usePathname(); const pathname = usePathname();
const [user, setUser] = useState<UserMetaCardProps | null>(null);
const [openSubmenu, setOpenSubmenu] = useState<{ const [openSubmenu, setOpenSubmenu] = useState<{
type: "main" | "others"; type: "main" | "others";
index: number; index: number;
} | null>(null); } | null>(null);
const [subMenuHeight, setSubMenuHeight] = useState<Record<string, number>>(
{}, const [subMenuHeight, setSubMenuHeight] = useState<Record<string, number>>({});
);
const subMenuRefs = useRef<Record<string, HTMLDivElement | null>>({}); const subMenuRefs = useRef<Record<string, HTMLDivElement | null>>({});
const isActive = useCallback((path: string) => path === pathname, [pathname]); 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") => { const handleSubmenuToggle = (index: number, menuType: "main" | "others") => {
setOpenSubmenu((prev) => 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(() => { useEffect(() => {
let submenuMatched = false; let submenuMatched = false;
[ const menuGroups = [
{ items: ALL_NAV_ITEMS, type: "main" }, { items: ALL_NAV_ITEMS, type: "main" as const },
{ items: OTHERS_ITEMS, type: "others" }, { items: OTHERS_ITEMS, type: "others" as const },
].forEach(({ items, type }) => { ];
menuGroups.forEach(({ items, type }) => {
items.forEach((nav, index) => { items.forEach((nav, index) => {
nav.subItems?.forEach((sub) => { nav.subItems?.forEach((sub) => {
if (isActive(sub.path)) { if (isActive(sub.path)) {
setOpenSubmenu((prev) => { setOpenSubmenu((prev) => {
if (prev?.type === type && prev?.index === index) return prev; if (prev?.type === type && prev?.index === index) return prev;
return { type: type as "main" | "others", index }; return { type, index };
}); });
submenuMatched = true; submenuMatched = true;
} }
@@ -145,44 +137,46 @@ const AppSidebar: React.FC = () => {
} }
}, [pathname, isActive]); }, [pathname, isActive]);
// Tính toán chiều cao cho hiệu ứng mượt mà của submenu
useEffect(() => { useEffect(() => {
if (openSubmenu !== null) { if (openSubmenu !== null) {
const key = `${openSubmenu.type}-${openSubmenu.index}`; const key = `${openSubmenu.type}-${openSubmenu.index}`;
if (subMenuRefs.current[key]) { if (subMenuRefs.current[key]) {
requestAnimationFrame(() => {
setSubMenuHeight((prev) => ({ setSubMenuHeight((prev) => ({
...prev, ...prev,
[key]: subMenuRefs.current[key]?.scrollHeight || 0, [key]: subMenuRefs.current[key]?.scrollHeight || 0,
})); }));
});
} }
} }
}, [openSubmenu]); }, [openSubmenu]);
const renderMenuItems = (items: NavItem[], menuType: "main" | "others") => ( const renderMenuItems = (items: NavItem[], menuType: "main" | "others") => (
<ul className="flex flex-col gap-4"> <ul className="flex flex-col gap-1.5">
{items.map((nav, index) => ( {items.map((nav, index) => {
const isOpen = openSubmenu?.type === menuType && openSubmenu?.index === index;
const refKey = `${menuType}-${index}`;
return (
<li key={nav.name}> <li key={nav.name}>
{nav.subItems ? ( {nav.subItems ? (
<button <button
onClick={() => handleSubmenuToggle(index, menuType)} onClick={() => handleSubmenuToggle(index, menuType)}
className={`menu-item group uppercase ${openSubmenu?.type === menuType && openSubmenu?.index === index className={`menu-item group capitalize ${
? "menu-item-active" isOpen ? "menu-item-active" : "menu-item-icon-inactive"
: "menu-item-inactive" } ${!isExpanded ? "lg:justify-center px-2" : "lg:justify-start"}`}
} cursor-pointer ${!isExpanded && !isHovered ? "lg:justify-center" : "lg:justify-start"}`}
>
<span
className={
openSubmenu?.type === menuType && openSubmenu?.index === index
? "menu-item-icon-active"
: "menu-item-icon-inactive"
}
> >
<span className={`flex h-5 w-5 shrink-0 items-center justify-center ${isOpen ? "text-gray-900" : "text-gray-400"}`}>
{nav.icon} {nav.icon}
</span> </span>
{(isExpanded || isHovered || isMobileOpen) && ( {isSidebarVisible && (
<> <>
<span className={`menu-item-text`}>{nav.name}</span> <span className="block truncate text-[14px] font-medium">
{nav.name}
</span>
<ChevronDownIcon <ChevronDownIcon
className={`ml-auto w-5 h-5 transition-transform duration-200 ${openSubmenu?.type === menuType && openSubmenu?.index === index ? "rotate-180 text-brand-500" : ""}`} className={`ml-auto h-4 w-4 transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
/> />
</> </>
)} )}
@@ -191,60 +185,43 @@ const AppSidebar: React.FC = () => {
nav.path && ( nav.path && (
<Link <Link
href={nav.path} href={nav.path}
className={`menu-item group ${isActive(nav.path) ? "menu-item-active" : "menu-item-inactive"}`} className={`menu-item group ${
> isActive(nav.path) ? "menu-item-active" : "menu-item-icon-inactive"
<span } ${!isExpanded ? "lg:justify-center px-2" : "lg:justify-start"}`}
className={
isActive(nav.path)
? "menu-item-icon-active"
: "menu-item-icon-inactive"
}
> >
<span className={`menu-item-icon ${isActive(nav.path) ? "text-gray-950" : "text-gray-400"}`}>
{nav.icon} {nav.icon}
</span> </span>
{(isExpanded || isHovered || isMobileOpen) && ( {isSidebarVisible && (
<span className={`menu-item-text`}>{nav.name}</span> <span className="block truncate text-[14px]">{nav.name}</span>
)} )}
</Link> </Link>
) )
)} )}
{nav.subItems && (isExpanded || isHovered || isMobileOpen) && (
{/* Submenu Area */}
{nav.subItems && isSidebarVisible && (
<div <div
ref={(el) => { ref={(el) => {
subMenuRefs.current[`${menuType}-${index}`] = el; subMenuRefs.current[refKey] = el;
}} }}
className="overflow-hidden transition-all duration-300" className="overflow-hidden ease-in-out transition-all duration-300"
style={{ style={{
height: height: isOpen ? `${subMenuHeight[refKey] || 0}px` : "0px",
openSubmenu?.type === menuType && openSubmenu?.index === index
? `${subMenuHeight[`${menuType}-${index}`]}px`
: "0px",
}} }}
> >
<ul className="mt-2 space-y-1 ml-9"> <ul className="ml-4 mt-1 space-y-1 border-l border-gray-200 pl-4">
{nav.subItems.map((subItem) => ( {nav.subItems.map((subItem) => (
<li key={subItem.name}> <li key={subItem.name}>
<Link <Link
href={subItem.path} href={subItem.path}
className={`menu-dropdown-item ${isActive(subItem.path) ? "menu-dropdown-item-active" : "menu-dropdown-item-inactive"}`} 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} {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> </Link>
</li> </li>
))} ))}
@@ -252,54 +229,163 @@ const AppSidebar: React.FC = () => {
</div> </div>
)} )}
</li> </li>
))} );
})}
</ul> </ul>
); );
return ( return (
<aside <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 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]
${isExpanded || isMobileOpen || isHovered ? "w-[290px] px-5" : "w-0 px-0 border-none overflow-hidden"} ${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`} ${isMobileOpen ? "translate-x-0" : "-translate-x-full"} lg:translate-x-0 transition-all duration-300`}
onMouseEnter={() => !isExpanded && setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
> >
{/* HEADER LOGO & TOGGLE */}
<div <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 <Image
src="/images/logo/logo.svg" src="/images/logo/logo.svg"
alt="Logo" alt="Logo"
width={isExpanded || isHovered || isMobileOpen ? 80 : 32} width={isSidebarVisible ? 80 : 36}
height={isExpanded || isHovered || isMobileOpen ? 50 : 32} height={isSidebarVisible ? 45 : 36}
priority
/> />
</Link> </Link>
</div>
<div className="flex flex-col overflow-y-auto duration-300 ease-linear no-scrollbar"> {isSidebarVisible && (
<nav className="mb-6"> <button
<div className="flex flex-col gap-4"> 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"
<div> onClick={handleToggle}
<h2 aria-label="Collapse Sidebar"
className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"}`}
> >
{isExpanded || isHovered || isMobileOpen ? ( <svg width="15" height="15" viewBox="0 0 16 12" fill="none" xmlns="http://www.w3.org/2000/svg">
"Menu" <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>
<HorizontaLDots /> </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>
{/* 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> </h2>
{renderMenuItems(ALL_NAV_ITEMS, "main")} {renderMenuItems(ALL_NAV_ITEMS, "main")}
</div> </div>
<div> <div>
<h2 className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"}`}> <h2 className={`mb-3 flex text-[11px] font-bold capitalize tracking-wider text-gray-400/80 ${!isExpanded ? "lg:justify-center" : "justify-start pl-2"}`}>
{isExpanded || isHovered || isMobileOpen ? "Others" : <HorizontaLDots />} {isSidebarVisible ? "Others" : <HorizontaLDots />}
</h2> </h2>
{renderMenuItems(OTHERS_ITEMS, "others")} {renderMenuItems(OTHERS_ITEMS, "others")}
</div> </div>
</div>
</nav> </nav>
</div> </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> </aside>
); );
}; };
+1 -1
View File
@@ -8,7 +8,7 @@ const Backdrop: React.FC = () => {
return ( return (
<div <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} onClick={toggleMobileSidebar}
/> />
); );
+2 -2
View File
@@ -4,7 +4,7 @@ export default function SidebarWidget() {
return ( return (
<div <div
className={` 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"> <h3 className="mb-2 font-semibold text-gray-900 dark:text-white">
#1 Tailwind CSS Dashboard #1 Tailwind CSS Dashboard
@@ -16,7 +16,7 @@ export default function SidebarWidget() {
href="https://tailadmin.com/pricing" href="https://tailadmin.com/pricing"
target="_blank" target="_blank"
rel="nofollow" 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 Upgrade To Pro
</a> </a>
+13 -2
View File
@@ -9,7 +9,17 @@ export const apiGetListUser = async (payload: getUserDto) => {
return response?.data; 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); const response = await api.patch(API.Admin.CHANGE_ROLE(id), payload);
return response?.data; return response?.data;
}; };
@@ -32,11 +42,12 @@ export const apiGetUserMedia = async (id: string) => {
return response?.data; 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); const response = await api.put(API.Admin.UPDATE_APPLICATION_STATUS(id), payload);
return response?.data; return response?.data;
}; };
export const apiGetUserById = async (userId: string) => { export const apiGetUserById = async (userId: string) => {
const response = await api.get(API.Admin.GET_USER_BY_ID(userId)); const response = await api.get(API.Admin.GET_USER_BY_ID(userId));
return response?.data; return response?.data;
+28 -4
View File
@@ -2,6 +2,29 @@ import api from "@/config/config";
import { API } from "../../api"; import { API } from "../../api";
import { clearStoredTokens, extractTokensFromResponsePayload, setStoredTokens } from "@/auth/tokenStore"; 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) => { export const apiCreateOTP = async (email: string, token_type: number = 2) => {
const response = await api.post(API.Auth.CREATEOTP, { const response = await api.post(API.Auth.CREATEOTP, {
email, email,
@@ -16,7 +39,7 @@ export const apiVerifyOTP = async (email: string, token: string, token_type: num
return response.data; return response.data;
}; };
export const apiSignUp = async (payload: any) => { export const apiSignUp = async (payload: SignUpPayload) => {
const response = await api.post(API.Auth.SIGNUP, payload); const response = await api.post(API.Auth.SIGNUP, payload);
return response.data; return response.data;
}; };
@@ -27,14 +50,14 @@ export const apiLogout = async () => {
return response.data; 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 response = await api.post(API.Auth.SIGNIN, payload);
const tokens = extractTokensFromResponsePayload(response?.data); const tokens = extractTokensFromResponsePayload(response?.data);
if (tokens) setStoredTokens(tokens); if (tokens) setStoredTokens(tokens);
return response.data; 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); const response = await api.post(API.Auth.FORGOT_PASSWORD, payload);
return response.data; return response.data;
}; };
@@ -44,7 +67,8 @@ export const apiGetCurrentUser = async () => {
return response?.data; 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); const response = await api.patch(API.User.CHANGE_PASSWORD, payload);
return response?.data; return response?.data;
}; };
+16 -2
View File
@@ -1,16 +1,30 @@
import api from "@/config/config"; import api from "@/config/config";
import { API } from "../../api"; 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); const response = await api.post(API.Historian.CREATE_CV, payload);
return response?.data; 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 }); const response = await api.get(API.Historian.APPLICATION, { params: payload });
return response?.data; return response?.data;
}; };
export const apiDeleteHistorianCV = async (id: number | string) => { export const apiDeleteHistorianCV = async (id: number | string) => {
const response = await api.delete(API.Historian.DELETE_CV(id)); const response = await api.delete(API.Historian.DELETE_CV(id));
return response?.data; return response?.data;
+10 -1
View File
@@ -121,7 +121,16 @@ export const deleteMediaById = async (mediaId: string) => {
return response?.data; 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, { const response = await api.get(API.Media.GET_MEDIA, {
params: payload, params: payload,
}); });
+38 -9
View File
@@ -1,9 +1,42 @@
// Production BackEndGo API base URL. import { API_URL_ROOT } from "../../../api";
// For local development, override with NEXT_PUBLIC_API_BASE_URL (e.g. http://localhost:3344).
const FALLBACK_API_BASE_URL = "https://history-api.kain.id.vn";
export const API_BASE_URL = const GOONG_TILES_BASE_URL = "https://tiles.goong.io";
process.env.NEXT_PUBLIC_API_BASE_URL || FALLBACK_API_BASE_URL;
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 = { export const API_ENDPOINTS = {
geometries: `${API_BASE_URL}/geometries`, geometries: `${API_BASE_URL}/geometries`,
@@ -18,8 +51,4 @@ export const API_ENDPOINTS = {
currentUserProjects: `${API_BASE_URL}/users/current/project`, currentUserProjects: `${API_BASE_URL}/users/current/project`,
projects: `${API_BASE_URL}/projects`, projects: `${API_BASE_URL}/projects`,
submissions: `${API_BASE_URL}/submissions`, 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; } as const;
+6 -4
View File
@@ -9,8 +9,9 @@ export type { GeometriesBBoxQuery } from "@/uhm/types/api";
export type EntityGeometrySearchGeo = { export type EntityGeometrySearchGeo = {
id: string; id: string;
type: string | null; type: string | null;
draw_geometry: unknown; draw_geometry: Geometry;
binding?: unknown; binding?: string[];
time_start?: number | null; time_start?: number | null;
time_end?: number | null; time_end?: number | null;
}; };
@@ -106,8 +107,9 @@ export async function searchGeometriesByEntityName(
type GeometryRow = { type GeometryRow = {
id: string; id: string;
geo_type: number; geo_type: number;
draw_geometry: unknown; draw_geometry: Geometry;
binding?: unknown; binding?: string[];
time_start?: number; time_start?: number;
time_end?: number; time_end?: number;
bbox?: { bbox?: {
+575 -11
View File
@@ -1,20 +1,584 @@
import { API_ENDPOINTS } from "@/uhm/api/config"; import {
import { requestJson } from "@/uhm/api/http"; 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 { type GoongStyleSource = {
return API_ENDPOINTS.vectorTiles; 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.");
} }
export function getRasterTileTemplateUrl(): string { if (!externalRasterSourcePromise) {
return API_ENDPOINTS.rasterTiles; externalRasterSourcePromise = loadGoongRasterSourceSpecification(
GOONG_SATELLITE_STYLE_UPSTREAM_URL
);
} }
export async function fetchVectorTilesMetadata(): Promise<TileMetadata> { try {
return requestJson<TileMetadata>(API_ENDPOINTS.vectorTilesMetadata); return await externalRasterSourcePromise;
} catch (error) {
externalRasterSourcePromise = null;
throw error;
}
} }
export async function fetchRasterTilesMetadata(): Promise<TileMetadata> { export async function getGoongBackgroundOverlayBundle(): Promise<GoongBackgroundOverlayBundle | null> {
return requestJson<TileMetadata>(API_ENDPOINTS.rasterTilesMetadata); 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;
}
}
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.");
}
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;
} }
+5 -4
View File
@@ -4,7 +4,7 @@ import { ApiError, requestJson } from "@/uhm/api/http";
export type Wiki = { export type Wiki = {
id: string; id: string;
project_id?: string; project_id: string;
title?: string; title?: string;
slug?: string | null; slug?: string | null;
content?: string; content?: string;
@@ -12,12 +12,13 @@ export type Wiki = {
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
content_sample?: { content_sample?: {
created_at?: string; id: string;
content?: string; title: string;
id?: string; created_at: string;
}[]; }[];
}; };
export async function searchWikisByTitle(title: string, options?: { limit?: number; cursor?: string; entityId?: string }): Promise<Wiki[]> { export async function searchWikisByTitle(title: string, options?: { limit?: number; cursor?: string; entityId?: string }): Promise<Wiki[]> {
const keyword = title.trim(); const keyword = title.trim();
const params = new URLSearchParams({ title: keyword }); const params = new URLSearchParams({ title: keyword });
+24 -75
View File
@@ -1,6 +1,6 @@
"use client"; "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 "maplibre-gl/dist/maplibre-gl.css";
import { Feature, FeatureCollection, Geometry } from "@/uhm/lib/editor/state/useEditorState"; import { Feature, FeatureCollection, Geometry } from "@/uhm/lib/editor/state/useEditorState";
@@ -19,6 +19,17 @@ export type MapHoverPayload = {
lngLat: { lng: number; lat: number }; 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 = { type MapProps = {
mode: EditorMode; mode: EditorMode;
draft: FeatureCollection; draft: FeatureCollection;
@@ -41,11 +52,9 @@ type MapProps = {
focusFeatureCollection?: FeatureCollection | null; focusFeatureCollection?: FeatureCollection | null;
focusRequestKey?: string | number | null; focusRequestKey?: string | number | null;
focusPadding?: number | import("maplibre-gl").PaddingOptions; focusPadding?: number | import("maplibre-gl").PaddingOptions;
hideOutside?: boolean;
onToggleHideOutside?: () => void;
}; };
export default function Map({ const Map = forwardRef<MapHandle, MapProps>(function Map({
mode, mode,
onSetMode, onSetMode,
draft, draft,
@@ -67,9 +76,7 @@ export default function Map({
focusFeatureCollection = null, focusFeatureCollection = null,
focusRequestKey = null, focusRequestKey = null,
focusPadding, focusPadding,
hideOutside = false, }, ref) {
onToggleHideOutside,
}: MapProps) {
const modeRef = useRef<MapProps["mode"]>(mode); const modeRef = useRef<MapProps["mode"]>(mode);
const draftRef = useRef<FeatureCollection>(draft); const draftRef = useRef<FeatureCollection>(draft);
const onSelectFeatureIdsRef = useRef(onSelectFeatureIds); const onSelectFeatureIdsRef = useRef(onSelectFeatureIds);
@@ -100,8 +107,14 @@ export default function Map({
geolocationCenteredRef, geolocationCenteredRef,
handleZoomByStep, handleZoomByStep,
handleZoomSliderChange, handleZoomSliderChange,
getViewState,
} = useMapInstance(); } = useMapInstance();
useImperativeHandle(ref, () => ({
getViewState,
getMap: () => mapRef.current,
}), [getViewState, mapRef]);
const { const {
editingEngineRef, editingEngineRef,
setupMapInteractions, setupMapInteractions,
@@ -165,7 +178,7 @@ export default function Map({
// Trigger resize after a short delay to allow layout to settle // Trigger resize after a short delay to allow layout to settle
setTimeout(() => map.resize(), 100); setTimeout(() => map.resize(), 100);
} }
}, [mode, isMapLoaded]); }, [mode, isMapLoaded, mapRef]);
return ( return (
<div style={{ width: "100%", height, position: "relative" }}> <div style={{ width: "100%", height, position: "relative" }}>
@@ -229,72 +242,6 @@ export default function Map({
pointerEvents: "auto", 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 <label
title={ title={
isGlobeProjection isGlobeProjection
@@ -406,7 +353,9 @@ export default function Map({
</div> </div>
</div> </div>
); );
} });
export default Map;
const zoomButtonStyle: React.CSSProperties = { const zoomButtonStyle: React.CSSProperties = {
width: "28px", width: "28px",
@@ -1,34 +1,65 @@
"use client"; "use client";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { useShallow } from "zustand/react/shallow";
import { persistBackgroundLayerVisibility } from "@/uhm/lib/editor/background/backgroundVisibilityStorage";
import { import {
BACKGROUND_LAYER_OPTIONS, BACKGROUND_LAYER_OPTIONS,
BackgroundLayerId, BackgroundLayerId,
BackgroundLayerVisibility, DEFAULT_BACKGROUND_LAYER_VISIBILITY,
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
} from "@/uhm/lib/map/styles/backgroundLayers"; } from "@/uhm/lib/map/styles/backgroundLayers";
import { GEO_TYPE_KEYS } from "@/uhm/lib/map/geo/geoTypeMap"; import { GEO_TYPE_KEYS } from "@/uhm/lib/map/geo/geoTypeMap";
import { useEditorStore } from "@/uhm/store/editorStore";
type Props = { type Props = {
visibility: BackgroundLayerVisibility;
onToggleLayer: (id: BackgroundLayerId) => void;
onShowAll: () => void;
onHideAll: () => void;
geometryVisibility?: Record<string, boolean>;
onToggleGeometryType?: (typeKey: string) => void;
topContent?: ReactNode; topContent?: ReactNode;
width?: number; width?: number;
}; };
export default function BackgroundLayersPanel({ export default function BackgroundLayersPanel({
visibility,
onToggleLayer,
onShowAll,
onHideAll,
geometryVisibility,
onToggleGeometryType,
topContent, topContent,
width = 240, width = 240,
}: Props) { }: 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 ( return (
<aside <aside
style={{ style={{
@@ -52,7 +83,7 @@ export default function BackgroundLayersPanel({
<button <button
key={layer.id} key={layer.id}
type="button" type="button"
onClick={() => onToggleLayer(layer.id)} onClick={() => handleToggleLayer(layer.id)}
style={{ style={{
border: "none", border: "none",
background: "transparent", background: "transparent",
@@ -75,7 +106,23 @@ export default function BackgroundLayersPanel({
})} })}
</div> </div>
{geometryVisibility && onToggleGeometryType ? ( <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={{ height: 1, background: "#1f2937", margin: "12px 0" }} />
<div style={{ margin: 0, marginBottom: 10, fontWeight: 800, fontSize: 13, color: "#e5e7eb" }}> <div style={{ margin: 0, marginBottom: 10, fontWeight: 800, fontSize: 13, color: "#e5e7eb" }}>
@@ -88,7 +135,12 @@ export default function BackgroundLayersPanel({
<button <button
key={typeKey} key={typeKey}
type="button" type="button"
onClick={() => onToggleGeometryType(typeKey)} onClick={() => {
setGeometryVisibility((prev) => ({
...prev,
[typeKey]: prev[typeKey] === false,
}));
}}
style={{ style={{
border: "none", border: "none",
background: "transparent", background: "transparent",
@@ -112,7 +164,17 @@ export default function BackgroundLayersPanel({
})} })}
</div> </div>
</> </>
) : null}
</aside> </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"; "use client";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import type { WikiSnapshot } from "@/uhm/types/wiki";
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects"; 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 NewBadge from "@/uhm/components/editor/NewBadge";
import { useEditorStore } from "@/uhm/store/editorStore";
type EntityChoice = { id: string; name: string; isNew?: boolean }; type EntityChoice = { id: string; name: string; isNew?: boolean };
type WikiChoice = { id: string; title: string; isNew?: boolean }; type WikiChoice = { id: string; title: string; isNew?: boolean };
@@ -18,9 +20,6 @@ type BindingRow = {
}; };
type Props = { type Props = {
entities: EntityChoice[];
wikis: WikiSnapshot[];
links: EntityWikiLinkSnapshot[];
setLinks: React.Dispatch<React.SetStateAction<EntityWikiLinkSnapshot[]>>; setLinks: React.Dispatch<React.SetStateAction<EntityWikiLinkSnapshot[]>>;
}; };
@@ -29,7 +28,20 @@ function wikiTitle(w: WikiSnapshot): string {
return t.length ? t : "Untitled wiki"; 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 [activeEntityId, setActiveEntityId] = useState<string>("");
const [activeWikiId, setActiveWikiId] = useState<string>(""); const [activeWikiId, setActiveWikiId] = useState<string>("");
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
@@ -46,11 +58,29 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
[wikis] [wikis]
); );
const entityChoices = useMemo(() => { const entityChoices = useMemo<EntityChoice[]>(() => {
const cleaned = (entities || []).filter((e) => e && typeof e.id === "string" && e.id.trim().length > 0); const visibleSnapshotEntities = new globalThis.Map<string, { id: string; name: string; isNew: boolean }>();
cleaned.sort((a, b) => a.name.localeCompare(b.name)); for (const ref of snapshotEntities || []) {
return cleaned; const id = String(ref?.id || "").trim();
}, [entities]); 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 activeLinks = useMemo(() => {
const set = new Set<string>(); const set = new Set<string>();
@@ -1,7 +1,9 @@
"use client"; "use client";
import { useMemo, useState, type CSSProperties, type KeyboardEvent } from "react"; import { useMemo, useState, type CSSProperties, type KeyboardEvent } from "react";
import { useShallow } from "zustand/react/shallow";
import NewBadge from "@/uhm/components/editor/NewBadge"; import NewBadge from "@/uhm/components/editor/NewBadge";
import { useEditorStore } from "@/uhm/store/editorStore";
type GeometryChoice = { type GeometryChoice = {
id: string; id: string;
@@ -11,13 +13,10 @@ type GeometryChoice = {
type Props = { type Props = {
geometries: GeometryChoice[]; geometries: GeometryChoice[];
selectedGeometryId: string | null; selectedGeometryId?: string | null;
selectedGeometryBindingIds: string[]; selectedGeometryBindingIds: string[];
onToggleBindGeometryForSelectedGeometry?: (geometryId: string, nextChecked: boolean) => void; onToggleBindGeometryForSelectedGeometry?: (geometryId: string, nextChecked: boolean) => void;
onFocusGeometry?: (geometryId: string) => void; onFocusGeometry?: (geometryId: string) => void;
statusText?: string | null;
bindingFilterEnabled: boolean;
onBindingFilterEnabledChange: (next: boolean) => void;
}; };
export default function GeometryBindingPanel({ export default function GeometryBindingPanel({
@@ -26,12 +25,29 @@ export default function GeometryBindingPanel({
selectedGeometryBindingIds, selectedGeometryBindingIds,
onToggleBindGeometryForSelectedGeometry, onToggleBindGeometryForSelectedGeometry,
onFocusGeometry, onFocusGeometry,
}: Props) {
const {
selectedFeatureIds,
statusText, statusText,
bindingFilterEnabled, bindingFilterEnabled,
onBindingFilterEnabledChange, setGeometryBindingFilterEnabled,
}: Props) { hoveredGeometryId,
setHoveredGeometryId,
} = useEditorStore(
useShallow((state) => ({
selectedFeatureIds: state.selectedFeatureIds,
statusText: state.geoBindingStatus,
bindingFilterEnabled: state.geometryBindingFilterEnabled,
setGeometryBindingFilterEnabled: state.setGeometryBindingFilterEnabled,
hoveredGeometryId: state.hoveredGeometryId,
setHoveredGeometryId: state.setHoveredGeometryId,
}))
);
const effectiveSelectedGeometryId =
selectedGeometryId ??
(selectedFeatureIds.length > 0 ? String(selectedFeatureIds[0]) : null);
const canBindToggle = const canBindToggle =
Boolean(selectedGeometryId) && typeof onToggleBindGeometryForSelectedGeometry === "function"; Boolean(effectiveSelectedGeometryId) && typeof onToggleBindGeometryForSelectedGeometry === "function";
const canFocusGeometry = typeof onFocusGeometry === "function"; const canFocusGeometry = typeof onFocusGeometry === "function";
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
@@ -46,24 +62,30 @@ export default function GeometryBindingPanel({
const bindingSet = useMemo(() => new Set(selectedGeometryBindingIds || []), [selectedGeometryBindingIds]); const bindingSet = useMemo(() => new Set(selectedGeometryBindingIds || []), [selectedGeometryBindingIds]);
const selectedGeometry = useMemo(() => { const selectedGeometry = useMemo(() => {
if (!selectedGeometryId) return null; if (!effectiveSelectedGeometryId) return null;
return rows.find((g) => g.id === selectedGeometryId) || null; return rows.find((g) => g.id === effectiveSelectedGeometryId) || null;
}, [rows, selectedGeometryId]); }, [effectiveSelectedGeometryId, rows]);
const visibleRows = useMemo(() => { const visibleRows = useMemo(() => {
return rows return rows
.filter((g) => g.id !== selectedGeometryId) .filter((g) => g.id !== effectiveSelectedGeometryId)
.sort((a, b) => { .sort((a, b) => {
const aBound = bindingSet.has(a.id); const aBound = bindingSet.has(a.id);
const bBound = bindingSet.has(b.id); const bBound = bindingSet.has(b.id);
if (aBound !== bBound) return aBound ? -1 : 1; if (aBound !== bBound) return aBound ? -1 : 1;
return a.id.localeCompare(b.id); return a.id.localeCompare(b.id);
}); });
}, [bindingSet, rows, selectedGeometryId]); }, [bindingSet, effectiveSelectedGeometryId, rows]);
const handleFocusKeyDown = (event: KeyboardEvent<HTMLDivElement>, geometryId: string) => { const handleFocusKeyDown = (event: KeyboardEvent<HTMLDivElement>, geometryId: string) => {
if (!canFocusGeometry) return; if (!canFocusGeometry) return;
if (event.key !== "Enter" && event.key !== " ") return; if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault(); event.preventDefault();
setHoveredGeometryId((current) => (current === geometryId ? null : current));
onFocusGeometry?.(geometryId);
};
const handleFocusGeometry = (geometryId: string) => {
setHoveredGeometryId((current) => (current === geometryId ? null : current));
onFocusGeometry?.(geometryId); onFocusGeometry?.(geometryId);
}; };
@@ -75,6 +97,7 @@ export default function GeometryBindingPanel({
borderRadius: "8px", borderRadius: "8px",
border: "1px solid #1f2937", border: "1px solid #1f2937",
}} }}
onMouseLeave={() => setHoveredGeometryId(null)}
> >
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}> <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}> <div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
@@ -92,7 +115,7 @@ export default function GeometryBindingPanel({
<input <input
type="checkbox" type="checkbox"
checked={bindingFilterEnabled} checked={bindingFilterEnabled}
onChange={(e) => onBindingFilterEnabledChange(e.target.checked)} onChange={(e) => setGeometryBindingFilterEnabled(e.target.checked)}
style={{ width: 14, height: 14 }} style={{ width: 14, height: 14 }}
/> />
<span style={{ fontSize: 12, color: "#94a3b8", whiteSpace: "nowrap" }}>Filter</span> <span style={{ fontSize: 12, color: "#94a3b8", whiteSpace: "nowrap" }}>Filter</span>
@@ -130,15 +153,26 @@ export default function GeometryBindingPanel({
marginTop: 10, marginTop: 10,
padding: "8px", padding: "8px",
borderRadius: "6px", borderRadius: "6px",
border: "1px solid rgba(59, 130, 246, 0.45)", border:
background: "rgba(37, 99, 235, 0.12)", hoveredGeometryId === selectedGeometry.id
? "1px solid rgba(245, 158, 11, 0.95)"
: "1px solid rgba(59, 130, 246, 0.45)",
background:
hoveredGeometryId === selectedGeometry.id
? "rgba(245, 158, 11, 0.18)"
: "rgba(37, 99, 235, 0.12)",
cursor: canFocusGeometry ? "pointer" : "default", cursor: canFocusGeometry ? "pointer" : "default",
boxShadow:
hoveredGeometryId === selectedGeometry.id
? "0 0 0 2px rgba(251, 191, 36, 0.18)"
: "none",
}} }}
title={selectedGeometry.id} title={selectedGeometry.id}
role={canFocusGeometry ? "button" : undefined} role={canFocusGeometry ? "button" : undefined}
tabIndex={canFocusGeometry ? 0 : undefined} tabIndex={canFocusGeometry ? 0 : undefined}
onClick={() => onFocusGeometry?.(selectedGeometry.id)} onClick={() => handleFocusGeometry(selectedGeometry.id)}
onKeyDown={(event) => handleFocusKeyDown(event, selectedGeometry.id)} onKeyDown={(event) => handleFocusKeyDown(event, selectedGeometry.id)}
onMouseEnter={() => setHoveredGeometryId(selectedGeometry.id)}
> >
<div <div
style={{ style={{
@@ -187,25 +221,36 @@ export default function GeometryBindingPanel({
{visibleRows {visibleRows
.map((g) => { .map((g) => {
const isBound = bindingSet.has(g.id); const isBound = bindingSet.has(g.id);
const isHovered = hoveredGeometryId === g.id;
return ( return (
<div <div
key={g.id} key={g.id}
style={{ style={{
padding: "8px", padding: "8px",
borderRadius: "6px", borderRadius: "6px",
border: isBound ? "1px solid rgba(20, 184, 166, 0.65)" : "1px solid #1f2937", border: isHovered
background: isBound ? "rgba(20, 184, 166, 0.12)" : "transparent", ? "1px solid rgba(245, 158, 11, 0.95)"
: isBound
? "1px solid rgba(20, 184, 166, 0.65)"
: "1px solid #1f2937",
background: isHovered
? "rgba(245, 158, 11, 0.18)"
: isBound
? "rgba(20, 184, 166, 0.12)"
: "transparent",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: 10, gap: 10,
cursor: canFocusGeometry ? "pointer" : "default", cursor: canFocusGeometry ? "pointer" : "default",
opacity: canBindToggle ? 1 : 0.75, opacity: canBindToggle ? 1 : 0.75,
boxShadow: isHovered ? "0 0 0 2px rgba(251, 191, 36, 0.18)" : "none",
}} }}
title={g.id} title={g.id}
role={canFocusGeometry ? "button" : undefined} role={canFocusGeometry ? "button" : undefined}
tabIndex={canFocusGeometry ? 0 : undefined} tabIndex={canFocusGeometry ? 0 : undefined}
onClick={() => onFocusGeometry?.(g.id)} onClick={() => handleFocusGeometry(g.id)}
onKeyDown={(event) => handleFocusKeyDown(event, g.id)} onKeyDown={(event) => handleFocusKeyDown(event, g.id)}
onMouseEnter={() => setHoveredGeometryId(g.id)}
> >
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div <div
+7
View File
@@ -43,5 +43,12 @@ export function ModeHint({ mode }: { mode: EditorMode }) {
</div> </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; return null;
} }
@@ -2,34 +2,40 @@
import { useMemo, useState, type CSSProperties } from "react"; import { useMemo, useState, type CSSProperties } from "react";
import type { EntitySnapshot } from "@/uhm/types/entities"; 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 NewBadge from "@/uhm/components/editor/NewBadge";
import { useEditorStore } from "@/uhm/store/editorStore";
type Props = { type Props = {
entityRefs: EntitySnapshot[];
entityForm: EntityFormState;
onEntityFormChange: (key: keyof EntityFormState, value: string) => void;
isEntitySubmitting: boolean;
onCreateEntityOnly: () => void; onCreateEntityOnly: () => void;
onUpdateEntity?: (entityId: string, payload: { name: string; description: string | null }) => void; onUpdateEntity?: (entityId: string, payload: { name: string; description: string | null }) => void;
entityFormStatus: string | null;
selectedGeometryEntityIds?: string[];
hasSelectedGeometry?: boolean; hasSelectedGeometry?: boolean;
onToggleBindEntityForSelectedGeometry?: (entityId: string, nextChecked: boolean) => void; onToggleBindEntityForSelectedGeometry?: (entityId: string, nextChecked: boolean) => void;
}; };
export default function ProjectEntityRefsPanel({ export default function ProjectEntityRefsPanel({
entityRefs,
entityForm,
onEntityFormChange,
isEntitySubmitting,
onCreateEntityOnly, onCreateEntityOnly,
onUpdateEntity, onUpdateEntity,
entityFormStatus,
selectedGeometryEntityIds,
hasSelectedGeometry, hasSelectedGeometry,
onToggleBindEntityForSelectedGeometry, onToggleBindEntityForSelectedGeometry,
}: Props) { }: 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 = const canBindToggle =
Boolean(hasSelectedGeometry) && Boolean(hasSelectedGeometry) &&
Array.isArray(selectedGeometryEntityIds) && Array.isArray(selectedGeometryEntityIds) &&
@@ -43,6 +49,16 @@ export default function ProjectEntityRefsPanel({
() => new Set((selectedGeometryEntityIds || []).map(String)), () => new Set((selectedGeometryEntityIds || []).map(String)),
[selectedGeometryEntityIds] [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 sortedEntityRefs = useMemo(() => {
const rows = [...(entityRefs || [])]; const rows = [...(entityRefs || [])];
rows.sort((a, b) => { rows.sort((a, b) => {
@@ -68,6 +84,9 @@ export default function ProjectEntityRefsPanel({
setEditName(typeof entity.name === "string" ? entity.name : ""); setEditName(typeof entity.name === "string" ? entity.name : "");
setEditDescription(entity.description == null ? "" : String(entity.description)); setEditDescription(entity.description == null ? "" : String(entity.description));
}; };
const handleEntityFormChange = (key: "name" | "description", value: string) => {
setEntityForm((prev) => ({ ...prev, [key]: value }));
};
return ( return (
<div <div
@@ -325,14 +344,14 @@ export default function ProjectEntityRefsPanel({
<> <>
<input <input
value={entityForm.name} value={entityForm.name}
onChange={(event) => onEntityFormChange("name", event.target.value)} onChange={(event) => handleEntityFormChange("name", event.target.value)}
placeholder="Tên entity mới" placeholder="Tên entity mới"
disabled={isEntitySubmitting} disabled={isEntitySubmitting}
style={entityInputStyle} style={entityInputStyle}
/> />
<input <input
value={entityForm.description} value={entityForm.description}
onChange={(event) => onEntityFormChange("description", event.target.value)} onChange={(event) => handleEntityFormChange("description", event.target.value)}
placeholder="Description" placeholder="Description"
disabled={isEntitySubmitting} disabled={isEntitySubmitting}
style={entityInputStyle} style={entityInputStyle}
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"; "use client";
import { type CSSProperties, useMemo, useState } from "react"; import { type CSSProperties, useMemo, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { Feature } from "@/uhm/lib/editor/state/useEditorState"; import { Feature } from "@/uhm/lib/editor/state/useEditorState";
import { import {
GEOMETRY_TYPE_OPTIONS,
GeometryPreset, GeometryPreset,
GeometryTypeGroupId, GeometryTypeGroupId,
GeometryTypeOption,
findGeometryTypeOption, findGeometryTypeOption,
groupGeometryTypeOptions, groupGeometryTypeOptions,
} from "@/uhm/lib/map/geo/geometryTypeOptions"; } from "@/uhm/lib/map/geo/geometryTypeOptions";
import { normalizeGeoTypeKey } from "@/uhm/lib/map/geo/geoTypeMap"; 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 = { type Props = {
selectedFeatures: Feature[]; selectedFeatures: Feature[];
entityTypeOptions: GeometryTypeOption[];
geometryMetaForm: GeometryMetaFormState;
onGeometryMetaFormChange: (key: keyof GeometryMetaFormState, value: string) => void;
isEntitySubmitting: boolean;
onApplyGeometryMetadata: () => Promise<{ ok: boolean; error?: string }>; onApplyGeometryMetadata: () => Promise<{ ok: boolean; error?: string }>;
changeCount: number; changeCount: number;
onReplayEdit?: (id: string | number) => void;
}; };
export default function SelectedGeometryPanel({ export default function SelectedGeometryPanel({
selectedFeatures, selectedFeatures,
entityTypeOptions,
geometryMetaForm,
onGeometryMetaFormChange,
isEntitySubmitting,
onApplyGeometryMetadata, onApplyGeometryMetadata,
changeCount, changeCount,
onReplayEdit,
}: Props) { }: Props) {
const {
geometryMetaForm,
setGeometryMetaForm,
isEntitySubmitting,
} = useEditorStore(
useShallow((state) => ({
geometryMetaForm: state.geometryMetaForm,
setGeometryMetaForm: state.setGeometryMetaForm,
isEntitySubmitting: state.isEntitySubmitting,
}))
);
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [geoApplyFeedback, setGeoApplyFeedback] = useState< const [geoApplyFeedback, setGeoApplyFeedback] = useState<
| { | {
@@ -71,7 +77,7 @@ export default function SelectedGeometryPanel({
if (!selectedFeatures || selectedFeatures.length === 0) return null; if (!selectedFeatures || selectedFeatures.length === 0) return null;
const representativeFeature = selectedFeatures[0]; const representativeFeature = selectedFeatures[0];
const groupedGeometryTypeOptions = groupGeometryTypeOptions(entityTypeOptions); const groupedGeometryTypeOptions = groupGeometryTypeOptions(GEOMETRY_TYPE_OPTIONS);
const featureGeometryPreset = resolveFeatureGeometryPreset(representativeFeature); const featureGeometryPreset = resolveFeatureGeometryPreset(representativeFeature);
const allowedGroupIds = getAllowedGroupIdsForPreset(featureGeometryPreset); const allowedGroupIds = getAllowedGroupIdsForPreset(featureGeometryPreset);
const groupedGeoTypeOptions = groupedGeometryTypeOptions.filter((group) => const groupedGeoTypeOptions = groupedGeometryTypeOptions.filter((group) =>
@@ -141,7 +147,12 @@ export default function SelectedGeometryPanel({
</div> </div>
<select <select
value={geometryMetaForm.type_key} value={geometryMetaForm.type_key}
onChange={(event) => onGeometryMetaFormChange("type_key", event.target.value)} onChange={(event) =>
setGeometryMetaForm((prev) => ({
...prev,
type_key: event.target.value,
}))
}
disabled={isEntitySubmitting} disabled={isEntitySubmitting}
style={entityInputStyle} style={entityInputStyle}
> >
@@ -174,14 +185,24 @@ export default function SelectedGeometryPanel({
) : null} ) : null}
<input <input
value={geometryMetaForm.time_start} 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" placeholder="time_start"
disabled={isEntitySubmitting} disabled={isEntitySubmitting}
style={entityInputStyle} style={entityInputStyle}
/> />
<input <input
value={geometryMetaForm.time_end} 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" placeholder="time_end"
disabled={isEntitySubmitting} disabled={isEntitySubmitting}
style={entityInputStyle} style={entityInputStyle}
@@ -201,6 +222,20 @@ export default function SelectedGeometryPanel({
> >
Apply Apply
</button> </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 ? ( {visibleGeoApplyFeedback ? (
<div <div
style={{ style={{
@@ -48,6 +48,8 @@ export function formatUndoLabel(action: UndoAction) {
case "snapshot_entities": case "snapshot_entities":
case "snapshot_wikis": case "snapshot_wikis":
case "snapshot_entity_wiki": case "snapshot_entity_wiki":
case "replay":
case "replay_session":
case "group": case "group":
return action.label; return action.label;
default: default:
+51 -21
View File
@@ -5,13 +5,12 @@ import { Feature, FeatureCollection, Geometry } from "@/uhm/lib/editor/state/use
import { import {
FEATURE_STATE_SOURCE_IDS, FEATURE_STATE_SOURCE_IDS,
PATH_ARROW_ICON_ID, PATH_ARROW_ICON_ID,
RASTER_BASE_INSERT_BEFORE_LAYER_ID,
RASTER_BASE_LAYER_ID, RASTER_BASE_LAYER_ID,
RASTER_BASE_SOURCE_ID, RASTER_BASE_SOURCE_ID,
PATH_ARROW_SOURCE_ID PATH_ARROW_SOURCE_ID
} from "@/uhm/lib/map/constants"; } from "@/uhm/lib/map/constants";
import { PATH_RENDER_BY_TYPE } from "@/uhm/lib/map/styles/style"; 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 { newId } from "@/uhm/lib/utils/id";
import { normalizeGeoTypeKey } from "@/uhm/lib/map/geo/geoTypeMap"; import { normalizeGeoTypeKey } from "@/uhm/lib/map/geo/geoTypeMap";
@@ -30,33 +29,46 @@ export function applyBackgroundLayerVisibility(
for (const layer of BACKGROUND_LAYER_OPTIONS) { for (const layer of BACKGROUND_LAYER_OPTIONS) {
if (layer.id === RASTER_BASE_LAYER_ID) continue; if (layer.id === RASTER_BASE_LAYER_ID) continue;
if (!map.getLayer(layer.id)) continue; const nextVisibility = visibility[layer.id] ? "visible" : "none";
map.setLayoutProperty(
layer.id, if (map.getLayer(layer.id)) {
"visibility", map.setLayoutProperty(layer.id, "visibility", nextVisibility);
visibility[layer.id] ? "visible" : "none" }
);
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) { export function syncRasterBaseVisibility(map: maplibregl.Map, shouldShow: boolean) {
if (shouldShow) { if (shouldShow) {
ensureRasterBaseLayer(map); void ensureRasterBaseLayer(map).catch((error) => {
console.error("Failed to load proxied raster background.", error);
removeRasterBaseLayer(map);
});
return; return;
} }
removeRasterBaseLayer(map); removeRasterBaseLayer(map);
} }
export function ensureRasterBaseLayer(map: maplibregl.Map) { export async function ensureRasterBaseLayer(map: maplibregl.Map) {
if (!map.getSource(RASTER_BASE_SOURCE_ID)) { 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)) { 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); 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"); map.setLayoutProperty(RASTER_BASE_LAYER_ID, "visibility", "visible");
@@ -73,13 +85,7 @@ export function removeRasterBaseLayer(map: maplibregl.Map) {
} }
export function createRasterBaseSource() { export function createRasterBaseSource() {
return { return getBackgroundRasterSourceSpecification();
type: "raster" as const,
tiles: [getRasterTileTemplateUrl()],
tileSize: 256,
minzoom: 0,
maxzoom: 6,
};
} }
export function createRasterBaseLayer() { export function createRasterBaseLayer() {
@@ -94,6 +100,30 @@ 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[] { export function getSelectableLayers(map: maplibregl.Map): string[] {
const selectableSources = ["countries", "places", PATH_ARROW_SOURCE_ID]; const selectableSources = ["countries", "places", PATH_ARROW_SOURCE_ID];
const style = map.getStyle(); const style = map.getStyle();
+15
View File
@@ -125,6 +125,20 @@ export function useMapInstance() {
setZoomLevel(next); setZoomLevel(next);
}, [zoomBounds]); }, [zoomBounds]);
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 { return {
mapRef, mapRef,
containerRef, containerRef,
@@ -138,5 +152,6 @@ export function useMapInstance() {
geolocationCenteredRef, geolocationCenteredRef,
handleZoomByStep, handleZoomByStep,
handleZoomSliderChange, handleZoomSliderChange,
getViewState,
}; };
} }
+10 -5
View File
@@ -7,7 +7,7 @@ import { initLine } from "@/uhm/lib/map/engines/lineEngine";
import { initPath } from "@/uhm/lib/map/engines/pathEngine"; import { initPath } from "@/uhm/lib/map/engines/pathEngine";
import { initCircle } from "@/uhm/lib/map/engines/circleEngine"; import { initCircle } from "@/uhm/lib/map/engines/circleEngine";
import { createEditingEngine } from "@/uhm/lib/map/engines/editingEngine"; 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 { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
import { buildClientFeatureId, getSelectableLayers } from "./mapUtils"; import { buildClientFeatureId, getSelectableLayers } from "./mapUtils";
import { MapHoverPayload } from "../Map"; import { MapHoverPayload } from "../Map";
@@ -15,7 +15,7 @@ import { MapHoverPayload } from "../Map";
type EngineBinding = { type EngineBinding = {
cleanup: () => void; cleanup: () => void;
cancel?: () => void; cancel?: () => void;
clearSelection?: () => void; clearSelection?: (skipNotify?: boolean) => void;
}; };
type UseMapInteractionProps = { type UseMapInteractionProps = {
@@ -26,7 +26,7 @@ type UseMapInteractionProps = {
allowGeometryEditing: boolean; allowGeometryEditing: boolean;
selectedFeatureIds: (string | number)[]; selectedFeatureIds: (string | number)[];
onSelectFeatureIdsRef: React.MutableRefObject<(ids: (string | number)[]) => void>; 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>; onCreateRef: React.MutableRefObject<((feature: FeatureCollection["features"][number]) => void) | undefined>;
onDeleteRef: React.MutableRefObject<((id: string | number) => void) | undefined>; onDeleteRef: React.MutableRefObject<((id: string | number) => void) | undefined>;
onUpdateRef: React.MutableRefObject<((id: string | number, geometry: Geometry) => void) | undefined>; onUpdateRef: React.MutableRefObject<((id: string | number, geometry: Geometry) => void) | undefined>;
@@ -62,8 +62,11 @@ export function useMapInteraction({
}, [mapRef, onUpdateRef]); }, [mapRef, onUpdateRef]);
useEffect(() => { useEffect(() => {
if (mode !== "select" || !selectedFeatureIds || selectedFeatureIds.length === 0) { const allowsSelectionMode = mode === "select" || mode === "replay";
if (!allowsSelectionMode || !selectedFeatureIds || selectedFeatureIds.length === 0) {
editingEngineRef.current?.clearEditing(); 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]); }, [mode, selectedFeatureIds]);
@@ -141,7 +144,9 @@ export function useMapInteraction({
const originalFeature = draftRef.current.features.find( const originalFeature = draftRef.current.features.find(
(item) => String(item.properties.id) === String(rawId) (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, : undefined,
(ids) => onSelectFeatureIdsRef.current?.(ids), (ids) => onSelectFeatureIdsRef.current?.(ids),
+33 -171
View File
@@ -1,20 +1,11 @@
import { useEffect } from "react";
import maplibregl from "maplibre-gl"; import maplibregl from "maplibre-gl";
import { getVectorTileTemplateUrl } from "@/uhm/api/tiles"; import { GOONG_GLYPHS_PROXY_URL } from "@/uhm/api/config";
import { import { getGoongBackgroundOverlayBundle } from "@/uhm/api/tiles";
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 { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants"; 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 { 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 { ensurePointGeotypeIcons, getAllGeotypeLabelLayers, getAllGeotypeLayers } from "@/uhm/lib/map/styles/geotypeLayers";
import { import {
applyBackgroundLayerVisibility, applyBackgroundLayerVisibility,
buildTypeMatchExpression,
ensurePathArrowIcon, ensurePathArrowIcon,
} from "./mapUtils"; } from "./mapUtils";
import { BackgroundLayerVisibility } from "@/uhm/lib/map/styles/backgroundLayers"; 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 { export function getBaseMapStyle(): maplibregl.StyleSpecification {
return { return {
version: 8, version: 8,
glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf", glyphs: GOONG_GLYPHS_PROXY_URL,
sources: { sources: {},
base: {
type: "vector",
tiles: [getVectorTileTemplateUrl()],
minzoom: 0,
maxzoom: 6,
},
},
layers: [ layers: [
{ {
id: "background", id: "background",
@@ -40,157 +24,6 @@ export function getBaseMapStyle(): maplibregl.StyleSpecification {
"background-color": "#0b1220", "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 applyHighlightToMap: (fc: FeatureCollection) => void
) { ) {
applyBackgroundLayerVisibility(map, backgroundVisibility); applyBackgroundLayerVisibility(map, backgroundVisibility);
void replaceBackgroundLayersWithGoong(map, backgroundVisibility).catch((error) => {
console.error("Failed to load proxied background overlay bundle.", error);
});
const hasPathArrowIcon = ensurePathArrowIcon(map); const hasPathArrowIcon = ensurePathArrowIcon(map);
// preview (drawing) // preview (drawing)
@@ -432,3 +268,29 @@ export function setupMapLayers(
}); });
applyHighlightToMap(highlightFeatures || EMPTY_FEATURE_COLLECTION); 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);
}
@@ -20,24 +20,6 @@ type Props = {
onWikiLinkRequest: (request: { slug: string; rect: DOMRect }) => void; onWikiLinkRequest: (request: { slug: string; rect: DOMRect }) => void;
}; };
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 { function escapeHtml(input: string): string {
return input return input
.replaceAll("&", "&amp;") .replaceAll("&", "&amp;")
@@ -53,17 +35,6 @@ function normalizeWikiContentToHtml(raw: string | null | undefined): string {
if (value[0] === "<") return value; if (value[0] === "<") return value;
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
}
}
return `<p>${escapeHtml(value).replace(/\n/g, "<br/>")}</p>`; return `<p>${escapeHtml(value).replace(/\n/g, "<br/>")}</p>`;
} }
+32 -61
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import "react-quill-new/dist/quill.snow.css"; import "react-quill-new/dist/quill.snow.css";
import { useShallow } from "zustand/react/shallow";
import { Modal } from "@/components/ui/modal"; import { Modal } from "@/components/ui/modal";
import Button from "@/components/ui/button/Button"; 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 type ReactQuill from "react-quill-new";
import { checkWikiSlugExists, fetchWikiBySlug, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis"; import { checkWikiSlugExists, fetchWikiBySlug, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
import NewBadge from "@/uhm/components/editor/NewBadge"; import NewBadge from "@/uhm/components/editor/NewBadge";
import { useEditorStore } from "@/uhm/store/editorStore";
type ReactQuillProps = ComponentProps<typeof ReactQuill>; type ReactQuillProps = ComponentProps<typeof ReactQuill>;
type QuillRange = { index: number; length: number }; type QuillRange = { index: number; length: number };
@@ -36,6 +38,13 @@ type QuillLinkFormat = {
__uhmAllowSlugHref?: boolean; __uhmAllowSlugHref?: boolean;
__uhmOriginalSanitize?: unknown; __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"), { const ReactQuillEditor = dynamic<ReactQuillProps>(() => import("react-quill-new"), {
ssr: false, ssr: false,
@@ -46,10 +55,7 @@ let quillLinkSanitizePatched = false;
type Props = { type Props = {
projectId: string; projectId: string;
wikis: WikiSnapshot[];
setWikis: React.Dispatch<React.SetStateAction<WikiSnapshot[]>>; setWikis: React.Dispatch<React.SetStateAction<WikiSnapshot[]>>;
autoOpen?: boolean;
requestedActiveId?: string | null;
}; };
function clampTitle(title: string) { function clampTitle(title: string) {
@@ -57,7 +63,13 @@ function clampTitle(title: string) {
return t.length ? t.slice(0, 120) : "Untitled wiki"; 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 [open, setOpen] = useState(false);
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
@@ -93,12 +105,6 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
const globalWikiSearchRequestRef = useRef(0); const globalWikiSearchRequestRef = useRef(0);
const importFileInputRef = useRef<HTMLInputElement | null>(null); 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). // Allow Quill to keep wiki links where href is a slug (no scheme).
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; 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); 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) { if (ImageFormat) {
class CustomImage extends ImageFormat { const BaseImageFormat = ImageFormat;
class CustomImage extends BaseImageFormat {
static formats(domNode: Element) { static formats(domNode: Element) {
const formats = ImageFormat.formats(domNode) || {}; const formats = BaseImageFormat.formats(domNode) || {};
if (domNode.hasAttribute("style")) formats.style = domNode.getAttribute("style"); const style = domNode.getAttribute("style");
if (domNode.hasAttribute("width")) formats.width = domNode.getAttribute("width"); const width = domNode.getAttribute("width");
if (domNode.hasAttribute("height")) formats.height = domNode.getAttribute("height"); const height = domNode.getAttribute("height");
if (domNode.hasAttribute("class")) formats.class = domNode.getAttribute("class"); 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; return formats;
} }
@@ -205,7 +216,6 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
title: seedTitle, title: seedTitle,
slug: slug ?? null, slug: slug ?? null,
doc: "", doc: "",
updated_at: new Date().toISOString(),
}; };
setWikis((prev) => [seed, ...prev]); setWikis((prev) => [seed, ...prev]);
setActiveId(id); setActiveId(id);
@@ -280,7 +290,6 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
title: nextTitle, title: nextTitle,
slug: nextSlug, slug: nextSlug,
doc: payload, doc: payload,
updated_at: new Date().toISOString(),
} }
) )
); );
@@ -291,11 +300,8 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
if (!activeId) return; if (!activeId) return;
const fmt = detectWikiDocStorageFormat(wikiDocHtml); const fmt = detectWikiDocStorageFormat(wikiDocHtml);
const label = fmt === "json" ? "json" : fmt === "text" ? "txt" : "html"; const label = fmt === "text" ? "txt" : "html";
const mime = const mime = fmt === "text"
fmt === "json"
? "application/json;charset=utf-8"
: fmt === "text"
? "text/plain;charset=utf-8" ? "text/plain;charset=utf-8"
: "text/html;charset=utf-8"; : "text/html;charset=utf-8";
@@ -1066,22 +1072,8 @@ function normalizeWikiDocForQuill(doc: string | null): string {
const raw = (doc || "").trim(); const raw = (doc || "").trim();
if (!raw.length) return ""; if (!raw.length) return "";
// New format (Quill): HTML string.
if (raw[0] === "<") return raw; 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>`; return `<p>${escapeHtml(raw).replace(/\n/g, "<br/>")}</p>`;
} }
@@ -1103,24 +1095,6 @@ function slugifyWikiTitle(raw: string): string {
.slice(0, 80); .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 { function escapeHtml(input: string): string {
return input return input
.replaceAll("&", "&amp;") .replaceAll("&", "&amp;")
@@ -1130,15 +1104,12 @@ function escapeHtml(input: string): string {
.replaceAll("'", "&#39;"); .replaceAll("'", "&#39;");
} }
type WikiDocStorageFormat = "html" | "json" | "text"; type WikiDocStorageFormat = "html" | "text";
function detectWikiDocStorageFormat(doc: string): WikiDocStorageFormat { function detectWikiDocStorageFormat(doc: string): WikiDocStorageFormat {
const raw = String(doc || "").trim(); const raw = String(doc || "").trim();
if (!raw.length) return "html"; if (!raw.length) return "html";
const first = raw[0]; return raw[0] === "<" ? "html" : "text";
if (first === "<") return "html";
if (first === "{" || first === "[") return "json";
return "text";
} }
function downloadTextFile(filename: string, contents: string, mime: string): void { 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.
+280
View File
@@ -0,0 +1,280 @@
# UHM Editor - state và vòng đời dữ liệu
Tài liệu này mô tả state thật đang được dùng bởi editor hiện tại.
Entry point chính là `useEditorSessionState()``useEditorState()`.
## 1. Hai lớp state chính
Editor đang tách làm hai khối:
- `useEditorSessionState()`
- state UI, session, form, project, timeline, background, wiki
- `useEditorState(initialData, snapshotUndo)`
- state draft hình học, diff và undo
Nói ngắn gọn:
- `session state` quyết định editor đang nhìn cái gì và panel đang thao tác gì
- `editor state` quyết định geometry nào đang tồn tại trong draft và khác baseline ra sao
## 2. State geometry trung tâm
### `initialData`
- Nằm ở `useEditorSessionState()`
-`FeatureCollection` đang được nạp vào editor khi mở project hoặc restore commit
- Khi thay đổi, `useEditorState()` sẽ reset toàn bộ draft và baseline tương ứng
### `draft`
- Nằm trong `useEditorState()`
- Là nguồn dữ liệu render trực tiếp cho `Map`
- Mọi thao tác create/update/delete geometry đều đi qua đây
### `draftRef`
- Bản ref của `draft`
- Được dùng trong event handlers của map engine để luôn đọc được state mới nhất mà không phải rebind callback liên tục
### `initialMapRef`
- `Map<featureId, Feature>` tạo từ `initialData`
- Là baseline để tính diff giữa draft hiện tại và dữ liệu gốc của session
### `changes`
- Kết quả `diffDraftToInitial(draft, initialMapRef.current)`
- Map theo `feature.properties.id`
- Mỗi phần tử có thể là:
- `create`
- `update`
- `delete`
Lưu ý: diff hiện chỉ là cơ chế nhận biết geometry nào đã thay đổi so với baseline. Snapshot commit thực tế vẫn được build từ toàn bộ `draft` cộng với các snapshot bảng phụ.
### `changeCount`
- Số lượng geometry thay đổi hiện tại
- Được cộng thêm dirty state của wiki/entity/entity-wiki để tạo `pendingSaveCount`
## 3. Undo state
Undo được quản lý bởi `useUndoStack()`.
Kiểu action hiện có:
- `create`
- `delete`
- `update`
- `properties`
- `snapshot_entities`
- `snapshot_wikis`
- `snapshot_entity_wiki`
- `group`
Ý nghĩa:
- geometry create/delete/update/properties undo được trực tiếp trên `draft`
- snapshot entity/wiki/link undo được apply qua `snapshotUndo` API truyền vào `useEditorState`
- `group` dùng để gom nhiều thay đổi thành một thao tác undo logic
Editor hiện có `undo`, nhưng chưa có redo.
## 4. Session state theo nhóm
### 4.1. Mode và selection
- `mode: EditorMode`
- `selectedFeatureIds`
- `selectedGeometryEntityIds`
`selectedFeatureIds` là state gốc cho:
- panel metadata geometry
- bind entity
- bind geometry
- focus geometry từ search/binding panel
### 4.2. Form state
- `entityForm`
- dùng cho form tạo entity local
- `geometryMetaForm`
- `type_key`
- `time_start`
- `time_end`
- `binding`
`geometryMetaForm.binding` hiện chủ yếu là giá trị hiển thị/đồng bộ UI, còn chỉnh sửa binding thật đi qua `GeometryBindingPanel`.
### 4.3. Project/session task state
`useProjectSessionState()` gom các cờ async vào một state machine nhỏ:
- `sectionTask: "idle" | "saving" | "submitting" | "opening-project"`
Từ đó sinh ra:
- `isSaving`
- `isSubmitting`
- `isOpeningSection`
Ngoài ra còn có:
- `activeSection`
- `projectState`
- `sectionCommits`
- `baselineSnapshot`
- `commitTitle`
### 4.4. Timeline state
`useTimelineState()` giữ:
- `timelineYear`
- `timelineDraftYear`
- `isTimelineLoading`
- `timelineStatus`
Trong page hiện tại, timeline filter đang dùng `timelineDraftYear`.
Không có fetch dữ liệu project theo `timelineYear`; timeline đang là client-side visibility filter.
### 4.5. Background/session UI
`useBackgroundSessionState()` giữ:
- `backgroundVisibility`
- `isBackgroundVisibilityReady`
Giá trị thật được load từ `localStorage` key `uhm.backgroundLayerVisibility.v1`.
### 4.6. Wiki/session state
`useWikiSessionState()` giữ:
- `snapshotWikis`
- `snapshotEntityWikiLinks`
Đây là single source of truth cho phần wiki trong snapshot commit.
## 5. Snapshot state
Editor đang làm việc với 3 snapshot collection chính ngoài geometry:
- `snapshotEntities`
- `snapshotWikis`
- `snapshotEntityWikiLinks`
Chúng đại diện cho "current session snapshot", không phải danh sách delta thô.
Ví dụ:
- entity ref được giữ bằng `operation: "reference"`
- entity/wiki local mới tạo có thể mang `operation: "create"`
- link entity-wiki mới tạo dùng `operation: "binding"`
Khi commit, `buildEditorSnapshot()` sẽ so với `baselineSnapshot` để chuyển các collection này thành snapshot đúng semantic cho backend.
## 6. Baseline snapshot là gì
`baselineSnapshot` là snapshot đang được xem như gốc của session hiện tại.
Nó được cập nhật khi:
- mở project
- commit thành công
- restore từ một commit
`baselineSnapshot` được dùng để:
- biết link nào là `reference`, link nào là `binding`, link nào là `delete`
- biết wiki/entity nào là thay đổi thực sự so với snapshot trước
- giữ lại inline entity/wiki từ snapshot trước nếu user chưa xóa chúng
## 7. Derived state quan trọng trong page
### `timelineVisibleDraft`
-`draft` đã qua filter timeline nếu `timelineFilterEnabled = true`
- geometry mới tạo trong session không bị timeline filter ẩn
### `snapshotEntitiesVisible`
- loại bỏ các row `delete`
- dedupe theo `id`
### `selectedFeatures`
- map từ `selectedFeatureIds` sang feature thật trong `editor.draft.features`
### `isMultiEditValid`
- chỉ `true` khi tất cả geometry đang chọn cùng `geometry.type`
- một số thao tác bind sẽ chặn nếu giá trị này là `false`
### `pendingSaveCount`
Được tính như sau:
- `editor.changeCount`
- `+1` nếu wiki dirty
- `+1` nếu entities dirty
- `+1` nếu entity-wiki dirty
Đây là con số dùng trong UI commit, không phải số record backend chắc chắn sẽ thay đổi.
## 8. Dirty detection
Dirty check của:
- `snapshotWikis`
- `snapshotEntities`
- `snapshotEntityWikiLinks`
đều đang làm bằng cách normalize trước rồi so `JSON.stringify`.
Điều này đủ thực dụng cho snapshot cỡ vừa, nhưng cần lưu ý:
- không tối ưu cho dữ liệu rất lớn
- phụ thuộc vào tính ổn định của thứ tự mảng sau normalize
## 9. State được persist vào localStorage
Hiện editor chỉ persist hai nhóm nhỏ:
- background layer visibility
- key: `uhm.backgroundLayerVisibility.v1`
- map projection
- key: `uhm:mapProjection`
Editor hiện không persist toàn bộ draft/project snapshot vào localStorage.
Nếu cần autosave local draft, đó là tính năng phải làm thêm, không phải behavior hiện tại.
## 10. Khi nào state bị reset
### Reset toàn phần
Xảy ra khi:
- mở project khác
- mở lại project
- restore commit
Hiệu ứng:
- `initialData` đổi
- `useEditorState()` reset `draft`
- `undoStack` bị clear
- baseline map được build lại
### Reset cục bộ
- đổi selection có thể reset `geometryMetaForm`
- đóng/mở wiki modal không reset snapshot wiki, chỉ reset form local của modal
## 11. Một số giới hạn hiện tại cần nhớ khi đọc code
-`undo`, chưa có `redo`
- timeline state có `timelineYear`, nhưng page hiện dùng `timelineDraftYear` cho filtering
- dirty count của commit không tương ứng một-một với số mutation backend
- map selection, binding filter và timeline filter đều là state client-side
+251
View File
@@ -0,0 +1,251 @@
# Replay Export JSON
Tài liệu này mô tả đúng payload mà nút `Export JSON` của replay đang xuất ra hiện tại.
Nguồn thật:
- `src/uhm/components/editor/ReplayTimelineSidebar.tsx`
## 1. Kết luận ngắn
Export hiện tại có dạng:
```json
{
"exported_at": "2026-05-17T12:34:56.000Z",
"geometry_id": "geo-main-id",
"current_replay": { "...": "BattleReplay hiện tại" },
"snapshot_fragment": {
"replays": [
{ "...": "chính current_replay" }
]
}
}
```
Trong đó:
- `current_replay` là replay đang edit
- `snapshot_fragment.replays[0]` là cùng replay đó, nhưng đặt vào đúng chỗ trong commit snapshot
## 2. Root payload
```ts
type ReplayExportPayload = {
exported_at: string;
geometry_id: string;
current_replay: BattleReplay;
snapshot_fragment: {
replays: BattleReplay[];
};
};
```
Ý nghĩa:
- `exported_at`
- timestamp ISO lúc bấm export
- chỉ để debug
- `geometry_id`
- copy nhanh từ `current_replay.geometry_id`
- `current_replay`
- replay draft hiện tại
- `snapshot_fragment`
- fragment để test replay này nếu đặt vào commit snapshot thật
## 3. Shape của `current_replay`
```ts
type BattleReplay = {
id: string;
geometry_id: string;
target_geometry_ids: string[];
detail: ReplayStage[];
};
```
Ý nghĩa:
- `geometry_id`
- MAIN geo của replay
- `id`
- hiện luôn bằng `geometry_id`
- `target_geometry_ids`
- toàn bộ geo thuộc replay
- phần tử đầu nên luôn là MAIN geo
- `detail`
- stage/step/actions của replay script
## 4. `target_geometry_ids` là gì
Đây là phần thay thế cho `replay_features` cũ.
FE không còn export/persist cả `FeatureCollection` riêng của replay nữa. Thay vào đó chỉ lưu:
- geo MAIN
- các geo được đưa vào replay từ bulk select
- binding của MAIN geo
Khi mở replay, FE sẽ hydrate lại `replayDraft` từ:
- `mainDraft`
- `target_geometry_ids`
## 5. Shape của `detail`
```ts
type ReplayStage = {
id: number;
title?: string;
detail_time_start: string;
detail_time_stop: string;
steps: ReplayStep[];
};
```
```ts
type ReplayStep = {
duration: number;
use_UI_function: ReplayAction<UIOptionName>[];
use_map_function: ReplayAction<MapFunctionName>[];
use_geo_function: ReplayAction<GeoFunctionName>[];
use_narrow_function: ReplayAction<NarrativeFunctionName>[];
};
```
Ý nghĩa:
- `stage` là cụm lớn theo mốc thời gian hoặc nhịp kể chuyện
- `step` là đơn vị phát nhỏ hơn trong một stage
- `duration` là trọng số thời gian của step
- action hiện tách thành 4 nhóm
## 6. Ví dụ JSON gần thực tế
```json
{
"exported_at": "2026-05-17T12:34:56.000Z",
"geometry_id": "019e13ab-4823-76c5-afde-2391c0cf311d",
"current_replay": {
"id": "019e13ab-4823-76c5-afde-2391c0cf311d",
"geometry_id": "019e13ab-4823-76c5-afde-2391c0cf311d",
"target_geometry_ids": [
"019e13ab-4823-76c5-afde-2391c0cf311d",
"019e13ab-6063-713d-a28f-98a1556817a7",
"019e13ab-5896-713a-111111111111"
],
"detail": [
{
"id": 0,
"title": "Mở đầu chiến dịch",
"detail_time_start": "1939",
"detail_time_stop": "1940",
"steps": [
{
"duration": 1000,
"use_UI_function": [
{
"function_name": "timeline",
"params": [false]
}
],
"use_map_function": [
{
"function_name": "set_time_filter",
"params": [1939]
}
],
"use_geo_function": [
{
"function_name": "fly_to_geometries",
"params": [
[
"019e13ab-4823-76c5-afde-2391c0cf311d",
"019e13ab-6063-713d-a28f-98a1556817a7"
]
]
}
],
"use_narrow_function": [
{
"function_name": "set_title",
"params": ["Chiến dịch bắt đầu"]
}
]
}
]
}
]
},
"snapshot_fragment": {
"replays": [
{
"id": "019e13ab-4823-76c5-afde-2391c0cf311d",
"geometry_id": "019e13ab-4823-76c5-afde-2391c0cf311d",
"target_geometry_ids": [
"019e13ab-4823-76c5-afde-2391c0cf311d",
"019e13ab-6063-713d-a28f-98a1556817a7",
"019e13ab-5896-713a-111111111111"
],
"detail": [
{
"id": 0,
"title": "Mở đầu chiến dịch",
"detail_time_start": "1939",
"detail_time_stop": "1940",
"steps": [
{
"duration": 1000,
"use_UI_function": [
{
"function_name": "timeline",
"params": [false]
}
],
"use_map_function": [
{
"function_name": "set_time_filter",
"params": [1939]
}
],
"use_geo_function": [
{
"function_name": "fly_to_geometries",
"params": [
[
"019e13ab-4823-76c5-afde-2391c0cf311d",
"019e13ab-6063-713d-a28f-98a1556817a7"
]
]
}
],
"use_narrow_function": [
{
"function_name": "set_title",
"params": ["Chiến dịch bắt đầu"]
}
]
}
]
}
]
}
]
}
}
```
## 7. Cách đọc file export
Khi nhìn file export:
- nếu cần biết replay bám vào geo nào, xem `geometry_id`
- nếu cần biết replay gồm những geo nào, xem `target_geometry_ids`
- nếu cần biết script sẽ làm gì, xem `detail[].steps[]`
- nếu cần so với commit snapshot, xem `snapshot_fragment.replays`
## 8. Ghi chú quan trọng
- Export hiện tại không còn chứa `replay_features`
- Nếu mở replay cũ từng dùng `replay_features`, FE sẽ migrate sang `target_geometry_ids` trước khi export
- `current_replay``snapshot_fragment.replays[0]` hiện vẫn là cùng một replay, chỉ khác góc nhìn
+425
View File
@@ -0,0 +1,425 @@
# Goong APIs In Use
Mục tiêu của tài liệu này:
- mô tả **chính xác** frontend hiện tại đang dùng gì từ Goong
- mô tả **backend cần proxy gì** để giấu `api_key`
- mô tả **response nào phải rewrite**
- tránh liệt kê thừa các API Goong mà app hiện tại không đụng tới
Phạm vi kiểm tra:
- [config.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/config.ts:1)
- [tiles.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/tiles.ts:1)
- [useMapLayers.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/components/map/useMapLayers.ts:1)
- style JSON đã tải về:
- [goong_map_web.json](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/tmp/goong-styles/goong_map_web.json)
- [goong_satellite.json](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/tmp/goong-styles/goong_satellite.json)
## 1. Tóm tắt kỹ thuật
Frontend hiện tại **không** `map.setStyle(goongStyleJson)` trực tiếp.
Thay vào đó:
1. app tự `fetch()` 2 style JSON của Goong
2. app parse style JSON để lấy:
- `raster source` từ `goong_satellite.json`
- `sources + layers` cần thiết từ `goong_map_web.json`
3. app `map.addSource(...)``map.addLayer(...)` thủ công
4. từ thời điểm đó, **MapLibre tự request tiếp** các `source.url`
5. rồi từ các source manifest đó, **MapLibre lại tự request tiếp** các tile URLs nằm trong `tiles[]`
Hệ quả:
- nếu BE chỉ proxy `assets/*.json` thì **chưa đủ**
- nếu BE chỉ proxy `sources/*.json`**không rewrite `tiles[]`** thì **vẫn lộ key ở request tile**
## 2. Luồng request thật hiện tại
### 2.1. App fetch trực tiếp style JSON
Frontend gọi trực tiếp:
1. `https://tiles.goong.io/assets/goong_satellite.json?api_key=...`
2. `https://tiles.goong.io/assets/goong_map_web.json?api_key=...`
Nguồn trong code:
- `GOONG_SATELLITE_STYLE_URL` ở [config.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/config.ts:15)
- `GOONG_VECTOR_OVERLAY_STYLE_URL` ở [config.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/config.ts:19)
- `loadGoongStyleDocument(...)` ở [tiles.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/tiles.ts:211)
Mục đích:
- `goong_satellite.json`
- app lấy ra raster source đầu tiên
- dùng làm nền satellite
- `goong_map_web.json`
- app lấy ra các layer/source phục vụ:
- `Country Borders`
- `Province Borders`
- `District Borders`
- `Country Labels`
- `Rivers`
### 2.2. MapLibre fetch source manifests
Sau khi app clone source spec từ style JSON và `addSource(...)`, MapLibre tự bắn tiếp các request `source.url`.
Các source URL đang xuất hiện trong style JSON:
#### Trong `goong_satellite.json`
- `https://tiles.goong.io/sources/satellite.json?api_key=...`
- `https://tiles.goong.io/sources/base.json?api_key=...`
- `https://tiles.goong.io/sources/goong.json?api_key=...`
#### Trong `goong_map_web.json`
- `https://tiles.goong.io/sources/base.json?api_key=...`
- `https://tiles.goong.io/sources/goong.json?api_key=...`
Ý nghĩa:
- `sources/satellite.json`
- raster source manifest cho nền satellite
- `sources/base.json`
- vector source manifest cho các lớp `boundary`, `worldcountriespoints`, `worldnationalcapitals`
- `sources/goong.json`
- vector source manifest cho các lớp `riversandlakes`, `vietnam_administrator`
### 2.3. MapLibre fetch tile URLs nằm trong source manifests
Đây là phần dễ bị bỏ sót nhất.
Khi MapLibre đã tải `sources/satellite.json`, `sources/base.json`, `sources/goong.json`, nó sẽ tiếp tục request các URL nằm trong field:
- `tiles[]`
Tức là runtime thật của frontend hiện tại là:
1. fetch style JSON
2. fetch source manifest
3. fetch tile URL bên trong source manifest
Nếu backend muốn che key hoàn toàn, thì **bước 3 bắt buộc phải được proxy hoặc rewrite về domain backend**.
## 3. Những upstream Goong resource đang dùng thật
Tính theo runtime hiện tại, upstream Goong đang được dùng thật là:
### 3.1. Style JSON
- `assets/goong_satellite.json`
- `assets/goong_map_web.json`
### 3.2. Source manifests
- `sources/satellite.json`
- `sources/base.json`
- `sources/goong.json`
### 3.3. Tile endpoints bên trong source manifests
- raster tile URLs nằm trong `sources/satellite.json`
- vector tile URLs nằm trong `sources/base.json`
- vector tile URLs nằm trong `sources/goong.json`
Lưu ý:
- tile URL pattern chính xác phải đọc từ source manifest upstream ở runtime
- backend không nên hardcode khi chưa xác minh nội dung `tiles[]`
## 4. Những thứ frontend hiện tại dùng thêm hoặc KHÔNG dùng
### 4.1. Goong glyphs / fonts
Style JSON của Goong có field:
- `glyphs: https://tiles.goong.io/fonts/{fontstack}/{range}.pbf?api_key=...`
Flow hiện tại **có dùng glyphs của Goong qua proxy**.
Map đang trỏ `glyphs` vào:
- `/proxy/{encoded-https://tiles.goong.io/fonts/{fontstack}/{range}.pbf}`
Nguồn trong code:
- [useMapLayers.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/components/map/useMapLayers.ts:17)
- [config.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/config.ts:12)
Kết luận:
- **backend proxy Goong fonts/glyphs là bắt buộc cho flow hiện tại**
### 4.2. Goong sprite
Style JSON của Goong có:
- `sprite: https://tiles.goong.io/sprite`
Nhưng flow hiện tại **không phụ thuộc sprite** vì:
- app không nạp toàn bộ Goong style vào map
- app chỉ nhặt `sources``layers`
- khi clone overlay labels, code còn chủ động loại bớt icon fields
Nguồn trong code:
- `cloneOverlayLayer(...)` ở [tiles.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/tiles.ts:411)
Kết luận:
- **không cần backend proxy Goong sprite cho flow hiện tại**
### 4.3. Các REST API khác của Goong
Không dùng:
- geocoding
- autocomplete
- directions
- distance matrix
- place details
- static map
## 5. Backend cần làm gì
### 5.1. Mục tiêu backend
Backend phải đảm bảo:
1. browser không gọi Goong trực tiếp
2. browser không nhìn thấy `api_key`
3. frontend vẫn nhận được dữ liệu theo format mà MapLibre/app hiện tại cần
### 5.2. Hai kiểu triển khai
Có 2 cách:
#### Cách A: Transparent proxy
BE trả về gần như đúng response của Goong, chỉ rewrite URL.
Ưu điểm:
- gần với Goong
- ít phải đổi frontend hơn
Nhược điểm:
- BE phải rewrite nhiều chỗ
#### Cách B: Normalize thành API nội bộ
BE không trả nguyên style/source của Goong mà trả dữ liệu đã xử lý sẵn cho FE.
Ưu điểm:
- hợp đồng BE-FE rõ hơn
- ít phụ thuộc format Goong hơn
Nhược điểm:
- cần sửa frontend nhiều hơn
Với frontend hiện tại, **Cách A** là hợp lý nhất.
## 6. Contract backend được khuyến nghị
### 6.1. Proxy style JSON
#### `GET /proxy/goong/assets/goong_satellite.json`
Upstream:
- `https://tiles.goong.io/assets/goong_satellite.json?api_key=<server-side-key>`
Backend phải:
- fetch upstream bằng key server-side
- parse JSON
- rewrite `sources.*.url` về domain backend
- có thể giữ nguyên các field khác
Response:
- `Content-Type: application/json`
- body: style JSON đã rewrite
#### `GET /proxy/goong/assets/goong_map_web.json`
Upstream:
- `https://tiles.goong.io/assets/goong_map_web.json?api_key=<server-side-key>`
Backend phải:
- fetch upstream bằng key server-side
- parse JSON
- rewrite `sources.*.url` về domain backend
- có thể giữ nguyên các field khác
Response:
- `Content-Type: application/json`
- body: style JSON đã rewrite
### 6.2. Proxy source manifests
#### `GET /proxy/goong/sources/satellite.json`
Upstream:
- `https://tiles.goong.io/sources/satellite.json?api_key=<server-side-key>`
Backend phải:
- fetch upstream
- parse JSON
- rewrite mọi URL trong `tiles[]` về domain backend
- giữ nguyên metadata quan trọng:
- `tileSize`
- `minzoom`
- `maxzoom`
- `bounds`
- `scheme`
- `attribution`
Response:
- `Content-Type: application/json`
- body: source manifest đã rewrite
#### `GET /proxy/goong/sources/base.json`
Upstream:
- `https://tiles.goong.io/sources/base.json?api_key=<server-side-key>`
Backend phải:
- fetch upstream
- parse JSON
- rewrite mọi URL trong `tiles[]` về domain backend
- giữ nguyên metadata tilejson khác
#### `GET /proxy/goong/sources/goong.json`
Upstream:
- `https://tiles.goong.io/sources/goong.json?api_key=<server-side-key>`
Backend phải:
- fetch upstream
- parse JSON
- rewrite mọi URL trong `tiles[]` về domain backend
- giữ nguyên metadata tilejson khác
### 6.3. Proxy tile endpoints
Backend bắt buộc phải có route để trả tile thật.
Có thể làm generic, ví dụ:
- `GET /proxy/goong/tiles/*`
hoặc explicit hơn theo source:
- `GET /proxy/goong/tiles/satellite/...`
- `GET /proxy/goong/tiles/base/...`
- `GET /proxy/goong/tiles/goong/...`
Yêu cầu:
- request browser -> backend
- backend -> upstream Goong bằng key server-side
- stream response về browser
- pass through hoặc preserve:
- `Content-Type`
- `Cache-Control`
- `ETag`
- `Last-Modified`
Response type có thể là:
- raster image
- vector tile protobuf
## 7. Runtime dependency map cho BE
### 7.1. Satellite background
Luồng:
1. FE đọc `goong_satellite.json`
2. FE lấy `sources.satellite`
3. MapLibre gọi `sources/satellite.json`
4. MapLibre gọi raster tile URLs trong `tiles[]`
BE cần cover:
- style JSON
- source manifest
- raster tile URLs
### 7.2. Overlay borders / labels / rivers
Luồng:
1. FE đọc `goong_map_web.json`
2. FE lấy selected layers + selected sources
3. MapLibre gọi `sources/base.json`
4. MapLibre gọi `sources/goong.json`
5. MapLibre gọi vector tile URLs của 2 source manifest này
BE cần cover:
- style JSON
- 2 source manifests
- vector tile URLs tương ứng
## 8. Danh sách tối thiểu BE phải cover
Nếu chỉ làm đúng những gì frontend hiện tại dùng, checklist tối thiểu là:
1. proxy `assets/goong_satellite.json`
2. proxy `assets/goong_map_web.json`
3. proxy `sources/satellite.json`
4. proxy `sources/base.json`
5. proxy `sources/goong.json`
6. proxy toàn bộ tile URL được khai báo trong `sources/satellite.json`
7. proxy toàn bộ tile URL được khai báo trong `sources/base.json`
8. proxy toàn bộ tile URL được khai báo trong `sources/goong.json`
## 9. Những gì BE chưa cần làm ngay
Cho flow hiện tại, BE **chưa cần**:
- proxy Goong `glyphs`
- proxy Goong `sprite`
- proxy geocoding / directions / autocomplete
Điều này chỉ đúng khi frontend vẫn giữ kiến trúc hiện tại.
Nếu sau này frontend chuyển sang `map.setStyle(goongStyleJson)` trực tiếp, hãy đánh giá lại:
- `glyphs`
- `sprite`
vì khi đó chúng có thể trở thành dependency bắt buộc.
## 10. Gợi ý ngắn cho team BE
Nếu muốn làm ít rủi ro nhất:
1. làm proxy `assets/*.json`
2. rewrite `sources.*.url`
3. làm proxy `sources/*.json`
4. rewrite `tiles[]`
5. làm proxy generic cho tile
Nếu làm thiếu bước 4 hoặc 5 thì key vẫn có thể lộ ở request tile.
+129
View File
@@ -0,0 +1,129 @@
# Goong Map Web Structure
Nguồn JSON gốc được tải về tại:
- `FrontEndUser/tmp/goong-styles/goong_map_web.json`
File này là style vector/label đầy đủ hơn, phù hợp để dò:
- water và water labels
- boundary theo cấp
- place labels cho lịch sử
## Mermaid overview
```mermaid
graph TD
ROOT[goong_map_web.json]
ROOT --> S1[source: base]
ROOT --> S2[source: composite]
S1 --> B1[source-layer: boundary]
S1 --> B2[source-layer: worldcountriespoints]
S1 --> B3[source-layer: worldnationalcapitals]
S2 --> C1[source-layer: riversandlakes]
S2 --> C2[source-layer: rivernames]
S2 --> C3[source-layer: lakenames]
S2 --> C4[source-layer: vietnam_administrator]
S2 --> C5[source-layer: streets_label]
B1 --> BL0[boundary-land-type-0 / type-0-bg]
B1 --> BL1[boundary-land-type-1 / type-1-bg]
B1 --> BL2[boundary-land-type-2 / type-2-bg]
B2 --> PC1[place-country-1]
B2 --> PC2[place-country-2]
B3 --> CAP0[place-city-capital]
C1 --> W1[water]
C1 --> W2[water-shadow]
C2 --> RN0[river-name-0]
C2 --> RN1[river-name-1]
C2 --> RN2[river-name-2]
C3 --> LN0[lake-name_priority_0]
C3 --> LN1[lake-name_priority_1]
C3 --> LN2[lake-name_priority_2]
C4 --> VA0[place-city-capital-vietnam]
C4 --> VA1[place-city1 / place-city2]
C4 --> VA2[place-town1 / place-town2]
C4 --> VA3[place-suburb / borough / neighbourhood]
C4 --> VA4[place-village]
C5 --> RD0[highway-name-minor]
C5 --> RD1[highway-name-medium]
C5 --> RD2[highway-name-major]
```
## Boundary layers
Các layer boundary nổi bật:
- `boundary-land-type-0-bg`
- `boundary-land-type-0`
- `boundary-land-type-1-bg`
- `boundary-land-type-1`
- `boundary-land-type-2-bg`
- `boundary-land-type-2`
Minzoom quan sát được:
- `type-0`: từ zoom `1`
- `type-1`: từ zoom `5`
- `type-2-bg`: từ zoom `7`
- `type-2`: từ zoom `13`
Suy luận thực dụng:
- `type-0` có khả năng là biên giới quốc gia
- `type-1` có khả năng là cấp tỉnh/thành
- `type-2` có khả năng là cấp sâu hơn
## Water layers
Water fill:
- `water`
- `water-shadow`
Water labels:
- `river-name-0`
- `river-name-1`
- `river-name-2`
- `lake-name_priority_0`
- `lake-name_priority_1`
- `lake-name_priority_2`
## Place labels
Những label đáng quan tâm cho historical use:
- `place-country-1`
- `place-country-2`
- `place-city-capital`
- `place-city-capital-vietnam`
- `place-city1`
- `place-city2`
- `place-town1`
- `place-town2`
Những label dễ gây rối nếu bật nhiều:
- `highway-name-*`
- `place-suburb*`
- `place-neighbourhood*`
- `place-village`
## Gợi ý mapping cho UI
- `Country Borders` -> `boundary-land-type-0` + `boundary-land-type-0-bg`
- `Province Borders` -> `boundary-land-type-1` + `boundary-land-type-1-bg`
- `District Borders` -> `boundary-land-type-2` + `boundary-land-type-2-bg`
- `Country Labels` -> `place-country-*`, `place-city-capital*`, `place-city*`, `place-town*`
- `Rivers` -> `water`, `water-shadow`, `river-name-*`, `lake-name_*`
+384
View File
@@ -0,0 +1,384 @@
# Goong Proxy Backend Guide
Tài liệu này mô tả:
- luồng request thật của frontend hiện tại
- backend cần proxy chỗ nào
- backend cần rewrite chỗ nào
- trade-off hiệu suất nếu proxy/rewrite toàn bộ Goong
- khuyến nghị triển khai thực dụng cho team BE
Tài liệu liên quan:
- [goong_apis_in_use.md](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/doc/goong_apis_in_use.md)
- [goong_map_web_structure.md](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/doc/goong_map_web_structure.md)
- [goong_satellite_structure.md](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/doc/goong_satellite_structure.md)
Code liên quan:
- [config.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/config.ts:1)
- [tiles.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/tiles.ts:1)
- [useMapLayers.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/components/map/useMapLayers.ts:1)
## 1. Bối cảnh hiện tại
Frontend hiện tại không `setStyle(goongStyle)` trực tiếp cho MapLibre.
Thay vào đó:
1. FE tự `fetch()` style JSON của Goong
2. FE parse style JSON
3. FE lấy ra:
- raster source cho satellite
- selected vector sources/layers cho borders, labels, rivers
4. FE `addSource()``addLayer()` thủ công
5. MapLibre tự request tiếp `source.url`
6. Từ source manifest, MapLibre tự request tiếp các tile URLs trong `tiles[]`
Điểm quan trọng:
- browser có thể không chỉ gọi `assets/*.json`
- browser sẽ đi sâu thêm ít nhất 2 tầng:
- `sources/*.json`
- tile URLs trong `tiles[]`
## 2. Luồng request hiện tại
```mermaid
sequenceDiagram
participant FE as Frontend
participant GL as MapLibre
participant GO as Goong
FE->>GO: GET assets/goong_satellite.json?api_key=...
FE->>GO: GET assets/goong_map_web.json?api_key=...
FE->>GL: addSource(raster/vector) + addLayer(...)
GL->>GO: GET sources/satellite.json?api_key=...
GL->>GO: GET sources/base.json?api_key=...
GL->>GO: GET sources/goong.json?api_key=...
GL->>GO: GET raster tile URLs from satellite tiles[]
GL->>GO: GET vector tile URLs from base tiles[]
GL->>GO: GET vector tile URLs from goong tiles[]
```
## 3. Mục tiêu của backend proxy
Nếu mục tiêu là:
- không lộ `api_key` ở browser
- vẫn giữ frontend hiện tại gần như nguyên
thì backend phải đảm bảo:
1. browser chỉ gọi domain BE
2. BE gọi Goong bằng key server-side
3. mọi URL Goong lồng bên trong JSON đều được rewrite về domain BE
Nếu thiếu bước 3:
- `api_key` vẫn có thể lộ ở request tầng sau
## 4. Những gì cần rewrite
### 4.1. Style JSON
Trong `goong_satellite.json``goong_map_web.json`, BE cần rewrite:
- `sources.*.url`
Ví dụ:
- từ `https://tiles.goong.io/sources/base.json?api_key=...`
- thành `/proxy/goong/sources/base.json`
### 4.2. Source manifests
Trong `sources/satellite.json`, `sources/base.json`, `sources/goong.json`, BE cần rewrite:
- mọi phần tử trong `tiles[]`
Ví dụ:
- từ `https://.../{z}/{x}/{y}...api_key=...`
- thành `/proxy/goong/tiles/...`
### 4.3. Những field còn phải để ý cho flow hiện tại
Với kiến trúc frontend hiện tại:
- `glyphs` đang được FE dùng qua proxy
- `sprite` hiện chưa dùng
Nghĩa là:
- BE **phải** proxy được `fonts/{fontstack}/{range}.pbf`
- BE hiện **chưa cần** proxy `sprite`
Nếu sau này FE chuyển sang `map.setStyle(goongStyleJson)` trực tiếp thì phải đánh giá lại `sprite` ngay.
## 5. Backend endpoint được khuyến nghị
### 5.1. Style endpoints
- `GET /proxy/goong/assets/goong_satellite.json`
- `GET /proxy/goong/assets/goong_map_web.json`
Nhiệm vụ:
- gọi upstream Goong bằng key server-side
- parse JSON
- rewrite `sources.*.url`
- trả JSON đã rewrite
### 5.2. Source endpoints
- `GET /proxy/goong/sources/satellite.json`
- `GET /proxy/goong/sources/base.json`
- `GET /proxy/goong/sources/goong.json`
Nhiệm vụ:
- gọi upstream Goong bằng key server-side
- parse JSON
- rewrite `tiles[]`
- giữ nguyên:
- `bounds`
- `minzoom`
- `maxzoom`
- `scheme`
- `tileSize`
- `attribution`
### 5.3. Tile endpoint
Gợi ý route generic:
- `GET /proxy/goong/tiles/*`
Nhiệm vụ:
- nhận tile request từ browser
- map sang upstream tile URL tương ứng
- gọi Goong bằng key server-side nếu upstream yêu cầu
- stream response về browser
Điểm quan trọng:
- tile response không nên parse lại
- tile response nên stream/pass-through
- giữ cache headers càng nhiều càng tốt
## 6. Luồng request sau khi proxy
```mermaid
sequenceDiagram
participant FE as Frontend
participant GL as MapLibre
participant BE as Backend Proxy
participant GO as Goong
FE->>BE: GET /proxy/goong/assets/goong_satellite.json
FE->>BE: GET /proxy/goong/assets/goong_map_web.json
BE->>GO: fetch upstream style JSON
GO-->>BE: style JSON
BE-->>FE: rewritten style JSON
FE->>GL: addSource(raster/vector) + addLayer(...)
GL->>BE: GET /proxy/goong/sources/satellite.json
GL->>BE: GET /proxy/goong/sources/base.json
GL->>BE: GET /proxy/goong/sources/goong.json
BE->>GO: fetch upstream source manifests
GO-->>BE: source manifests
BE-->>GL: rewritten source manifests
GL->>BE: GET /proxy/goong/tiles/...
BE->>GO: fetch upstream tile
GO-->>BE: tile bytes
BE-->>GL: tile bytes
```
## 7. Trade-off hiệu suất
### 7.1. Rewrite JSON có chậm không?
Có overhead, nhưng **rất nhỏ** so với tile traffic.
JSON cần rewrite hiện tại chỉ gồm:
- 2 style JSON
- 3 source manifests
Những file này nhỏ, số lượng ít, và có thể cache rất mạnh.
Kết luận:
- rewrite JSON không phải bottleneck chính
### 7.2. Tile proxy mới là chỗ đắt
Chi phí hiệu suất chính nằm ở:
- mọi tile phải đi qua backend
- backend phải giữ thêm một hop mạng
- mất lợi thế gọi trực tiếp CDN của Goong từ browser
Các ảnh hưởng có thể thấy:
- tăng latency
- tăng bandwidth qua BE
- tăng CPU/memory nếu BE buffer response thay vì stream
- tăng load connection pool tới Goong
### 7.3. Nếu không rewrite tile URL
Nếu BE chỉ rewrite style/source JSON nhưng không rewrite `tiles[]`:
- browser vẫn gọi Goong trực tiếp ở bước tile
- `api_key` vẫn có thể lộ
Tức là:
- hiệu suất tốt hơn
- nhưng mục tiêu bảo mật key không đạt
## 8. Cách giảm thiểu impact hiệu suất
### 8.1. Cache rewritten JSON ở BE
Khuyến nghị:
- cache in-memory hoặc Redis cho:
- `goong_satellite.json`
- `goong_map_web.json`
- `sources/satellite.json`
- `sources/base.json`
- `sources/goong.json`
TTL có thể dài vì:
- style/source manifest không đổi liên tục
Tối ưu:
- chỉ rewrite một lần rồi reuse
### 8.2. Stream tile response
Cho tile route:
- không parse body
- không buffer toàn bộ file vào memory nếu không cần
- stream thẳng upstream -> client
### 8.3. Preserve cache headers
Với tile route, BE nên pass-through hoặc preserve:
- `Cache-Control`
- `ETag`
- `Last-Modified`
- `Content-Type`
Nếu BE/ngược phía CDN có cache tốt, impact sẽ giảm rất nhiều.
### 8.4. Dùng CDN/reverse proxy trước BE nếu có thể
Nếu production có CDN/nginx/edge cache:
- cache mạnh cho:
- rewritten style JSON
- rewritten source manifests
- tile responses
Điều này quan trọng hơn tối ưu code rewrite.
### 8.5. Đừng rewrite tile mỗi request theo kiểu string building phức tạp
Nên:
- rewrite `tiles[]` một lần ở source manifest
- tile route chỉ resolve path đơn giản và forward
Không nên:
- parse lại manifest ở mỗi tile request
## 9. Recommendation thực dụng
Nếu team BE muốn giải pháp cân bằng giữa bảo mật và hiệu suất:
### Option A. Full proxy, full rewrite
BE cover:
1. style JSON
2. source manifests
3. tiles
Ưu điểm:
- key không lộ ra browser
- FE không cần biết upstream Goong
Nhược điểm:
- BE chịu toàn bộ traffic tile
### Option B. Hybrid
BE cover:
1. style JSON
2. source manifests
Nhưng không rewrite `tiles[]`
Ưu điểm:
- BE nhẹ hơn
Nhược điểm:
- key vẫn lộ ở tile request
Kết luận:
- nếu ưu tiên bảo mật key thật sự: dùng **Option A**
- nếu ưu tiên hiệu suất hơn và chấp nhận domain restrictions của Goong: dùng **Option B**
## 10. Recommendation cho codebase hiện tại
Với frontend hiện tại, hướng hợp lý nhất là:
1. giữ nguyên FE logic parse style/source như hiện nay
2. chuyển các URL Goong ở `config.ts` sang endpoint nội bộ BE
3. để BE rewrite:
- `sources.*.url`
- `tiles[]`
4. để BE stream tile response
5. cache rewritten JSON ở BE
Nói ngắn:
- rewrite JSON: nên làm
- rewrite tile URLs: bắt buộc nếu muốn giấu key
- proxy tile: phần tốn hiệu suất nhất
- muốn bù hiệu suất: phải dùng cache/stream/CDN tốt
## 11. Checklist cho team BE
1. Tạo route proxy cho 2 style JSON
2. Tạo route proxy cho 3 source manifests
3. Rewrite `sources.*.url` trong style JSON
4. Rewrite `tiles[]` trong source manifests
5. Tạo route proxy tile generic
6. Stream tile response
7. Preserve cache headers
8. Cache rewritten JSON
9. Kiểm tra browser không còn request trực tiếp `tiles.goong.io`
+99
View File
@@ -0,0 +1,99 @@
# Goong Satellite Structure
Nguồn JSON gốc được tải về tại:
- `FrontEndUser/tmp/goong-styles/goong_satellite.json`
File này là style satellite. Nó vẫn có boundary và labels, nhưng ít lớp nước hơn `goong_map_web.json`.
## Mermaid overview
```mermaid
graph TD
ROOT[goong_satellite.json]
ROOT --> S0[source: satellite]
ROOT --> S1[source: base]
ROOT --> S2[source: composite]
S1 --> B1[source-layer: boundary]
S1 --> B2[source-layer: worldcountriespoints]
S1 --> B3[source-layer: worldnationalcapitals]
S2 --> C1[source-layer: vietnam_administrator]
S2 --> C2[source-layer: streets_label]
B1 --> BL0[boundary-land-type-0 / type-0-bg]
B1 --> BL1[boundary-land-type-1 / type-1-bg]
B1 --> BL2[boundary-land-type-2 / type-2-bg]
B2 --> PC1[place-country-1]
B2 --> PC2[place-country-2]
B3 --> CAP0[place-city-capital]
C1 --> VA0[place-city-capital-vietnam]
C1 --> VA1[place-city1 / place-city2]
C1 --> VA2[place-town1 / place-town2]
C1 --> VA3[place-suburb / borough / neighbourhood]
C1 --> VA4[place-village]
C2 --> RD0[highway-name-minor]
C2 --> RD1[highway-name-medium]
C2 --> RD2[highway-name-major]
```
## Boundary layers
Các layer boundary nổi bật:
- `boundary-land-type-0-bg`
- `boundary-land-type-0`
- `boundary-land-type-1-bg`
- `boundary-land-type-1`
- `boundary-land-type-2-bg`
- `boundary-land-type-2`
Minzoom quan sát được:
- `type-0`: từ zoom `1`
- `type-1`: từ zoom `5`
- `type-2-bg`: từ zoom `7`
- `type-2`: từ zoom `7`
## Place labels
Labels hữu ích:
- `place-country-1`
- `place-country-2`
- `place-city-capital`
- `place-city-capital-vietnam`
- `place-city1`
- `place-city2`
- `place-town1`
- `place-town2`
Labels dễ gây rối:
- `highway-name-*`
- `place-suburb*`
- `place-neighbourhood*`
- `place-village`
## Khác biệt thực dụng so với goong_map_web
-`source: satellite`
- Boundary vẫn hiện diện rõ
- Labels hành chính vẫn có
- Không lộ ra nhóm water chi tiết rõ như `goong_map_web`
- Phù hợp làm raster/satellite nền hơn là style để dò water layers
## Gợi ý dùng thực tế
- Dùng `goong_satellite.json` cho nền satellite
- Dùng `goong_map_web.json` để dò:
- water
- water labels
- boundary theo cấp
- labels hành chính
+199
View File
@@ -0,0 +1,199 @@
# UHM Map engine - kiến trúc hiện tại
Map editor hiện dùng `MapLibre GL` và được ghép từ 4 lớp chính:
- `useMapInstance`
- `setupMapLayers`
- `useMapInteraction`
- `useMapSync`
Container chính là `src/uhm/components/Map.tsx`.
## 1. `useMapInstance`
Phụ trách lifecycle của đối tượng `maplibregl.Map`.
Các behavior đang có:
- khởi tạo map với `getBaseMapStyle()`
- `center: [0, 20]`, `zoom: 2`
- áp `minZoom``maxZoom`
- lưu projection vào `localStorage` key `uhm:mapProjection`
- cho phép chuyển giữa:
- `mercator`
- `globe`
- theo dõi `zoomLevel`
- thử center theo geolocation một lần khi map load xong
Nếu map init lỗi, `Map.tsx` render overlay lỗi thay vì crash im lặng.
## 2. Base style và background layers
`getBaseMapStyle()` dựng style MapLibre từ vector tile source `base`.
Background layers hiện có:
- `graticules-line`
- `land`
- `bg-countries-fill`
- `bg-country-borders-line`
- `country-labels`
- `regions-line`
- `lakes-fill`
- `rivers-line`
- `geolines-line`
Visibility của các layer này đi qua `BackgroundLayerVisibility`.
## 3. Sources mà editor đang dùng
### Preview sources
- `draw-preview`
- `draw-circle-preview`
- `draw-line-preview`
- `draw-path-preview`
Chúng chỉ dùng cho hình preview trong lúc user đang vẽ.
### Data sources
- `countries`
- polygon + line-like features sau khi split/decorate
- `places`
- point features
- `PATH_ARROW_SOURCE_ID`
- shape phụ để render arrow cho path-like geometries
- `POLYGON_LABEL_SOURCE_ID`
- label source cho polygon names
### Editing overlay
- `edit-shape`
- `edit-handles`
### Highlight/focus
- `entity-focus`
Source này dùng cho:
- highlight geometry khi cần focus
- visual emphasis khi zoom từ search/binding panel
## 4. Tách dữ liệu trước khi đẩy lên map
`useMapSync()` chịu trách nhiệm:
1. filter draft theo binding nếu `respectBindingFilter = true`
2. filter theo geometry visibility
3. split feature thành nhóm polygon/line/point
4. decorate line/polygon/point cho label rendering
5. build source riêng cho path arrows
6. set selected feature state
Điểm quan trọng:
- data mà map nhận không phải raw `draft` nguyên xi
- nó là `draft` sau khi đã qua visibility, binding filter và label decoration
## 5. Map interaction layer
`useMapInteraction()` nối editor mode với các engine.
Binding hiện tại:
- `draw` -> `initDrawing`
- `select` -> `initSelect`
- `replay` -> `initSelect`
- `add-line` -> `initLine`
- `add-path` -> `initPath`
- `add-circle` -> `initCircle`
`add-point` được init riêng bằng `initPoint`, nhưng hiện chưa được đưa vào `engineBindingsRef` như các mode còn lại; logic create point vẫn được bind trong `setupMapInteractions`.
## 6. Các engine cụ thể
### `initDrawing`
- vẽ polygon bằng chuỗi click
- preview fill + line
- hỗ trợ snap bằng `Shift` hoặc `Alt`
### `initPoint`
- tạo point bằng một click
### `initLine`
- tạo line nhiều đỉnh
- preview dashed line
### `initPath`
- giống line nhưng có path arrow layer khi preview/render
### `initCircle`
- tạo circle bằng kéo chuột
- kết quả cuối là `Polygon` có metadata circle
### `createEditingEngine`
- chỉ edit `Polygon`
- nếu polygon có `circle_center`, engine chuyển sang circle-edit mode
- hỗ trợ kéo handle và chèn thêm đỉnh bằng `Ctrl/Cmd`
## 7. Chế độ `select` và `replay`
`initSelect` hiện đóng nhiều vai trò:
- chọn geometry
- xóa geometry
- bắt đầu edit geometry
- chuyển sang `replay`
`replay` hiện không phải cinematic replay đầy đủ.
Nó là mode hiển thị tập trung vào một geometry:
- có nút thoát replay
- có thể ẩn geometry ngoài danh sách `binding`
## 8. Đồng bộ selection và feature state
`useMapSync()` xóa feature state cũ trên các source liên quan, sau đó set lại `selected` cho `selectedFeatureIds`.
Điều này giúp:
- selected style trên map không bị stale
- selection vẫn đúng sau mỗi lần source data đổi
## 9. Fit/focus behavior
Map có hai kiểu focus khác nhau:
- `fitToDraftBounds`
- dùng khi muốn fit toàn bộ draft
- `focusFeatureCollection` + `focusRequestKey`
- dùng khi zoom tới geometry cụ thể từ panel/search
Focus này đi qua `fitMapToFeatureCollection(...)`.
## 10. Geolocation
Sau khi map load:
- nếu chưa từng center theo geolocation trong session
- và không bật `fitToDraftBounds`
- và browser hỗ trợ geolocation
thì map sẽ thử `navigator.geolocation.getCurrentPosition(...)` một lần để dời tâm người dùng.
Nếu thất bại, map giữ nguyên center mặc định.
## 11. Những điều cần nhớ khi sửa map engine
- preview source/layer và persisted source/layer là hai tầng khác nhau
- `draftRef` được dùng để tránh closure stale trong event handlers
- `Map` chỉ là orchestration component; logic lớn nằm ở hooks
- geometry render pipeline phụ thuộc khá nhiều vào `mapUtils.ts`, không chỉ mỗi `useMapSync.ts`
+182
View File
@@ -0,0 +1,182 @@
# UHM map styling - hệ thống layer và style
Tài liệu này mô tả styling thật đang được map editor dùng.
## 1. Hai nhóm style chính
Map hiện có hai nhóm style tách biệt:
- background/base map style
- geotype style cho dữ liệu editor
### Background/base map
Định nghĩa trong `useMapLayers.ts` qua `getBaseMapStyle()`.
### Geotype style
Định nghĩa trong `src/uhm/lib/map/styles/`.
## 2. Background layers đang có
Danh sách layer toggle được expose ở `backgroundLayers.ts`:
- `raster-base-layer`
- `graticules-line`
- `land`
- `bg-countries-fill`
- `bg-country-borders-line`
- `country-labels`
- `regions-line`
- `lakes-fill`
- `rivers-line`
- `geolines-line`
Lưu ý:
- không phải layer nào trong list cũng nhất thiết được add từ cùng một source path trong tương lai
- `BackgroundLayersPanel` chỉ biết toggle theo `id`
Visibility mặc định:
- tất cả `true`
- được persist bằng `uhm.backgroundLayerVisibility.v1`
## 3. Geotype registry
Geotype render hiện được tập trung ở `getAllGeotypeLayers(...)` trong `geotypeLayers.ts`.
Các type đang được register:
- `defense_line`
- `attack_route`
- `retreat_route`
- `invasion_route`
- `migration_route`
- `refugee_route`
- `trade_route`
- `shipping_route`
- `country`
- `state`
- `empire`
- `kingdom`
- `war`
- `battle`
- `civilization`
- `rebellion_zone`
- `person_deathplace`
- `person_birthplace`
- `person_activity`
- `temple`
- `capital`
- `city`
- `fortress`
- `castle`
- `ruin`
- `port`
- `bridge`
`GEOMETRY_TYPE_OPTIONS` trong `geometryTypeOptions.ts` phải khớp với tập geotype này nếu muốn user chọn được từ UI.
## 4. Type matching
Style matcher trung tâm là:
- `TYPE_MATCH_EXPR = ["coalesce", ["get", "type"], ["get", "entity_type_id"], ""]`
Điều này cho phép layer match theo:
- `feature.properties.type`
- fallback sang `entity_type_id` nếu cần
Với editor hiện tại, `type` là field chính.
## 5. Point, line, polygon và label sources
Map không render mọi thứ từ một source duy nhất theo nghĩa trực tiếp.
Pipeline hiện tại tách ra:
- `countries`
- polygon và line-like feature data
- `places`
- point data
- `PATH_ARROW_SOURCE_ID`
- arrow shapes cho route/path
- `POLYGON_LABEL_SOURCE_ID`
- polygon labels
Label layer cho polygon/line đi qua:
- `getAllGeotypeLabelLayers(...)`
- helper trong `shared/polygonLabels.ts`
- helper trong `shared/lineLabels.ts`
## 6. Icon point
Point geotype dùng icon pipeline trong:
- `shared/pointStyle.ts`
- `ensurePointGeotypeIcons(map)`
Điều này có nghĩa là khi thêm geotype point mới, chỉ thêm layer là chưa đủ; cần chắc icon/style builder cũng hiểu type mới đó.
## 7. Preview và edit styling
Ngoài style dữ liệu chính, map còn có style riêng cho:
### Draw preview
- `draw-preview-fill`
- `draw-preview-line`
- `draw-circle-preview-fill`
- `draw-circle-preview-line`
- `draw-line-preview-line`
- `draw-path-preview-line`
- `draw-path-preview-arrows`
### Editing overlay
- `edit-shape-line`
- `edit-handles-circle`
### Focus/highlight
- `entity-focus-fill`
- `entity-focus-line`
- `entity-focus-points`
Các layer này không đi qua geotype registry.
## 8. Visibility filtering
Có ba lớp filter hiển thị trong runtime:
1. background layer visibility
2. geometry visibility theo type key từ panel phải
3. binding filter / replay filter / timeline filter ở phía data trước khi set source
Vì vậy khi một geometry "không hiện", có thể nguyên nhân nằm ở data filtering chứ không phải style layer.
## 9. Thêm geotype mới - checklist đúng với code hiện tại
Nếu thêm một geotype mới, nên đi theo checklist này:
1. Thêm mapping vào `geoTypeMap` nếu backend dùng numeric/type code.
2. Thêm option vào `geometryTypeOptions.ts`.
3. Tạo file style mới trong `styles/geotypes/`.
4. Register nó trong `getAllGeotypeLayers(...)`.
5. Nếu cần label riêng, cập nhật layer builder tương ứng.
6. Nếu là point type, kiểm tra icon pipeline.
7. Nếu muốn user tạo geometry mới với type đó mặc định từ tool nào đó, cập nhật `useMapInteraction.ts`.
## 10. Điều doc cũ mô tả chưa chính xác
Doc cũ nói tới filter thời gian ở từng layer như một biểu thức layer-level chuẩn.
Implementation hiện tại không làm vậy.
Thay vào đó:
- timeline filter đang chạy phía data trong `page.tsx`
- binding filter và geometry visibility cũng chủ yếu chạy trước khi set source
Tức là phần lớn filtering là `prepare data -> set source`, không phải `add layer filter expression per year`.
+175
View File
@@ -0,0 +1,175 @@
# UHM Editor - project workflow hiện tại
Tài liệu này mô tả đúng luồng project editor đang chạy ở frontend hiện tại.
## 1. Mở project
Editor vào từ route `/editor/[id]`.
Luồng mở project:
1. `fetchCurrentUser()` để chắc phiên đăng nhập còn hợp lệ.
2. `openSectionEditor(projectId)`:
- gọi API project detail
- gọi API commit list
- lấy `latest_commit_id`
- load `snapshot_json` của head commit nếu có
3. `normalizeEditorSnapshot()` để đưa snapshot về shape editor hiện tại.
4. `toEditorSessionSnapshot()` để chuyển snapshot thành session state:
- entities
- wikis
- entity-wiki
- feature collection đã rehydrate entity ids / names / metadata
Nếu project chưa có commit, editor mở với `EMPTY_FEATURE_COLLECTION`.
## 2. Rule khóa editor khi có pending submission
Backend mới chặn chỉnh sửa nếu project có submission `PENDING`.
Frontend xử lý như sau:
- `openSectionEditor()` ném `ApiError(409)` kèm `pending_submission_id`
- page editor bắt lỗi đó
- hiển thị màn hình lock riêng
- cho phép xóa submission pending để mở khóa
Trong trạng thái này:
- không vào map editor
- không commit
- không submit mới
## 3. Trạng thái project mà editor thực sự dùng
`ProjectState` đang được FE dùng gồm:
- `status`
- `head_commit_id`
- `locked_by`
Editor page không tự dựng đầy đủ workflow `Approved/Rejected` ở UI.
Phần nó thật sự quan tâm là:
- project có mở được không
-`head_commit_id` để submit không
- có pending submission đang khóa project không
## 4. Vòng đời một phiên chỉnh sửa
### Bước 1: load baseline
- `baselineSnapshot` lấy từ head commit hoặc commit được restore
- `initialData` lấy từ `baselineSnapshot.editor_feature_collection`
- `useEditorState()` reset draft và undo
### Bước 2: chỉnh sửa cục bộ
User có thể sửa:
- geometry
- entity snapshot
- wiki snapshot
- entity-wiki snapshot
Tất cả thay đổi lúc này mới chỉ ở memory của frontend.
### Bước 3: commit
`commitSection()` chỉ chạy khi:
- đã mở được project
- `pendingSaveCount > 0`
Luồng commit:
1. build geometry diff từ `editor.buildPayload()`
2. build snapshot đầy đủ bằng `buildEditorSnapshot(...)`
3. kiểm tra kích thước payload trước khi gửi
4. gọi `createProjectCommit(projectId, { snapshot, edit_summary })`
5. nếu thành công:
- refresh `projectState`
- refresh `sectionCommits`
- cập nhật `baselineSnapshot`
- set `initialData = editor.draft`
- `editor.clearChanges()`
- clear `commitTitle`
### Bước 4: submit
`submitCurrentSection(content)` chỉ chạy khi:
- project đang mở
-`head_commit_id`
- `pendingSaveCount === 0`
Frontend sẽ lấy latest commit từ project hiện tại rồi tạo submission mới.
## 5. Restore commit
Nút `Restore` trong `CommitHistoryPanel` hiện là restore phía frontend:
- tải commit list mới nhất
- lấy snapshot của commit được chọn
- normalize snapshot
- nạp lại vào editor state
Restore này:
- không gọi endpoint đổi head commit
- không thay đổi head trên backend
- chủ yếu để user tiếp tục edit từ snapshot cũ
Nói cách khác, đây là `load snapshot into editor`, không phải `server-side restore`.
## 6. Snapshot commit được build như thế nào
`buildEditorSnapshot()` nhận:
- `draft`
- `changes`
- `snapshotEntities`
- `snapshotWikis`
- `snapshotEntityWikiLinks`
- `previousSnapshot`
và sinh ra:
- `editor_feature_collection`
- `entities`
- `geometries`
- `geometry_entity`
- `wikis`
- `entity_wiki`
Các điểm quan trọng:
- geometry many-to-many với entity được persist ở `geometry_entity[]`
- denormalized fields trên feature như `entity_ids`, `entity_name`, `binding`, `time_start` sẽ bị strip khỏi `editor_feature_collection` trước khi gửi API
- wiki/entity/link được chuẩn hóa lại thành `reference`, `binding`, `delete`, `create`, `update` tùy baseline
## 7. Dirty state mà user nhìn thấy
Số ở nút `Commit``pendingSaveCount`.
Nó gồm:
- số geometry change thật
- cộng thêm 1 nếu entity dirty
- cộng thêm 1 nếu wiki dirty
- cộng thêm 1 nếu entity-wiki dirty
Vì vậy:
- `Commit (3)` không có nghĩa là backend sẽ nhận đúng 3 record thay đổi
- nó là chỉ báo "có bao nhiêu nhóm thay đổi cần commit"
## 8. Những gì workflow hiện chưa làm
Editor hiện chưa có các behavior sau:
- autosave local draft toàn project
- collaborative locking nhiều user ở FE
- review UI cho `Approved/Rejected`
- restore head commit trên backend từ trang editor
- branch/merge nhiều phiên edit song song
+180
View File
@@ -0,0 +1,180 @@
# UHM Wiki system - trạng thái hiện tại
Wiki trong UHM editor hiện chạy qua hai phần:
- editor: `WikiSidebarPanel.tsx`
- viewer/sidebar public: `PublicWikiSidebar.tsx`
## 1. Storage format của wiki doc
Field `doc` trong `WikiSnapshot` hiện là `string | null`.
Frontend hiện hỗ trợ hai dạng:
- HTML string
- plain text fallback
Quy ước hiện tại:
- format ghi mới từ editor Quill là HTML
`normalizeWikiDocForQuill()``normalizeWikiContentToHtml()` hiện chỉ xử lý HTML hoặc plain text.
## 2. Editor hiện dùng Quill, không dùng Tiptap
Trong editor project, wiki đang dùng:
- `react-quill-new`
- theme `snow`
- toolbar custom
- dynamic import để tránh SSR issues
Toolbar hiện có:
- heading `h1`, `h2`, `h3`
- align
- `bold`, `italic`, `underline`, `strike`
- ordered list, bullet list
- `blockquote`
- `code-block`
- `link`
- `image`
- `clean`
## 3. Tạo, sửa và xóa wiki trong project editor
`WikiSidebarPanel` hỗ trợ:
- tạo wiki local từ panel
- sửa `title`, `slug`, `doc`
- xóa wiki khỏi `snapshotWikis`
- mở modal để sửa nội dung chi tiết
Quy ước operation:
- wiki mới local: `source: "inline"`, `operation: "create"`
- wiki ref thêm từ search: `source: "ref"`, `operation: "reference"`
- wiki đã tồn tại nhưng sửa nội dung: `operation: "update"`
- wiki bị remove khỏi current state: được chuyển thành `delete` khi build snapshot so với baseline
## 4. Slug
Slug trong editor hiện:
- không tự generate bắt buộc ở lúc save
- có helper `slugifyWikiTitle()` để fill nhanh khi tạo mới
- được kiểm tra uniqueness bằng `checkWikiSlugExists(slug)` khi tạo wiki mới
Với wiki mới:
- nếu slug trống thì không cho create
- nếu slug đã tồn tại trên server thì chặn create/save
## 5. Import và export
### Import
Editor chỉ hỗ trợ import file HTML:
- chấp nhận `.html`, `.htm`, `text/html`
- nội dung phải parse được như HTML thô
- nếu file không phải HTML thì báo lỗi
### Export
Export hiện chỉ là download text từ `wikiDocHtml`.
Định dạng file được đoán từ nội dung hiện tại:
- bắt đầu bằng `<` -> `html`
- còn lại -> `txt`
Đây là export client-side, không có API export chuyên biệt.
## 6. Link nội bộ giữa các wiki
### Cách link đang được lưu
Quill link hiện lưu trực tiếp `href = slug`.
Ví dụ:
- `dai-viet`
- `tran-dynasty`
Quill sanitize mặc định đã được patch để chấp nhận slug/relative href, miễn không phải `javascript:`.
### Modal chọn link
Khi bấm nút link trên toolbar:
- editor lấy selection hiện tại
- mở modal search
- tìm trong wiki local của project
- đồng thời có thể search wiki global từ server
User có thể:
- chọn một wiki local/global để chèn link
- chèn `__missing__` như một link placeholder
- remove link hiện tại
### `__missing__`
`__missing__` là sentinel để đánh dấu một link chưa map được tới wiki cụ thể.
Trong editor:
- link này được tô đỏ
Trong viewer:
- link này không click được
- vẫn được render như tín hiệu nội dung còn thiếu
## 7. Render wiki phía public/sidebar
`PublicWikiSidebar.tsx` xử lý wiki render như sau:
1. normalize `content` về HTML
2. parse HTML bằng `DOMParser`
3. remove `<script>`
4. rewrite link nội bộ từ slug thành `#wiki:{slug}`
5. giữ external links mở tab mới
6. sinh TOC từ `h1..h6`
Viewer này còn hỗ trợ:
- auto tạo heading id
- TOC dạng chip ngang
- intercept click vào `a[data-wiki-slug]` để điều hướng wiki nội bộ bằng logic app
## 8. Wiki và entity-wiki binding
Link giữa entity và wiki không nằm trong field của chính wiki.
Nó sống ở collection riêng:
- `snapshotEntityWikiLinks`
- payload commit: `entity_wiki[]`
`EntityWikiBindingsPanel` cho phép:
- chọn entity
- chọn wiki
- link/unlink cặp đó
Khi build snapshot:
- cặp mới -> `binding`
- cặp đã có từ baseline -> `reference`
- cặp bị gỡ -> `delete`
## 9. Những gì wiki system hiện chưa có
Hiện tại chưa có:
- media upload workflow riêng lên server cho Quill image
- version history riêng cho từng wiki ngoài commit history của project
- markdown storage/render
- schema block editor mới cho project wiki
- cross-project link graph UI
File `doc/commit_snapshot.ts` có chứa schema `replays[]`, nhưng phần replay narrative đó chưa được nối với wiki editor hiện tại.
@@ -4,7 +4,7 @@ import {
DEFAULT_BACKGROUND_LAYER_VISIBILITY, DEFAULT_BACKGROUND_LAYER_VISIBILITY,
} from "@/uhm/lib/map/styles/backgroundLayers"; } from "@/uhm/lib/map/styles/backgroundLayers";
const BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY = "uhm.backgroundLayerVisibility.v1"; const BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY = "uhm.backgroundLayerVisibility.v2";
export function loadBackgroundLayerVisibilityFromStorage(): BackgroundLayerVisibility { export function loadBackgroundLayerVisibilityFromStorage(): BackgroundLayerVisibility {
if (typeof window === "undefined") { if (typeof window === "undefined") {
@@ -41,7 +41,9 @@ export function persistBackgroundLayerVisibility(visibility: BackgroundLayerVisi
function normalizeBackgroundLayerVisibility(raw: unknown): BackgroundLayerVisibility | null { function normalizeBackgroundLayerVisibility(raw: unknown): BackgroundLayerVisibility | null {
if (!raw || typeof raw !== "object") return null; if (!raw || typeof raw !== "object") return null;
const source = raw as Record<string, unknown>; const source = raw as Partial<Record<string, boolean>>;
const next: BackgroundLayerVisibility = { const next: BackgroundLayerVisibility = {
...DEFAULT_BACKGROUND_LAYER_VISIBILITY, ...DEFAULT_BACKGROUND_LAYER_VISIBILITY,
}; };
@@ -55,4 +57,3 @@ function normalizeBackgroundLayerVisibility(raw: unknown): BackgroundLayerVisibi
return next; return next;
} }
+3 -1
View File
@@ -6,7 +6,7 @@ import type {
} from "@/uhm/types/geo"; } from "@/uhm/types/geo";
import type { EntitySnapshot } from "@/uhm/types/entities"; import type { EntitySnapshot } from "@/uhm/types/entities";
import type { WikiSnapshot } from "@/uhm/types/wiki"; import type { WikiSnapshot } from "@/uhm/types/wiki";
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects"; import type { BattleReplay, EntityWikiLinkSnapshot } from "@/uhm/types/projects";
export type Change = GeometryChange; export type Change = GeometryChange;
@@ -15,6 +15,8 @@ export type UndoAction =
| { type: "properties"; id: FeatureProperties["id"]; prevProperties: FeatureProperties } | { type: "properties"; id: FeatureProperties["id"]; prevProperties: FeatureProperties }
| { type: "delete"; feature: Feature } | { type: "delete"; feature: Feature }
| { type: "create"; id: FeatureProperties["id"] } | { type: "create"; id: FeatureProperties["id"] }
| { type: "replay"; geometryId: string; label: string; prevReplay: BattleReplay | null }
| { type: "replay_session"; geometryId: string; label: string; prevReplay: BattleReplay | null }
// Snapshot-scoped undo (affects commit snapshot but not GeoJSON draft directly) // Snapshot-scoped undo (affects commit snapshot but not GeoJSON draft directly)
| { type: "snapshot_entities"; label: string; prev: EntitySnapshot[] } | { type: "snapshot_entities"; label: string; prev: EntitySnapshot[] }
| { type: "snapshot_wikis"; label: string; prev: WikiSnapshot[] } | { type: "snapshot_wikis"; label: string; prev: WikiSnapshot[] }

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