refactor state storge, UI editor
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 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();
|
||||
}
|
||||
+375
-346
@@ -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 có 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 có commit
|
||||
</div>
|
||||
<div style={{ color: "#64748b", fontSize: 12 }}>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)}
|
||||
<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ụ có 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 có thao tác</div>
|
||||
<div style={{ color: "#94a3b8", fontSize: 13 }}>Chưa có 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 có geometry mới chờ commit
|
||||
</div>
|
||||
<div style={{ color: "#64748b", fontSize: 12 }}>Chưa có 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":
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 có entity nào được gắn.
|
||||
</div>
|
||||
)}
|
||||
Bỏ
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
|
||||
Chưa có 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 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...
|
||||
{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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll("\"", """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user