refactor state storge, UI editor

This commit is contained in:
taDuc
2026-05-07 13:38:52 +07:00
parent a29a3a2049
commit 8b1df73797
46 changed files with 3345 additions and 3112 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
// FrontEndAdmin is the primary FE and follows BackEndGo cookie-based auth.
// FrontEndUser 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() {
+31 -52
View File
@@ -13,6 +13,7 @@ type Props = {
onShowAll: () => void;
onHideAll: () => void;
topContent?: ReactNode;
width?: number;
};
export default function BackgroundLayersPanel({
@@ -21,11 +22,12 @@ export default function BackgroundLayersPanel({
onShowAll,
onHideAll,
topContent,
width = 240,
}: Props) {
return (
<aside
style={{
width: "240px",
width,
background: "#111827",
color: "#e5e7eb",
borderLeft: "1px solid #1f2937",
@@ -38,57 +40,34 @@ export default function BackgroundLayersPanel({
<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 style={{ display: "flex", flexWrap: "wrap", gap: "10px 12px" }}>
{BACKGROUND_LAYER_OPTIONS.map((layer) => {
const on = Boolean(visibility[layer.id]);
return (
<button
key={layer.id}
type="button"
onClick={() => onToggleLayer(layer.id)}
style={{
border: "none",
background: "transparent",
padding: 0,
margin: 0,
cursor: "pointer",
color: on ? "#22c55e" : "#e5e7eb",
textDecorationLine: on ? "none" : "line-through",
textDecorationThickness: on ? undefined : "2px",
textDecorationColor: on ? undefined : "rgba(148, 163, 184, 0.7)",
fontSize: 13,
fontWeight: 750,
whiteSpace: "nowrap",
}}
title={on ? "On" : "Off"}
>
{layer.label}
</button>
);
})}
</div>
</aside>
);
-349
View File
@@ -1,349 +0,0 @@
"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();
}
+375 -346
View File
@@ -1,6 +1,7 @@
"use client";
import { UndoAction } from "@/uhm/lib/useEditorState";
import type { ReactNode } from "react";
import type { UndoAction } from "@/uhm/lib/useEditorState";
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
type Props = {
@@ -34,7 +35,6 @@ type Props = {
createdEntities: Array<{
id: string;
name: string;
type_id?: string | null;
}>;
createdGeometries: Array<{
id: string | number;
@@ -42,46 +42,44 @@ type Props = {
semanticType?: string | null;
entityNames: string[];
}>;
width?: number;
};
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)}`;
mode,
setMode,
entityStatus,
onUndo,
onCommit,
onSubmit,
onRestoreCommit,
isSaving,
isSubmitting,
sectionTitle,
sectionStatus,
commitTitle,
commitNote,
onCommitTitleChange,
onCommitNoteChange,
commitCount,
hasHeadCommit,
headCommitId,
latestCommitLabel,
commits,
changesCount,
undoStack,
createdEntities,
createdGeometries,
width = 280,
}: Props) {
const toggleMode = (newMode: EditorMode) => {
if (mode === newMode) {
setMode("idle"); // bấm lại → tắt
setMode("idle");
} else {
setMode(newMode); // chuyển mode
setMode(newMode);
}
};
// 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[] = [];
@@ -94,387 +92,418 @@ export default function Editor({
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",
});
const formatCommitTitle = (commit: Props["commits"][number]) =>
commit.edit_summary?.trim() || `Commit ${commit.id.slice(0, 8)}`;
const modeButtonStyle = (btnMode: EditorMode) =>
({
padding: "8px 10px",
borderRadius: 6,
border: "1px solid #334155",
background: mode === btnMode ? "#16a34a" : "#111827",
color: "white",
cursor: "pointer",
fontWeight: 800,
fontSize: 12,
minHeight: 34,
boxSizing: "border-box",
}) as const;
const primaryButtonStyle =
({
width: "100%",
padding: "8px 10px",
borderRadius: 6,
border: "none",
cursor: "pointer",
fontWeight: 850,
fontSize: 12,
}) as const;
return (
<div
style={{
width: "220px",
width,
height: "100vh",
overflowY: "auto",
background: "#111",
background: "#0b1220",
color: "white",
padding: "12px",
borderRight: "1px solid #333",
padding: "12px 12px 20px",
borderRight: "1px solid #1f2937",
}}
>
<h3 style={{ marginBottom: "10px" }}>Editor</h3>
<div style={{ position: "sticky", top: 0, zIndex: 5, background: "#0b1220", paddingBottom: 10 }}>
<div style={{ fontWeight: 950, fontSize: 14, marginBottom: 10 }}>Editor</div>
<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>
<Panel title="Project" defaultOpen>
<div style={{ fontSize: 12, color: "#cbd5e1", lineHeight: 1.4 }}>
<div style={{ color: "white", fontWeight: 850, overflowWrap: "anywhere" }}>{sectionTitle}</div>
<div style={{ marginTop: 6 }}>
Status: <span style={{ color: "#e2e8f0" }}>{sectionStatus}</span>
</div>
<div style={{ marginTop: 6 }}>
Commits: <span style={{ color: "#e2e8f0" }}>{commitCount}</span>
</div>
<div style={{ marginTop: 6 }}>
{latestCommitLabel ? (
<span style={{ color: "#e2e8f0" }}>{latestCommitLabel}</span>
) : (
<span style={{ color: "#94a3b8" }}>Chưa head commit</span>
)}
</div>
</div>
</Panel>
<Panel title="Tools" defaultOpen>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
<button style={modeButtonStyle("select")} onClick={() => toggleMode("select")} title="Select">
Select
</button>
<button style={modeButtonStyle("draw")} onClick={() => toggleMode("draw")} title="Draw polygon">
Draw
</button>
<button style={modeButtonStyle("add-point")} onClick={() => setMode("add-point")} title="Add point">
Point
</button>
<button style={modeButtonStyle("add-line")} onClick={() => setMode("add-line")} title="Add line">
Line
</button>
<button style={modeButtonStyle("add-path")} onClick={() => setMode("add-path")} title="Add path">
Path
</button>
<button style={modeButtonStyle("add-circle")} onClick={() => setMode("add-circle")} title="Add circle">
Circle
</button>
</div>
<div style={{ marginTop: 10, fontSize: 12, color: "#94a3b8" }}>
Mode: <span style={{ color: "white", fontWeight: 850 }}>{mode}</span>
</div>
<ModeHint mode={mode} />
<div style={{ marginTop: 10, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
<button
style={{
...modeButtonStyle("idle"),
background: "#111827",
}}
onClick={() => setMode("idle")}
title="Tắt tool hiện tại"
>
Idle
</button>
<button
style={{
...modeButtonStyle("idle"),
background: "#334155",
}}
onClick={onUndo}
title="Undo thao tác gần nhất"
>
Undo
</button>
</div>
</Panel>
{entityStatus ? (
<div
style={{
marginTop: 10,
padding: "10px",
background: "#111827",
borderRadius: 8,
border: "1px solid #7f1d1d",
color: "#fecaca",
fontSize: 12,
overflowWrap: "anywhere",
}}
>
{entityStatus}
</div>
) : null}
</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" }}>
<Panel title="Commit" defaultOpen>
<input
value={commitTitle}
onChange={(event) => onCommitTitleChange(event.target.value)}
placeholder="Commit title"
disabled={isSaving || isSubmitting}
style={textInputStyle}
/>
<textarea
value={commitNote}
onChange={(event) => onCommitNoteChange(event.target.value)}
placeholder="Commit note"
disabled={isSaving || isSubmitting}
rows={3}
style={textAreaStyle}
/>
<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",
...primaryButtonStyle,
marginTop: 8,
background: isSaving || isSubmitting || changesCount <= 0 ? "#475569" : "#0f766e",
cursor: isSaving || isSubmitting || changesCount <= 0 ? "not-allowed" : "pointer",
opacity: changesCount <= 0 ? 0.75 : 1,
}}
onClick={onCommit}
disabled={isSaving || isSubmitting}
disabled={isSaving || isSubmitting || changesCount <= 0}
title={changesCount <= 0 ? "Khong co thay doi de commit" : undefined}
>
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>
<button
style={{
...primaryButtonStyle,
marginTop: 8,
background: isSubmitting || !hasHeadCommit ? "#475569" : "#16a34a",
cursor: isSubmitting || !hasHeadCommit ? "not-allowed" : "pointer",
opacity: !hasHeadCommit ? 0.6 : 1,
}}
onClick={onSubmit}
disabled={isSubmitting || !hasHeadCommit}
>
Submit
</button>
</Panel>
<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>
<Panel title="Commit History" badge={String(commits.length)} defaultOpen={false}>
{commits.length === 0 ? (
<div style={{ color: "#64748b", fontSize: "12px" }}>
Chưa commit
</div>
<div style={{ color: "#64748b", fontSize: 12 }}>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)}
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: 12 }}>
{commits.slice(0, 8).map((commit) => {
const isHead = Boolean(headCommitId && commit.id === headCommitId);
return (
<li
key={commit.id}
style={{
fontWeight: 600,
color: "#f8fafc",
overflowWrap: "anywhere",
padding: "8px 0",
borderBottom: "1px solid #1f2937",
color: "#e2e8f0",
display: "flex",
flexDirection: "row"
}}
>
{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>
))}
<div style={{flex:1}}>
<div
title={formatCommitTitle(commit)}
style={{
fontWeight: 750,
color: "#f8fafc",
overflowWrap: "anywhere",
}}
>
{formatCommitTitle(commit)}
</div>
<div style={{ marginTop: 3, color: "#94a3b8" }}>
{commit.created_at ? new Date(commit.created_at).toLocaleString() : ""}
</div>
</div>
<button
style={{
marginTop: 6,
padding: "6px 8px",
borderRadius: 6,
border: "1px solid #334155",
background: isHead ? "#0b1220" : "#334155",
color: "white",
cursor: isSaving || isSubmitting || isHead ? "not-allowed" : "pointer",
opacity: isHead ? 0.65 : 1,
fontWeight: 800,
fontSize: 12,
}}
onClick={() => onRestoreCommit(commit.id)}
disabled={isSaving || isSubmitting || isHead}
title={isHead ? "Đang là head commit" : "Restore snapshot từ commit này (FE-only)"}
>
Restore
</button>
</li>
);
})}
</ul>
)}
</div>
</Panel>
<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>
<Panel title="Undo List" badge={String(recentUndoLabels.length)} defaultOpen={false}>
{recentUndoLabels.length === 0 ? (
<div style={{ color: "#94a3b8", fontSize: "13px" }}>Chưa thao tác</div>
<div style={{ color: "#94a3b8", fontSize: 13 }}>Chưa thao tác</div>
) : (
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: "13px", color: "#e2e8f0" }}>
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: 13, color: "#e2e8f0" }}>
{recentUndoLabels.map((label, idx) => (
<li key={`${label}-${idx}`} style={{ padding: "4px 0", borderBottom: "1px solid #1f2937" }}>
<li key={`${label}-${idx}`} style={{ padding: "6px 0", borderBottom: "1px solid #1f2937" }}>
{label}
</li>
))}
</ul>
)}
</div>
</Panel>
<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" }}>
<Panel title="This Session" defaultOpen={false}>
<div style={{ fontSize: 13, color: "#cbd5e1", marginBottom: 6 }}>
Entities ({createdEntities.length})
</div>
{createdEntities.length === 0 ? (
<div style={{ color: "#64748b", fontSize: "12px", marginBottom: "10px" }}>
Chưa tạo entity mới
</div>
<div style={{ color: "#64748b", fontSize: 12, marginBottom: 10 }}>Chưa tạo entity mới</div>
) : (
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: "12px", marginBottom: "10px" }}>
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: 12, marginBottom: 10 }}>
{createdEntities.map((entity) => (
<li
key={entity.id}
style={{
padding: "4px 0",
borderBottom: "1px solid #1f2937",
color: "#e2e8f0",
}}
style={{ padding: "6px 0", borderBottom: "1px solid #1f2937", color: "#e2e8f0" }}
title={entity.id}
>
{entity.name} ({entity.type_id || "country"})
{entity.name}
</li>
))}
</ul>
)}
<div style={{ fontSize: "13px", color: "#cbd5e1", marginBottom: "6px" }}>
<div style={{ fontSize: 13, color: "#cbd5e1", marginBottom: 6 }}>
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>
<div style={{ color: "#64748b", fontSize: 12 }}>Chưa geometry mới chờ commit</div>
) : (
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: "12px" }}>
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: 12 }}>
{createdGeometries.map((geometry) => (
<li
key={String(geometry.id)}
style={{
padding: "4px 0",
borderBottom: "1px solid #1f2937",
color: "#e2e8f0",
}}
style={{ padding: "6px 0", borderBottom: "1px solid #1f2937", color: "#e2e8f0" }}
>
#{geometry.id} [{geometry.geometryType}] {geometry.semanticType ? `- ${geometry.semanticType}` : ""}
#{geometry.id} [{geometry.geometryType}]{" "}
{geometry.semanticType ? `- ${geometry.semanticType}` : ""}
{geometry.entityNames.length ? ` | ${geometry.entityNames.join(", ")}` : ""}
</li>
))}
</ul>
)}
</div>
</Panel>
</div>
);
}
const textInputStyle = {
width: "100%",
marginTop: 0,
padding: "8px 10px",
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "white",
boxSizing: "border-box",
fontSize: 13,
outline: "none",
} as const;
const textAreaStyle = {
...textInputStyle,
marginTop: 8,
resize: "vertical",
fontFamily: "inherit",
} as const;
function Panel({
title,
badge,
defaultOpen,
children,
}: {
title: string;
badge?: string | null;
defaultOpen?: boolean;
children: ReactNode;
}) {
return (
<details
open={Boolean(defaultOpen)}
style={{
marginTop: 10,
padding: 10,
background: "#111827",
borderRadius: 8,
border: "1px solid #1f2937",
}}
>
<summary
style={{
cursor: "pointer",
listStyle: "none",
fontWeight: 900,
fontSize: 13,
color: "white",
userSelect: "none",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 8,
}}
>
<span>{title}</span>
{badge ? (
<span
style={{
padding: "2px 8px",
borderRadius: 999,
border: "1px solid #334155",
background: "#0b1220",
color: "#cbd5e1",
fontSize: 12,
fontWeight: 850,
flex: "0 0 auto",
}}
>
{badge}
</span>
) : null}
</summary>
<div style={{ marginTop: 10 }}>{children}</div>
</details>
);
}
function ModeHint({ mode }: { mode: EditorMode }) {
if (mode === "add-line" || mode === "add-path") {
return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
Click đ thêm điểm, Enter đ hoàn tất, Esc đ hủy.
</div>
);
}
if (mode === "add-circle") {
return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
Giữ chuột trái kéo đ mở bán kính, thả chuột đ hoàn tất.
</div>
);
}
if (mode === "add-point") {
return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
Chọn 1 điểm trên bản đ đ đt đa điểm.
</div>
)
}
if (mode === "select") {
return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
Chọn 1 hình, đưng, điểm trên bản đ đ xem chi tiết.
</div>
)
}
if (mode === "draw") {
return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
Chọn các điểm trên bản đ đ vẽ hình, ENTER đ kết thúc, ESC đ hủy.
</div>
)
}
return null;
}
function formatUndoLabel(action: UndoAction) {
switch (action.type) {
case "create":
+132 -44
View File
@@ -22,6 +22,7 @@ function wikiTitle(w: WikiSnapshot): string {
export default function EntityWikiBindingsPanel({ entities, wikis, links, setLinks }: Props) {
const [activeEntityId, setActiveEntityId] = useState<string>("");
const [activeWikiId, setActiveWikiId] = useState<string>("");
const wikiChoices: WikiChoice[] = useMemo(
() =>
@@ -60,19 +61,22 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
const currentlyOn = existing.operation !== "delete";
next[idx] = {
...existing,
operation: currentlyOn ? "delete" : "reference",
operation: currentlyOn ? "delete" : "binding",
};
return next;
}
next.push({
entity_id: activeEntityId,
wiki_id: id,
operation: "reference",
operation: "binding",
});
return next;
});
};
const activeWikiLinked = activeEntityId && activeWikiId ? activeLinks.has(activeWikiId) : false;
const activeWikiChoice = activeWikiId ? wikiChoices.find((w) => w.id === activeWikiId) || null : null;
return (
<div
style={{
@@ -115,48 +119,132 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
<div>
<div style={{ fontSize: "12px", color: "#94a3b8", marginBottom: "6px" }}>Wikis</div>
{!wikiChoices.length ? (
<div style={{ fontSize: "12px", color: "#94a3b8" }}>No wiki in project yet.</div>
) : !activeEntityId ? (
<div style={{ fontSize: "12px", color: "#94a3b8" }}>Pick an entity to bind wikis.</div>
) : (
<div style={{ display: "grid", gap: "6px" }}>
{wikiChoices.slice(0, 12).map((w) => {
const checked = activeLinks.has(w.id);
const isRefWiki = wikis.find((x) => x.id === w.id)?.source === "ref";
return (
<label
key={w.id}
style={{
display: "flex",
alignItems: "center",
gap: "8px",
padding: "8px",
borderRadius: "6px",
border: "1px solid #1f2937",
cursor: "pointer",
background: checked ? "#111827" : "transparent",
}}
title={w.id}
>
<input type="checkbox" checked={checked} onChange={() => toggle(w.id)} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: "#e5e7eb", fontSize: "12px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{w.title}
{isRefWiki ? " (ref)" : ""}
</div>
<div style={{ color: "#94a3b8", fontSize: "11px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{w.id}
</div>
</div>
</label>
);
})}
{wikiChoices.length > 12 ? (
<div style={{ fontSize: "12px", color: "#94a3b8" }}>+{wikiChoices.length - 12} more</div>
) : null}
</div>
)}
<div style={{ display: "grid", gap: "8px" }}>
<select
value={activeWikiId}
onChange={(e) => setActiveWikiId(e.target.value)}
disabled={wikiChoices.length === 0}
style={{
width: "100%",
border: "1px solid #1f2937",
background: "#0b1220",
color: "#e5e7eb",
borderRadius: "6px",
padding: "8px 10px",
fontSize: "12px",
outline: "none",
opacity: wikiChoices.length === 0 ? 0.7 : 1,
cursor: wikiChoices.length === 0 ? "not-allowed" : "pointer",
}}
>
<option value="">
{wikiChoices.length === 0 ? "No wikis available" : "Select wiki…"}
</option>
{wikiChoices.map((w) => (
<option key={w.id} value={w.id}>
{w.title}
</option>
))}
</select>
{wikiChoices.length === 0 ? (
<div style={{ fontSize: "12px", color: "#94a3b8" }}>No wiki in project yet.</div>
) : (
<>
<button
type="button"
disabled={!activeEntityId || !activeWikiId}
onClick={() => toggle(activeWikiId)}
style={{
border: "none",
borderRadius: "6px",
padding: "8px 10px",
cursor: !activeEntityId || !activeWikiId ? "not-allowed" : "pointer",
background: activeWikiLinked ? "#334155" : "#16a34a",
color: "white",
fontWeight: 800,
fontSize: 12,
opacity: !activeEntityId || !activeWikiId ? 0.65 : 1,
}}
>
{activeWikiLinked ? "Unlink wiki" : "Link wiki"}
</button>
{activeWikiChoice ? (
<div style={{ fontSize: 12, color: "#94a3b8", overflowWrap: "anywhere" }}>
{activeWikiChoice.id}
</div>
) : null}
{!activeEntityId ? (
<div style={{ fontSize: 12, color: "#94a3b8" }}>Pick an entity to see/link wikis.</div>
) : activeLinks.size ? (
<div style={{ display: "grid", gap: "6px" }}>
<div style={{ fontSize: 12, color: "#94a3b8" }}>Linked wikis ({activeLinks.size})</div>
{Array.from(activeLinks).slice(0, 8).map((id) => {
const w = wikiChoices.find((x) => x.id === id) || null;
return (
<div
key={id}
style={{
padding: "8px",
borderRadius: "6px",
border: "1px solid #1f2937",
background: "#111827",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
title={id}
>
<div style={{ minWidth: 0 }}>
<div
style={{
color: "#e5e7eb",
fontSize: 12,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
fontWeight: 700,
}}
>
{w?.title || "Untitled wiki"}
</div>
<div style={{ color: "#94a3b8", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{id}
</div>
</div>
<button
type="button"
onClick={() => toggle(id)}
style={{
border: "none",
background: "#0b1220",
color: "#fecaca",
cursor: "pointer",
borderRadius: 6,
padding: "6px 8px",
fontSize: 12,
fontWeight: 800,
flex: "0 0 auto",
}}
>
Unlink
</button>
</div>
);
})}
{activeLinks.size > 8 ? (
<div style={{ fontSize: 12, color: "#94a3b8" }}>+{activeLinks.size - 8} more</div>
) : null}
</div>
) : (
<div style={{ fontSize: 12, color: "#94a3b8" }}>No wiki linked yet.</div>
)}
</>
)}
</div>
</div>
</div>
</div>
+36 -4
View File
@@ -111,6 +111,8 @@ export default function Map({
const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | null>(null);
// Đánh dấu đã fitBounds cho fitBoundsKey hiện tại (tránh fit lặp).
const fitBoundsAppliedRef = useRef(false);
// Auto center theo vị trí người dùng chỉ nên chạy 1 lần / mount.
const geolocationCenteredRef = useRef(false);
// Danh sách cleanup fns để dọn listeners/engines khi unmount map.
const mapCleanupFnsRef = useRef<Array<() => void>>([]);
// Các engine bindings theo mode để gọi cancel/cleanup khi đổi mode.
@@ -257,6 +259,34 @@ export default function Map({
}
}, []);
const tryCenterToUserLocation = useCallback(() => {
if (geolocationCenteredRef.current) return;
// Nếu đang "fit to draft bounds" thì không nên override center.
if (fitToDraftBoundsRef.current) return;
if (typeof window === "undefined") return;
if (!("geolocation" in navigator)) return;
const map = mapRef.current;
if (!map) return;
geolocationCenteredRef.current = true;
navigator.geolocation.getCurrentPosition(
(pos) => {
if (mapRef.current !== map) return;
const { longitude, latitude } = pos.coords;
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return;
const currentZoom = map.getZoom();
const nextZoom = Number.isFinite(currentZoom) ? Math.max(currentZoom, 5) : 5;
map.easeTo({ center: [longitude, latitude], zoom: nextZoom, duration: 900 });
},
() => {
// Người dùng từ chối / lỗi định vị: im lặng.
},
{ enableHighAccuracy: false, timeout: 4000, maximumAge: 60_000 }
);
}, []);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
@@ -841,7 +871,7 @@ export default function Map({
type: "Feature",
properties: {
id,
type: null,
type: "country",
geometry_preset: "polygon",
entity_id: null,
entity_ids: [],
@@ -880,7 +910,7 @@ export default function Map({
type: "Feature",
properties: {
id,
type: null,
type: "city",
geometry_preset: "point",
entity_id: null,
entity_ids: [],
@@ -946,7 +976,7 @@ export default function Map({
type: "Feature",
properties: {
id,
type: null,
type: "war",
geometry_preset: "circle-area",
entity_id: null,
entity_ids: [],
@@ -979,6 +1009,8 @@ export default function Map({
// after everything mounted, push current draft to sources
applyDraftToMap(draftRef.current);
// Khi vao web, thu auto center theo vi tri user (neu co quyen).
tryCenterToUserLocation();
if (allowGeometryEditing) {
editingEngineRef.current?.bindEditEvents(map);
@@ -1000,7 +1032,7 @@ export default function Map({
}
map.remove();
};
}, [allowGeometryEditing, applyDraftToMap]);
}, [allowGeometryEditing, applyDraftToMap, tryCenterToUserLocation]);
const handleZoomByStep = (delta: number) => {
const map = mapRef.current;
+225 -131
View File
@@ -1,69 +1,38 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import type { Entity } from "@/uhm/types/entities";
import { useState, type CSSProperties } from "react";
import type { EntitySnapshot } from "@/uhm/types/entities";
import { searchEntitiesByName } from "@/uhm/api/entities";
import type { EntityFormState } from "@/uhm/lib/editor/session/sessionTypes";
type Props = {
entityRefs: EntitySnapshot[];
setEntityRefs: React.Dispatch<React.SetStateAction<EntitySnapshot[]>>;
entityForm: EntityFormState;
onEntityFormChange: (key: keyof EntityFormState, value: string) => void;
isEntitySubmitting: boolean;
onCreateEntityOnly: () => void;
entityFormStatus: string | null;
selectedGeometryEntityIds?: string[];
hasSelectedGeometry?: boolean;
onToggleBindEntityForSelectedGeometry?: (entityId: string, nextChecked: boolean) => void;
};
export default function ProjectEntityRefsPanel({ entityRefs, setEntityRefs }: Props) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<Entity[]>([]);
const [isSearching, setIsSearching] = useState(false);
const searchRequestRef = useState(() => ({ id: 0 }))[0];
export default function ProjectEntityRefsPanel({
entityRefs,
entityForm,
onEntityFormChange,
isEntitySubmitting,
onCreateEntityOnly,
entityFormStatus,
selectedGeometryEntityIds,
hasSelectedGeometry,
onToggleBindEntityForSelectedGeometry,
}: Props) {
const canBindToggle =
Boolean(hasSelectedGeometry) &&
Array.isArray(selectedGeometryEntityIds) &&
typeof onToggleBindEntityForSelectedGeometry === "function";
const existingIds = useMemo(() => new Set(entityRefs.map((e) => String(e.id))), [entityRefs]);
useEffect(() => {
const keyword = query.trim();
if (!keyword.length) {
setResults([]);
setIsSearching(false);
return;
}
let disposed = false;
const requestId = ++searchRequestRef.id;
const t = window.setTimeout(async () => {
setIsSearching(true);
try {
const rows = await searchEntitiesByName(keyword, { limit: 20 });
if (disposed || requestId !== searchRequestRef.id) return;
setResults(rows);
} catch (err) {
if (disposed || requestId !== searchRequestRef.id) return;
console.error("Search entities failed", err);
setResults([]);
} finally {
if (disposed || requestId !== searchRequestRef.id) return;
setIsSearching(false);
}
}, 250);
return () => {
disposed = true;
window.clearTimeout(t);
};
}, [query, searchRequestRef]);
const addRef = (e: Entity) => {
const id = String(e.id || "").trim();
if (!id) return;
if (existingIds.has(id)) return;
setEntityRefs((prev) => [
{
id,
source: "ref",
name: e.name,
description: e.description ?? null,
},
...prev,
]);
};
const [isCreateOpen, setIsCreateOpen] = useState(false);
return (
<div
@@ -79,75 +48,6 @@ export default function ProjectEntityRefsPanel({ entityRefs, setEntityRefs }: Pr
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{entityRefs.length}</div>
</div>
<div style={{ marginTop: "10px" }}>
<div style={{ fontSize: "12px", color: "#94a3b8", marginBottom: "6px" }}>Add existing entity</div>
<input
value={query}
onChange={(ev) => setQuery(ev.target.value)}
placeholder="Search by name…"
style={{
width: "100%",
border: "1px solid #1f2937",
background: "#0b1220",
color: "#e5e7eb",
borderRadius: "6px",
padding: "8px 10px",
fontSize: "12px",
outline: "none",
}}
/>
{isSearching ? (
<div style={{ marginTop: "6px", fontSize: "12px", color: "#94a3b8" }}>Searching</div>
) : null}
{!isSearching && query.trim().length > 0 ? (
<div style={{ marginTop: "6px", display: "grid", gap: "6px" }}>
{results.slice(0, 8).map((r) => (
<div
key={r.id}
style={{
display: "flex",
alignItems: "center",
gap: "8px",
padding: "8px",
borderRadius: "6px",
border: "1px solid #1f2937",
background: "transparent",
opacity: existingIds.has(r.id) ? 0.55 : 1,
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: "#e5e7eb", fontSize: "12px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{r.name}
</div>
<div style={{ color: "#94a3b8", fontSize: "11px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{r.id}
</div>
</div>
<button
type="button"
onClick={() => addRef(r)}
disabled={existingIds.has(r.id)}
style={{
border: "none",
background: "#111827",
color: existingIds.has(r.id) ? "#64748b" : "#93c5fd",
cursor: existingIds.has(r.id) ? "not-allowed" : "pointer",
borderRadius: "6px",
padding: "6px 8px",
fontSize: "12px",
fontWeight: 700,
flex: "0 0 auto",
}}
>
Add
</button>
</div>
))}
{!results.length ? <div style={{ fontSize: "12px", color: "#94a3b8" }}>No results.</div> : null}
</div>
) : null}
</div>
{entityRefs.length ? (
<div style={{ marginTop: "10px", display: "grid", gap: "6px" }}>
{entityRefs.slice(0, 8).map((e) => (
@@ -158,14 +58,54 @@ export default function ProjectEntityRefsPanel({ entityRefs, setEntityRefs }: Pr
borderRadius: "6px",
border: "1px solid #1f2937",
background: "transparent",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<div style={{ fontSize: "12px", color: "#e5e7eb", fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{e.name || e.id}
</div>
<div style={{ fontSize: "11px", color: "#94a3b8", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{e.id}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: "12px", color: "#e5e7eb", fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{e.name || e.id}
</div>
<div style={{ fontSize: "11px", color: "#94a3b8", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{e.id}
</div>
</div>
{canBindToggle ? (
<button
type="button"
title={selectedGeometryEntityIds!.includes(String(e.id)) ? "Unbind from selected geometry" : "Bind to selected geometry"}
onClick={() =>
onToggleBindEntityForSelectedGeometry!(
String(e.id),
!selectedGeometryEntityIds!.includes(String(e.id))
)
}
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 22,
height: 22,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
cursor: "pointer",
flex: "0 0 auto",
}}
aria-label={
selectedGeometryEntityIds!.includes(String(e.id))
? `Unbind entity ${String(e.id)} from selected geometry`
: `Bind entity ${String(e.id)} to selected geometry`
}
>
{selectedGeometryEntityIds!.includes(String(e.id)) ? (
<UnlockIcon />
) : (
<LockIcon />
)}
</button>
) : null}
</div>
))}
{entityRefs.length > 8 ? <div style={{ fontSize: "12px", color: "#94a3b8" }}>+{entityRefs.length - 8} more</div> : null}
@@ -173,6 +113,160 @@ export default function ProjectEntityRefsPanel({ entityRefs, setEntityRefs }: Pr
) : (
<div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>No entity ref yet for this project.</div>
)}
<div
style={{
marginTop: "10px",
display: "grid",
gap: "8px",
border: "1px solid #1e3a8a",
borderRadius: "8px",
padding: "8px",
background: "#0f172a",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
Tạo entity mới
</div>
<button
type="button"
onClick={() => setIsCreateOpen((v) => !v)}
disabled={isEntitySubmitting}
title={isCreateOpen ? "Dong" : "Mo"}
aria-label={isCreateOpen ? "Dong tao entity" : "Mo tao entity"}
style={{
width: 26,
height: 26,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "#e2e8f0",
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
opacity: isEntitySubmitting ? 0.6 : 1,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
}}
>
{isCreateOpen ? <CloseIcon /> : <PlusIcon />}
</button>
</div>
{isCreateOpen ? (
<>
<input
value={entityForm.name}
onChange={(event) => onEntityFormChange("name", event.target.value)}
placeholder="Tên entity mới"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={entityForm.description}
onChange={(event) => onEntityFormChange("description", event.target.value)}
placeholder="Description"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<button
type="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>
</>
) : null}
</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",
};
function LockIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M7 10V8a5 5 0 0 1 10 0v2"
stroke="#cbd5e1"
strokeWidth="2"
strokeLinecap="round"
/>
<rect
x="6"
y="10"
width="12"
height="10"
rx="2"
stroke="#cbd5e1"
strokeWidth="2"
/>
</svg>
);
}
function UnlockIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M17 10V8a5 5 0 0 0-9.5-2"
stroke="#a7f3d0"
strokeWidth="2"
strokeLinecap="round"
/>
<rect
x="6"
y="10"
width="12"
height="10"
rx="2"
stroke="#a7f3d0"
strokeWidth="2"
/>
</svg>
);
}
function PlusIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 5v14M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function CloseIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
+150 -279
View File
@@ -1,6 +1,6 @@
"use client";
import { type CSSProperties } from "react";
import { type CSSProperties, useMemo, useState } from "react";
import { Entity } from "@/uhm/api/entities";
import { Feature } from "@/uhm/lib/useEditorState";
import {
@@ -10,7 +10,7 @@ import {
findEntityTypeOption,
groupEntityTypeOptions,
} from "@/uhm/lib/entityTypeOptions";
import type { EntityFormState, GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionTypes";
import type { GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionTypes";
type Props = {
selectedFeature: Feature | null;
@@ -19,24 +19,12 @@ type Props = {
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;
onApplyGeometryMetadata: () => Promise<{ ok: boolean; error?: string }>;
changeCount: number;
entityFormStatus: string | null;
};
export default function SelectedGeometryPanel({
@@ -46,41 +34,60 @@ export default function SelectedGeometryPanel({
entities,
selectedGeometryEntityIds,
onEntityIdsChange,
entitySearchQuery,
onEntitySearchQueryChange,
entitySearchResults,
selectedSearchEntityId,
onSelectSearchEntityId,
onAddSelectedSearchEntity,
isEntitySearchLoading,
entityForm,
onEntityFormChange,
entityTypeOptions,
geometryMetaForm,
onGeometryMetaFormChange,
isEntitySubmitting,
onCreateEntityOnly,
onApplyGeometryMetadata,
onApplyEntitiesForSelectedGeometry,
changeCount,
entityFormStatus,
}: Props) {
const [geoApplyFeedback, setGeoApplyFeedback] = useState<
| {
kind: "ok" | "error";
text: string;
signature: string;
}
| null
>(null);
const geoMetaSignature = useMemo(() => {
return [
geometryMetaForm.type_key,
geometryMetaForm.time_start,
geometryMetaForm.time_end,
geometryMetaForm.binding,
].join("|");
}, [
geometryMetaForm.binding,
geometryMetaForm.time_end,
geometryMetaForm.time_start,
geometryMetaForm.type_key,
]);
const handleApplyGeoMeta = async () => {
setGeoApplyFeedback(null);
const result = await onApplyGeometryMetadata();
if (result.ok) {
setGeoApplyFeedback({ kind: "ok", text: "đã apply thành công", signature: geoMetaSignature });
} else if (result.error) {
setGeoApplyFeedback({ kind: "error", text: result.error, signature: geoMetaSignature });
}
};
const visibleGeoApplyFeedback =
geoApplyFeedback && geoApplyFeedback.signature === geoMetaSignature ? geoApplyFeedback : null;
if (!selectedFeature) return null;
const groupedEntityTypeOptions = groupEntityTypeOptions(entityTypeOptions);
const featureGeometryPreset = selectedFeature
? resolveFeatureGeometryPreset(selectedFeature)
: null;
const allowedGroupIds = featureGeometryPreset
? getAllowedGroupIdsForPreset(featureGeometryPreset)
: [];
const visibleGroupedEntityTypeOptions = groupedEntityTypeOptions.filter((group) =>
const featureGeometryPreset = resolveFeatureGeometryPreset(selectedFeature);
const allowedGroupIds = getAllowedGroupIdsForPreset(featureGeometryPreset);
const groupedGeoTypeOptions = 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)
const selectedTypeOption = findEntityTypeOption(geometryMetaForm.type_key);
const hasCurrentVisibleTypeOption = groupedGeoTypeOptions.some((group) =>
group.options.some((option) => option.value === geometryMetaForm.type_key)
);
return (
@@ -96,72 +103,67 @@ export default function SelectedGeometryPanel({
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 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={{ 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;
<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",
}}
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}
>
<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>
)}
Bỏ
</button>
</div>
);
})}
</div>
) : (
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
Chưa entity nào đưc gắn.
</div>
)}
<div
style={{
@@ -179,6 +181,42 @@ export default function SelectedGeometryPanel({
<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>
<div style={{ color: "#e2e8f0", fontWeight: 600, fontSize: "12px" }}>
Loại GEO
</div>
<select
value={geometryMetaForm.type_key}
onChange={(event) => onGeometryMetaFormChange("type_key", event.target.value)}
disabled={isEntitySubmitting}
style={entityInputStyle}
>
{!hasCurrentVisibleTypeOption && geometryMetaForm.type_key ? (
<option value={geometryMetaForm.type_key}>
Custom Type ({geometryMetaForm.type_key})
</option>
) : null}
{groupedGeoTypeOptions.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" }}>
Đang chọn: <b>{selectedTypeOption.label}</b> ({selectedTypeOption.groupLabel})
</div>
) : geometryMetaForm.type_key ? (
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
Đang chọn: <b>{geometryMetaForm.type_key}</b>
</div>
) : null}
<input
value={geometryMetaForm.time_start}
onChange={(event) => onGeometryMetaFormChange("time_start", event.target.value)}
@@ -195,81 +233,23 @@ export default function SelectedGeometryPanel({
/>
<button
type="button"
onClick={onApplyGeometryMetadata}
onClick={handleApplyGeoMeta}
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...
{visibleGeoApplyFeedback ? (
<div
style={{
fontSize: "12px",
color:
visibleGeoApplyFeedback.kind === "ok" ? "#22c55e" : "#fca5a5",
}}
>
{visibleGeoApplyFeedback.text}
</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 ? (
@@ -277,107 +257,7 @@ export default function SelectedGeometryPanel({
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>
);
}
@@ -402,15 +282,6 @@ const removeButtonStyle: CSSProperties = {
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",
+63 -42
View File
@@ -8,6 +8,8 @@ type Props = {
isLoading: boolean;
disabled: boolean;
statusText?: string | null;
filterEnabled?: boolean;
onFilterEnabledChange?: (enabled: boolean) => void;
};
export default function TimelineBar({
@@ -16,6 +18,8 @@ export default function TimelineBar({
isLoading,
disabled,
statusText,
filterEnabled,
onFilterEnabledChange,
}: Props) {
const lower = FIXED_TIMELINE_START_YEAR;
const upper = FIXED_TIMELINE_END_YEAR;
@@ -24,7 +28,7 @@ export default function TimelineBar({
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.";
: statusText || null;
const handleYearChange = (nextYear: number) => {
onYearChange(clampYearValue(Math.trunc(nextYear), lower, upper));
@@ -41,39 +45,69 @@ export default function TimelineBar({
background: "rgba(15, 23, 42, 0.9)",
border: "1px solid rgba(148, 163, 184, 0.3)",
borderRadius: "10px",
padding: "12px 14px",
padding: "10px 12px",
color: "#e2e8f0",
backdropFilter: "blur(2px)",
}}
title={helperText || undefined}
>
<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",
fontSize: "12px",
}}
>
{typeof filterEnabled === "boolean" && onFilterEnabledChange ? (
<label
title={filterEnabled ? "Dang bat loc timeline" : "Dang tat loc timeline (hien thi tat ca geometry)"}
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
cursor: effectiveDisabled ? "not-allowed" : "pointer",
userSelect: "none",
opacity: effectiveDisabled ? 0.6 : 1,
}}
>
<span
aria-hidden="true"
style={{
width: 36,
height: 20,
borderRadius: 999,
border: "1px solid rgba(148, 163, 184, 0.45)",
background: filterEnabled ? "rgba(34, 197, 94, 0.9)" : "rgba(148, 163, 184, 0.25)",
position: "relative",
flex: "0 0 auto",
}}
>
<span
style={{
position: "absolute",
top: 2,
left: filterEnabled ? 18 : 2,
width: 16,
height: 16,
borderRadius: 999,
background: "#0b1220",
border: "1px solid rgba(148, 163, 184, 0.35)",
transition: "left 120ms ease",
}}
/>
</span>
<input
type="checkbox"
checked={filterEnabled}
onChange={(e) => onFilterEnabledChange(e.target.checked)}
disabled={effectiveDisabled}
aria-label="Toggle timeline filter"
style={{ display: "none" }}
/>
</label>
) : null}
<span style={{ color: "#94a3b8", minWidth: 44 }}>{formatYear(lower)}</span>
<input
type="range"
min={lower}
@@ -84,12 +118,16 @@ export default function TimelineBar({
disabled={effectiveDisabled}
aria-label="Timeline year"
style={{
width: "100%",
flex: 1,
minWidth: 0,
accentColor: "#22c55e",
cursor: effectiveDisabled ? "not-allowed" : "pointer",
opacity: effectiveDisabled ? 0.6 : 1,
}}
/>
<span style={{ color: "#94a3b8", minWidth: 44, textAlign: "right" }}>
{formatYear(upper)}
</span>
<input
type="number"
min={lower}
@@ -100,7 +138,7 @@ export default function TimelineBar({
disabled={effectiveDisabled}
aria-label="Timeline exact year"
style={{
width: "100%",
width: "128px",
border: "1px solid rgba(148, 163, 184, 0.45)",
borderRadius: "6px",
padding: "6px 8px",
@@ -111,23 +149,6 @@ export default function TimelineBar({
}}
/>
</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>
);
}
+86
View File
@@ -0,0 +1,86 @@
"use client";
import type { CSSProperties } from "react";
export type UnifiedSearchKind = "entity" | "wiki" | "geo";
type Props = {
kind: UnifiedSearchKind;
onKindChange: (kind: UnifiedSearchKind) => void;
query: string;
onQueryChange: (query: string) => void;
disabledGeo?: boolean;
};
export default function UnifiedSearchBar({ kind, onKindChange, query, onQueryChange, disabledGeo }: Props) {
const selectStyle: CSSProperties = {
width: 110,
border: "1px solid #1f2937",
background: "#0b1220",
color: "#e5e7eb",
borderRadius: 6,
padding: "8px 10px",
fontSize: 12,
outline: "none",
flex: "0 0 auto",
};
const inputStyle: CSSProperties = {
width: "100%",
border: "1px solid #1f2937",
background: "#0b1220",
color: "#e5e7eb",
borderRadius: 6,
padding: "8px 10px",
fontSize: 12,
outline: "none",
minWidth: 0,
};
const helperText =
kind === "entity"
? "Search entity theo name"
: kind === "wiki"
? "Search wiki theo title"
: "Search geo theo entity name";
return (
<div
style={{
padding: 10,
background: "#0b1220",
borderRadius: 8,
border: "1px solid #1f2937",
display: "grid",
gap: 8,
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div style={{ fontWeight: 700, fontSize: 14 }}>Search</div>
<div style={{ fontSize: 12, color: "#94a3b8" }}>{helperText}</div>
</div>
<div style={{ display: "flex", gap: 8 }}>
<select
value={kind}
onChange={(e) => onKindChange(e.target.value as UnifiedSearchKind)}
style={selectStyle}
aria-label="Search kind"
>
<option value="entity">Entity</option>
<option value="wiki">Wiki</option>
<option value="geo" disabled={Boolean(disabledGeo)}>
Geo
</option>
</select>
<input
value={query}
onChange={(e) => onQueryChange(e.target.value)}
placeholder={kind === "entity" ? "Nhập tên entity…" : kind === "wiki" ? "Nhập title wiki…" : "Nhập tên entity…"}
style={inputStyle}
aria-label="Search query"
/>
</div>
</div>
);
}
+203 -330
View File
@@ -1,24 +1,30 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { EditorContent, useEditor, type JSONContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import TiptapLink from "@tiptap/extension-link";
import { searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
import { useEffect, useMemo, useState, type ComponentProps } from "react";
import dynamic from "next/dynamic";
import "react-quill-new/dist/quill.snow.css";
import { Modal } from "@/components/ui/modal";
import Button from "@/components/ui/button/Button";
import Badge from "@/components/ui/badge/Badge";
import Label from "@/components/form/Label";
import type { WikiSnapshot } from "@/uhm/types/wiki";
import { newId } from "@/uhm/lib/id";
import type ReactQuill from "react-quill-new";
type ReactQuillProps = ComponentProps<typeof ReactQuill>;
const ReactQuillEditor = dynamic<ReactQuillProps>(() => import("react-quill-new"), {
ssr: false,
loading: () => <div className="h-[480px] w-full animate-pulse bg-gray-100 rounded-lg" />,
});
type Props = {
projectId: string;
wikis: WikiSnapshot[];
setWikis: React.Dispatch<React.SetStateAction<WikiSnapshot[]>>;
autoOpen?: boolean;
requestedActiveId?: string | null;
};
function clampTitle(title: string) {
@@ -26,35 +32,15 @@ function clampTitle(title: string) {
return t.length ? t.slice(0, 120) : "Untitled wiki";
}
export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen }: Props) {
export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen, requestedActiveId }: Props) {
const [open, setOpen] = useState(false);
const [activeId, setActiveId] = useState<string | null>(null);
const activeWiki = useMemo(() => wikis.find((w) => w.id === activeId) || null, [activeId, wikis]);
const [wikiTitle, setWikiTitle] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<Wiki[]>([]);
const [isSearching, setIsSearching] = useState(false);
const searchRequestRef = useState(() => ({ id: 0 }))[0];
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
}),
TiptapLink.configure({
openOnClick: false,
autolink: true,
linkOnPaste: true,
}),
],
content: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "" }] }] },
editorProps: {
attributes: {
class: "tiptap-editor focus:outline-none min-h-[320px] px-4 py-3",
},
},
});
const [wikiDocHtml, setWikiDocHtml] = useState("");
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [createTitle, setCreateTitle] = useState("");
useEffect(() => {
if (!autoOpen) return;
@@ -62,17 +48,20 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen
setOpen(true);
}, [autoOpen]);
useEffect(() => {
if (!requestedActiveId) return;
if (wikis.some((w) => w.id === requestedActiveId)) {
setActiveId(requestedActiveId);
}
}, [requestedActiveId, wikis]);
// keep editor content in sync when switching wiki
useEffect(() => {
if (!editor) return;
if (!open) return;
const doc = (activeWiki?.doc || null) as JSONContent | null;
editor.commands.setContent(
(doc && typeof doc === "object" ? doc : { type: "doc", content: [{ type: "paragraph" }] }) as any
);
setWikiTitle(activeWiki?.title || "");
}, [activeWiki?.doc, activeWiki?.title, editor, open]);
setWikiDocHtml(normalizeWikiDocForQuill(activeWiki?.doc || null));
}, [activeWiki?.doc, activeWiki?.title, open]);
const ensureActive = () => {
if (activeId && wikis.some((w) => w.id === activeId)) return;
@@ -84,60 +73,6 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wikis.length]);
useEffect(() => {
const keyword = searchQuery.trim();
if (!keyword.length) {
setSearchResults([]);
setIsSearching(false);
return;
}
let disposed = false;
const requestId = ++searchRequestRef.id;
const t = window.setTimeout(async () => {
setIsSearching(true);
try {
const rows = await searchWikisByTitle(keyword, { limit: 12 });
if (disposed || requestId !== searchRequestRef.id) return;
setSearchResults(rows);
} catch (err) {
if (disposed || requestId !== searchRequestRef.id) return;
console.error("Search wikis failed", err);
setSearchResults([]);
} finally {
if (disposed || requestId !== searchRequestRef.id) return;
setIsSearching(false);
}
}, 250);
return () => {
disposed = true;
window.clearTimeout(t);
};
}, [searchQuery, searchRequestRef]);
const addWikiRef = (wiki: Wiki) => {
const id = String(wiki.id || "").trim();
if (!id) return;
if (wikis.some((w) => w.id === id)) {
setActiveId(id);
return;
}
const title = (wiki.title || "").trim() || "Untitled wiki";
setWikis((prev) => [
{
id,
source: "ref",
operation: "reference",
title,
doc: null,
updated_at: wiki.updated_at,
},
...prev,
]);
setActiveId(id);
};
const openEditor = () => {
if (!wikis.length) {
const id = newId();
@@ -146,7 +81,7 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen
source: "inline",
operation: "create",
title: "Untitled wiki",
doc: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "" }] }] },
doc: "",
updated_at: new Date().toISOString(),
};
setWikis((prev) => [seed, ...prev]);
@@ -155,17 +90,18 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen
setOpen(true);
};
const createWiki = () => {
const createWikiAndOpen = (title?: string) => {
const id = newId();
const next: WikiSnapshot = {
const seedTitle = clampTitle(title || "Untitled wiki");
const seed: WikiSnapshot = {
id,
source: "inline",
operation: "create",
title: "Untitled wiki",
doc: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "" }] }] },
title: seedTitle,
doc: "",
updated_at: new Date().toISOString(),
};
setWikis((prev) => [next, ...prev]);
setWikis((prev) => [seed, ...prev]);
setActiveId(id);
setOpen(true);
};
@@ -176,8 +112,8 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen
};
const saveWiki = () => {
if (!editor || !activeId) return;
const payload = editor.getJSON();
if (!activeId) return;
const payload = wikiDocHtml;
const nextTitle = clampTitle(wikiTitle);
setWikis((prev) =>
prev.map((w) =>
@@ -196,19 +132,6 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen
setOpen(false);
};
const setLink = () => {
if (!editor) return;
const prev = editor.getAttributes("link")?.href as string | undefined;
const href = window.prompt("Link URL", prev || "https://");
if (href == null) return;
const next = href.trim();
if (!next.length) {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
return;
}
editor.chain().focus().extendMarkRange("link").setLink({ href: next }).run();
};
return (
<div
style={{
@@ -218,192 +141,13 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen
border: "1px solid #1f2937",
}}
>
<style jsx global>{`
.tiptap-editor p {
margin: 0.5rem 0;
line-height: 1.65;
font-size: 0.95rem;
}
.tiptap-editor h1 {
margin: 1rem 0 0.5rem;
font-size: 1.5rem;
font-weight: 800;
line-height: 1.25;
}
.tiptap-editor h2 {
margin: 0.9rem 0 0.4rem;
font-size: 1.25rem;
font-weight: 700;
line-height: 1.3;
}
.tiptap-editor h3 {
margin: 0.8rem 0 0.35rem;
font-size: 1.1rem;
font-weight: 700;
line-height: 1.35;
}
.tiptap-editor ul,
.tiptap-editor ol {
margin: 0.6rem 0;
padding-left: 1.25rem;
}
.tiptap-editor li {
margin: 0.2rem 0;
}
.tiptap-editor blockquote {
margin: 0.75rem 0;
padding-left: 0.75rem;
border-left: 4px solid rgba(148, 163, 184, 0.55);
color: rgba(100, 116, 139, 1);
}
.dark .tiptap-editor blockquote {
border-left-color: rgba(71, 85, 105, 1);
color: rgba(148, 163, 184, 1);
}
.tiptap-editor code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
"Liberation Mono", "Courier New", monospace;
font-size: 0.85em;
padding: 0.1rem 0.25rem;
border-radius: 0.35rem;
background: rgba(148, 163, 184, 0.15);
}
.tiptap-editor pre {
margin: 0.8rem 0;
padding: 0.9rem 1rem;
border-radius: 0.75rem;
border: 1px solid rgba(226, 232, 240, 1);
background: rgba(248, 250, 252, 1);
overflow: auto;
}
.dark .tiptap-editor pre {
border-color: rgba(30, 41, 59, 1);
background: rgba(13, 17, 23, 1);
}
.tiptap-editor pre code {
background: transparent;
padding: 0;
}
.tiptap-editor a {
text-decoration: underline;
text-underline-offset: 2px;
}
`}</style>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
<div style={{ fontWeight: 700, fontSize: "14px" }}>Wiki</div>
<Badge size="sm" variant="light" color="info">
{wikis.length}
</Badge>
</div>
<div style={{ display: "flex", gap: "8px", marginTop: "10px" }}>
<button
type="button"
onClick={openEditor}
style={{
flex: 1,
border: "none",
borderRadius: "6px",
padding: "8px",
cursor: "pointer",
background: "#2563eb",
color: "white",
fontWeight: 700,
}}
>
Open wiki editor
</button>
<button
type="button"
onClick={createWiki}
title="New wiki"
style={{
width: "42px",
border: "none",
borderRadius: "6px",
padding: "8px",
cursor: "pointer",
background: "#1f2937",
color: "white",
fontWeight: 900,
}}
>
+
</button>
</div>
<div style={{ marginTop: "10px" }}>
<div style={{ fontSize: "12px", color: "#94a3b8", marginBottom: "6px" }}>Add existing wiki</div>
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search by title…"
style={{
width: "100%",
border: "1px solid #1f2937",
background: "#0b1220",
color: "#e5e7eb",
borderRadius: "6px",
padding: "8px 10px",
fontSize: "12px",
outline: "none",
}}
/>
{isSearching ? (
<div style={{ marginTop: "6px", fontSize: "12px", color: "#94a3b8" }}>Searching</div>
) : null}
{!isSearching && searchQuery.trim().length > 0 ? (
<div style={{ marginTop: "6px", display: "grid", gap: "6px" }}>
{searchResults.slice(0, 8).map((w) => (
<div
key={w.id}
style={{
display: "flex",
alignItems: "center",
gap: "8px",
padding: "8px",
borderRadius: "6px",
border: "1px solid #1f2937",
background: "transparent",
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: "#e5e7eb", fontSize: "12px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{(w.title || "").trim() || "Untitled wiki"}
</div>
<div style={{ color: "#94a3b8", fontSize: "11px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{w.id}
</div>
</div>
<button
type="button"
onClick={() => addWikiRef(w)}
style={{
border: "none",
background: "#111827",
color: "#93c5fd",
cursor: "pointer",
borderRadius: "6px",
padding: "6px 8px",
fontSize: "12px",
fontWeight: 700,
flex: "0 0 auto",
}}
>
Add
</button>
</div>
))}
{!searchResults.length ? (
<div style={{ fontSize: "12px", color: "#94a3b8" }}>No results.</div>
) : null}
</div>
) : null}
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{wikis.length}</div>
</div>
{wikis.length ? (
<div style={{ marginTop: "10px", display: "grid", gap: "8px" }}>
<div style={{ marginTop: "10px", display: "grid", gap: "6px" }}>
{wikis.slice(0, 8).map((w) => (
<div
key={w.id}
@@ -414,7 +158,7 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen
padding: "8px",
borderRadius: "6px",
border: "1px solid #1f2937",
background: w.id === activeId ? "#111827" : "transparent",
background: "transparent",
}}
>
<button
@@ -467,6 +211,83 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen
</div>
)}
<div
style={{
marginTop: "10px",
display: "grid",
gap: "8px",
border: "1px solid #1e3a8a",
borderRadius: "8px",
padding: "8px",
background: "#0f172a",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
Tạo wiki mới
</div>
<button
type="button"
onClick={() => setIsCreateOpen((v) => !v)}
title={isCreateOpen ? "Dong" : "Mo"}
aria-label={isCreateOpen ? "Dong tao wiki" : "Mo tao wiki"}
style={{
width: 26,
height: 26,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "#e2e8f0",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
}}
>
{isCreateOpen ? <CloseIcon /> : <PlusIcon />}
</button>
</div>
{isCreateOpen ? (
<>
<input
value={createTitle}
onChange={(e) => setCreateTitle(e.target.value)}
placeholder="Tieu de wiki"
style={{
width: "100%",
borderRadius: "6px",
border: "1px solid #334155",
background: "#111827",
color: "#f8fafc",
padding: "6px 8px",
fontSize: "13px",
}}
/>
<button
type="button"
onClick={() => {
createWikiAndOpen(createTitle);
setCreateTitle("");
setIsCreateOpen(false);
}}
style={{
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: "pointer",
background: "#2563eb",
color: "#ffffff",
fontWeight: 600,
}}
>
Tạo wiki mới
</button>
</>
) : null}
</div>
<Modal
isOpen={open}
onClose={() => setOpen(false)}
@@ -484,7 +305,7 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen
<Button size="sm" variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button size="sm" className="bg-brand-500 hover:bg-brand-600 text-white" onClick={saveWiki} disabled={!editor || !activeId}>
<Button size="sm" className="bg-brand-500 hover:bg-brand-600 text-white" onClick={saveWiki} disabled={!activeId}>
Save
</Button>
</div>
@@ -510,7 +331,7 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen
<div className="text-[11px] text-gray-500 dark:text-gray-400 truncate">{w.id}</div>
</button>
))}
<Button size="sm" variant="outline" onClick={createWiki}>
<Button size="sm" variant="outline" onClick={openEditor}>
+ New wiki
</Button>
</div>
@@ -529,41 +350,16 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen
/>
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleBold().run()} disabled={!editor}>
B
</Button>
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleItalic().run()} disabled={!editor}>
I
</Button>
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleHeading({ level: 1 }).run()} disabled={!editor}>
H1
</Button>
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()} disabled={!editor}>
H2
</Button>
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()} disabled={!editor}>
H3
</Button>
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleBulletList().run()} disabled={!editor}>
Bullets
</Button>
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleOrderedList().run()} disabled={!editor}>
Numbers
</Button>
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleBlockquote().run()} disabled={!editor}>
Quote
</Button>
<Button size="sm" variant="outline" onClick={() => editor?.chain().focus().toggleCodeBlock().run()} disabled={!editor}>
Code
</Button>
<Button size="sm" variant="outline" onClick={setLink} disabled={!editor}>
Link
</Button>
</div>
<div className="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-[#0d1117]">
{editor ? <EditorContent editor={editor} /> : <div className="p-4 text-sm text-gray-500">Loading editor...</div>}
<div className="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-[#0d1117] overflow-hidden">
<ReactQuillEditor
theme="snow"
value={wikiDocHtml}
onChange={(content: string) => setWikiDocHtml(content)}
modules={QUILL_MODULES}
className="min-h-[320px]"
placeholder="Nhap noi dung wiki..."
readOnly={!activeId}
/>
</div>
</div>
</div>
@@ -577,3 +373,80 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen
</div>
);
}
function PlusIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 5v14M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function CloseIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
const QUILL_MODULES = {
toolbar: [
[{ header: [1, 2, 3, false] }],
["bold", "italic", "underline", "strike"],
[{ list: "ordered" }, { list: "bullet" }],
["blockquote", "code-block"],
["link", "image"],
["clean"],
],
};
function normalizeWikiDocForQuill(doc: string | null): string {
const raw = (doc || "").trim();
if (!raw.length) return "";
// New format (Quill): HTML string.
if (raw[0] === "<") return raw;
// Legacy format (Tiptap): JSON string.
if (raw[0] === "{") {
try {
const json: unknown = JSON.parse(raw);
const text = tiptapJsonToPlainText(json).trim();
if (!text.length) return "";
return `<p>${escapeHtml(text).replace(/\n/g, "<br/>")}</p>`;
} catch {
// fall through
}
}
// Unknown plaintext: treat as plain text.
return `<p>${escapeHtml(raw).replace(/\n/g, "<br/>")}</p>`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function tiptapJsonToPlainText(node: unknown): string {
if (node == null) return "";
if (typeof node === "string") return node;
if (Array.isArray(node)) return node.map(tiptapJsonToPlainText).join("");
if (isRecord(node)) {
if (node.type === "text" && typeof node.text === "string") return node.text;
if (node.type === "hardBreak") return "\n";
if ("content" in node) return tiptapJsonToPlainText(node.content);
}
return "";
}
function escapeHtml(input: string): string {
return input
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;")
.replaceAll("'", "&#39;");
}