refactor: migrate editor components and utilities to centralized uhm directory structure

This commit is contained in:
taDuc
2026-05-24 10:51:45 +07:00
parent c8d2415e50
commit 23b2c6f534
5 changed files with 5 additions and 5 deletions
-281
View File
@@ -1,281 +0,0 @@
"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
@@ -1,49 +0,0 @@
"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",
}}
/>
);
}
-142
View File
@@ -1,142 +0,0 @@
import type { ProjectCommit } from "@/uhm/api/projects";
import type { EntitySnapshot } from "@/uhm/types/entities";
import type { Feature, Geometry } from "@/uhm/types/geo";
import type { BattleReplay } from "@/uhm/types/projects";
import type { WikiSnapshot } from "@/uhm/types/wiki";
import { normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline";
// Giới hạn kích thước panel khi drag resize để tránh layout bị vỡ.
export function clampNumber(value: number, min: number, max: number): number {
if (value < min) return min;
if (value > max) return max;
return value;
}
// Tạo label ngắn cho commit history, ưu tiên summary người dùng nhập.
export function formatCommitTitle(commit: ProjectCommit): string {
return commit.edit_summary?.trim() || `Commit ${commit.id.slice(0, 8)}`;
}
// Kiểm tra feature có nằm trong năm timeline đang active hay không.
export function isFeatureVisibleAtYear(feature: Feature, year: number): boolean {
const start = normalizeTimelineYearValue(feature.properties.time_start);
const end = normalizeTimelineYearValue(feature.properties.time_end);
if (start !== null && year < start) return false;
if (end !== null && year > end) return false;
return true;
}
// Chuẩn hóa wiki snapshot để so sánh dirty-state ổn định, không phụ thuộc thứ tự mảng.
export function normalizeWikisForCompare(input: WikiSnapshot[] | null | undefined) {
const list = Array.isArray(input) ? input : [];
return list
.filter((w) => w && typeof w.id === "string" && w.id.trim().length > 0)
.filter((w) => {
if (w.source === "ref") return true;
if (w.operation === "create" || w.operation === "update" || w.operation === "delete") return true;
const title = typeof w.title === "string" ? w.title.trim() : "";
const doc = typeof w.doc === "string" ? w.doc.trim() : "";
return title.length > 0 || (w.doc !== null && doc.length > 0);
})
.map((w) => ({
id: w.id,
source: w.source,
title: typeof w.title === "string" ? w.title.trim() : "",
slug: typeof w.slug === "string" ? w.slug : null,
doc: w.doc === null ? null : typeof w.doc === "string" ? w.doc.trim() : null,
}))
.sort((a, b) => a.id.localeCompare(b.id));
}
// Chuẩn hóa entity snapshot để phát hiện thay đổi name/description/source.
export function normalizeEntitiesForCompare(input: EntitySnapshot[] | null | undefined) {
const list = Array.isArray(input) ? input : [];
return list
.filter((e) => e && (typeof e.id === "string" || typeof e.id === "number"))
.map((e) => ({
id: String(e.id),
source: e.source,
name: typeof e.name === "string" ? e.name.trim() : "",
description: e.description == null ? null : String(e.description),
time_start: normalizeTimelineYearValue(e.time_start),
time_end: normalizeTimelineYearValue(e.time_end),
}))
.sort((a, b) => a.id.localeCompare(b.id));
}
// Chuẩn hóa binding entity-wiki để dirty check không bị nhiễu bởi thứ tự.
export function normalizeEntityWikiLinksForCompare(
input: Array<{ entity_id: string; wiki_id: string; operation?: string }> | null | undefined
) {
const list = Array.isArray(input) ? input : [];
return list
.filter((l) => l && typeof l.entity_id === "string" && typeof l.wiki_id === "string")
.map((l) => ({
entity_id: l.entity_id,
wiki_id: l.wiki_id,
operation: l.operation === "delete" ? "delete" : "binding",
}))
.sort((a, b) => (a.entity_id + a.wiki_id).localeCompare(b.entity_id + b.wiki_id));
}
// Chuẩn hóa replay để phát hiện thay đổi script/target geometry.
export function normalizeReplaysForCompare(input: BattleReplay[] | null | undefined) {
const list = Array.isArray(input) ? input : [];
return list
.filter((replay) => replay && typeof replay.geometry_id === "string" && replay.geometry_id.trim().length > 0)
.map((replay) => ({
id: typeof replay.id === "string" ? replay.id : replay.geometry_id,
geometry_id: replay.geometry_id,
target_geometry_ids: normalizeReplayTargetGeometryIdsForCompare(
replay.target_geometry_ids,
replay.geometry_id
),
detail: Array.isArray(replay.detail) ? replay.detail : [],
}))
.sort((a, b) => a.geometry_id.localeCompare(b.geometry_id));
}
// Bảo toàn geometry chính ở vị trí đầu và loại bỏ id trùng trong replay target list.
function normalizeReplayTargetGeometryIdsForCompare(
input: string[] | null | undefined,
geometryId: string
) {
const orderedIds: string[] = [];
const seen = new Set<string>();
const pushId = (rawId: string | number | null | undefined) => {
if (rawId == null) return;
const id = String(rawId).trim();
if (!id || seen.has(id)) return;
seen.add(id);
orderedIds.push(id);
};
pushId(geometryId);
for (const rawId of input || []) pushId(rawId);
return orderedIds;
}
// Validate tối thiểu geometry trả về từ search trước khi đưa vào draft.
export function normalizeGeoSearchGeometry(value: unknown): Geometry | null {
if (!value || typeof value !== "object") return null;
const geometry = value as Record<string, unknown>;
if (typeof geometry.type !== "string") return null;
if (!("coordinates" in geometry)) return null;
return value as Geometry;
}
// Chuẩn hóa danh sách binding id từ API search GEO.
export function normalizeGeoSearchBindingIds(value: unknown): string[] {
if (!Array.isArray(value)) return [];
const deduped: string[] = [];
const seen = new Set<string>();
for (const rawId of value) {
if (typeof rawId !== "string" && typeof rawId !== "number") continue;
const id = String(rawId).trim();
if (!id || seen.has(id)) continue;
seen.add(id);
deduped.push(id);
}
return deduped;
}
-131
View File
@@ -1,131 +0,0 @@
"use client";
import { useCallback } from "react";
import type { Dispatch, SetStateAction } from "react";
import type { Entity } from "@/uhm/types/entities";
import type { Feature, FeatureProperties } from "@/uhm/types/geo";
import { ApiError } from "@/uhm/api/http";
import { buildFeatureEntityPatch } from "@/uhm/lib/editor/entity/entityBinding";
import { buildGeometryMetadataPatch } from "@/uhm/lib/editor/geometry/geometryMetadata";
import { uniqueEntityIds } from "@/uhm/lib/editor/snapshot/editorSnapshot";
import type { GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionTypes";
type EditorDraftApi = {
patchFeatureProperties: (id: FeatureProperties["id"], patch: Partial<FeatureProperties>) => void;
patchFeaturePropertiesBatch: (
patches: Array<{ id: FeatureProperties["id"]; patch: Partial<FeatureProperties> }>,
label?: string
) => void;
};
type Options = {
editor: EditorDraftApi;
selectedFeatures: Feature[];
geometryMetaForm: GeometryMetaFormState;
setGeometryMetaForm: Dispatch<SetStateAction<GeometryMetaFormState>>;
selectedGeometryEntityIds: string[];
setSelectedGeometryEntityIds: Dispatch<SetStateAction<string[]>>;
entities: Entity[];
setIsEntitySubmitting: Dispatch<SetStateAction<boolean>>;
setEntityFormStatus: Dispatch<SetStateAction<string | null>>;
};
export function useFeatureCommands(options: Options) {
const {
editor,
selectedFeatures,
geometryMetaForm,
setGeometryMetaForm,
selectedGeometryEntityIds,
setSelectedGeometryEntityIds,
entities,
setIsEntitySubmitting,
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.";
setEntityFormStatus(msg);
return { ok: false, error: msg };
}
let metadata;
try {
metadata = buildGeometryMetadataPatch(geometryMetaForm);
} catch (err) {
const msg = err instanceof Error ? err.message : "Thời gian không hợp lệ.";
setEntityFormStatus(msg);
return { ok: false, error: msg };
}
setIsEntitySubmitting(true);
setEntityFormStatus(null);
try {
editor.patchFeaturePropertiesBatch(
selectedFeatures.map((feature) => ({
id: feature.properties.id,
patch: metadata.patch,
})),
"Cập nhật thuộc tính GEO"
);
setGeometryMetaForm(metadata.formState);
setEntityFormStatus("Đã cập nhật thuộc tính GEO. Commit khi sẵn sàng.");
return { ok: true };
} finally {
setIsEntitySubmitting(false);
}
}, [
editor,
geometryMetaForm,
selectedFeatures,
setEntityFormStatus,
setGeometryMetaForm,
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.");
return;
}
const entityIds = uniqueEntityIds(selectedGeometryEntityIds);
setIsEntitySubmitting(true);
setEntityFormStatus(null);
try {
editor.patchFeaturePropertiesBatch(
selectedFeatures.map((feature) => ({
id: feature.properties.id,
patch: buildFeatureEntityPatch(feature, entityIds, entities),
})),
"Cập nhật entity cho GEO"
);
setSelectedGeometryEntityIds(entityIds);
setEntityFormStatus("Đã cập nhật danh sách entity. Commit khi sẵn sàng.");
} catch (err) {
if (err instanceof ApiError) {
setEntityFormStatus(`Lưu thất bại: ${err.body}`);
} else {
setEntityFormStatus("Lưu thất bại.");
}
} finally {
setIsEntitySubmitting(false);
}
}, [
editor,
entities,
selectedFeatures,
selectedGeometryEntityIds,
setEntityFormStatus,
setIsEntitySubmitting,
setSelectedGeometryEntityIds,
]);
return {
applyGeometryMetadata,
applyEntitiesToSelectedGeometry,
};
}
+4 -4
View File
@@ -53,7 +53,7 @@ import {
type MapImageOverlay,
} from "@/uhm/components/map/imageOverlay";
import { FIXED_TIMELINE_RANGE, clampYearToFixedRange, normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline";
import { useFeatureCommands } from "./featureCommands";
import { useFeatureCommands } from "@/uhm/lib/editor/geometry/useFeatureCommands";
import { deleteSubmission } from "@/uhm/api/projects";
import type { WikiSnapshot } from "@/uhm/types/wiki";
import type { BattleReplay, EntityWikiLinkSnapshot } from "@/uhm/types/projects";
@@ -62,8 +62,8 @@ import {
useEditorStore,
useEditorStoreApi,
} from "@/uhm/store/editorStore";
import { EditorSearchResults } from "./EditorSearchResults";
import { ResizeHandle } from "./ResizeHandle";
import { EditorSearchResults } from "@/uhm/components/editor/EditorSearchResults";
import { ResizeHandle } from "@/uhm/components/ui/ResizeHandle";
import {
clampNumber,
formatCommitTitle,
@@ -74,7 +74,7 @@ import {
normalizeGeoSearchGeometry,
normalizeReplaysForCompare,
normalizeWikisForCompare,
} from "./editorPageUtils";
} from "@/uhm/lib/editor/editorPageUtils";
const CURRENT_YEAR = new Date().getUTCFullYear();
const DEFAULT_EDITOR_USER_ID = "local-editor";