add editor
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 có 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();
|
||||
}
|
||||
@@ -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 có 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ụ có thể undo ({recentUndoLabels.length})
|
||||
</div>
|
||||
{recentUndoLabels.length === 0 ? (
|
||||
<div style={{ color: "#94a3b8", fontSize: "13px" }}>Chưa có 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 có 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 có 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 có 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";
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user