add editor

This commit is contained in:
taDuc
2026-05-02 02:48:17 +07:00
parent 41af501b51
commit a74047fd09
62 changed files with 9049 additions and 9 deletions
+9
View File
@@ -0,0 +1,9 @@
"use client";
// FrontEndAdmin is the primary FE and follows BackEndGo cookie-based auth.
// Users sign in via the app's /signin page; the editor reuses those httpOnly cookies.
// This component remains as a no-op placeholder for any legacy imports.
export default function AuthPanel() {
return null;
}
@@ -0,0 +1,95 @@
"use client";
import { ReactNode } from "react";
import {
BACKGROUND_LAYER_OPTIONS,
BackgroundLayerId,
BackgroundLayerVisibility,
} from "@/uhm/lib/backgroundLayers";
type Props = {
visibility: BackgroundLayerVisibility;
onToggleLayer: (id: BackgroundLayerId) => void;
onShowAll: () => void;
onHideAll: () => void;
topContent?: ReactNode;
};
export default function BackgroundLayersPanel({
visibility,
onToggleLayer,
onShowAll,
onHideAll,
topContent,
}: Props) {
return (
<aside
style={{
width: "240px",
background: "#111827",
color: "#e5e7eb",
borderLeft: "1px solid #1f2937",
padding: "12px",
height: "100vh",
overflowY: "auto",
}}
>
{topContent ? <div style={{ marginBottom: "12px" }}>{topContent}</div> : null}
<h3 style={{ margin: 0, marginBottom: "10px" }}>Map Layers</h3>
<div style={{ display: "flex", gap: "8px", marginBottom: "12px" }}>
<button
onClick={onShowAll}
style={{
flex: 1,
border: "none",
borderRadius: "6px",
padding: "6px 8px",
cursor: "pointer",
background: "#374151",
color: "#f9fafb",
}}
>
Bật hết
</button>
<button
onClick={onHideAll}
style={{
flex: 1,
border: "none",
borderRadius: "6px",
padding: "6px 8px",
cursor: "pointer",
background: "#1f2937",
color: "#f9fafb",
}}
>
Tắt hết
</button>
</div>
<div style={{ display: "grid", gap: "8px" }}>
{BACKGROUND_LAYER_OPTIONS.map((layer) => (
<label
key={layer.id}
style={{
display: "flex",
alignItems: "center",
gap: "8px",
fontSize: "14px",
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={visibility[layer.id]}
onChange={() => onToggleLayer(layer.id)}
/>
<span>{layer.label}</span>
</label>
))}
</div>
</aside>
);
}
+349
View File
@@ -0,0 +1,349 @@
"use client";
import { useEffect, useMemo, type ComponentProps } from "react";
import Tree from "react-d3-tree";
export type CommitTreeItem = {
id: string;
parent_commit_id: string | null;
restored_from_commit_id: string | null;
commit_no: number;
kind: string;
created_by: string;
created_at: string;
title: string | null;
};
type CommitTreeNode = {
commit: CommitTreeItem;
children: CommitTreeNode[];
};
type CommitTreeDatum = {
name: string;
commit: CommitTreeItem;
isHead: boolean;
detail: string;
restoredFromLabel: string | null;
children?: CommitTreeDatum[];
};
type Props = {
open: boolean;
commits: CommitTreeItem[];
headCommitId: string | null;
onClose: () => void;
};
type TreeRenderNode = NonNullable<ComponentProps<typeof Tree>["renderCustomNodeElement"]>;
export default function CommitTreePopup({
open,
commits,
headCommitId,
onClose,
}: Props) {
const { roots, commitById } = useMemo(() => buildCommitTree(commits), [commits]);
const treeData = useMemo(
() => roots.map((node) => toTreeDatum(node, commitById, headCommitId)),
[roots, commitById, headCommitId]
);
useEffect(() => {
if (!open) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [open, onClose]);
if (!open) return null;
return (
<div
role="presentation"
onClick={onClose}
style={{
position: "fixed",
inset: 0,
zIndex: 1000,
background: "rgba(2, 6, 23, 0.72)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "24px",
}}
>
<section
role="dialog"
aria-modal="true"
aria-label="Commit tree"
onClick={(event) => event.stopPropagation()}
style={{
width: "min(1120px, calc(100vw - 48px))",
maxHeight: "min(720px, calc(100vh - 48px))",
overflow: "hidden",
border: "1px solid #334155",
borderRadius: "8px",
background: "#0f172a",
color: "#e2e8f0",
boxShadow: "0 24px 80px rgba(0, 0, 0, 0.45)",
display: "flex",
flexDirection: "column",
}}
>
<style>
{`
.commit-tree-link {
fill: none;
stroke: #ffffff;
stroke-width: 4px;
stroke-opacity: 1;
filter: drop-shadow(0 0 5px rgba(255, 255, 255, 0.75));
}
`}
</style>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "12px",
padding: "14px 16px",
borderBottom: "1px solid #1f2937",
}}
>
<div>
<div style={{ fontSize: "16px", fontWeight: 700, color: "#f8fafc" }}>
Commit tree
</div>
<div style={{ marginTop: "3px", fontSize: "12px", color: "#94a3b8" }}>
{commits.length} commit{commits.length === 1 ? "" : "s"}
</div>
</div>
<button
type="button"
onClick={onClose}
style={{
padding: "7px 10px",
border: "1px solid #475569",
borderRadius: "4px",
background: "#111827",
color: "#f8fafc",
cursor: "pointer",
}}
>
Close
</button>
</div>
<div
style={{
padding: "16px",
overflow: "auto",
}}
>
{treeData.length === 0 ? (
<div style={{ color: "#94a3b8", fontSize: "14px" }}>
Chưa commit.
</div>
) : (
<div
style={{
width: "100%",
minWidth: "640px",
height: "540px",
border: "1px solid #64748b",
borderRadius: "6px",
background: "#111827",
overflow: "hidden",
}}
>
<Tree
data={treeData}
orientation="vertical"
translate={{ x: 520, y: 56 }}
nodeSize={{ x: 300, y: 165 }}
separation={{ siblings: 1.15, nonSiblings: 1.45 }}
pathFunc="step"
collapsible={false}
zoomable
draggable
scaleExtent={{ min: 0.45, max: 1.4 }}
renderCustomNodeElement={renderCommitTreeNode}
pathClassFunc={() => "commit-tree-link"}
/>
</div>
)}
</div>
</section>
</div>
);
}
const renderCommitTreeNode: TreeRenderNode = function renderCommitTreeNode({ nodeDatum }) {
const datum = nodeDatum as unknown as CommitTreeDatum;
const commit = datum.commit;
const isHead = datum.isHead;
return (
<g>
<circle
r={8}
fill={isHead ? "#16a34a" : "#111827"}
stroke={isHead ? "#bbf7d0" : "#f8fafc"}
strokeWidth={3}
/>
<foreignObject x={-115} y={18} width={230} height={96}>
<div
style={{
width: "220px",
minHeight: "78px",
padding: "8px 9px",
border: isHead ? "2px solid #86efac" : "2px solid #e2e8f0",
borderRadius: "6px",
background: isHead ? "#14532d" : "#1f2937",
color: "#f8fafc",
fontSize: "12px",
lineHeight: 1.35,
boxSizing: "border-box",
overflow: "hidden",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "6px",
minWidth: 0,
}}
>
<span style={{ color: "#f8fafc", fontWeight: 700 }}>
#{commit.commit_no}
</span>
{isHead ? (
<span
style={{
padding: "1px 5px",
border: "1px solid #22c55e",
borderRadius: "4px",
color: "#bbf7d0",
fontSize: "10px",
fontWeight: 700,
}}
>
HEAD
</span>
) : null}
</div>
<div
title={formatCommitTitle(commit)}
style={{
marginTop: "4px",
color: "#f8fafc",
fontWeight: 700,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{formatCommitTitle(commit)}
</div>
<div style={{ marginTop: "4px", color: "#94a3b8" }}>
{datum.detail}
</div>
{datum.restoredFromLabel ? (
<div
title={datum.restoredFromLabel}
style={{
marginTop: "3px",
color: "#93c5fd",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{datum.restoredFromLabel}
</div>
) : null}
</div>
</foreignObject>
</g>
);
};
function buildCommitTree(commits: CommitTreeItem[]) {
const commitById = new Map<string, CommitTreeItem>();
const nodeById = new Map<string, CommitTreeNode>();
for (const commit of commits) {
commitById.set(commit.id, commit);
nodeById.set(commit.id, { commit, children: [] });
}
const roots: CommitTreeNode[] = [];
for (const node of nodeById.values()) {
const parentId = getDisplayParentCommitId(node.commit);
const parent = parentId ? nodeById.get(parentId) : null;
if (parent) {
parent.children.push(node);
} else {
roots.push(node);
}
}
const sortNodes = (nodes: CommitTreeNode[]) => {
nodes.sort((a, b) => a.commit.commit_no - b.commit.commit_no);
for (const node of nodes) {
sortNodes(node.children);
}
};
sortNodes(roots);
return { roots, commitById };
}
function toTreeDatum(
node: CommitTreeNode,
commitById: Map<string, CommitTreeItem>,
headCommitId: string | null
): CommitTreeDatum {
const commit = node.commit;
const restoredFromCommit = commit.restored_from_commit_id
? commitById.get(commit.restored_from_commit_id) || null
: null;
const children = node.children.map((child) => toTreeDatum(child, commitById, headCommitId));
return {
name: formatCommitTitle(commit),
commit,
isHead: headCommitId === commit.id,
detail: `${commit.kind} by ${commit.created_by} - ${formatDateTime(commit.created_at)}`,
restoredFromLabel: restoredFromCommit
? `restored from #${restoredFromCommit.commit_no} ${formatCommitTitle(restoredFromCommit)}`
: null,
children: children.length ? children : undefined,
};
}
function getDisplayParentCommitId(commit: CommitTreeItem): string | null {
if (commit.kind === "restore" && commit.restored_from_commit_id) {
return commit.restored_from_commit_id;
}
return commit.parent_commit_id;
}
function formatCommitTitle(commit: CommitTreeItem): string {
return commit.title?.trim() || `Commit #${commit.commit_no}`;
}
function formatDateTime(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
}
+491
View File
@@ -0,0 +1,491 @@
"use client";
import { UndoAction } from "@/uhm/lib/useEditorState";
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
type Props = {
mode: EditorMode;
setMode: (mode: EditorMode) => void;
entityStatus?: string | null;
onUndo: () => void;
onCommit: () => void;
onSubmit: () => void;
onRestoreCommit: (commitId: string) => void;
isSaving: boolean;
isSubmitting: boolean;
sectionTitle: string;
sectionStatus: string;
commitTitle: string;
commitNote: string;
onCommitTitleChange: (title: string) => void;
onCommitNoteChange: (note: string) => void;
commitCount: number;
hasHeadCommit: boolean;
headCommitId: string | null;
latestCommitLabel: string | null;
commits: Array<{
id: string;
created_at?: string;
edit_summary: string;
user_id: string;
}>;
changesCount: number;
undoStack: UndoAction[];
createdEntities: Array<{
id: string;
name: string;
type_id?: string | null;
}>;
createdGeometries: Array<{
id: string | number;
geometryType: string;
semanticType?: string | null;
entityNames: string[];
}>;
};
export default function Editor({
mode,
setMode,
entityStatus,
onUndo,
onCommit,
onSubmit,
onRestoreCommit,
isSaving,
isSubmitting,
sectionTitle,
sectionStatus,
commitTitle,
commitNote,
onCommitTitleChange,
onCommitNoteChange,
commitCount,
hasHeadCommit,
headCommitId,
latestCommitLabel,
commits,
changesCount,
undoStack,
createdEntities,
createdGeometries,
}: Props) {
const formatCommitTitle = (commit: Props["commits"][number]) =>
commit.edit_summary?.trim() || `Commit ${commit.id.slice(0, 8)}`;
const toggleMode = (newMode: EditorMode) => {
if (mode === newMode) {
setMode("idle"); // bấm lại → tắt
} else {
setMode(newMode); // chuyển mode
}
};
// Lấy tối đa 8 tác vụ mới nhất, bỏ trùng nhãn (cùng loại/cùng id)
const recentUndoLabels = (() => {
const seen = new Set<string>();
const labels: string[] = [];
for (let i = undoStack.length - 1; i >= 0 && labels.length < 8; i -= 1) {
const label = formatUndoLabel(undoStack[i]);
if (seen.has(label)) continue;
seen.add(label);
labels.push(label);
}
return labels.reverse();
})();
const getButtonStyle = (btnMode: EditorMode) => ({
width: "100%",
padding: "8px",
marginBottom: "6px",
border: "none",
cursor: "pointer",
background: mode === btnMode ? "#4caf50" : "#222",
color: "white",
borderRadius: "4px",
});
return (
<div
style={{
width: "220px",
height: "100vh",
overflowY: "auto",
background: "#111",
color: "white",
padding: "12px",
borderRight: "1px solid #333",
}}
>
<h3 style={{ marginBottom: "10px" }}>Editor</h3>
<div
style={{
marginBottom: "12px",
padding: "10px",
background: "#0b1220",
borderRadius: "6px",
border: "1px solid #1f2937",
fontSize: "12px",
color: "#cbd5e1",
}}
>
<div style={{ color: "white", fontWeight: 600 }}>{sectionTitle}</div>
<div style={{ marginTop: "4px" }}>Status: {sectionStatus}</div>
<div>Commits: {commitCount}</div>
<div>{latestCommitLabel || "Chưa có commit"}</div>
</div>
<div
style={{
marginBottom: "12px",
padding: "10px",
background: "#0b1220",
borderRadius: "6px",
border: "1px solid #1f2937",
fontSize: "12px",
color: "#cbd5e1",
}}
>
<div style={{ marginBottom: "8px", fontWeight: 600, color: "white" }}>Project</div>
</div>
<button
style={getButtonStyle("draw")}
onClick={() => toggleMode("draw")}
>
Draw
</button>
<button
style={getButtonStyle("select")}
onClick={() => toggleMode("select")}
>
Select
</button>
<button
style={getButtonStyle("idle")}
onClick={() => setMode("idle")}
>
Idle
</button>
<button
style={getButtonStyle("add-point")}
onClick={() => setMode("add-point")}
>
Add point
</button>
<button
style={getButtonStyle("add-line")}
onClick={() => setMode("add-line")}
>
Add line
</button>
<button
style={getButtonStyle("add-path")}
onClick={() => setMode("add-path")}
>
Add path
</button>
<button
style={getButtonStyle("add-circle")}
onClick={() => setMode("add-circle")}
>
Add circle
</button>
<div style={{ marginTop: "12px", fontSize: "14px" }}>
Mode: <b>{mode}</b>
</div>
{mode === "add-line" ? (
<div style={{ marginTop: "6px", fontSize: "12px", color: "#93c5fd" }}>
Click đ thêm điểm, Enter đ hoàn tất, Esc đ hủy.
</div>
) : null}
{mode === "add-path" ? (
<div style={{ marginTop: "6px", fontSize: "12px", color: "#93c5fd" }}>
Click đ thêm điểm, Enter đ hoàn tất, Esc đ hủy.
</div>
) : null}
{mode === "add-circle" ? (
<div style={{ marginTop: "6px", fontSize: "12px", color: "#93c5fd" }}>
Giữ chuột trái kéo đ mở bán kính, thả chuột đ hoàn tất.
</div>
) : null}
{entityStatus ? (
<div
style={{
marginTop: "12px",
padding: "10px",
background: "#0b1220",
borderRadius: "6px",
border: "1px solid #1f2937",
color: "#fca5a5",
fontSize: "12px",
}}
>
{entityStatus}
</div>
) : null}
<div style={{ marginTop: "12px" }}>
<button
style={{
width: "100%",
padding: "8px",
borderRadius: "4px",
border: "none",
cursor: "pointer",
background: "#334155",
color: "white",
}}
onClick={onUndo}
>
Undo
</button>
</div>
<input
value={commitTitle}
onChange={(event) => onCommitTitleChange(event.target.value)}
placeholder="Commit title"
disabled={isSaving || isSubmitting}
style={{
width: "100%",
marginTop: "8px",
padding: "7px",
borderRadius: "4px",
border: "1px solid #334155",
background: "#111827",
color: "white",
boxSizing: "border-box",
}}
/>
<textarea
value={commitNote}
onChange={(event) => onCommitNoteChange(event.target.value)}
placeholder="Commit note"
disabled={isSaving || isSubmitting}
rows={3}
style={{
width: "100%",
marginTop: "8px",
padding: "7px",
borderRadius: "4px",
border: "1px solid #334155",
background: "#111827",
color: "white",
boxSizing: "border-box",
resize: "vertical",
fontFamily: "inherit",
}}
/>
<div style={{ display: "grid", gridTemplateColumns: "1fr 68px", gap: "8px", marginTop: "8px" }}>
<button
style={{
width: "100%",
padding: "8px",
borderRadius: "4px",
border: "none",
cursor: isSaving || isSubmitting ? "not-allowed" : "pointer",
background: isSaving || isSubmitting ? "#555" : "#0f766e",
color: "white",
}}
onClick={onCommit}
disabled={isSaving || isSubmitting}
>
Commit ({changesCount})
</button>
<div />
</div>
<button
style={{
width: "100%",
marginTop: "8px",
padding: "8px",
borderRadius: "4px",
border: "none",
cursor: isSubmitting || !hasHeadCommit ? "not-allowed" : "pointer",
background: isSubmitting || !hasHeadCommit ? "#555" : "#16a34a",
color: "white",
opacity: !hasHeadCommit ? 0.6 : 1,
}}
onClick={onSubmit}
disabled={isSubmitting || !hasHeadCommit}
>
Submit
</button>
<div
style={{
marginTop: "16px",
padding: "10px",
background: "#0b1220",
borderRadius: "6px",
border: "1px solid #1f2937",
}}
>
<div style={{ marginBottom: "8px", fontWeight: 600, fontSize: "14px" }}>
Commit history
</div>
{commits.length === 0 ? (
<div style={{ color: "#64748b", fontSize: "12px" }}>
Chưa commit
</div>
) : (
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: "12px" }}>
{commits.slice(0, 8).map((commit) => (
<li
key={commit.id}
style={{
padding: "6px 0",
borderBottom: "1px solid #1f2937",
color: "#e2e8f0",
}}
>
<div
title={formatCommitTitle(commit)}
style={{
fontWeight: 600,
color: "#f8fafc",
overflowWrap: "anywhere",
}}
>
{formatCommitTitle(commit)}
</div>
<div style={{ marginTop: "2px", color: "#94a3b8" }}>
{commit.created_at ? new Date(commit.created_at).toLocaleString() : ""}
</div>
<button
style={{
marginTop: "4px",
padding: "4px 6px",
borderRadius: "4px",
border: "none",
background: "#334155",
color: "white",
cursor: isSaving || isSubmitting ? "not-allowed" : "pointer",
}}
onClick={() => onRestoreCommit(commit.id)}
disabled={isSaving || isSubmitting}
>
Restore
</button>
</li>
))}
</ul>
)}
</div>
<div
style={{
marginTop: "16px",
padding: "10px",
background: "#0b1220",
borderRadius: "6px",
border: "1px solid #1f2937",
}}
>
<div style={{ marginBottom: "6px", fontWeight: 600, fontSize: "14px" }}>
Tác vụ thể undo ({recentUndoLabels.length})
</div>
{recentUndoLabels.length === 0 ? (
<div style={{ color: "#94a3b8", fontSize: "13px" }}>Chưa thao tác</div>
) : (
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: "13px", color: "#e2e8f0" }}>
{recentUndoLabels.map((label, idx) => (
<li key={`${label}-${idx}`} style={{ padding: "4px 0", borderBottom: "1px solid #1f2937" }}>
{label}
</li>
))}
</ul>
)}
</div>
<div
style={{
marginTop: "16px",
padding: "10px",
background: "#0b1220",
borderRadius: "6px",
border: "1px solid #1f2937",
}}
>
<div style={{ marginBottom: "8px", fontWeight: 600, fontSize: "14px" }}>
Mới tạo trong phiên
</div>
<div style={{ fontSize: "13px", color: "#cbd5e1", marginBottom: "6px" }}>
Entities ({createdEntities.length})
</div>
{createdEntities.length === 0 ? (
<div style={{ color: "#64748b", fontSize: "12px", marginBottom: "10px" }}>
Chưa tạo entity mới
</div>
) : (
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: "12px", marginBottom: "10px" }}>
{createdEntities.map((entity) => (
<li
key={entity.id}
style={{
padding: "4px 0",
borderBottom: "1px solid #1f2937",
color: "#e2e8f0",
}}
title={entity.id}
>
{entity.name} ({entity.type_id || "country"})
</li>
))}
</ul>
)}
<div style={{ fontSize: "13px", color: "#cbd5e1", marginBottom: "6px" }}>
Geometries mới chưa commit ({createdGeometries.length})
</div>
{createdGeometries.length === 0 ? (
<div style={{ color: "#64748b", fontSize: "12px" }}>
Chưa geometry mới chờ commit
</div>
) : (
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: "12px" }}>
{createdGeometries.map((geometry) => (
<li
key={String(geometry.id)}
style={{
padding: "4px 0",
borderBottom: "1px solid #1f2937",
color: "#e2e8f0",
}}
>
#{geometry.id} [{geometry.geometryType}] {geometry.semanticType ? `- ${geometry.semanticType}` : ""}
{geometry.entityNames.length ? ` | ${geometry.entityNames.join(", ")}` : ""}
</li>
))}
</ul>
)}
</div>
</div>
);
}
function formatUndoLabel(action: UndoAction) {
switch (action.type) {
case "create":
return `Thêm mới #${action.id}`;
case "delete":
return `Xóa #${action.feature.properties.id}`;
case "update":
return `Chỉnh sửa #${action.id}`;
case "properties":
return `Cập nhật thuộc tính #${action.id}`;
default:
return "Tác vụ";
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,493 @@
"use client";
import { type CSSProperties } from "react";
import { Entity } from "@/uhm/api/entities";
import { Feature } from "@/uhm/lib/useEditorState";
import {
EntityGeometryPreset,
EntityTypeGroupId,
EntityTypeOption,
findEntityTypeOption,
groupEntityTypeOptions,
} from "@/uhm/lib/entityTypeOptions";
import type { EntityFormState, GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionTypes";
type Props = {
selectedFeature: Feature | null;
selectedFeatureEntitySummary: string;
selectedFeatureBindingSummary: string;
entities: Entity[];
selectedGeometryEntityIds: string[];
onEntityIdsChange: (values: string[]) => void;
entitySearchQuery: string;
onEntitySearchQueryChange: (value: string) => void;
entitySearchResults: Entity[];
selectedSearchEntityId: string | null;
onSelectSearchEntityId: (value: string | null) => void;
onAddSelectedSearchEntity: () => void;
isEntitySearchLoading: boolean;
entityForm: EntityFormState;
onEntityFormChange: (key: keyof EntityFormState, value: string) => void;
entityTypeOptions: EntityTypeOption[];
geometryMetaForm: GeometryMetaFormState;
onGeometryMetaFormChange: (key: keyof GeometryMetaFormState, value: string) => void;
isEntitySubmitting: boolean;
onCreateEntityOnly: () => void;
onApplyGeometryMetadata: () => void;
onApplyEntitiesForSelectedGeometry: () => void;
changeCount: number;
entityFormStatus: string | null;
};
export default function SelectedGeometryPanel({
selectedFeature,
selectedFeatureEntitySummary,
selectedFeatureBindingSummary,
entities,
selectedGeometryEntityIds,
onEntityIdsChange,
entitySearchQuery,
onEntitySearchQueryChange,
entitySearchResults,
selectedSearchEntityId,
onSelectSearchEntityId,
onAddSelectedSearchEntity,
isEntitySearchLoading,
entityForm,
onEntityFormChange,
entityTypeOptions,
geometryMetaForm,
onGeometryMetaFormChange,
isEntitySubmitting,
onCreateEntityOnly,
onApplyGeometryMetadata,
onApplyEntitiesForSelectedGeometry,
changeCount,
entityFormStatus,
}: Props) {
const groupedEntityTypeOptions = groupEntityTypeOptions(entityTypeOptions);
const featureGeometryPreset = selectedFeature
? resolveFeatureGeometryPreset(selectedFeature)
: null;
const allowedGroupIds = featureGeometryPreset
? getAllowedGroupIdsForPreset(featureGeometryPreset)
: [];
const visibleGroupedEntityTypeOptions = groupedEntityTypeOptions.filter((group) =>
allowedGroupIds.includes(group.id)
);
const groupedEntityTypeOptionsForCreate = selectedFeature
? visibleGroupedEntityTypeOptions
: groupedEntityTypeOptions;
const selectedTypeOption = findEntityTypeOption(entityForm.type_id);
const hasCurrentVisibleTypeOption = groupedEntityTypeOptionsForCreate.some((group) =>
group.options.some((option) => option.value === entityForm.type_id)
);
return (
<div
style={{
padding: "10px",
background: "#0b1220",
borderRadius: "8px",
border: "1px solid #1f2937",
}}
>
<div style={{ fontWeight: 700, marginBottom: "8px", fontSize: "14px" }}>
Entity & Geometry
</div>
{!selectedFeature ? (
<div style={{ color: "#94a3b8", fontSize: "13px" }}>
Chưa chọn geometry. Tạo entity mới khối bên dưới, hoặc vào mode Select đ bind entity cho geometry.
</div>
) : (
<div style={{ display: "grid", gap: "8px", fontSize: "13px" }}>
<div style={{ color: "#e2e8f0" }}>
ID: {String(selectedFeature.properties.id)}
</div>
<div style={{ color: "#cbd5e1" }}>
Entities hiện tại: {selectedFeatureEntitySummary}
</div>
<div style={{ color: "#cbd5e1" }}>
Binding hiện tại: {selectedFeatureBindingSummary}
</div>
<div style={{ color: "#cbd5e1" }}>
Geometry preset: {formatGeometryPresetLabel(featureGeometryPreset)}
</div>
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
Entities đã chọn:
</div>
{selectedGeometryEntityIds.length ? (
<div style={{ display: "grid", gap: "6px" }}>
{selectedGeometryEntityIds.map((entityId) => {
const entity = entities.find((item) => item.id === entityId) || null;
const label = entity?.name
? `${entity.name} (${entityId})`
: entityId;
return (
<div
key={entityId}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "8px",
background: "#111827",
border: "1px solid #334155",
borderRadius: "6px",
padding: "6px 8px",
}}
>
<span style={{ color: "#e2e8f0" }}>{label}</span>
<button
type="button"
onClick={() =>
onEntityIdsChange(
selectedGeometryEntityIds.filter((id) => id !== entityId)
)
}
disabled={isEntitySubmitting}
style={removeButtonStyle}
>
Bỏ
</button>
</div>
);
})}
</div>
) : (
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
Chưa entity nào đưc gắn.
</div>
)}
<div
style={{
display: "grid",
gap: "8px",
border: "1px solid #243244",
borderRadius: "8px",
padding: "8px",
background: "#0f172a",
}}
>
<div style={{ color: "#e2e8f0", fontWeight: 700, fontSize: "12px" }}>
Thuộc tính GEO
</div>
<div style={{ color: "#94a3b8", fontSize: "11px" }}>
Các giá trị này thuộc về GEO đang chọn, không phụ thuộc entity.
</div>
<input
value={geometryMetaForm.time_start}
onChange={(event) => onGeometryMetaFormChange("time_start", event.target.value)}
placeholder="time_start"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={geometryMetaForm.time_end}
onChange={(event) => onGeometryMetaFormChange("time_end", event.target.value)}
placeholder="time_end"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<button
type="button"
onClick={onApplyGeometryMetadata}
disabled={isEntitySubmitting}
style={primaryGeometryButtonStyle}
>
Apply
</button>
</div>
<div
style={{
display: "grid",
gap: "8px",
border: "1px solid #1f3b5a",
borderRadius: "8px",
padding: "8px",
background: "#0f172a",
}}
>
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
Bind entity sẵn
</div>
<div style={{ color: "#93c5fd", fontSize: "11px" }}>
Dùng khi entity đã tồn tại. Tìm kiếm, thêm vào danh sách rồi bấm nút áp dụng.
</div>
<input
value={entitySearchQuery}
onChange={(event) => onEntitySearchQueryChange(event.target.value)}
placeholder="Search entity theo name..."
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<select
value={selectedSearchEntityId || ""}
onChange={(event) =>
onSelectSearchEntityId(event.target.value ? event.target.value : null)
}
disabled={isEntitySubmitting || isEntitySearchLoading}
style={entityInputStyle}
>
<option value="">-- Chọn entity từ kết quả search --</option>
{entitySearchResults.map((entity) => (
<option key={entity.id} value={entity.id}>
{entity.name} ({entity.id})
</option>
))}
</select>
<button
type="button"
onClick={onAddSelectedSearchEntity}
disabled={isEntitySubmitting || isEntitySearchLoading}
style={secondaryActionButtonStyle}
>
Thêm entity đã chọn vào danh sách gắn
</button>
{isEntitySearchLoading ? (
<div style={{ color: "#93c5fd", fontSize: "12px" }}>
Đang tìm entity...
</div>
) : null}
<button
onClick={onApplyEntitiesForSelectedGeometry}
disabled={isEntitySubmitting}
style={{
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
background: "#0f766e",
color: "#ffffff",
opacity: isEntitySubmitting ? 0.7 : 1,
fontWeight: 600,
}}
>
Áp dụng danh sách entity
</button>
</div>
{changeCount > 0 ? (
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
Thay đi sẽ vào lịch sử khi Commit.
</div>
) : null}
</div>
)}
<div
style={{
display: "grid",
gap: "8px",
border: "1px solid #1e3a8a",
borderRadius: "8px",
padding: "8px",
background: "#0f172a",
marginTop: "10px",
}}
>
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
Tạo entity mới (đc lập)
</div>
<div style={{ color: "#93c5fd", fontSize: "11px" }}>
Chỉ tạo entity, không tự bind vào geometry.
</div>
{selectedFeature ? (
<div style={{ color: "#93c5fd", fontSize: "11px" }}>
Type đang bị giới hạn theo geometry: <b>{formatGeometryPresetLabel(featureGeometryPreset)}</b>.
</div>
) : null}
<input
value={entityForm.name}
onChange={(event) => onEntityFormChange("name", event.target.value)}
placeholder="Tên entity mới"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={entityForm.slug}
onChange={(event) => onEntityFormChange("slug", event.target.value)}
placeholder="Slug"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<div style={{ color: "#e2e8f0", fontWeight: 600, fontSize: "12px" }}>
Chọn loại entity
</div>
<select
value={entityForm.type_id}
onChange={(event) => onEntityFormChange("type_id", event.target.value)}
disabled={isEntitySubmitting}
style={entityInputStyle}
>
{!selectedFeature && !hasCurrentVisibleTypeOption && entityForm.type_id ? (
<option value={entityForm.type_id}>
Custom Type ({entityForm.type_id})
</option>
) : null}
{groupedEntityTypeOptionsForCreate.map((group) => (
<optgroup
key={group.id}
label={`${group.label} (${group.geometryLabel})`}
>
{group.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</optgroup>
))}
</select>
{selectedTypeOption ? (
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
Type đang chọn: <b>{selectedTypeOption.label}</b> ({selectedTypeOption.groupLabel})
</div>
) : entityForm.type_id ? (
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
Type đang chọn: <b>{entityForm.type_id}</b>
</div>
) : null}
<button
onClick={onCreateEntityOnly}
disabled={isEntitySubmitting}
style={{
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
background: "#2563eb",
color: "#ffffff",
opacity: isEntitySubmitting ? 0.7 : 1,
fontWeight: 600,
}}
>
Tạo entity mới
</button>
</div>
{entityFormStatus ? (
<div style={{ color: "#93c5fd", fontSize: "12px", marginTop: "8px" }}>
{entityFormStatus}
</div>
) : null}
</div>
);
}
const entityInputStyle: CSSProperties = {
width: "100%",
borderRadius: "6px",
border: "1px solid #334155",
background: "#111827",
color: "#f8fafc",
padding: "6px 8px",
fontSize: "13px",
};
const removeButtonStyle: CSSProperties = {
border: "none",
borderRadius: "6px",
padding: "4px 8px",
cursor: "pointer",
background: "#7f1d1d",
color: "#ffffff",
fontSize: "12px",
};
const secondaryActionButtonStyle: CSSProperties = {
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: "pointer",
background: "#1d4ed8",
color: "#ffffff",
};
const primaryGeometryButtonStyle: CSSProperties = {
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: "pointer",
background: "#0f766e",
color: "#ffffff",
fontWeight: 600,
};
function resolveFeatureGeometryPreset(feature: Feature): EntityGeometryPreset {
const explicitPreset = normalizeGeometryPreset(feature.properties.geometry_preset);
if (explicitPreset) return explicitPreset;
const semanticType = normalizeTypeId(feature.properties.type) || normalizeTypeId(feature.properties.entity_type_id);
if (semanticType) {
const option = findEntityTypeOption(semanticType);
if (option) return option.geometryPreset;
}
return mapGeometryTypeToPreset(feature.geometry.type);
}
function normalizeGeometryPreset(value: unknown): EntityGeometryPreset | null {
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
if (
normalized === "point" ||
normalized === "line" ||
normalized === "polygon" ||
normalized === "circle-area"
) {
return normalized;
}
return null;
}
function normalizeTypeId(value: unknown): string | null {
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
return normalized.length ? normalized : null;
}
function mapGeometryTypeToPreset(
geometryType: Feature["geometry"]["type"]
): EntityGeometryPreset {
if (geometryType === "Point" || geometryType === "MultiPoint") {
return "point";
}
if (geometryType === "LineString" || geometryType === "MultiLineString") {
return "line";
}
return "polygon";
}
function getAllowedGroupIdsForPreset(
geometryPreset: EntityGeometryPreset
): EntityTypeGroupId[] {
if (geometryPreset === "point") {
return ["point"];
}
if (geometryPreset === "line") {
return ["line"];
}
if (geometryPreset === "circle-area") {
return ["circle"];
}
return ["polygon"];
}
function formatGeometryPresetLabel(preset: EntityGeometryPreset | null): string {
if (preset === "point") return "point - Điểm";
if (preset === "line") return "line - Tuyến";
if (preset === "circle-area") return "circle - Tròn";
if (preset === "polygon") return "polygon - Đa giác";
return "unknown";
}
+140
View File
@@ -0,0 +1,140 @@
"use client";
import { FIXED_TIMELINE_END_YEAR, FIXED_TIMELINE_START_YEAR, clampYearValue } from "@/uhm/lib/timeline";
type Props = {
year: number;
onYearChange: (year: number) => void;
isLoading: boolean;
disabled: boolean;
statusText?: string | null;
};
export default function TimelineBar({
year,
onYearChange,
isLoading,
disabled,
statusText,
}: Props) {
const lower = FIXED_TIMELINE_START_YEAR;
const upper = FIXED_TIMELINE_END_YEAR;
const effectiveDisabled = disabled;
const safeYear = clampYearValue(year, lower, upper);
const helperText = isLoading
? "Đang tải geometry theo mốc thời gian..."
: statusText || "Kéo thanh hoặc nhập số năm để query chính xác.";
const handleYearChange = (nextYear: number) => {
onYearChange(clampYearValue(Math.trunc(nextYear), lower, upper));
};
return (
<div
style={{
position: "absolute",
left: "18px",
right: "18px",
bottom: "16px",
zIndex: 10,
background: "rgba(15, 23, 42, 0.9)",
border: "1px solid rgba(148, 163, 184, 0.3)",
borderRadius: "10px",
padding: "12px 14px",
color: "#e2e8f0",
backdropFilter: "blur(2px)",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "8px",
gap: "8px",
}}
>
<span style={{ fontSize: "13px", fontWeight: 600, letterSpacing: "0.02em" }}>
Timeline
</span>
<span style={{ fontSize: "16px", fontWeight: 700, color: "#f8fafc" }}>
{formatYear(safeYear)}
</span>
</div>
<div style={{ fontSize: "12px", color: "#cbd5e1", marginTop: "8px", marginBottom: "6px" }}>
Mốc thời gian chi tiết
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "minmax(0, 1fr) 120px",
alignItems: "center",
gap: "10px",
}}
>
<input
type="range"
min={lower}
max={upper}
step={1}
value={safeYear}
onChange={(event) => handleYearChange(Number(event.target.value))}
disabled={effectiveDisabled}
aria-label="Timeline year"
style={{
width: "100%",
accentColor: "#22c55e",
cursor: effectiveDisabled ? "not-allowed" : "pointer",
opacity: effectiveDisabled ? 0.6 : 1,
}}
/>
<input
type="number"
min={lower}
max={upper}
step={1}
value={safeYear}
onChange={(event) => handleYearChange(Number(event.target.value))}
disabled={effectiveDisabled}
aria-label="Timeline exact year"
style={{
width: "100%",
border: "1px solid rgba(148, 163, 184, 0.45)",
borderRadius: "6px",
padding: "6px 8px",
background: "rgba(15, 23, 42, 0.7)",
color: "#f8fafc",
fontSize: "13px",
outline: "none",
}}
/>
</div>
<div
style={{
marginTop: "8px",
display: "grid",
gridTemplateColumns: "1fr auto 1fr",
alignItems: "center",
columnGap: "10px",
fontSize: "12px",
}}
>
<span style={{ color: "#94a3b8" }}>{formatYear(lower)}</span>
<span style={{ color: "#cbd5e1", textAlign: "center", whiteSpace: "nowrap" }}>
{helperText}
</span>
<span style={{ color: "#94a3b8", textAlign: "right" }}>{formatYear(upper)}</span>
</div>
</div>
);
}
function formatYear(year: number): string {
if (year < 0) {
return `${Math.abs(year)} TCN`;
}
return `${year}`;
}