add somenew UI editor feature for more effêcncy

This commit is contained in:
taDuc
2026-05-20 02:14:56 +07:00
parent 488eee1a25
commit 194b3ad3c2
36 changed files with 2608 additions and 597 deletions
+281
View File
@@ -0,0 +1,281 @@
"use client";
import type { CSSProperties, ReactNode } from "react";
import type { Entity } from "@/uhm/api/entities";
import type { EntityGeometriesSearchItem, EntityGeometrySearchGeo } from "@/uhm/api/geometries";
import type { Wiki } from "@/uhm/api/wikis";
import UnifiedSearchBar, { type UnifiedSearchKind } from "@/uhm/components/ui/UnifiedSearchBar";
type EditorSearchResultsProps = {
searchKind: UnifiedSearchKind;
onSearchKindChange: (kind: UnifiedSearchKind) => void;
searchQuery: string;
onSearchQueryChange: (query: string) => void;
onLocalSearchQueryChange: (query: string) => void;
searchQueryDraft: string;
entitySearchResults: Entity[];
isEntitySearchLoading: boolean;
onAddEntityRefToProject: (entity: Entity) => void;
wikiSearchResults: Wiki[];
isWikiSearching: boolean;
onAddWikiRefToProject: (wiki: Wiki) => void;
geoSearchResults: EntityGeometriesSearchItem[];
isGeoSearching: boolean;
onImportGeoFromSearch: (
entityItem: EntityGeometriesSearchItem,
geo: EntityGeometrySearchGeo
) => void;
};
export function EditorSearchResults({
searchKind,
onSearchKindChange,
searchQuery,
onSearchQueryChange,
onLocalSearchQueryChange,
searchQueryDraft,
entitySearchResults,
isEntitySearchLoading,
onAddEntityRefToProject,
wikiSearchResults,
isWikiSearching,
onAddWikiRefToProject,
geoSearchResults,
isGeoSearching,
onImportGeoFromSearch,
}: EditorSearchResultsProps) {
// Draft query quyết định có render kết quả hay không; query chính đã debounce ở page.
const hasQuery = searchQueryDraft.trim().length > 0;
return (
<>
<UnifiedSearchBar
kind={searchKind}
onKindChange={onSearchKindChange}
query={searchQuery}
onQueryChange={onSearchQueryChange}
onLocalQueryChange={onLocalSearchQueryChange}
/>
{searchKind === "entity" && hasQuery ? (
<SearchBox
title="Entity Results"
status={isEntitySearchLoading ? "Searching..." : `${entitySearchResults.length} results`}
>
{entitySearchResults.slice(0, 8).map((entity) => (
<ResultRow
key={entity.id}
title={entity.name}
subtitle={entity.id}
actionLabel="Add"
actionTitle="Add entity ref to project snapshot"
onAction={() => onAddEntityRefToProject(entity)}
/>
))}
{!isEntitySearchLoading && entitySearchResults.length === 0 ? <EmptyResult /> : null}
</SearchBox>
) : null}
{searchKind === "wiki" && hasQuery ? (
<SearchBox
title="Wiki Results"
status={isWikiSearching ? "Searching..." : `${wikiSearchResults.length} results`}
>
{wikiSearchResults.slice(0, 8).map((wiki) => (
<ResultRow
key={wiki.id}
title={(wiki.title || "").trim() || "Untitled wiki"}
subtitle={wiki.id}
actionLabel="Add"
actionTitle="Add wiki ref to project snapshot"
onAction={() => onAddWikiRefToProject(wiki)}
/>
))}
{!isWikiSearching && wikiSearchResults.length === 0 ? <EmptyResult /> : null}
</SearchBox>
) : null}
{searchKind === "geo" && hasQuery ? (
<SearchBox
title="Geo Results"
status={isGeoSearching ? "Searching..." : `${geoSearchResults.length} entities`}
>
{geoSearchResults.slice(0, 6).map((item) => (
<GeoResultGroup
key={item.entity_id}
item={item}
onImportGeoFromSearch={onImportGeoFromSearch}
/>
))}
{!isGeoSearching && geoSearchResults.length === 0 ? <EmptyResult /> : null}
</SearchBox>
) : null}
</>
);
}
function SearchBox({
title,
status,
children,
}: {
title: string;
status: string;
children: ReactNode;
}) {
return (
<div style={{ padding: 10, background: "#0b1220", borderRadius: 8, border: "1px solid #1f2937" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div style={{ fontWeight: 700, fontSize: 13, color: "white" }}>{title}</div>
<div style={{ fontSize: 12, color: "#94a3b8" }}>{status}</div>
</div>
<div style={{ marginTop: 8, display: "grid", gap: 6 }}>{children}</div>
</div>
);
}
function ResultRow({
title,
subtitle,
actionLabel,
actionTitle,
onAction,
}: {
title: string;
subtitle: string;
actionLabel: string;
actionTitle: string;
onAction: () => void;
}) {
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: 8,
borderRadius: 6,
border: "1px solid #1f2937",
background: "transparent",
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: "#e5e7eb", fontSize: 12, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{title}
</div>
<div style={{ color: "#94a3b8", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{subtitle}
</div>
</div>
<button
type="button"
onClick={onAction}
style={actionButtonStyle}
title={actionTitle}
>
{actionLabel}
</button>
</div>
);
}
function GeoResultGroup({
item,
onImportGeoFromSearch,
}: {
item: EntityGeometriesSearchItem;
onImportGeoFromSearch: (
entityItem: EntityGeometriesSearchItem,
geo: EntityGeometrySearchGeo
) => void;
}) {
const geometries = Array.isArray(item.geometries) ? item.geometries : [];
return (
<div
style={{
padding: 8,
borderRadius: 6,
border: "1px solid #1f2937",
background: "transparent",
display: "grid",
gap: 6,
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div style={{ minWidth: 0 }}>
<div style={{ color: "#e5e7eb", fontSize: 12, fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{item.name?.trim() || item.entity_id}
</div>
<div style={{ color: "#94a3b8", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{item.entity_id}
</div>
</div>
<div style={{ fontSize: 12, color: "#94a3b8", flex: "0 0 auto" }}>
{geometries.length} geos
</div>
</div>
{item.description?.trim() ? (
<div style={{ color: "#cbd5e1", fontSize: 12, lineHeight: 1.35 }}>
{item.description.trim()}
</div>
) : null}
{geometries.length ? (
<div style={{ display: "grid", gap: 6, maxHeight: 200, overflowY: "auto", paddingRight: 4 }}>
{geometries.map((geo) => (
<div
key={geo.id}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
padding: 8,
borderRadius: 6,
border: "1px solid #243244",
background: "#0f172a",
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ color: "#e5e7eb", fontSize: 12, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
#{geo.id}
</div>
<div style={{ color: "#94a3b8", fontSize: 11 }}>
type: {geo.type || "unknown"}{" "}
{geo.time_start != null || geo.time_end != null
? `| time: ${geo.time_start ?? "?"} -> ${geo.time_end ?? "?"}`
: ""}
</div>
</div>
<button
type="button"
onClick={() => onImportGeoFromSearch(item, geo)}
style={{ ...actionButtonStyle, flex: "0 0 auto" }}
title="Import geometry into current editor draft"
>
Import
</button>
</div>
))}
</div>
) : (
<div style={{ fontSize: 12, color: "#94a3b8" }}>No geometry linked.</div>
)}
</div>
);
}
function EmptyResult() {
return <div style={{ fontSize: 12, color: "#94a3b8" }}>No results.</div>;
}
const actionButtonStyle: CSSProperties = {
border: "none",
background: "#111827",
color: "#93c5fd",
cursor: "pointer",
borderRadius: 6,
padding: "6px 8px",
fontSize: 12,
fontWeight: 700,
};
+49
View File
@@ -0,0 +1,49 @@
"use client";
import type { PointerEvent as ReactPointerEvent } from "react";
type ResizeHandleProps = {
onDrag: (deltaX: number) => void;
title: string;
};
export function ResizeHandle({ onDrag, title }: ResizeHandleProps) {
// Theo dõi pointer toàn window để resize vẫn mượt khi cursor đi ra khỏi handle.
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
event.preventDefault();
const startX = event.clientX;
let lastX = startX;
const onMove = (e: PointerEvent) => {
const deltaX = e.clientX - lastX;
if (deltaX !== 0) {
onDrag(deltaX);
lastX = e.clientX;
}
};
const onUp = () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
};
return (
<div
role="separator"
aria-orientation="vertical"
title={title}
onPointerDown={handlePointerDown}
style={{
width: 6,
cursor: "col-resize",
background: "rgba(148, 163, 184, 0.08)",
borderLeft: "1px solid rgba(148, 163, 184, 0.18)",
borderRight: "1px solid rgba(148, 163, 184, 0.18)",
flex: "0 0 auto",
}}
/>
);
}
+141
View File
@@ -0,0 +1,141 @@
import type { ProjectCommit } from "@/uhm/api/projects";
import type { EntitySnapshot } from "@/uhm/types/entities";
import type { Feature, Geometry } from "@/uhm/types/geo";
import type { BattleReplay } from "@/uhm/types/projects";
import type { WikiSnapshot } from "@/uhm/types/wiki";
// Giới hạn kích thước panel khi drag resize để tránh layout bị vỡ.
export function clampNumber(value: number, min: number, max: number): number {
if (value < min) return min;
if (value > max) return max;
return value;
}
// Tạo label ngắn cho commit history, ưu tiên summary người dùng nhập.
export function formatCommitTitle(commit: ProjectCommit): string {
return commit.edit_summary?.trim() || `Commit ${commit.id.slice(0, 8)}`;
}
// Kiểm tra feature có nằm trong năm timeline đang active hay không.
export function isFeatureVisibleAtYear(feature: Feature, year: number): boolean {
const start = feature.properties.time_start;
const end = feature.properties.time_end;
if (typeof start === "number" && Number.isFinite(start) && year < start) return false;
if (typeof end === "number" && Number.isFinite(end) && year > end) return false;
return true;
}
// Chuẩn hóa wiki snapshot để so sánh dirty-state ổn định, không phụ thuộc thứ tự mảng.
export function normalizeWikisForCompare(input: WikiSnapshot[] | null | undefined) {
const list = Array.isArray(input) ? input : [];
return list
.filter((w) => w && typeof w.id === "string" && w.id.trim().length > 0)
.filter((w) => {
if (w.source === "ref") return true;
if (w.operation === "create" || w.operation === "update" || w.operation === "delete") return true;
const title = typeof w.title === "string" ? w.title.trim() : "";
const doc = typeof w.doc === "string" ? w.doc.trim() : "";
return title.length > 0 || (w.doc !== null && doc.length > 0);
})
.map((w) => ({
id: w.id,
source: w.source,
title: typeof w.title === "string" ? w.title.trim() : "",
slug: typeof w.slug === "string" ? w.slug : null,
doc: w.doc === null ? null : typeof w.doc === "string" ? w.doc.trim() : null,
}))
.sort((a, b) => a.id.localeCompare(b.id));
}
// Chuẩn hóa entity snapshot để phát hiện thay đổi name/description/source.
export function normalizeEntitiesForCompare(input: EntitySnapshot[] | null | undefined) {
const list = Array.isArray(input) ? input : [];
return list
.filter((e) => e && (typeof e.id === "string" || typeof e.id === "number"))
.map((e) => ({
id: String(e.id),
source: e.source,
name: typeof e.name === "string" ? e.name.trim() : "",
description: e.description == null ? null : String(e.description),
time_start: typeof e.time_start === "number" ? e.time_start : null,
time_end: typeof e.time_end === "number" ? e.time_end : null,
}))
.sort((a, b) => a.id.localeCompare(b.id));
}
// Chuẩn hóa binding entity-wiki để dirty check không bị nhiễu bởi thứ tự.
export function normalizeEntityWikiLinksForCompare(
input: Array<{ entity_id: string; wiki_id: string; operation?: string }> | null | undefined
) {
const list = Array.isArray(input) ? input : [];
return list
.filter((l) => l && typeof l.entity_id === "string" && typeof l.wiki_id === "string")
.map((l) => ({
entity_id: l.entity_id,
wiki_id: l.wiki_id,
operation: l.operation === "delete" ? "delete" : "binding",
}))
.sort((a, b) => (a.entity_id + a.wiki_id).localeCompare(b.entity_id + b.wiki_id));
}
// Chuẩn hóa replay để phát hiện thay đổi script/target geometry.
export function normalizeReplaysForCompare(input: BattleReplay[] | null | undefined) {
const list = Array.isArray(input) ? input : [];
return list
.filter((replay) => replay && typeof replay.geometry_id === "string" && replay.geometry_id.trim().length > 0)
.map((replay) => ({
id: typeof replay.id === "string" ? replay.id : replay.geometry_id,
geometry_id: replay.geometry_id,
target_geometry_ids: normalizeReplayTargetGeometryIdsForCompare(
replay.target_geometry_ids,
replay.geometry_id
),
detail: Array.isArray(replay.detail) ? replay.detail : [],
}))
.sort((a, b) => a.geometry_id.localeCompare(b.geometry_id));
}
// Bảo toàn geometry chính ở vị trí đầu và loại bỏ id trùng trong replay target list.
function normalizeReplayTargetGeometryIdsForCompare(
input: string[] | null | undefined,
geometryId: string
) {
const orderedIds: string[] = [];
const seen = new Set<string>();
const pushId = (rawId: string | number | null | undefined) => {
if (rawId == null) return;
const id = String(rawId).trim();
if (!id || seen.has(id)) return;
seen.add(id);
orderedIds.push(id);
};
pushId(geometryId);
for (const rawId of input || []) pushId(rawId);
return orderedIds;
}
// Validate tối thiểu geometry trả về từ search trước khi đưa vào draft.
export function normalizeGeoSearchGeometry(value: unknown): Geometry | null {
if (!value || typeof value !== "object") return null;
const geometry = value as Record<string, unknown>;
if (typeof geometry.type !== "string") return null;
if (!("coordinates" in geometry)) return null;
return value as Geometry;
}
// Chuẩn hóa danh sách binding id từ API search GEO.
export function normalizeGeoSearchBindingIds(value: unknown): string[] {
if (!Array.isArray(value)) return [];
const deduped: string[] = [];
const seen = new Set<string>();
for (const rawId of value) {
if (typeof rawId !== "string" && typeof rawId !== "number") continue;
const id = String(rawId).trim();
if (!id || seen.has(id)) continue;
seen.add(id);
deduped.push(id);
}
return deduped;
}
+2 -6
View File
@@ -43,6 +43,7 @@ export function useFeatureCommands(options: Options) {
setEntityFormStatus,
} = options;
// Áp metadata GEO (type/time/binding) cho toàn bộ selectedFeatures.
const applyGeometryMetadata = useCallback(async (): Promise<{ ok: boolean; error?: string }> => {
if (!selectedFeatures || selectedFeatures.length === 0) {
const msg = "Hãy chọn ít nhất một geometry trước.";
@@ -50,12 +51,6 @@ export function useFeatureCommands(options: Options) {
return { ok: false, error: msg };
}
if (!geometryMetaForm.time_start.trim() || !geometryMetaForm.time_end.trim()) {
const msg = "time_start và time_end là bắt buộc.";
setEntityFormStatus(msg);
return { ok: false, error: msg };
}
let metadata;
try {
metadata = buildGeometryMetadataPatch(geometryMetaForm);
@@ -90,6 +85,7 @@ export function useFeatureCommands(options: Options) {
setIsEntitySubmitting,
]);
// Áp danh sách entity đã chọn vào toàn bộ selectedFeatures.
const applyEntitiesToSelectedGeometry = useCallback(async () => {
if (!selectedFeatures || selectedFeatures.length === 0) {
setEntityFormStatus("Hãy chọn ít nhất một geometry trước.");
File diff suppressed because it is too large Load Diff
+128 -4
View File
@@ -1,7 +1,131 @@
import { redirect } from "next/navigation";
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { ApiError } from "@/uhm/api/http";
import { fetchProjects, type Project } from "@/uhm/api/projects";
export default function EditorIndexPage() {
// Editor must be opened from a specific project (see /user/projects).
redirect("/user/projects");
}
const router = useRouter();
// State danh sách project mà user hiện tại có quyền mở trong editor.
const [projects, setProjects] = useState<Project[]>([]);
// State loading cho lần tải đầu của route /editor.
const [isLoading, setIsLoading] = useState(true);
// State lỗi hiển thị trực tiếp khi API hoặc auth không hợp lệ.
const [error, setError] = useState<string | null>(null);
// Sắp xếp project mới cập nhật lên đầu để user mở nhanh project đang làm.
const sortedProjects = useMemo(() => {
return [...projects].sort((a, b) => {
const aTime = Date.parse(a.updated_at || a.created_at || "");
const bTime = Date.parse(b.updated_at || b.created_at || "");
return (Number.isFinite(bTime) ? bTime : 0) - (Number.isFinite(aTime) ? aTime : 0);
});
}, [projects]);
// Route /editor là landing page: tải project list và để /editor/[id] xử lý editor đầy đủ.
useEffect(() => {
let disposed = false;
async function loadProjects() {
try {
setIsLoading(true);
setError(null);
const rows = await fetchProjects();
if (!disposed) setProjects(rows || []);
} catch (err) {
if (disposed) return;
if (err instanceof ApiError && err.status === 401) {
router.replace("/signin");
return;
}
setError(err instanceof Error ? err.message : "Không tải được danh sách project.");
} finally {
if (!disposed) setIsLoading(false);
}
}
void loadProjects();
return () => {
disposed = true;
};
}, [router]);
return (
<main style={{ minHeight: "100vh", background: "#0b1220", color: "#e5e7eb", padding: 24 }}>
<div style={{ maxWidth: 960, margin: "0 auto" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16 }}>
<div>
<h1 style={{ margin: 0, fontSize: 28, fontWeight: 800 }}>Editor</h1>
<p style={{ margin: "8px 0 0", color: "#94a3b8", fontSize: 14 }}>
Chọn project đ mở route <code>/editor/[id]</code>.
</p>
</div>
<button
type="button"
onClick={() => router.push("/user/projects")}
style={{
border: "1px solid #334155",
background: "#111827",
color: "#e5e7eb",
borderRadius: 10,
padding: "10px 12px",
cursor: "pointer",
fontWeight: 700,
}}
>
Quản project
</button>
</div>
<section
style={{
marginTop: 24,
border: "1px solid #1f2937",
borderRadius: 16,
background: "#111827",
overflow: "hidden",
}}
>
{isLoading ? (
<div style={{ padding: 24, color: "#94a3b8" }}>Đang tải project...</div>
) : error ? (
<div style={{ padding: 24, color: "#fecaca" }}>{error}</div>
) : sortedProjects.length === 0 ? (
<div style={{ padding: 24, color: "#94a3b8" }}>
Chưa project. Vào trang quản project đ tạo mới.
</div>
) : (
<div style={{ display: "grid" }}>
{sortedProjects.map((project) => (
<button
key={project.id}
type="button"
onClick={() => router.push(`/editor/${project.id}`)}
style={{
display: "grid",
gap: 6,
textAlign: "left",
border: 0,
borderBottom: "1px solid #1f2937",
background: "transparent",
color: "#e5e7eb",
padding: 16,
cursor: "pointer",
}}
>
<span style={{ fontSize: 15, fontWeight: 800 }}>
{project.title || `Project ${project.id}`}
</span>
<span style={{ fontSize: 12, color: "#94a3b8" }}>
{project.project_status || "ACTIVE"} · {project.id}
</span>
</button>
))}
</div>
)}
</section>
</div>
</main>
);
}
+46
View File
@@ -249,6 +249,10 @@ export default function Page() {
const activeEntityGeometries = activeEntityId
? relations.entityGeometriesById[activeEntityId] || EMPTY_FEATURE_COLLECTION
: EMPTY_FEATURE_COLLECTION;
const mapLabelContextDraft = useMemo(
() => buildEntityLabelContextDraft(data, relations.geometryEntityIds, relations.entitiesById),
[data, relations.entitiesById, relations.geometryEntityIds]
);
const activeWiki = useMemo(() => {
if (!activeWikiSlug) return null;
@@ -466,6 +470,8 @@ export default function Page() {
<Map
mode="select"
draft={data}
labelContextDraft={mapLabelContextDraft}
labelTimelineYear={timelineDraftYear}
selectedFeatureIds={selectedFeatureIds}
onSelectFeatureIds={setSelectedFeatureIds}
backgroundVisibility={backgroundVisibility}
@@ -768,6 +774,46 @@ function normalizeRelationArrays(target: Record<string, string[]>) {
}
}
function buildEntityLabelContextDraft(
draft: FeatureCollection,
geometryEntityIds: Record<string, string[]>,
entitiesById: Record<string, Entity>
): FeatureCollection {
if (!draft.features.length) return draft;
return {
...draft,
features: draft.features.map((feature) => {
const entityIds = geometryEntityIds[String(feature.properties.id)] || [];
if (!entityIds.length) return feature;
const candidates = entityIds.map((id) => {
const entity = entitiesById[id] || null;
const name = String(entity?.name || id).trim();
if (!name) return null;
return {
id,
name,
time_start: entity?.time_start ?? null,
time_end: entity?.time_end ?? null,
};
}).filter((candidate) => candidate !== null);
return {
...feature,
properties: {
...feature.properties,
entity_id: entityIds[0] || null,
entity_ids: entityIds,
entity_name: candidates[0]?.name || null,
entity_names: candidates.map((candidate) => candidate.name),
entity_label_candidates: candidates,
},
};
}),
};
}
function clampNumber(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
if (value < min) return min;