json import export project | UI cleaner | binding geometry to each other
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction, type PointerEvent as ReactPointerEvent } from "react";
|
||||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
import Map from "@/uhm/components/Map";
|
import Map from "@/uhm/components/Map";
|
||||||
import Editor from "@/uhm/components/Editor";
|
import Editor from "@/uhm/components/Editor";
|
||||||
@@ -10,6 +10,7 @@ import SelectedGeometryPanel from "@/uhm/components/SelectedGeometryPanel";
|
|||||||
import WikiSidebarPanel from "@/uhm/components/WikiSidebarPanel";
|
import WikiSidebarPanel from "@/uhm/components/WikiSidebarPanel";
|
||||||
import ProjectEntityRefsPanel from "@/uhm/components/ProjectEntityRefsPanel";
|
import ProjectEntityRefsPanel from "@/uhm/components/ProjectEntityRefsPanel";
|
||||||
import EntityWikiBindingsPanel from "@/uhm/components/EntityWikiBindingsPanel";
|
import EntityWikiBindingsPanel from "@/uhm/components/EntityWikiBindingsPanel";
|
||||||
|
import GeometryBindingPanel from "@/uhm/components/GeometryBindingPanel";
|
||||||
import { Entity, fetchEntities, searchEntitiesByName } from "@/uhm/api/entities";
|
import { Entity, fetchEntities, searchEntitiesByName } from "@/uhm/api/entities";
|
||||||
import { ApiError } from "@/uhm/api/http";
|
import { ApiError } from "@/uhm/api/http";
|
||||||
import { fetchCurrentUser } from "@/uhm/api/auth";
|
import { fetchCurrentUser } from "@/uhm/api/auth";
|
||||||
@@ -62,6 +63,7 @@ import { FIXED_TIMELINE_RANGE, clampYearToFixedRange } from "@/uhm/lib/timeline"
|
|||||||
import { useFeatureCommands } from "./featureCommands";
|
import { useFeatureCommands } from "./featureCommands";
|
||||||
import { deleteSubmission } from "@/uhm/api/sections";
|
import { deleteSubmission } from "@/uhm/api/sections";
|
||||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
|
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
||||||
import UnifiedSearchBar, { type UnifiedSearchKind } from "@/uhm/components/UnifiedSearchBar";
|
import UnifiedSearchBar, { type UnifiedSearchKind } from "@/uhm/components/UnifiedSearchBar";
|
||||||
|
|
||||||
const CURRENT_YEAR = new Date().getUTCFullYear();
|
const CURRENT_YEAR = new Date().getUTCFullYear();
|
||||||
@@ -87,7 +89,10 @@ export default function Page() {
|
|||||||
const [leftPanelWidth, setLeftPanelWidth] = useState(280);
|
const [leftPanelWidth, setLeftPanelWidth] = useState(280);
|
||||||
const [rightPanelWidth, setRightPanelWidth] = useState(420);
|
const [rightPanelWidth, setRightPanelWidth] = useState(420);
|
||||||
const [timelineFilterEnabled, setTimelineFilterEnabled] = useState(true);
|
const [timelineFilterEnabled, setTimelineFilterEnabled] = useState(true);
|
||||||
|
const [geometryBindingFilterEnabled, setGeometryBindingFilterEnabled] = useState(true);
|
||||||
const entityFormStatusTimeoutRef = useRef<number | null>(null);
|
const entityFormStatusTimeoutRef = useRef<number | null>(null);
|
||||||
|
const geoBindingStatusTimeoutRef = useRef<number | null>(null);
|
||||||
|
const [geoBindingStatus, setGeoBindingStatus] = useState<string | null>(null);
|
||||||
const lastSelectedFeatureIdRef = useRef<string | null>(null);
|
const lastSelectedFeatureIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -162,7 +167,39 @@ export default function Page() {
|
|||||||
const wikiSearchRequestRef = useRef(0);
|
const wikiSearchRequestRef = useRef(0);
|
||||||
const geoSearchRequestRef = useRef(0);
|
const geoSearchRequestRef = useRef(0);
|
||||||
|
|
||||||
const editor = useEditorState(initialData);
|
const snapshotEntitiesRef = useRef(snapshotEntities);
|
||||||
|
const snapshotWikisRef = useRef(snapshotWikis);
|
||||||
|
const snapshotEntityWikiLinksRef = useRef(snapshotEntityWikiLinks);
|
||||||
|
useEffect(() => {
|
||||||
|
snapshotEntitiesRef.current = snapshotEntities;
|
||||||
|
}, [snapshotEntities]);
|
||||||
|
useEffect(() => {
|
||||||
|
snapshotWikisRef.current = snapshotWikis;
|
||||||
|
}, [snapshotWikis]);
|
||||||
|
useEffect(() => {
|
||||||
|
snapshotEntityWikiLinksRef.current = snapshotEntityWikiLinks;
|
||||||
|
}, [snapshotEntityWikiLinks]);
|
||||||
|
|
||||||
|
const editor = useEditorState(initialData, {
|
||||||
|
snapshotEntitiesRef,
|
||||||
|
setSnapshotEntities,
|
||||||
|
snapshotWikisRef,
|
||||||
|
setSnapshotWikis,
|
||||||
|
snapshotEntityWikiLinksRef,
|
||||||
|
setSnapshotEntityWikiLinks,
|
||||||
|
});
|
||||||
|
const setSnapshotWikisUndoable = useCallback(
|
||||||
|
(next: SetStateAction<WikiSnapshot[]>) => {
|
||||||
|
editor.setSnapshotWikis(next, "Cập nhật wiki");
|
||||||
|
},
|
||||||
|
[editor]
|
||||||
|
);
|
||||||
|
const setSnapshotEntityWikiLinksUndoable = useCallback(
|
||||||
|
(next: SetStateAction<EntityWikiLinkSnapshot[]>) => {
|
||||||
|
editor.setSnapshotEntityWikiLinks(next, "Cập nhật entity-wiki");
|
||||||
|
},
|
||||||
|
[editor]
|
||||||
|
);
|
||||||
const editorUserId = normalizeEditorUserId(editorUserIdInput);
|
const editorUserId = normalizeEditorUserId(editorUserIdInput);
|
||||||
const snapshotEntitiesAsEntities = useMemo(() => {
|
const snapshotEntitiesAsEntities = useMemo(() => {
|
||||||
const rows = snapshotEntities || [];
|
const rows = snapshotEntities || [];
|
||||||
@@ -229,6 +266,24 @@ export default function Page() {
|
|||||||
String(feature.properties.id) === String(selectedFeatureId)
|
String(feature.properties.id) === String(selectedFeatureId)
|
||||||
) || null;
|
) || null;
|
||||||
|
|
||||||
|
const geometryChoices = useMemo(() => {
|
||||||
|
const rows = (editor.draft.features || [])
|
||||||
|
.filter((f) => f && f.properties && (typeof f.properties.id === "string" || typeof f.properties.id === "number"))
|
||||||
|
.map((f) => {
|
||||||
|
const id = String(f.properties.id);
|
||||||
|
const semantic = String(f.properties.type || getDefaultTypeIdForFeature(f) || "").trim();
|
||||||
|
const label = semantic.length ? `${semantic} (${f.geometry.type})` : f.geometry.type;
|
||||||
|
return { id, label };
|
||||||
|
});
|
||||||
|
rows.sort((a, b) => a.id.localeCompare(b.id));
|
||||||
|
return rows;
|
||||||
|
}, [editor.draft.features]);
|
||||||
|
|
||||||
|
const selectedGeometryBindingIds = useMemo(() => {
|
||||||
|
if (!selectedFeature) return [];
|
||||||
|
return normalizeFeatureBindingIds(selectedFeature);
|
||||||
|
}, [selectedFeature]);
|
||||||
|
|
||||||
const createdEntities = useMemo(() => {
|
const createdEntities = useMemo(() => {
|
||||||
return (snapshotEntities || [])
|
return (snapshotEntities || [])
|
||||||
.filter((e) => e && e.source === "inline" && e.operation === "create")
|
.filter((e) => e && e.source === "inline" && e.operation === "create")
|
||||||
@@ -691,6 +746,20 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
}, [setEntityFormStatus]);
|
}, [setEntityFormStatus]);
|
||||||
|
|
||||||
|
const flashGeoBindingStatus = useCallback((msg: string | null, timeoutMs = 3000) => {
|
||||||
|
if (geoBindingStatusTimeoutRef.current) {
|
||||||
|
window.clearTimeout(geoBindingStatusTimeoutRef.current);
|
||||||
|
geoBindingStatusTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
setGeoBindingStatus(msg);
|
||||||
|
if (msg && timeoutMs > 0) {
|
||||||
|
geoBindingStatusTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
setGeoBindingStatus(null);
|
||||||
|
geoBindingStatusTimeoutRef.current = null;
|
||||||
|
}, timeoutMs);
|
||||||
|
}
|
||||||
|
}, [setGeoBindingStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage());
|
setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage());
|
||||||
setIsBackgroundVisibilityReady(true);
|
setIsBackgroundVisibilityReady(true);
|
||||||
@@ -740,7 +809,7 @@ export default function Page() {
|
|||||||
const handleAddEntityRefToProject = useCallback((entity: Entity) => {
|
const handleAddEntityRefToProject = useCallback((entity: Entity) => {
|
||||||
const id = String(entity.id || "").trim();
|
const id = String(entity.id || "").trim();
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
setSnapshotEntities((prev) => {
|
editor.setSnapshotEntities((prev) => {
|
||||||
if (prev.some((e) => String(e.id) === id)) return prev;
|
if (prev.some((e) => String(e.id) === id)) return prev;
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -752,7 +821,7 @@ export default function Page() {
|
|||||||
},
|
},
|
||||||
...prev,
|
...prev,
|
||||||
];
|
];
|
||||||
});
|
}, `Thêm entity ref #${id}`);
|
||||||
// Keep entity catalog centralized as a single in-memory list.
|
// Keep entity catalog centralized as a single in-memory list.
|
||||||
setEntityCatalog((prev) => {
|
setEntityCatalog((prev) => {
|
||||||
const byId = new globalThis.Map<string, Entity>();
|
const byId = new globalThis.Map<string, Entity>();
|
||||||
@@ -763,7 +832,38 @@ export default function Page() {
|
|||||||
byId.set(id, entity);
|
byId.set(id, entity);
|
||||||
return Array.from(byId.values());
|
return Array.from(byId.values());
|
||||||
});
|
});
|
||||||
}, [setEntityCatalog, setSnapshotEntities]);
|
}, [editor, setEntityCatalog]);
|
||||||
|
|
||||||
|
const handleUpdateEntityInProject = useCallback((entityId: string, payload: { name: string; description: string | null }) => {
|
||||||
|
const id = String(entityId || "").trim();
|
||||||
|
if (!id) return;
|
||||||
|
const nextName = String(payload?.name || "").trim();
|
||||||
|
if (!nextName.length) {
|
||||||
|
flashEntityFormStatus("Ten entity la bat buoc.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextDescription = payload?.description == null ? null : String(payload.description);
|
||||||
|
|
||||||
|
editor.setSnapshotEntities((prev) => prev.map((e) => {
|
||||||
|
if (!e || String(e.id) !== id) return e;
|
||||||
|
const source = e.source === "inline" ? "inline" : "ref";
|
||||||
|
const operation =
|
||||||
|
source === "ref"
|
||||||
|
? "reference"
|
||||||
|
: e.operation === "create"
|
||||||
|
? "create"
|
||||||
|
: "update";
|
||||||
|
return {
|
||||||
|
...e,
|
||||||
|
id,
|
||||||
|
source,
|
||||||
|
operation,
|
||||||
|
name: nextName,
|
||||||
|
description: nextDescription,
|
||||||
|
};
|
||||||
|
}), `Cap nhat entity #${id}`);
|
||||||
|
flashEntityFormStatus("Da cap nhat entity. Commit khi san sang.", 3000);
|
||||||
|
}, [editor, flashEntityFormStatus]);
|
||||||
|
|
||||||
const handleToggleBindEntityForSelectedGeometry = useCallback((entityId: string, nextChecked: boolean) => {
|
const handleToggleBindEntityForSelectedGeometry = useCallback((entityId: string, nextChecked: boolean) => {
|
||||||
if (!selectedFeature) {
|
if (!selectedFeature) {
|
||||||
@@ -810,11 +910,53 @@ export default function Page() {
|
|||||||
setSelectedGeometryEntityIds,
|
setSelectedGeometryEntityIds,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const handleToggleBindGeometryForSelectedGeometry = useCallback((geoId: string, nextChecked: boolean) => {
|
||||||
|
if (!selectedFeature) {
|
||||||
|
flashGeoBindingStatus("Chưa chọn geometry để bind.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = String(geoId || "").trim();
|
||||||
|
if (!id) return;
|
||||||
|
if (String(selectedFeature.properties.id) === id) return;
|
||||||
|
|
||||||
|
const prevBindingIds = normalizeFeatureBindingIds(selectedFeature);
|
||||||
|
const has = prevBindingIds.includes(id);
|
||||||
|
const nextBindingIds = (() => {
|
||||||
|
if (nextChecked) {
|
||||||
|
if (has) return prevBindingIds;
|
||||||
|
return [...prevBindingIds, id];
|
||||||
|
}
|
||||||
|
if (!has) return prevBindingIds;
|
||||||
|
return prevBindingIds.filter((x) => x !== id);
|
||||||
|
})();
|
||||||
|
|
||||||
|
setIsEntitySubmitting(true);
|
||||||
|
flashGeoBindingStatus(null, 0);
|
||||||
|
try {
|
||||||
|
editor.patchFeatureProperties(selectedFeature.properties.id, { binding: nextBindingIds });
|
||||||
|
setGeometryMetaForm((prev) => ({ ...prev, binding: nextBindingIds.join(", ") }));
|
||||||
|
flashGeoBindingStatus(
|
||||||
|
nextChecked
|
||||||
|
? "Đã bind geometry vào binding. Commit khi sẵn sàng."
|
||||||
|
: "Đã gỡ binding geometry. Commit khi sẵn sàng.",
|
||||||
|
3000
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsEntitySubmitting(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
editor,
|
||||||
|
flashGeoBindingStatus,
|
||||||
|
selectedFeature,
|
||||||
|
setGeometryMetaForm,
|
||||||
|
setIsEntitySubmitting,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleAddWikiRefToProject = useCallback((wiki: Wiki) => {
|
const handleAddWikiRefToProject = useCallback((wiki: Wiki) => {
|
||||||
const id = String(wiki.id || "").trim();
|
const id = String(wiki.id || "").trim();
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
const title = (wiki.title || "").trim() || "Untitled wiki";
|
const title = (wiki.title || "").trim() || "Untitled wiki";
|
||||||
setSnapshotWikis((prev) => {
|
editor.setSnapshotWikis((prev) => {
|
||||||
if (prev.some((w) => w.id === id)) return prev;
|
if (prev.some((w) => w.id === id)) return prev;
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -827,9 +969,9 @@ export default function Page() {
|
|||||||
},
|
},
|
||||||
...prev,
|
...prev,
|
||||||
];
|
];
|
||||||
});
|
}, `Thêm wiki ref #${id}`);
|
||||||
setRequestedActiveWikiId(id);
|
setRequestedActiveWikiId(id);
|
||||||
}, [setSnapshotWikis]);
|
}, [editor, setRequestedActiveWikiId]);
|
||||||
|
|
||||||
const handleImportGeoFromSearch = useCallback((
|
const handleImportGeoFromSearch = useCallback((
|
||||||
entityItem: EntityGeometriesSearchItem,
|
entityItem: EntityGeometriesSearchItem,
|
||||||
@@ -932,7 +1074,7 @@ export default function Page() {
|
|||||||
setIsEntitySubmitting(true);
|
setIsEntitySubmitting(true);
|
||||||
setEntityFormStatus(null);
|
setEntityFormStatus(null);
|
||||||
try {
|
try {
|
||||||
setSnapshotEntities((prev) => {
|
editor.setSnapshotEntities((prev) => {
|
||||||
if (prev.some((e) => String(e.id) === entityId)) return prev;
|
if (prev.some((e) => String(e.id) === entityId)) return prev;
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -946,7 +1088,7 @@ export default function Page() {
|
|||||||
},
|
},
|
||||||
...prev,
|
...prev,
|
||||||
];
|
];
|
||||||
});
|
}, `Tạo entity #${entityId}`);
|
||||||
setEntityCatalog((prev) => {
|
setEntityCatalog((prev) => {
|
||||||
const byId = new globalThis.Map<string, Entity>();
|
const byId = new globalThis.Map<string, Entity>();
|
||||||
for (const row of prev || []) {
|
for (const row of prev || []) {
|
||||||
@@ -1069,6 +1211,7 @@ export default function Page() {
|
|||||||
onDeleteFeature={editor.deleteFeature}
|
onDeleteFeature={editor.deleteFeature}
|
||||||
onUpdateFeature={editor.updateFeature}
|
onUpdateFeature={editor.updateFeature}
|
||||||
backgroundVisibility={backgroundVisibility}
|
backgroundVisibility={backgroundVisibility}
|
||||||
|
respectBindingFilter={geometryBindingFilterEnabled}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
|
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
|
||||||
@@ -1333,30 +1476,42 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
<GeometryBindingPanel
|
||||||
<WikiSidebarPanel
|
geometries={geometryChoices}
|
||||||
projectId={projectId}
|
selectedGeometryId={selectedFeature ? String(selectedFeature.properties.id) : null}
|
||||||
wikis={snapshotWikis}
|
selectedGeometryBindingIds={selectedGeometryBindingIds}
|
||||||
setWikis={setSnapshotWikis}
|
onToggleBindGeometryForSelectedGeometry={handleToggleBindGeometryForSelectedGeometry}
|
||||||
autoOpen={autoOpenWiki}
|
statusText={geoBindingStatus}
|
||||||
requestedActiveId={requestedActiveWikiId}
|
bindingFilterEnabled={geometryBindingFilterEnabled}
|
||||||
|
onBindingFilterEnabledChange={setGeometryBindingFilterEnabled}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ProjectEntityRefsPanel
|
<ProjectEntityRefsPanel
|
||||||
entityRefs={snapshotEntitiesVisible}
|
entityRefs={snapshotEntitiesVisible}
|
||||||
entityForm={entityForm}
|
entityForm={entityForm}
|
||||||
onEntityFormChange={handleEntityFormChange}
|
onEntityFormChange={handleEntityFormChange}
|
||||||
isEntitySubmitting={isEntitySubmitting}
|
isEntitySubmitting={isEntitySubmitting}
|
||||||
onCreateEntityOnly={handleCreateEntityOnly}
|
onCreateEntityOnly={handleCreateEntityOnly}
|
||||||
|
onUpdateEntity={handleUpdateEntityInProject}
|
||||||
entityFormStatus={entityFormStatus}
|
entityFormStatus={entityFormStatus}
|
||||||
hasSelectedGeometry={Boolean(selectedFeature)}
|
hasSelectedGeometry={Boolean(selectedFeature)}
|
||||||
selectedGeometryEntityIds={selectedGeometryEntityIds}
|
selectedGeometryEntityIds={selectedGeometryEntityIds}
|
||||||
onToggleBindEntityForSelectedGeometry={handleToggleBindEntityForSelectedGeometry}
|
onToggleBindEntityForSelectedGeometry={handleToggleBindEntityForSelectedGeometry}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<WikiSidebarPanel
|
||||||
|
projectId={projectId}
|
||||||
|
wikis={snapshotWikis}
|
||||||
|
setWikis={setSnapshotWikisUndoable}
|
||||||
|
autoOpen={autoOpenWiki}
|
||||||
|
requestedActiveId={requestedActiveWikiId}
|
||||||
|
/>
|
||||||
|
|
||||||
<EntityWikiBindingsPanel
|
<EntityWikiBindingsPanel
|
||||||
entities={projectEntityChoices}
|
entities={projectEntityChoices}
|
||||||
wikis={snapshotWikis}
|
wikis={snapshotWikis}
|
||||||
links={snapshotEntityWikiLinks}
|
links={snapshotEntityWikiLinks}
|
||||||
setLinks={setSnapshotEntityWikiLinks}
|
setLinks={setSnapshotEntityWikiLinksUndoable}
|
||||||
/>
|
/>
|
||||||
{!wikiOnly && selectedFeature ? (
|
{!wikiOnly && selectedFeature ? (
|
||||||
<SelectedGeometryPanel
|
<SelectedGeometryPanel
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
||||||
@@ -12,7 +12,9 @@ import Button from "@/components/ui/button/Button";
|
|||||||
import Label from "@/components/form/Label";
|
import Label from "@/components/form/Label";
|
||||||
import Badge from "@/components/ui/badge/Badge";
|
import Badge from "@/components/ui/badge/Badge";
|
||||||
import { CreateProjectPayload, Project } from "@/interface/project";
|
import { CreateProjectPayload, Project } from "@/interface/project";
|
||||||
import { apiCreateProject, getCurrentProject } from "@/service/projectService";
|
import { apiCreateProject, apiCreateProjectCommit, apiGetProjectCommits, getCurrentProject } from "@/service/projectService";
|
||||||
|
import { normalizeEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
||||||
|
import type { EditorSnapshot } from "@/uhm/types/sections";
|
||||||
|
|
||||||
export type ProjectSortColumn = "created_at" | "updated_at" | "title";
|
export type ProjectSortColumn = "created_at" | "updated_at" | "title";
|
||||||
|
|
||||||
@@ -21,12 +23,16 @@ export default function ProjectsPage() {
|
|||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [isExportingProjectId, setIsExportingProjectId] = useState<string | null>(null);
|
||||||
|
|
||||||
const [sortBy, setSortBy] = useState<ProjectSortColumn>("updated_at");
|
const [sortBy, setSortBy] = useState<ProjectSortColumn>("updated_at");
|
||||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
|
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
|
||||||
|
|
||||||
const { isOpen, openModal, closeModal } = useModal();
|
const { isOpen, openModal, closeModal } = useModal();
|
||||||
const [formData, setFormData] = useState<CreateProjectPayload>({ title: "", description: "", project_status: "PRIVATE" });
|
const [formData, setFormData] = useState<CreateProjectPayload>({ title: "", description: "", project_status: "PRIVATE" });
|
||||||
|
const importJsonInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const [importSnapshot, setImportSnapshot] = useState<EditorSnapshot | null>(null);
|
||||||
|
const [importSnapshotName, setImportSnapshotName] = useState<string | null>(null);
|
||||||
|
|
||||||
const fetchProjects = async () => {
|
const fetchProjects = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -58,11 +64,15 @@ export default function ProjectsPage() {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
await apiCreateProject(formData);
|
const created = await apiCreateProject(formData);
|
||||||
|
const projectId = created?.data?.id;
|
||||||
toast.success("Tạo dự án mới thành công!");
|
toast.success("Tạo dự án mới thành công!");
|
||||||
closeModal();
|
closeModal();
|
||||||
setFormData({ title: "", description: "", project_status: "PRIVATE" });
|
setFormData({ title: "", description: "", project_status: "PRIVATE" });
|
||||||
|
setImportSnapshot(null);
|
||||||
|
setImportSnapshotName(null);
|
||||||
fetchProjects();
|
fetchProjects();
|
||||||
|
if (projectId) router.push(`/editor/${projectId}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Lỗi tạo dự án:", error);
|
console.error("Lỗi tạo dự án:", error);
|
||||||
toast.error("Có lỗi xảy ra khi tạo dự án.");
|
toast.error("Có lỗi xảy ra khi tạo dự án.");
|
||||||
@@ -71,6 +81,103 @@ export default function ProjectsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePickImportJson = () => {
|
||||||
|
importJsonInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportJsonFile = async (file: File | null) => {
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const raw = JSON.parse(text) as unknown;
|
||||||
|
const normalized = normalizeEditorSnapshot(raw);
|
||||||
|
if (!normalized) {
|
||||||
|
toast.error("JSON snapshot không hợp lệ.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setImportSnapshot(normalized);
|
||||||
|
setImportSnapshotName(file.name);
|
||||||
|
toast.success("Đã nạp JSON snapshot. Bấm 'Tạo với JSON' để khởi tạo dự án.");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Import JSON failed", err);
|
||||||
|
toast.error("Không đọc được file JSON.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateProjectWithJson = async () => {
|
||||||
|
if (!formData.title.trim()) {
|
||||||
|
toast.warning("Vui lòng nhập tên dự án!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!importSnapshot) {
|
||||||
|
toast.warning("Chưa chọn JSON snapshot.");
|
||||||
|
handlePickImportJson();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
const created = await apiCreateProject(formData);
|
||||||
|
const projectId = created?.data?.id;
|
||||||
|
if (!projectId) {
|
||||||
|
toast.error("Tạo dự án thất bại: thiếu project id.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await apiCreateProjectCommit(projectId, {
|
||||||
|
edit_summary: "Init project from JSON",
|
||||||
|
snapshot_json: importSnapshot as any,
|
||||||
|
} as any);
|
||||||
|
toast.success("Tạo dự án (kèm JSON) thành công!");
|
||||||
|
closeModal();
|
||||||
|
setFormData({ title: "", description: "", project_status: "PRIVATE" });
|
||||||
|
setImportSnapshot(null);
|
||||||
|
setImportSnapshotName(null);
|
||||||
|
fetchProjects();
|
||||||
|
router.push(`/editor/${projectId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Lỗi tạo dự án với JSON:", error);
|
||||||
|
toast.error("Có lỗi xảy ra khi tạo dự án với JSON.");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportHeadSnapshot = async (project: Project) => {
|
||||||
|
const projectId = String(project.id || "").trim();
|
||||||
|
if (!projectId) return;
|
||||||
|
const headCommitId = project.latest_commit_id ? String(project.latest_commit_id) : "";
|
||||||
|
if (!headCommitId) {
|
||||||
|
toast.warning("Dự án chưa có head commit để export.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsExportingProjectId(projectId);
|
||||||
|
try {
|
||||||
|
const res: any = await apiGetProjectCommits(projectId);
|
||||||
|
const rawList = res?.data?.items ?? res?.data ?? res?.items ?? [];
|
||||||
|
const commits = Array.isArray(rawList) ? rawList : [];
|
||||||
|
const head = commits.find((c: any) => String(c?.id || "") === headCommitId) || null;
|
||||||
|
const snapshot = head?.snapshot_json ?? null;
|
||||||
|
if (!snapshot) {
|
||||||
|
toast.error("Không tìm thấy snapshot_json của head commit.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blob = new Blob([JSON.stringify(snapshot, null, 2)], { type: "application/json;charset=utf-8" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `project-${projectId}-head-${headCommitId}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
toast.success("Đã export JSON snapshot.");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Export snapshot failed", err);
|
||||||
|
toast.error("Export thất bại.");
|
||||||
|
} finally {
|
||||||
|
setIsExportingProjectId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSort = (column: ProjectSortColumn) => {
|
const handleSort = (column: ProjectSortColumn) => {
|
||||||
if (sortBy === column) {
|
if (sortBy === column) {
|
||||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||||
@@ -132,6 +239,11 @@ export default function ProjectsPage() {
|
|||||||
|
|
||||||
console.log(projects);
|
console.log(projects);
|
||||||
|
|
||||||
|
const importLabel = useMemo(() => {
|
||||||
|
if (!importSnapshotName) return "Chưa chọn JSON snapshot";
|
||||||
|
return `JSON: ${importSnapshotName}`;
|
||||||
|
}, [importSnapshotName]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto pb-10">
|
<div className="max-w-7xl mx-auto pb-10">
|
||||||
<PageBreadcrumb pageTitle="Quản lý dự án" />
|
<PageBreadcrumb pageTitle="Quản lý dự án" />
|
||||||
@@ -225,6 +337,15 @@ export default function ProjectsPage() {
|
|||||||
>
|
>
|
||||||
Editor
|
Editor
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isExportingProjectId === String(project.id)}
|
||||||
|
onClick={() => handleExportHeadSnapshot(project)}
|
||||||
|
title="Export head commit snapshot_json"
|
||||||
|
>
|
||||||
|
ExportJSON
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -334,11 +455,39 @@ export default function ProjectsPage() {
|
|||||||
placeholder="Mô tả ngắn gọn về dự án..."
|
placeholder="Mô tả ngắn gọn về dự án..."
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Khởi tạo từ JSON</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="sm" variant="outline" type="button" onClick={handlePickImportJson}>
|
||||||
|
Chọn JSON
|
||||||
|
</Button>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||||
|
{importLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={importJsonInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/json"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => handleImportJsonFile(e.target.files?.[0] || null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex items-center justify-end gap-3 mt-4">
|
<div className="flex items-center justify-end gap-3 mt-4">
|
||||||
<Button size="sm" variant="outline" type="button" onClick={closeModal}>Hủy</Button>
|
<Button size="sm" variant="outline" type="button" onClick={closeModal}>Hủy</Button>
|
||||||
<Button size="sm" type="submit" disabled={isSubmitting} className="bg-brand-500 hover:bg-brand-600 text-white">
|
<Button size="sm" type="submit" disabled={isSubmitting} className="bg-brand-500 hover:bg-brand-600 text-white">
|
||||||
{isSubmitting ? "Đang tạo..." : "Khởi tạo"}
|
{isSubmitting ? "Đang tạo..." : "Khởi tạo"}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="bg-gray-900 hover:bg-gray-800 text-white"
|
||||||
|
onClick={handleCreateProjectWithJson}
|
||||||
|
title="Tạo dự án và tạo commit đầu tiên từ JSON snapshot"
|
||||||
|
>
|
||||||
|
Tạo với JSON
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,3 +28,23 @@ export async function fetchWikiById(id: string): Promise<Wiki> {
|
|||||||
return requestJson<Wiki>(`${API_ENDPOINTS.wikis}/${encodeURIComponent(wikiId)}`);
|
return requestJson<Wiki>(`${API_ENDPOINTS.wikis}/${encodeURIComponent(wikiId)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function checkWikiSlugExists(slug: string): Promise<boolean> {
|
||||||
|
const value = String(slug || "").trim();
|
||||||
|
if (!value.length) return false;
|
||||||
|
|
||||||
|
const params = new URLSearchParams({ slug: value });
|
||||||
|
const url = `${API_ENDPOINTS.wikis}/slug/exists?${params.toString()}`;
|
||||||
|
const payload = await requestJson<unknown>(url);
|
||||||
|
|
||||||
|
if (typeof payload === "boolean") return payload;
|
||||||
|
if (payload && typeof payload === "object") {
|
||||||
|
const anyPayload = payload as any;
|
||||||
|
if (typeof anyPayload.exists === "boolean") return anyPayload.exists;
|
||||||
|
if (typeof anyPayload.exists === "number") return anyPayload.exists !== 0;
|
||||||
|
if (typeof anyPayload.is_exists === "boolean") return anyPayload.is_exists;
|
||||||
|
if (typeof anyPayload.is_exists === "number") return anyPayload.is_exists !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Be conservative: unknown payload shape, treat as "exists" to prevent creating conflicting slugs.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|||||||
@@ -514,6 +514,10 @@ function formatUndoLabel(action: UndoAction) {
|
|||||||
return `Chỉnh sửa #${action.id}`;
|
return `Chỉnh sửa #${action.id}`;
|
||||||
case "properties":
|
case "properties":
|
||||||
return `Cập nhật thuộc tính #${action.id}`;
|
return `Cập nhật thuộc tính #${action.id}`;
|
||||||
|
case "snapshot_entities":
|
||||||
|
case "snapshot_wikis":
|
||||||
|
case "snapshot_entity_wiki":
|
||||||
|
return action.label;
|
||||||
default:
|
default:
|
||||||
return "Tác vụ";
|
return "Tác vụ";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import type { Entity } from "@/uhm/types/entities";
|
import type { Entity } from "@/uhm/types/entities";
|
||||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
||||||
@@ -23,6 +23,7 @@ function wikiTitle(w: WikiSnapshot): string {
|
|||||||
export default function EntityWikiBindingsPanel({ entities, wikis, links, setLinks }: Props) {
|
export default function EntityWikiBindingsPanel({ entities, wikis, links, setLinks }: Props) {
|
||||||
const [activeEntityId, setActiveEntityId] = useState<string>("");
|
const [activeEntityId, setActiveEntityId] = useState<string>("");
|
||||||
const [activeWikiId, setActiveWikiId] = useState<string>("");
|
const [activeWikiId, setActiveWikiId] = useState<string>("");
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
const wikiChoices: WikiChoice[] = useMemo(
|
const wikiChoices: WikiChoice[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -38,6 +39,17 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
|
|||||||
return cleaned;
|
return cleaned;
|
||||||
}, [entities]);
|
}, [entities]);
|
||||||
|
|
||||||
|
// Don't auto-select entity. The user must explicitly pick one.
|
||||||
|
// Only clear the selection if the currently selected entity is no longer available.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeEntityId) return;
|
||||||
|
const stillExists = entityChoices.some((e) => e.id === activeEntityId);
|
||||||
|
if (!stillExists) {
|
||||||
|
setActiveEntityId("");
|
||||||
|
setActiveWikiId("");
|
||||||
|
}
|
||||||
|
}, [activeEntityId, entityChoices]);
|
||||||
|
|
||||||
const activeLinks = useMemo(() => {
|
const activeLinks = useMemo(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
for (const l of links || []) {
|
for (const l of links || []) {
|
||||||
@@ -54,23 +66,16 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
|
|||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
||||||
setLinks((prev) => {
|
setLinks((prev) => {
|
||||||
const next = [...prev];
|
const idx = prev.findIndex((l) => l.entity_id === activeEntityId && l.wiki_id === id);
|
||||||
const idx = next.findIndex((l) => l.entity_id === activeEntityId && l.wiki_id === id);
|
// If link exists (reference/binding), unlink by removing the row entirely.
|
||||||
if (idx >= 0) {
|
if (idx >= 0 && prev[idx]?.operation !== "delete") {
|
||||||
const existing = next[idx];
|
return prev.filter((_, i) => i !== idx);
|
||||||
const currentlyOn = existing.operation !== "delete";
|
|
||||||
next[idx] = {
|
|
||||||
...existing,
|
|
||||||
operation: currentlyOn ? "delete" : "binding",
|
|
||||||
};
|
|
||||||
return next;
|
|
||||||
}
|
}
|
||||||
next.push({
|
// If link doesn't exist, add as a new binding (create for relation).
|
||||||
entity_id: activeEntityId,
|
return [
|
||||||
wiki_id: id,
|
...prev.filter((l) => !(l.entity_id === activeEntityId && l.wiki_id === id)),
|
||||||
operation: "binding",
|
{ entity_id: activeEntityId, wiki_id: id, operation: "binding" },
|
||||||
});
|
];
|
||||||
return next;
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -88,9 +93,33 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
|
|||||||
>
|
>
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
|
||||||
<div style={{ fontWeight: 700, fontSize: "14px" }}>Entity ↔ Wiki</div>
|
<div style={{ fontWeight: 700, fontSize: "14px" }}>Entity ↔ Wiki</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{links.length}</div>
|
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{links.length}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCollapsed((v) => !v)}
|
||||||
|
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",
|
||||||
|
}}
|
||||||
|
title={collapsed ? "Mo panel" : "Thu gon panel"}
|
||||||
|
aria-label={collapsed ? "Mo panel Entity Wiki" : "Thu gon panel Entity Wiki"}
|
||||||
|
>
|
||||||
|
{collapsed ? <PlusIcon /> : <MinusIcon />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{collapsed ? null : (
|
||||||
<div style={{ marginTop: "10px", display: "grid", gap: "8px" }}>
|
<div style={{ marginTop: "10px", display: "grid", gap: "8px" }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: "12px", color: "#94a3b8", marginBottom: "6px" }}>Entity</div>
|
<div style={{ fontSize: "12px", color: "#94a3b8", marginBottom: "6px" }}>Entity</div>
|
||||||
@@ -247,6 +276,23 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</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 MinusIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
258
src/uhm/components/GeometryBindingPanel.tsx
Normal file
258
src/uhm/components/GeometryBindingPanel.tsx
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
type GeometryChoice = {
|
||||||
|
id: string;
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
geometries: GeometryChoice[];
|
||||||
|
selectedGeometryId: string | null;
|
||||||
|
selectedGeometryBindingIds: string[];
|
||||||
|
onToggleBindGeometryForSelectedGeometry?: (geometryId: string, nextChecked: boolean) => void;
|
||||||
|
statusText?: string | null;
|
||||||
|
bindingFilterEnabled: boolean;
|
||||||
|
onBindingFilterEnabledChange: (next: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function GeometryBindingPanel({
|
||||||
|
geometries,
|
||||||
|
selectedGeometryId,
|
||||||
|
selectedGeometryBindingIds,
|
||||||
|
onToggleBindGeometryForSelectedGeometry,
|
||||||
|
statusText,
|
||||||
|
bindingFilterEnabled,
|
||||||
|
onBindingFilterEnabledChange,
|
||||||
|
}: Props) {
|
||||||
|
const canBindToggle =
|
||||||
|
Boolean(selectedGeometryId) && typeof onToggleBindGeometryForSelectedGeometry === "function";
|
||||||
|
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const cleaned = (geometries || [])
|
||||||
|
.filter((g) => g && typeof g.id === "string" && g.id.trim().length > 0)
|
||||||
|
.map((g) => ({ id: g.id.trim(), label: (g.label || "").trim() }));
|
||||||
|
cleaned.sort((a, b) => a.id.localeCompare(b.id));
|
||||||
|
return cleaned;
|
||||||
|
}, [geometries]);
|
||||||
|
|
||||||
|
const bindingSet = useMemo(() => new Set(selectedGeometryBindingIds || []), [selectedGeometryBindingIds]);
|
||||||
|
|
||||||
|
const visibleRows = rows.slice(0, 12);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "10px",
|
||||||
|
background: "#0b1220",
|
||||||
|
borderRadius: "8px",
|
||||||
|
border: "1px solid #1f2937",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: "14px", whiteSpace: "nowrap" }}>Geometry Binding</div>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
cursor: "pointer",
|
||||||
|
userSelect: "none",
|
||||||
|
}}
|
||||||
|
title={bindingFilterEnabled ? "Đang ẩn geo theo binding" : "Đang hiển thị tất cả geo"}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={bindingFilterEnabled}
|
||||||
|
onChange={(e) => onBindingFilterEnabledChange(e.target.checked)}
|
||||||
|
style={{ width: 14, height: 14 }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 12, color: "#94a3b8", whiteSpace: "nowrap" }}>Filter</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{rows.length}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCollapsed((v) => !v)}
|
||||||
|
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",
|
||||||
|
}}
|
||||||
|
title={collapsed ? "Mở panel" : "Thu gọn panel"}
|
||||||
|
aria-label={collapsed ? "Mở panel Geometry Binding" : "Thu gọn panel Geometry Binding"}
|
||||||
|
>
|
||||||
|
{collapsed ? <PlusIcon /> : <MinusIcon />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{collapsed ? null : rows.length ? (
|
||||||
|
<div style={{ marginTop: "10px", display: "grid", gap: "6px" }}>
|
||||||
|
{visibleRows
|
||||||
|
.filter((g) => g.id !== selectedGeometryId)
|
||||||
|
.map((g) => {
|
||||||
|
const isBound = bindingSet.has(g.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={g.id}
|
||||||
|
style={{
|
||||||
|
padding: "8px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #1f2937",
|
||||||
|
background: "transparent",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
opacity: canBindToggle ? 1 : 0.75,
|
||||||
|
}}
|
||||||
|
title={g.id}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "#e5e7eb",
|
||||||
|
fontWeight: 700,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{g.label || g.id}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "11px",
|
||||||
|
color: "#94a3b8",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{g.id}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canBindToggle ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={isBound ? "Unbind from selected geometry" : "Bind to selected geometry"}
|
||||||
|
onClick={() => onToggleBindGeometryForSelectedGeometry!(g.id, !isBound)}
|
||||||
|
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={
|
||||||
|
isBound
|
||||||
|
? `Unbind geometry ${g.id} from selected geometry`
|
||||||
|
: `Bind geometry ${g.id} to selected geometry`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isBound ? <UnlockIcon /> : <LockIcon />}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{rows.length > visibleRows.length ? (
|
||||||
|
<div style={{ fontSize: "12px", color: "#94a3b8" }}>
|
||||||
|
+{rows.length - visibleRows.length} more…
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>
|
||||||
|
No geometry yet for this project.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{collapsed ? null : statusText ? (
|
||||||
|
<div style={{ marginTop: 10, fontSize: 12, color: "#93c5fd" }}>
|
||||||
|
{statusText}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 MinusIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1366,21 +1366,33 @@ function filterDraftByBinding(
|
|||||||
selectedFeatureId: string | number | null
|
selectedFeatureId: string | number | null
|
||||||
): FeatureCollection {
|
): FeatureCollection {
|
||||||
const selectedId = selectedFeatureId !== null ? String(selectedFeatureId) : null;
|
const selectedId = selectedFeatureId !== null ? String(selectedFeatureId) : null;
|
||||||
if (selectedId === null) {
|
// Semantics:
|
||||||
return {
|
// - A feature's `binding` is a list of "child" geometry ids.
|
||||||
...fc,
|
// - Child geometries are hidden by default, and only shown when their parent is selected.
|
||||||
features: fc.features.filter((feature) => !normalizeBindingIds(feature.properties.binding).length),
|
const childIds = new Set<string>();
|
||||||
};
|
for (const feature of fc.features) {
|
||||||
|
for (const id of normalizeBindingIds(feature.properties.binding)) {
|
||||||
|
childIds.add(id);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedId === null) {
|
||||||
|
return { ...fc, features: fc.features.filter((f) => !childIds.has(String(f.properties.id))) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedFeature =
|
||||||
|
fc.features.find((feature) => String(feature.properties.id) === selectedId) || null;
|
||||||
|
const selectedChildren = new Set<string>(
|
||||||
|
normalizeBindingIds(selectedFeature?.properties.binding)
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...fc,
|
...fc,
|
||||||
features: fc.features.filter((feature) => {
|
features: fc.features.filter((feature) => {
|
||||||
const featureId = String(feature.properties.id);
|
const featureId = String(feature.properties.id);
|
||||||
if (featureId === selectedId) return true;
|
if (featureId === selectedId) return true;
|
||||||
const bindingIds = normalizeBindingIds(feature.properties.binding);
|
if (selectedChildren.has(featureId)) return true;
|
||||||
if (!bindingIds.length) return true;
|
return !childIds.has(featureId);
|
||||||
return bindingIds.includes(selectedId);
|
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, type CSSProperties } from "react";
|
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
||||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||||
import type { EntityFormState } from "@/uhm/lib/editor/session/sessionTypes";
|
import type { EntityFormState } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ type Props = {
|
|||||||
onEntityFormChange: (key: keyof EntityFormState, value: string) => void;
|
onEntityFormChange: (key: keyof EntityFormState, value: string) => void;
|
||||||
isEntitySubmitting: boolean;
|
isEntitySubmitting: boolean;
|
||||||
onCreateEntityOnly: () => void;
|
onCreateEntityOnly: () => void;
|
||||||
|
onUpdateEntity?: (entityId: string, payload: { name: string; description: string | null }) => void;
|
||||||
entityFormStatus: string | null;
|
entityFormStatus: string | null;
|
||||||
selectedGeometryEntityIds?: string[];
|
selectedGeometryEntityIds?: string[];
|
||||||
hasSelectedGeometry?: boolean;
|
hasSelectedGeometry?: boolean;
|
||||||
@@ -22,6 +23,7 @@ export default function ProjectEntityRefsPanel({
|
|||||||
onEntityFormChange,
|
onEntityFormChange,
|
||||||
isEntitySubmitting,
|
isEntitySubmitting,
|
||||||
onCreateEntityOnly,
|
onCreateEntityOnly,
|
||||||
|
onUpdateEntity,
|
||||||
entityFormStatus,
|
entityFormStatus,
|
||||||
selectedGeometryEntityIds,
|
selectedGeometryEntityIds,
|
||||||
hasSelectedGeometry,
|
hasSelectedGeometry,
|
||||||
@@ -32,7 +34,30 @@ export default function ProjectEntityRefsPanel({
|
|||||||
Array.isArray(selectedGeometryEntityIds) &&
|
Array.isArray(selectedGeometryEntityIds) &&
|
||||||
typeof onToggleBindEntityForSelectedGeometry === "function";
|
typeof onToggleBindEntityForSelectedGeometry === "function";
|
||||||
|
|
||||||
|
const canEditEntity = typeof onUpdateEntity === "function";
|
||||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const [activeEntityId, setActiveEntityId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const activeEntity = useMemo(
|
||||||
|
() => (activeEntityId ? entityRefs.find((e) => String(e.id) === String(activeEntityId)) || null : null),
|
||||||
|
[activeEntityId, entityRefs]
|
||||||
|
);
|
||||||
|
const [editName, setEditName] = useState("");
|
||||||
|
const [editDescription, setEditDescription] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeEntityId) return;
|
||||||
|
if (!entityRefs.some((e) => String(e.id) === String(activeEntityId))) {
|
||||||
|
setActiveEntityId(null);
|
||||||
|
}
|
||||||
|
}, [activeEntityId, entityRefs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeEntity) return;
|
||||||
|
setEditName(typeof activeEntity.name === "string" ? activeEntity.name : "");
|
||||||
|
setEditDescription(activeEntity.description == null ? "" : String(activeEntity.description));
|
||||||
|
}, [activeEntity?.description, activeEntity?.id, activeEntity?.name]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -45,10 +70,33 @@ export default function ProjectEntityRefsPanel({
|
|||||||
>
|
>
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
|
||||||
<div style={{ fontWeight: 700, fontSize: "14px" }}>Entities</div>
|
<div style={{ fontWeight: 700, fontSize: "14px" }}>Entities</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{entityRefs.length}</div>
|
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{entityRefs.length}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCollapsed((v) => !v)}
|
||||||
|
title={collapsed ? "Mo panel" : "Thu gon panel"}
|
||||||
|
aria-label={collapsed ? "Mo panel Entities" : "Thu gon panel Entities"}
|
||||||
|
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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{collapsed ? <PlusIcon /> : <MinusIcon />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{entityRefs.length ? (
|
{collapsed ? null : entityRefs.length ? (
|
||||||
<div style={{ marginTop: "10px", display: "grid", gap: "6px" }}>
|
<div style={{ marginTop: "10px", display: "grid", gap: "6px" }}>
|
||||||
{entityRefs.slice(0, 8).map((e) => (
|
{entityRefs.slice(0, 8).map((e) => (
|
||||||
<div
|
<div
|
||||||
@@ -56,21 +104,35 @@ export default function ProjectEntityRefsPanel({
|
|||||||
style={{
|
style={{
|
||||||
padding: "8px",
|
padding: "8px",
|
||||||
borderRadius: "6px",
|
borderRadius: "6px",
|
||||||
border: "1px solid #1f2937",
|
border: activeEntityId === String(e.id) ? "1px solid #2563eb" : "1px solid #1f2937",
|
||||||
background: "transparent",
|
background: "transparent",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 10,
|
gap: 10,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveEntityId(String(e.id))}
|
||||||
|
title="Chon de sua"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
textAlign: "left",
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
padding: 0,
|
||||||
|
cursor: canEditEntity ? "pointer" : "default",
|
||||||
|
}}
|
||||||
|
disabled={!canEditEntity}
|
||||||
|
>
|
||||||
<div style={{ fontSize: "12px", color: "#e5e7eb", fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
<div style={{ fontSize: "12px", color: "#e5e7eb", fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||||
{e.name || e.id}
|
{e.name || e.id}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: "11px", color: "#94a3b8", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
<div style={{ fontSize: "11px", color: "#94a3b8", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||||
{e.id}
|
{e.id}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
{canBindToggle ? (
|
{canBindToggle ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -114,6 +176,85 @@ export default function ProjectEntityRefsPanel({
|
|||||||
<div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>No entity ref yet for this project.</div>
|
<div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>No entity ref yet for this project.</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{collapsed ? null : canEditEntity && activeEntity ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "10px",
|
||||||
|
display: "grid",
|
||||||
|
gap: "8px",
|
||||||
|
border: "1px solid #0f766e",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "8px",
|
||||||
|
background: "#0f172a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
||||||
|
<div style={{ color: "#a7f3d0", fontWeight: 700, fontSize: "12px" }}>
|
||||||
|
Sua entity
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveEntityId(null)}
|
||||||
|
title="Dong"
|
||||||
|
aria-label="Dong sua entity"
|
||||||
|
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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: 11, color: "#94a3b8", overflowWrap: "anywhere" }}>
|
||||||
|
{String(activeEntity.id)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
value={editName}
|
||||||
|
onChange={(event) => setEditName(event.target.value)}
|
||||||
|
placeholder="Ten entity"
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={entityInputStyle}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={editDescription}
|
||||||
|
onChange={(event) => setEditDescription(event.target.value)}
|
||||||
|
placeholder="Description"
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={entityInputStyle}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onUpdateEntity!(String(activeEntity.id), { name: editName, description: editDescription.trim().length ? editDescription : null })}
|
||||||
|
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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Luu entity
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{collapsed ? null : (
|
||||||
|
<>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginTop: "10px",
|
marginTop: "10px",
|
||||||
@@ -197,6 +338,8 @@ export default function ProjectEntityRefsPanel({
|
|||||||
{entityFormStatus}
|
{entityFormStatus}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -263,6 +406,14 @@ function PlusIcon() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MinusIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CloseIcon() {
|
function CloseIcon() {
|
||||||
return (
|
return (
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export default function SelectedGeometryPanel({
|
|||||||
onApplyGeometryMetadata,
|
onApplyGeometryMetadata,
|
||||||
changeCount,
|
changeCount,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const [geoApplyFeedback, setGeoApplyFeedback] = useState<
|
const [geoApplyFeedback, setGeoApplyFeedback] = useState<
|
||||||
| {
|
| {
|
||||||
kind: "ok" | "error";
|
kind: "ok" | "error";
|
||||||
@@ -99,10 +100,34 @@ export default function SelectedGeometryPanel({
|
|||||||
border: "1px solid #1f2937",
|
border: "1px solid #1f2937",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ fontWeight: 700, marginBottom: "8px", fontSize: "14px" }}>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, marginBottom: "8px" }}>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: "14px" }}>
|
||||||
Entity & Geometry
|
Entity & Geometry
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCollapsed((v) => !v)}
|
||||||
|
title={collapsed ? "Mo panel" : "Thu gon panel"}
|
||||||
|
aria-label={collapsed ? "Mo panel Selected Geometry" : "Thu gon panel Selected Geometry"}
|
||||||
|
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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{collapsed ? <PlusIcon /> : <MinusIcon />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{collapsed ? null : (
|
||||||
<div style={{ display: "grid", gap: "8px", fontSize: "13px" }}>
|
<div style={{ display: "grid", gap: "8px", fontSize: "13px" }}>
|
||||||
<div style={{ color: "#e2e8f0" }}>
|
<div style={{ color: "#e2e8f0" }}>
|
||||||
ID: {String(selectedFeature.properties.id)}
|
ID: {String(selectedFeature.properties.id)}
|
||||||
@@ -231,6 +256,13 @@ export default function SelectedGeometryPanel({
|
|||||||
disabled={isEntitySubmitting}
|
disabled={isEntitySubmitting}
|
||||||
style={entityInputStyle}
|
style={entityInputStyle}
|
||||||
/>
|
/>
|
||||||
|
{/*<input*/}
|
||||||
|
{/* value={geometryMetaForm.binding}*/}
|
||||||
|
{/* onChange={(event) => onGeometryMetaFormChange("binding", event.target.value)}*/}
|
||||||
|
{/* placeholder="binding (geometry ids, comma separated)"*/}
|
||||||
|
{/* disabled={isEntitySubmitting}*/}
|
||||||
|
{/* style={entityInputStyle}*/}
|
||||||
|
{/*/>*/}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleApplyGeoMeta}
|
onClick={handleApplyGeoMeta}
|
||||||
@@ -258,6 +290,7 @@ export default function SelectedGeometryPanel({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -292,6 +325,22 @@ const primaryGeometryButtonStyle: CSSProperties = {
|
|||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 MinusIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveFeatureGeometryPreset(feature: Feature): EntityGeometryPreset {
|
function resolveFeatureGeometryPreset(feature: Feature): EntityGeometryPreset {
|
||||||
const explicitPreset = normalizeGeometryPreset(feature.properties.geometry_preset);
|
const explicitPreset = normalizeGeometryPreset(feature.properties.geometry_preset);
|
||||||
if (explicitPreset) return explicitPreset;
|
if (explicitPreset) return explicitPreset;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import Label from "@/components/form/Label";
|
|||||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
import { newId } from "@/uhm/lib/id";
|
import { newId } from "@/uhm/lib/id";
|
||||||
import type ReactQuill from "react-quill-new";
|
import type ReactQuill from "react-quill-new";
|
||||||
|
import { checkWikiSlugExists } from "@/uhm/api/wikis";
|
||||||
|
|
||||||
type ReactQuillProps = ComponentProps<typeof ReactQuill>;
|
type ReactQuillProps = ComponentProps<typeof ReactQuill>;
|
||||||
|
|
||||||
@@ -35,12 +36,19 @@ function clampTitle(title: string) {
|
|||||||
export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen, requestedActiveId }: Props) {
|
export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen, requestedActiveId }: Props) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const activeWiki = useMemo(() => wikis.find((w) => w.id === activeId) || null, [activeId, wikis]);
|
const activeWiki = useMemo(() => wikis.find((w) => w.id === activeId) || null, [activeId, wikis]);
|
||||||
|
|
||||||
const [wikiTitle, setWikiTitle] = useState("");
|
const [wikiTitle, setWikiTitle] = useState("");
|
||||||
|
const [wikiSlug, setWikiSlug] = useState("");
|
||||||
const [wikiDocHtml, setWikiDocHtml] = useState("");
|
const [wikiDocHtml, setWikiDocHtml] = useState("");
|
||||||
|
const [wikiSaveError, setWikiSaveError] = useState<string | null>(null);
|
||||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
const [createTitle, setCreateTitle] = useState("");
|
const [createTitle, setCreateTitle] = useState("");
|
||||||
|
const [createSlug, setCreateSlug] = useState("");
|
||||||
|
const [createSlugTouched, setCreateSlugTouched] = useState(false);
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
const [isCheckingCreateSlug, setIsCheckingCreateSlug] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoOpen) return;
|
if (!autoOpen) return;
|
||||||
@@ -60,8 +68,10 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
|
|||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|
||||||
setWikiTitle(activeWiki?.title || "");
|
setWikiTitle(activeWiki?.title || "");
|
||||||
|
setWikiSlug(typeof activeWiki?.slug === "string" ? activeWiki.slug : "");
|
||||||
setWikiDocHtml(normalizeWikiDocForQuill(activeWiki?.doc || null));
|
setWikiDocHtml(normalizeWikiDocForQuill(activeWiki?.doc || null));
|
||||||
}, [activeWiki?.doc, activeWiki?.title, open]);
|
setWikiSaveError(null);
|
||||||
|
}, [activeWiki?.doc, activeWiki?.slug, activeWiki?.title, open]);
|
||||||
|
|
||||||
const ensureActive = () => {
|
const ensureActive = () => {
|
||||||
if (activeId && wikis.some((w) => w.id === activeId)) return;
|
if (activeId && wikis.some((w) => w.id === activeId)) return;
|
||||||
@@ -81,6 +91,7 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
|
|||||||
source: "inline",
|
source: "inline",
|
||||||
operation: "create",
|
operation: "create",
|
||||||
title: "Untitled wiki",
|
title: "Untitled wiki",
|
||||||
|
slug: null,
|
||||||
doc: "",
|
doc: "",
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
@@ -90,7 +101,7 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
|
|||||||
setOpen(true);
|
setOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createWikiAndOpen = (title?: string) => {
|
const createWikiAndOpen = (title?: string, slug?: string | null) => {
|
||||||
const id = newId();
|
const id = newId();
|
||||||
const seedTitle = clampTitle(title || "Untitled wiki");
|
const seedTitle = clampTitle(title || "Untitled wiki");
|
||||||
const seed: WikiSnapshot = {
|
const seed: WikiSnapshot = {
|
||||||
@@ -98,6 +109,7 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
|
|||||||
source: "inline",
|
source: "inline",
|
||||||
operation: "create",
|
operation: "create",
|
||||||
title: seedTitle,
|
title: seedTitle,
|
||||||
|
slug: slug ?? null,
|
||||||
doc: "",
|
doc: "",
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
@@ -106,15 +118,63 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
|
|||||||
setOpen(true);
|
setOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCreateWikiFromPanel = async () => {
|
||||||
|
const title = clampTitle(createTitle);
|
||||||
|
const slug = normalizeWikiSlugInput(createSlug);
|
||||||
|
if (!slug) {
|
||||||
|
setCreateError("Slug la bat buoc. Hay thu mot slug khac.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCheckingCreateSlug(true);
|
||||||
|
setCreateError(null);
|
||||||
|
try {
|
||||||
|
const exists = await checkWikiSlugExists(slug);
|
||||||
|
if (exists) {
|
||||||
|
setCreateError("Slug da ton tai. Hay thu slug khac.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createWikiAndOpen(title, slug);
|
||||||
|
setCreateTitle("");
|
||||||
|
setCreateSlug("");
|
||||||
|
setCreateSlugTouched(false);
|
||||||
|
setIsCreateOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : "Khong check duoc slug.";
|
||||||
|
setCreateError(msg);
|
||||||
|
} finally {
|
||||||
|
setIsCheckingCreateSlug(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const removeWiki = (id: string) => {
|
const removeWiki = (id: string) => {
|
||||||
setWikis((prev) => prev.filter((w) => w.id !== id));
|
setWikis((prev) => prev.filter((w) => w.id !== id));
|
||||||
if (activeId === id) setActiveId(null);
|
if (activeId === id) setActiveId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveWiki = () => {
|
const saveWiki = async () => {
|
||||||
if (!activeId) return;
|
if (!activeId) return;
|
||||||
const payload = wikiDocHtml;
|
const payload = wikiDocHtml;
|
||||||
const nextTitle = clampTitle(wikiTitle);
|
const nextTitle = clampTitle(wikiTitle);
|
||||||
|
const nextSlug = normalizeWikiSlugInput(wikiSlug);
|
||||||
|
|
||||||
|
const current = wikis.find((w) => w.id === activeId) || null;
|
||||||
|
// Check uniqueness only when creating a brand-new wiki.
|
||||||
|
if (current?.operation === "create" && nextSlug) {
|
||||||
|
try {
|
||||||
|
const exists = await checkWikiSlugExists(nextSlug);
|
||||||
|
if (exists) {
|
||||||
|
setWikiSaveError("Slug da ton tai. Hay thu slug khac.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : "Khong check duoc slug.";
|
||||||
|
setWikiSaveError(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setWikiSaveError(null);
|
||||||
setWikis((prev) =>
|
setWikis((prev) =>
|
||||||
prev.map((w) =>
|
prev.map((w) =>
|
||||||
w.id !== activeId
|
w.id !== activeId
|
||||||
@@ -124,6 +184,7 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
|
|||||||
source: w.source,
|
source: w.source,
|
||||||
operation: w.operation === "create" ? "create" : "update",
|
operation: w.operation === "create" ? "create" : "update",
|
||||||
title: nextTitle,
|
title: nextTitle,
|
||||||
|
slug: nextSlug,
|
||||||
doc: payload,
|
doc: payload,
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
@@ -143,10 +204,33 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
|
|||||||
>
|
>
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
|
||||||
<div style={{ fontWeight: 700, fontSize: "14px" }}>Wiki</div>
|
<div style={{ fontWeight: 700, fontSize: "14px" }}>Wiki</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{wikis.length}</div>
|
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{wikis.length}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCollapsed((v) => !v)}
|
||||||
|
title={collapsed ? "Mo panel" : "Thu gon panel"}
|
||||||
|
aria-label={collapsed ? "Mo panel Wiki" : "Thu gon panel 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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{collapsed ? <PlusIcon /> : <MinusIcon />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{wikis.length ? (
|
{collapsed ? null : wikis.length ? (
|
||||||
<div style={{ marginTop: "10px", display: "grid", gap: "6px" }}>
|
<div style={{ marginTop: "10px", display: "grid", gap: "6px" }}>
|
||||||
{wikis.slice(0, 8).map((w) => (
|
{wikis.slice(0, 8).map((w) => (
|
||||||
<div
|
<div
|
||||||
@@ -211,6 +295,7 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{collapsed ? null : (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginTop: "10px",
|
marginTop: "10px",
|
||||||
@@ -228,7 +313,17 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsCreateOpen((v) => !v)}
|
onClick={() =>
|
||||||
|
setIsCreateOpen((v) => {
|
||||||
|
const next = !v;
|
||||||
|
if (next) {
|
||||||
|
setCreateError(null);
|
||||||
|
setIsCheckingCreateSlug(false);
|
||||||
|
setCreateSlugTouched(false);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
})
|
||||||
|
}
|
||||||
title={isCreateOpen ? "Dong" : "Mo"}
|
title={isCreateOpen ? "Dong" : "Mo"}
|
||||||
aria-label={isCreateOpen ? "Dong tao wiki" : "Mo tao wiki"}
|
aria-label={isCreateOpen ? "Dong tao wiki" : "Mo tao wiki"}
|
||||||
style={{
|
style={{
|
||||||
@@ -253,8 +348,35 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
|
|||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
value={createTitle}
|
value={createTitle}
|
||||||
onChange={(e) => setCreateTitle(e.target.value)}
|
onChange={(e) => {
|
||||||
|
const nextTitle = e.target.value;
|
||||||
|
setCreateTitle(nextTitle);
|
||||||
|
setCreateError(null);
|
||||||
|
if (!createSlugTouched) {
|
||||||
|
setCreateSlug(slugifyWikiTitle(nextTitle));
|
||||||
|
}
|
||||||
|
}}
|
||||||
placeholder="Tieu de wiki"
|
placeholder="Tieu de wiki"
|
||||||
|
disabled={isCheckingCreateSlug}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #334155",
|
||||||
|
background: "#111827",
|
||||||
|
color: "#f8fafc",
|
||||||
|
padding: "6px 8px",
|
||||||
|
fontSize: "13px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={createSlug}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCreateSlugTouched(true);
|
||||||
|
setCreateSlug(e.target.value);
|
||||||
|
setCreateError(null);
|
||||||
|
}}
|
||||||
|
placeholder="Slug"
|
||||||
|
disabled={isCheckingCreateSlug}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
borderRadius: "6px",
|
borderRadius: "6px",
|
||||||
@@ -267,26 +389,30 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={handleCreateWikiFromPanel}
|
||||||
createWikiAndOpen(createTitle);
|
disabled={isCheckingCreateSlug}
|
||||||
setCreateTitle("");
|
|
||||||
setIsCreateOpen(false);
|
|
||||||
}}
|
|
||||||
style={{
|
style={{
|
||||||
border: "none",
|
border: "none",
|
||||||
borderRadius: "6px",
|
borderRadius: "6px",
|
||||||
padding: "7px 8px",
|
padding: "7px 8px",
|
||||||
cursor: "pointer",
|
cursor: isCheckingCreateSlug ? "not-allowed" : "pointer",
|
||||||
background: "#2563eb",
|
background: "#2563eb",
|
||||||
color: "#ffffff",
|
color: "#ffffff",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
|
opacity: isCheckingCreateSlug ? 0.7 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Tạo wiki mới
|
Tạo wiki mới
|
||||||
</button>
|
</button>
|
||||||
|
{createError ? (
|
||||||
|
<div style={{ color: "#fca5a5", fontSize: 12 }}>
|
||||||
|
{createError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={open}
|
isOpen={open}
|
||||||
@@ -349,6 +475,21 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
|
|||||||
disabled={!activeId}
|
disabled={!activeId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Slug</Label>
|
||||||
|
<input
|
||||||
|
value={wikiSlug}
|
||||||
|
onChange={(e) => setWikiSlug(e.target.value)}
|
||||||
|
className="h-11 w-full rounded-xl border border-gray-200 bg-transparent px-4 py-2.5 text-sm text-gray-800 outline-none focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:text-white/90 dark:focus:border-brand-800"
|
||||||
|
placeholder="wiki-slug"
|
||||||
|
disabled={!activeId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{wikiSaveError ? (
|
||||||
|
<div className="text-xs text-red-600 dark:text-red-300">
|
||||||
|
{wikiSaveError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-[#0d1117] overflow-hidden">
|
<div className="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-[#0d1117] overflow-hidden">
|
||||||
<ReactQuillEditor
|
<ReactQuillEditor
|
||||||
@@ -382,6 +523,14 @@ function PlusIcon() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MinusIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CloseIcon() {
|
function CloseIcon() {
|
||||||
return (
|
return (
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
@@ -424,6 +573,24 @@ function normalizeWikiDocForQuill(doc: string | null): string {
|
|||||||
return `<p>${escapeHtml(raw).replace(/\n/g, "<br/>")}</p>`;
|
return `<p>${escapeHtml(raw).replace(/\n/g, "<br/>")}</p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeWikiSlugInput(raw: string): string | null {
|
||||||
|
const s = raw.trim();
|
||||||
|
return s.length ? s : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugifyWikiTitle(raw: string): string {
|
||||||
|
const input = String(raw || "").trim();
|
||||||
|
if (!input.length) return "";
|
||||||
|
return input
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+/, "")
|
||||||
|
.replace(/-+$/, "")
|
||||||
|
.slice(0, 80);
|
||||||
|
}
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
{
|
|
||||||
"snapshot_json": {
|
|
||||||
"editor_feature_collection": {
|
|
||||||
"type": "FeatureCollection",
|
|
||||||
"features": [
|
|
||||||
{
|
|
||||||
"type": "Feature",
|
|
||||||
"geometry": {
|
|
||||||
"type": "Polygon",
|
|
||||||
"coordinates": [
|
|
||||||
[
|
|
||||||
[102.00266149687627, 23.428776811740278],
|
|
||||||
[112.17600134062593, 23.166423207385705],
|
|
||||||
[109.14377477812644, 17.104884122540454],
|
|
||||||
[100.55246618437678, 18.15190838931096],
|
|
||||||
[102.00266149687627, 23.428776811740278]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"properties": {
|
|
||||||
"id": "019e0363-9a8d-72a9-b1b1-552e1113ae53",
|
|
||||||
"type": "country",
|
|
||||||
"time_start": 1000,
|
|
||||||
"time_end": 1500,
|
|
||||||
"geometry_preset": "polygon"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"entities": [
|
|
||||||
{
|
|
||||||
"id": "019e0364-10ca-7282-840a-c0e6f4069807",
|
|
||||||
"source": "inline",
|
|
||||||
"operation": "reference",
|
|
||||||
"name": "ent1",
|
|
||||||
"slug": null,
|
|
||||||
"description": null,
|
|
||||||
"status": 1
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"geometries": [
|
|
||||||
{
|
|
||||||
"id": "019e0363-9a8d-72a9-b1b1-552e1113ae53",
|
|
||||||
"source": "inline",
|
|
||||||
"operation": "update",
|
|
||||||
"type": "9",
|
|
||||||
"draw_geometry": {
|
|
||||||
"type": "Polygon",
|
|
||||||
"coordinates": [
|
|
||||||
[
|
|
||||||
[102.00266149687627, 23.428776811740278],
|
|
||||||
[112.17600134062593, 23.166423207385705],
|
|
||||||
[109.14377477812644, 17.104884122540454],
|
|
||||||
[100.55246618437678, 18.15190838931096],
|
|
||||||
[102.00266149687627, 23.428776811740278]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"binding": [],
|
|
||||||
"time_start": 1000,
|
|
||||||
"time_end": 1500,
|
|
||||||
"bbox": {
|
|
||||||
"min_lng": 100.55246618437678,
|
|
||||||
"min_lat": 17.104884122540454,
|
|
||||||
"max_lng": 112.17600134062593,
|
|
||||||
"max_lat": 23.428776811740278
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"geometry_entity": [
|
|
||||||
{
|
|
||||||
"geometry_id": "019e0363-9a8d-72a9-b1b1-552e1113ae53",
|
|
||||||
"entity_id": "019e0364-10ca-7282-840a-c0e6f4069807",
|
|
||||||
"base_links_hash": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"wikis": [
|
|
||||||
{
|
|
||||||
"id": "019e0363-dfce-769c-920a-cf23c7755463",
|
|
||||||
"source": "inline",
|
|
||||||
"operation": "reference",
|
|
||||||
"title": "wiki1",
|
|
||||||
"slug": null,
|
|
||||||
"doc": "<p>example</p>",
|
|
||||||
"updated_at": "2026-05-08T00:00:00.000Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"entity_wiki": [
|
|
||||||
{
|
|
||||||
"entity_id": "019e0364-10ca-7282-840a-c0e6f4069807",
|
|
||||||
"wiki_id": "019e0363-dfce-769c-920a-cf23c7755463",
|
|
||||||
"operation": "reference",
|
|
||||||
"is_deleted": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"edit_summary": "Edit 2026-05-08T00:00:00.000Z"
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
||||||
"$id": "uhm.commit_snapshot.contract",
|
|
||||||
"title": "History API Commit Snapshot (snapshot_json) - Contract",
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"editor_feature_collection",
|
|
||||||
"entities",
|
|
||||||
"geometries",
|
|
||||||
"geometry_entity",
|
|
||||||
"wikis",
|
|
||||||
"entity_wiki"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"editor_feature_collection": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["type", "features"],
|
|
||||||
"properties": {
|
|
||||||
"type": { "const": "FeatureCollection" },
|
|
||||||
"features": {
|
|
||||||
"type": "array",
|
|
||||||
"items": { "$ref": "#/$defs/feature" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"entities": {
|
|
||||||
"type": "array",
|
|
||||||
"items": { "$ref": "#/$defs/entitySnapshot" }
|
|
||||||
},
|
|
||||||
"geometries": {
|
|
||||||
"type": "array",
|
|
||||||
"items": { "$ref": "#/$defs/geometrySnapshot" }
|
|
||||||
},
|
|
||||||
"geometry_entity": {
|
|
||||||
"type": "array",
|
|
||||||
"items": { "$ref": "#/$defs/geometryEntitySnapshot" }
|
|
||||||
},
|
|
||||||
"wikis": {
|
|
||||||
"type": "array",
|
|
||||||
"items": { "$ref": "#/$defs/wikiSnapshot" }
|
|
||||||
},
|
|
||||||
"entity_wiki": {
|
|
||||||
"type": "array",
|
|
||||||
"items": { "$ref": "#/$defs/entityWikiLinkSnapshot" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"$defs": {
|
|
||||||
"uuidv7": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "UUID v7 string",
|
|
||||||
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
|
||||||
},
|
|
||||||
"feature": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["type", "geometry", "properties"],
|
|
||||||
"properties": {
|
|
||||||
"type": { "const": "Feature" },
|
|
||||||
"geometry": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "GeoJSON geometry. Backend expects JSON raw payload."
|
|
||||||
},
|
|
||||||
"properties": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": true,
|
|
||||||
"required": ["id"],
|
|
||||||
"properties": {
|
|
||||||
"id": {
|
|
||||||
"description": "Geometry id. FE uses uuidv7 strings but backend accepts 'any' for editor_feature_collection properties.id",
|
|
||||||
"oneOf": [{ "$ref": "#/$defs/uuidv7" }, { "type": "string" }, { "type": "number" }]
|
|
||||||
},
|
|
||||||
"type": { "type": "string" },
|
|
||||||
"geometry_preset": { "type": "string" },
|
|
||||||
"time_start": { "type": "number" },
|
|
||||||
"time_end": { "type": "number" },
|
|
||||||
"binding": { "type": "array", "items": { "type": "string" } },
|
|
||||||
"entity_id": { "type": "string" },
|
|
||||||
"entity_ids": { "type": "array", "items": { "type": "string" } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"entitySnapshot": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["id", "name"],
|
|
||||||
"properties": {
|
|
||||||
"id": { "$ref": "#/$defs/uuidv7" },
|
|
||||||
"source": { "type": "string", "enum": ["inline", "ref"] },
|
|
||||||
"operation": { "type": "string", "enum": ["create", "update", "delete", "reference"] },
|
|
||||||
"name": { "type": "string" },
|
|
||||||
"slug": { "type": ["string", "null"] },
|
|
||||||
"description": { "type": ["string", "null"] },
|
|
||||||
"status": { "type": ["number", "null"], "enum": [0, 1, null] },
|
|
||||||
"time_start": { "type": ["number", "null"] },
|
|
||||||
"time_end": { "type": ["number", "null"] },
|
|
||||||
"base_updated_at": { "type": ["string", "null"] },
|
|
||||||
"base_hash": { "type": ["string", "null"] }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"geometrySnapshot": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["id", "type"],
|
|
||||||
"properties": {
|
|
||||||
"id": { "$ref": "#/$defs/uuidv7" },
|
|
||||||
"source": { "type": "string", "enum": ["inline", "ref"] },
|
|
||||||
"operation": { "type": "string", "enum": ["create", "update", "delete", "reference"] },
|
|
||||||
"type": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Backend expects a string; FE currently sends the geo_type smallint code as a string."
|
|
||||||
},
|
|
||||||
"draw_geometry": {
|
|
||||||
"type": ["object", "null"],
|
|
||||||
"description": "GeoJSON geometry raw payload"
|
|
||||||
},
|
|
||||||
"binding": { "type": "array", "items": { "type": "string" } },
|
|
||||||
"time_start": { "type": ["number", "null"] },
|
|
||||||
"time_end": { "type": ["number", "null"] },
|
|
||||||
"bbox": {
|
|
||||||
"type": ["object", "null"],
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["min_lng", "min_lat", "max_lng", "max_lat"],
|
|
||||||
"properties": {
|
|
||||||
"min_lng": { "type": "number" },
|
|
||||||
"min_lat": { "type": "number" },
|
|
||||||
"max_lng": { "type": "number" },
|
|
||||||
"max_lat": { "type": "number" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"base_updated_at": { "type": ["string", "null"] },
|
|
||||||
"base_hash": { "type": ["string", "null"] }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"geometryEntitySnapshot": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["geometry_id", "entity_id"],
|
|
||||||
"properties": {
|
|
||||||
"geometry_id": { "$ref": "#/$defs/uuidv7" },
|
|
||||||
"entity_id": { "$ref": "#/$defs/uuidv7" },
|
|
||||||
"base_links_hash": { "type": ["string", "null"] }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"wikiSnapshot": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["id", "title"],
|
|
||||||
"properties": {
|
|
||||||
"id": { "$ref": "#/$defs/uuidv7" },
|
|
||||||
"source": { "type": "string", "enum": ["inline", "ref"] },
|
|
||||||
"operation": { "type": "string", "enum": ["create", "update", "delete", "reference"] },
|
|
||||||
"title": { "type": "string" },
|
|
||||||
"slug": { "type": ["string", "null"] },
|
|
||||||
"doc": { "type": ["string", "null"] },
|
|
||||||
"updated_at": { "type": ["string", "null"] }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"entityWikiLinkSnapshot": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["entity_id", "wiki_id"],
|
|
||||||
"properties": {
|
|
||||||
"entity_id": { "$ref": "#/$defs/uuidv7" },
|
|
||||||
"wiki_id": { "$ref": "#/$defs/uuidv7" },
|
|
||||||
"operation": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Server swagger.json currently enumerates ['reference','delete'] for this field. Some deployments may also accept 'binding'.",
|
|
||||||
"enum": ["reference", "delete", "binding"]
|
|
||||||
},
|
|
||||||
"is_deleted": { "type": ["number", "null"], "enum": [0, 1, null] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -4,6 +4,9 @@ import type {
|
|||||||
Geometry,
|
Geometry,
|
||||||
GeometryChange,
|
GeometryChange,
|
||||||
} from "@/uhm/types/geo";
|
} from "@/uhm/types/geo";
|
||||||
|
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||||
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
|
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
||||||
|
|
||||||
export type Change = GeometryChange;
|
export type Change = GeometryChange;
|
||||||
|
|
||||||
@@ -11,5 +14,8 @@ export type UndoAction =
|
|||||||
| { type: "update"; id: FeatureProperties["id"]; prevGeometry: Geometry }
|
| { type: "update"; id: FeatureProperties["id"]; prevGeometry: Geometry }
|
||||||
| { type: "properties"; id: FeatureProperties["id"]; prevProperties: FeatureProperties }
|
| { type: "properties"; id: FeatureProperties["id"]; prevProperties: FeatureProperties }
|
||||||
| { type: "delete"; feature: Feature }
|
| { type: "delete"; feature: Feature }
|
||||||
| { type: "create"; id: FeatureProperties["id"] };
|
| { type: "create"; id: FeatureProperties["id"] }
|
||||||
|
// Snapshot-scoped undo (affects commit snapshot but not GeoJSON draft directly)
|
||||||
|
| { type: "snapshot_entities"; label: string; prev: EntitySnapshot[] }
|
||||||
|
| { type: "snapshot_wikis"; label: string; prev: WikiSnapshot[] }
|
||||||
|
| { type: "snapshot_entity_wiki"; label: string; prev: EntityWikiLinkSnapshot[] };
|
||||||
|
|||||||
@@ -75,6 +75,18 @@ function isSameUndo(a: UndoAction | undefined, b: UndoAction) {
|
|||||||
JSON.stringify(a.prevProperties) === JSON.stringify(next.prevProperties)
|
JSON.stringify(a.prevProperties) === JSON.stringify(next.prevProperties)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
case "snapshot_entities": {
|
||||||
|
const next = b as Extract<UndoAction, { type: "snapshot_entities" }>;
|
||||||
|
return a.label === next.label && JSON.stringify(a.prev) === JSON.stringify(next.prev);
|
||||||
|
}
|
||||||
|
case "snapshot_wikis": {
|
||||||
|
const next = b as Extract<UndoAction, { type: "snapshot_wikis" }>;
|
||||||
|
return a.label === next.label && JSON.stringify(a.prev) === JSON.stringify(next.prev);
|
||||||
|
}
|
||||||
|
case "snapshot_entity_wiki": {
|
||||||
|
const next = b as Extract<UndoAction, { type: "snapshot_entity_wiki" }>;
|
||||||
|
return a.label === next.label && JSON.stringify(a.prev) === JSON.stringify(next.prev);
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from "@/uhm/api/sections";
|
} from "@/uhm/api/sections";
|
||||||
import { buildEditorSnapshot, normalizeEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
import { buildEditorSnapshot, normalizeEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
||||||
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
|
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
|
||||||
import type { Feature, FeatureCollection, FeatureId } from "@/uhm/types/geo";
|
import type { Feature, FeatureCollection, FeatureId, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
|
||||||
import type { EditorSnapshot, Section, SectionCommit, SectionState, EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
import type { EditorSnapshot, Section, SectionCommit, SectionState, EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
||||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
@@ -306,6 +306,8 @@ function toEditorSessionSnapshot(snapshot: EditorSnapshot): EditorSnapshot {
|
|||||||
return {
|
return {
|
||||||
...snapshot,
|
...snapshot,
|
||||||
entities: toEditorSessionEntities(snapshot.entities),
|
entities: toEditorSessionEntities(snapshot.entities),
|
||||||
|
geometries: toEditorSessionGeometries(snapshot.geometries),
|
||||||
|
geometry_entity: toEditorSessionGeometryEntity(snapshot.geometry_entity),
|
||||||
wikis: toEditorSessionWikis(snapshot.wikis),
|
wikis: toEditorSessionWikis(snapshot.wikis),
|
||||||
entity_wiki: toEditorSessionEntityWikiLinks(snapshot.entity_wiki),
|
entity_wiki: toEditorSessionEntityWikiLinks(snapshot.entity_wiki),
|
||||||
};
|
};
|
||||||
@@ -315,6 +317,7 @@ function toEditorSessionEntities(input: EditorSnapshot["entities"]): EntitySnaps
|
|||||||
const rows = Array.isArray(input) ? input : [];
|
const rows = Array.isArray(input) ? input : [];
|
||||||
return rows
|
return rows
|
||||||
.filter((e) => e && (typeof e.id === "string" || typeof e.id === "number"))
|
.filter((e) => e && (typeof e.id === "string" || typeof e.id === "number"))
|
||||||
|
.filter((e) => (e as any).operation !== "delete")
|
||||||
.map((e) => {
|
.map((e) => {
|
||||||
const { operation: _op, ...rest } = e;
|
const { operation: _op, ...rest } = e;
|
||||||
const id = String(e.id);
|
const id = String(e.id);
|
||||||
@@ -328,10 +331,52 @@ function toEditorSessionEntities(input: EditorSnapshot["entities"]): EntitySnaps
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toEditorSessionGeometries(input: EditorSnapshot["geometries"]): GeometrySnapshot[] {
|
||||||
|
const rows = Array.isArray(input) ? input : [];
|
||||||
|
return rows
|
||||||
|
.filter((g) => g && (typeof (g as any).id === "string" || typeof (g as any).id === "number"))
|
||||||
|
.filter((g) => (g as any).operation !== "delete")
|
||||||
|
.map((g) => {
|
||||||
|
const { operation: _op, ...rest } = g as any;
|
||||||
|
const id = String((g as any).id);
|
||||||
|
const source: GeometrySnapshot["source"] = (g as any).source === "inline" ? "inline" : "ref";
|
||||||
|
return {
|
||||||
|
...(rest as Omit<GeometrySnapshot, "id" | "source" | "operation">),
|
||||||
|
id,
|
||||||
|
source,
|
||||||
|
operation: "reference",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toEditorSessionGeometryEntity(input: EditorSnapshot["geometry_entity"]): GeometryEntitySnapshot[] {
|
||||||
|
const rows = Array.isArray(input) ? input : [];
|
||||||
|
const deduped = new globalThis.Map<string, GeometryEntitySnapshot>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row) continue;
|
||||||
|
if ((row as any).operation === "delete") continue;
|
||||||
|
const geometry_id = typeof (row as any).geometry_id === "string" || typeof (row as any).geometry_id === "number"
|
||||||
|
? String((row as any).geometry_id).trim()
|
||||||
|
: "";
|
||||||
|
const entity_id = typeof (row as any).entity_id === "string" || typeof (row as any).entity_id === "number"
|
||||||
|
? String((row as any).entity_id).trim()
|
||||||
|
: "";
|
||||||
|
if (!geometry_id || !entity_id) continue;
|
||||||
|
const key = `${geometry_id}::${entity_id}`;
|
||||||
|
deduped.set(key, { geometry_id, entity_id, operation: "reference", base_links_hash: (row as any).base_links_hash });
|
||||||
|
}
|
||||||
|
return Array.from(deduped.values()).sort((a, b) => {
|
||||||
|
const g = a.geometry_id.localeCompare(b.geometry_id);
|
||||||
|
if (g !== 0) return g;
|
||||||
|
return a.entity_id.localeCompare(b.entity_id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function toEditorSessionWikis(input: EditorSnapshot["wikis"]): WikiSnapshot[] {
|
function toEditorSessionWikis(input: EditorSnapshot["wikis"]): WikiSnapshot[] {
|
||||||
const rows = Array.isArray(input) ? input : [];
|
const rows = Array.isArray(input) ? input : [];
|
||||||
return rows
|
return rows
|
||||||
.filter((w) => w && typeof w.id === "string" && w.id.trim().length > 0)
|
.filter((w) => w && typeof w.id === "string" && w.id.trim().length > 0)
|
||||||
|
.filter((w) => (w as any).operation !== "delete")
|
||||||
.map((w) => {
|
.map((w) => {
|
||||||
const { operation: _op, ...rest } = w;
|
const { operation: _op, ...rest } = w;
|
||||||
const source: WikiSnapshot["source"] = w.source === "inline" ? "inline" : "ref";
|
const source: WikiSnapshot["source"] = w.source === "inline" ? "inline" : "ref";
|
||||||
@@ -353,7 +398,7 @@ function toEditorSessionEntityWikiLinks(input: EditorSnapshot["entity_wiki"]): E
|
|||||||
const wiki_id = row.wiki_id.trim();
|
const wiki_id = row.wiki_id.trim();
|
||||||
if (!entity_id || !wiki_id) continue;
|
if (!entity_id || !wiki_id) continue;
|
||||||
const key = `${entity_id}::${wiki_id}`;
|
const key = `${entity_id}::${wiki_id}`;
|
||||||
deduped.set(key, { entity_id, wiki_id, operation: "binding" });
|
deduped.set(key, { entity_id, wiki_id, operation: "reference" });
|
||||||
}
|
}
|
||||||
return Array.from(deduped.values()).sort((a, b) => {
|
return Array.from(deduped.values()).sort((a, b) => {
|
||||||
const e = a.entity_id.localeCompare(b.entity_id);
|
const e = a.entity_id.localeCompare(b.entity_id);
|
||||||
|
|||||||
@@ -162,21 +162,19 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
|||||||
.map((r) => {
|
.map((r) => {
|
||||||
const entity_id = typeof r.entity_id === "string" ? r.entity_id : "";
|
const entity_id = typeof r.entity_id === "string" ? r.entity_id : "";
|
||||||
const wiki_id = typeof r.wiki_id === "string" ? r.wiki_id : "";
|
const wiki_id = typeof r.wiki_id === "string" ? r.wiki_id : "";
|
||||||
const opRaw = typeof r.operation === "string" ? r.operation : "";
|
const opRaw = typeof r.operation === "string" ? r.operation.trim() : "";
|
||||||
const isDeleted =
|
const isDeleted =
|
||||||
typeof r.is_deleted === "number"
|
typeof r.is_deleted === "number"
|
||||||
? r.is_deleted === 1
|
? r.is_deleted === 1
|
||||||
: typeof r.is_deleted === "boolean"
|
: typeof r.is_deleted === "boolean"
|
||||||
? r.is_deleted
|
? r.is_deleted
|
||||||
: false;
|
: false;
|
||||||
const operation: "binding" | "delete" =
|
const operation: EntityWikiLinkSnapshot["operation"] =
|
||||||
opRaw === "delete"
|
isDeleted || opRaw === "delete"
|
||||||
? "delete"
|
? "delete"
|
||||||
: opRaw === "binding" || opRaw === "reference"
|
: opRaw === "binding"
|
||||||
? "binding"
|
? "binding"
|
||||||
: isDeleted
|
: "reference";
|
||||||
? "delete"
|
|
||||||
: "binding";
|
|
||||||
return { entity_id, wiki_id, operation };
|
return { entity_id, wiki_id, operation };
|
||||||
})
|
})
|
||||||
.filter((r) => r.entity_id.length > 0 && r.wiki_id.length > 0)
|
.filter((r) => r.entity_id.length > 0 && r.wiki_id.length > 0)
|
||||||
@@ -190,6 +188,7 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
|||||||
const links = geometryEntity || migratedGeometryEntity || [];
|
const links = geometryEntity || migratedGeometryEntity || [];
|
||||||
const byGeom = new Map<string, string[]>();
|
const byGeom = new Map<string, string[]>();
|
||||||
for (const row of links) {
|
for (const row of links) {
|
||||||
|
if ((row as any)?.operation === "delete") continue;
|
||||||
const list = byGeom.get(row.geometry_id) || [];
|
const list = byGeom.get(row.geometry_id) || [];
|
||||||
list.push(row.entity_id);
|
list.push(row.entity_id);
|
||||||
byGeom.set(row.geometry_id, list);
|
byGeom.set(row.geometry_id, list);
|
||||||
@@ -316,7 +315,11 @@ export function buildEditorSnapshot(options: {
|
|||||||
? cloned.name.trim()
|
? cloned.name.trim()
|
||||||
: id;
|
: id;
|
||||||
const source: "inline" | "ref" = cloned.source === "inline" ? "inline" : "ref";
|
const source: "inline" | "ref" = cloned.source === "inline" ? "inline" : "ref";
|
||||||
const operation = sanitizeEntitySnapshotOperation((cloned as any).operation);
|
const opRaw = sanitizeEntitySnapshotOperation((cloned as any).operation);
|
||||||
|
// Editor state should delete objects by removing them from the list.
|
||||||
|
// Keep this defensive guard to avoid emitting delete markers unexpectedly.
|
||||||
|
if (opRaw === "delete") continue;
|
||||||
|
const operation: EntitySnapshot["operation"] = source === "ref" ? "reference" : opRaw;
|
||||||
entityRows.set(id, {
|
entityRows.set(id, {
|
||||||
...cloned,
|
...cloned,
|
||||||
id,
|
id,
|
||||||
@@ -370,7 +373,7 @@ export function buildEditorSnapshot(options: {
|
|||||||
? "create"
|
? "create"
|
||||||
: changedIds.has(id) || changedFromPreviousSnapshot
|
: changedIds.has(id) || changedFromPreviousSnapshot
|
||||||
? "update"
|
? "update"
|
||||||
: undefined;
|
: "reference";
|
||||||
const bbox = getFeatureBBox(feature);
|
const bbox = getFeatureBBox(feature);
|
||||||
const typeKey = feature.properties.type || getDefaultTypeIdForFeature(feature);
|
const typeKey = feature.properties.type || getDefaultTypeIdForFeature(feature);
|
||||||
const typeCode = typeKeyToGeoTypeCode(typeKey);
|
const typeCode = typeKeyToGeoTypeCode(typeKey);
|
||||||
@@ -403,12 +406,44 @@ export function buildEditorSnapshot(options: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const geometryEntityRaw: GeometryEntitySnapshot[] = options.draft.features.flatMap((feature) => {
|
const baselineGeometryEntity = new globalThis.Map<string, string | undefined>();
|
||||||
const geometry_id = String(feature.properties.id);
|
for (const row of options.previousSnapshot?.geometry_entity || []) {
|
||||||
const entityIds = normalizeFeatureEntityIds(feature);
|
if (!row) continue;
|
||||||
return entityIds.map((entity_id) => ({ geometry_id, entity_id }));
|
if ((row as any).operation === "delete") continue;
|
||||||
|
const geometry_id = typeof row.geometry_id === "string" || typeof row.geometry_id === "number" ? String(row.geometry_id).trim() : "";
|
||||||
|
const entity_id = typeof row.entity_id === "string" || typeof row.entity_id === "number" ? String(row.entity_id).trim() : "";
|
||||||
|
if (!geometry_id || !entity_id) continue;
|
||||||
|
baselineGeometryEntity.set(`${geometry_id}::${entity_id}`, (row as any).base_links_hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentGeometryEntityRows: GeometryEntitySnapshot[] = [];
|
||||||
|
const currentGeometryEntityKeys = new Set<string>();
|
||||||
|
for (const feature of options.draft.features) {
|
||||||
|
const geometry_id = String(feature.properties.id).trim();
|
||||||
|
if (!geometry_id) continue;
|
||||||
|
for (const entity_id of normalizeFeatureEntityIds(feature)) {
|
||||||
|
const key = `${geometry_id}::${entity_id}`;
|
||||||
|
if (currentGeometryEntityKeys.has(key)) continue;
|
||||||
|
currentGeometryEntityKeys.add(key);
|
||||||
|
currentGeometryEntityRows.push({
|
||||||
|
geometry_id,
|
||||||
|
entity_id,
|
||||||
|
operation: baselineGeometryEntity.has(key) ? "reference" : "binding",
|
||||||
|
base_links_hash: baselineGeometryEntity.get(key),
|
||||||
});
|
});
|
||||||
const geometryEntity = dedupeAndSortGeometryEntity(geometryEntityRaw);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relations removed during this session are emitted as "delete" operations.
|
||||||
|
// NOTE: The editor state itself should remove the relation row; the commit payload is the delta.
|
||||||
|
for (const [key, base_links_hash] of baselineGeometryEntity.entries()) {
|
||||||
|
if (currentGeometryEntityKeys.has(key)) continue;
|
||||||
|
const [geometry_id, entity_id] = key.split("::");
|
||||||
|
if (!geometry_id || !entity_id) continue;
|
||||||
|
currentGeometryEntityRows.push({ geometry_id, entity_id, operation: "delete", base_links_hash });
|
||||||
|
}
|
||||||
|
|
||||||
|
const geometryEntity = dedupeAndSortGeometryEntity(currentGeometryEntityRows);
|
||||||
|
|
||||||
// Persist snapshot without denormalized entity fields on features (many-to-many lives in geometry_entity[]).
|
// Persist snapshot without denormalized entity fields on features (many-to-many lives in geometry_entity[]).
|
||||||
const draftForSnapshot = JSON.parse(JSON.stringify(options.draft)) as FeatureCollection;
|
const draftForSnapshot = JSON.parse(JSON.stringify(options.draft)) as FeatureCollection;
|
||||||
@@ -429,6 +464,7 @@ export function buildEditorSnapshot(options: {
|
|||||||
const previousWikis = new globalThis.Map<string, WikiSnapshot>();
|
const previousWikis = new globalThis.Map<string, WikiSnapshot>();
|
||||||
for (const item of options.previousSnapshot?.wikis || []) {
|
for (const item of options.previousSnapshot?.wikis || []) {
|
||||||
if (!item || typeof item !== "object") continue;
|
if (!item || typeof item !== "object") continue;
|
||||||
|
if ((item as any).operation === "delete") continue;
|
||||||
const id = (item as WikiSnapshot).id;
|
const id = (item as WikiSnapshot).id;
|
||||||
if (typeof id === "string" && id.length > 0) previousWikis.set(id, item as WikiSnapshot);
|
if (typeof id === "string" && id.length > 0) previousWikis.set(id, item as WikiSnapshot);
|
||||||
}
|
}
|
||||||
@@ -437,12 +473,12 @@ export function buildEditorSnapshot(options: {
|
|||||||
// Operation semantics:
|
// Operation semantics:
|
||||||
// - create/update/delete: this commit changes the wiki itself
|
// - create/update/delete: this commit changes the wiki itself
|
||||||
// - reference: this wiki is a ref used for linking (entity<->wiki), not a modification
|
// - reference: this wiki is a ref used for linking (entity<->wiki), not a modification
|
||||||
const wikis: WikiSnapshot[] = (options.snapshotWikis || [])
|
const wikisCurrent: WikiSnapshot[] = (options.snapshotWikis || [])
|
||||||
.filter((w) => {
|
.filter((w) => {
|
||||||
if (!w || typeof w.id !== "string" || w.id.trim().length === 0) return false;
|
if (!w || typeof w.id !== "string" || w.id.trim().length === 0) return false;
|
||||||
if (w.source === "ref") return true;
|
if (w.source === "ref") return true;
|
||||||
// Keep explicit operations (e.g. delete) even if content is empty.
|
// Keep explicit operations (e.g. delete) even if content is empty.
|
||||||
if (w.operation === "create" || w.operation === "update" || w.operation === "delete") return true;
|
if (w.operation === "create" || w.operation === "update") return true;
|
||||||
// Inline wiki with no content: don't persist it (treat as not written).
|
// Inline wiki with no content: don't persist it (treat as not written).
|
||||||
const title = typeof w.title === "string" ? w.title.trim() : "";
|
const title = typeof w.title === "string" ? w.title.trim() : "";
|
||||||
const doc = typeof w.doc === "string" ? w.doc.trim() : "";
|
const doc = typeof w.doc === "string" ? w.doc.trim() : "";
|
||||||
@@ -485,15 +521,57 @@ export function buildEditorSnapshot(options: {
|
|||||||
return cloned;
|
return cloned;
|
||||||
});
|
});
|
||||||
|
|
||||||
const entityWikisRaw: EntityWikiLinkSnapshot[] = (options.snapshotEntityWikiLinks || [])
|
// Wikis removed during this session are emitted as "delete" operations.
|
||||||
.filter((l) => l && typeof l.entity_id === "string" && typeof l.wiki_id === "string")
|
const currentWikiIds = new Set(wikisCurrent.map((w) => w.id));
|
||||||
.map((l) => ({
|
const deletedWikis: WikiSnapshot[] = [];
|
||||||
entity_id: l.entity_id,
|
for (const prev of previousWikis.values()) {
|
||||||
wiki_id: l.wiki_id,
|
if (!prev?.id) continue;
|
||||||
// Backend API expects "reference" to indicate an active link (not "binding").
|
if (currentWikiIds.has(prev.id)) continue;
|
||||||
operation: l.operation === "delete" ? "delete" : "reference",
|
deletedWikis.push({
|
||||||
}));
|
id: prev.id,
|
||||||
const entityWikis = dedupeAndSortEntityWiki(entityWikisRaw);
|
source: prev.source === "inline" ? "inline" : "ref",
|
||||||
|
operation: "delete",
|
||||||
|
title: typeof prev.title === "string" ? prev.title : "Untitled wiki",
|
||||||
|
slug: (prev as any).slug ?? null,
|
||||||
|
doc: (prev as any).doc ?? null,
|
||||||
|
updated_at: (prev as any).updated_at ?? undefined,
|
||||||
|
} as WikiSnapshot);
|
||||||
|
}
|
||||||
|
const wikis = [...wikisCurrent, ...deletedWikis];
|
||||||
|
|
||||||
|
const baselineEntityWiki = new Set<string>();
|
||||||
|
for (const row of options.previousSnapshot?.entity_wiki || []) {
|
||||||
|
if (!row || typeof (row as any).entity_id !== "string" || typeof (row as any).wiki_id !== "string") continue;
|
||||||
|
if ((row as any).operation === "delete") continue;
|
||||||
|
const entity_id = (row as any).entity_id.trim();
|
||||||
|
const wiki_id = (row as any).wiki_id.trim();
|
||||||
|
if (!entity_id || !wiki_id) continue;
|
||||||
|
baselineEntityWiki.add(`${entity_id}::${wiki_id}`);
|
||||||
|
}
|
||||||
|
const currentEntityWikiKeys = new Set<string>();
|
||||||
|
const entityWikisDelta: EntityWikiLinkSnapshot[] = [];
|
||||||
|
for (const l of options.snapshotEntityWikiLinks || []) {
|
||||||
|
if (!l || typeof l.entity_id !== "string" || typeof l.wiki_id !== "string") continue;
|
||||||
|
if (l.operation === "delete") continue;
|
||||||
|
const entity_id = l.entity_id.trim();
|
||||||
|
const wiki_id = l.wiki_id.trim();
|
||||||
|
if (!entity_id || !wiki_id) continue;
|
||||||
|
const key = `${entity_id}::${wiki_id}`;
|
||||||
|
if (currentEntityWikiKeys.has(key)) continue;
|
||||||
|
currentEntityWikiKeys.add(key);
|
||||||
|
entityWikisDelta.push({
|
||||||
|
entity_id,
|
||||||
|
wiki_id,
|
||||||
|
operation: baselineEntityWiki.has(key) ? "reference" : "binding",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const key of baselineEntityWiki) {
|
||||||
|
if (currentEntityWikiKeys.has(key)) continue;
|
||||||
|
const [entity_id, wiki_id] = key.split("::");
|
||||||
|
if (!entity_id || !wiki_id) continue;
|
||||||
|
entityWikisDelta.push({ entity_id, wiki_id, operation: "delete" });
|
||||||
|
}
|
||||||
|
const entityWikis = dedupeAndSortEntityWiki(entityWikisDelta);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
editor_feature_collection: draftForSnapshot,
|
editor_feature_collection: draftForSnapshot,
|
||||||
@@ -529,10 +607,17 @@ function dedupeAndSortGeometryEntity(rows: GeometryEntitySnapshot[]): GeometryEn
|
|||||||
const geometry_id = typeof row.geometry_id === "string" ? row.geometry_id : "";
|
const geometry_id = typeof row.geometry_id === "string" ? row.geometry_id : "";
|
||||||
const entity_id = typeof row.entity_id === "string" ? row.entity_id : "";
|
const entity_id = typeof row.entity_id === "string" ? row.entity_id : "";
|
||||||
if (!geometry_id || !entity_id) continue;
|
if (!geometry_id || !entity_id) continue;
|
||||||
|
const opRaw = (row as any).operation;
|
||||||
|
const operation: GeometryEntitySnapshot["operation"] =
|
||||||
|
opRaw === "delete"
|
||||||
|
? "delete"
|
||||||
|
: opRaw === "binding" || opRaw === "reference"
|
||||||
|
? opRaw
|
||||||
|
: undefined;
|
||||||
const key = `${geometry_id}::${entity_id}`;
|
const key = `${geometry_id}::${entity_id}`;
|
||||||
if (seen.has(key)) continue;
|
if (seen.has(key)) continue;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
deduped.push({ ...row, geometry_id, entity_id });
|
deduped.push({ ...row, geometry_id, entity_id, operation });
|
||||||
}
|
}
|
||||||
deduped.sort((a, b) => {
|
deduped.sort((a, b) => {
|
||||||
const g = a.geometry_id.localeCompare(b.geometry_id);
|
const g = a.geometry_id.localeCompare(b.geometry_id);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react";
|
||||||
import type {
|
import type {
|
||||||
Feature,
|
Feature,
|
||||||
FeatureCollection,
|
FeatureCollection,
|
||||||
@@ -9,15 +9,27 @@ import { buildInitialMap, deepClone, diffDraftToInitial } from "@/uhm/lib/editor
|
|||||||
import { useDraftState } from "@/uhm/lib/editor/draft/useDraftState";
|
import { useDraftState } from "@/uhm/lib/editor/draft/useDraftState";
|
||||||
import { useUndoStack } from "@/uhm/lib/editor/draft/useUndoStack";
|
import { useUndoStack } from "@/uhm/lib/editor/draft/useUndoStack";
|
||||||
import type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
|
import type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
|
||||||
|
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||||
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
|
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
||||||
|
|
||||||
export type { Feature, FeatureCollection, FeatureProperties, Geometry } from "@/uhm/types/geo";
|
export type { Feature, FeatureCollection, FeatureProperties, Geometry } from "@/uhm/types/geo";
|
||||||
export type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
|
export type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
|
||||||
|
|
||||||
|
type SnapshotUndoApi = {
|
||||||
|
snapshotEntitiesRef: { current: EntitySnapshot[] };
|
||||||
|
setSnapshotEntities: Dispatch<SetStateAction<EntitySnapshot[]>>;
|
||||||
|
snapshotWikisRef: { current: WikiSnapshot[] };
|
||||||
|
setSnapshotWikis: Dispatch<SetStateAction<WikiSnapshot[]>>;
|
||||||
|
snapshotEntityWikiLinksRef: { current: EntityWikiLinkSnapshot[] };
|
||||||
|
setSnapshotEntityWikiLinks: Dispatch<SetStateAction<EntityWikiLinkSnapshot[]>>;
|
||||||
|
};
|
||||||
|
|
||||||
// State trung tâm của editor:
|
// State trung tâm của editor:
|
||||||
// - draft: dữ liệu nguồn để render UI
|
// - draft: dữ liệu nguồn để render UI
|
||||||
// - changes: map các thay đổi chờ lưu
|
// - changes: map các thay đổi chờ lưu
|
||||||
// - undoStack: lịch sử thao tác tối thiểu để hoàn tác
|
// - undoStack: lịch sử thao tác tối thiểu để hoàn tác
|
||||||
export function useEditorState(initialData: FeatureCollection) {
|
export function useEditorState(initialData: FeatureCollection, snapshotUndo?: SnapshotUndoApi) {
|
||||||
const { draft, draftRef, commitDraft, resetDraft } = useDraftState(initialData);
|
const { draft, draftRef, commitDraft, resetDraft } = useDraftState(initialData);
|
||||||
|
|
||||||
// Map baseline (id -> feature) để diff draft hiện tại ra changes.
|
// Map baseline (id -> feature) để diff draft hiện tại ra changes.
|
||||||
@@ -72,10 +84,25 @@ export function useEditorState(initialData: FeatureCollection) {
|
|||||||
commitDraft({ ...draftRef.current, features: nextFeatures });
|
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
case "snapshot_entities": {
|
||||||
|
if (!snapshotUndo) return false;
|
||||||
|
snapshotUndo.setSnapshotEntities(deepClone(action.prev));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "snapshot_wikis": {
|
||||||
|
if (!snapshotUndo) return false;
|
||||||
|
snapshotUndo.setSnapshotWikis(deepClone(action.prev));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "snapshot_entity_wiki": {
|
||||||
|
if (!snapshotUndo) return false;
|
||||||
|
snapshotUndo.setSnapshotEntityWikiLinks(deepClone(action.prev));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [commitDraft, draftRef]);
|
}, [commitDraft, draftRef, snapshotUndo]);
|
||||||
|
|
||||||
const { undoStack, pushUndo, undo, clearUndo } = useUndoStack({ applyUndoAction });
|
const { undoStack, pushUndo, undo, clearUndo } = useUndoStack({ applyUndoAction });
|
||||||
|
|
||||||
@@ -169,6 +196,71 @@ export function useEditorState(initialData: FeatureCollection) {
|
|||||||
return initialMapRef.current.has(id);
|
return initialMapRef.current.has(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setSnapshotEntitiesUndoable = useCallback((
|
||||||
|
next: SetStateAction<EntitySnapshot[]>,
|
||||||
|
label = "Cập nhật entities"
|
||||||
|
) => {
|
||||||
|
if (!snapshotUndo) return;
|
||||||
|
snapshotUndo.setSnapshotEntities((prev) => {
|
||||||
|
const prevClone = deepClone(prev);
|
||||||
|
const computed = typeof next === "function" ? (next as (p: EntitySnapshot[]) => EntitySnapshot[])(prev) : next;
|
||||||
|
let changed = true;
|
||||||
|
try {
|
||||||
|
changed = JSON.stringify(prev) !== JSON.stringify(computed);
|
||||||
|
} catch {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
pushUndo({ type: "snapshot_entities", label, prev: prevClone });
|
||||||
|
}
|
||||||
|
return computed;
|
||||||
|
});
|
||||||
|
}, [pushUndo, snapshotUndo]);
|
||||||
|
|
||||||
|
const setSnapshotWikisUndoable = useCallback((
|
||||||
|
next: SetStateAction<WikiSnapshot[]>,
|
||||||
|
label = "Cập nhật wikis"
|
||||||
|
) => {
|
||||||
|
if (!snapshotUndo) return;
|
||||||
|
snapshotUndo.setSnapshotWikis((prev) => {
|
||||||
|
const prevClone = deepClone(prev);
|
||||||
|
const computed = typeof next === "function" ? (next as (p: WikiSnapshot[]) => WikiSnapshot[])(prev) : next;
|
||||||
|
let changed = true;
|
||||||
|
try {
|
||||||
|
changed = JSON.stringify(prev) !== JSON.stringify(computed);
|
||||||
|
} catch {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
pushUndo({ type: "snapshot_wikis", label, prev: prevClone });
|
||||||
|
}
|
||||||
|
return computed;
|
||||||
|
});
|
||||||
|
}, [pushUndo, snapshotUndo]);
|
||||||
|
|
||||||
|
const setSnapshotEntityWikiLinksUndoable = useCallback((
|
||||||
|
next: SetStateAction<EntityWikiLinkSnapshot[]>,
|
||||||
|
label = "Cập nhật entity-wiki"
|
||||||
|
) => {
|
||||||
|
if (!snapshotUndo) return;
|
||||||
|
snapshotUndo.setSnapshotEntityWikiLinks((prev) => {
|
||||||
|
const prevClone = deepClone(prev);
|
||||||
|
const computed = typeof next === "function"
|
||||||
|
? (next as (p: EntityWikiLinkSnapshot[]) => EntityWikiLinkSnapshot[])(prev)
|
||||||
|
: next;
|
||||||
|
let changed = true;
|
||||||
|
try {
|
||||||
|
changed = JSON.stringify(prev) !== JSON.stringify(computed);
|
||||||
|
} catch {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
pushUndo({ type: "snapshot_entity_wiki", label, prev: prevClone });
|
||||||
|
}
|
||||||
|
return computed;
|
||||||
|
});
|
||||||
|
}, [pushUndo, snapshotUndo]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
draft,
|
draft,
|
||||||
changes,
|
changes,
|
||||||
@@ -182,5 +274,9 @@ export function useEditorState(initialData: FeatureCollection) {
|
|||||||
buildPayload,
|
buildPayload,
|
||||||
clearChanges,
|
clearChanges,
|
||||||
hasPersistedFeature,
|
hasPersistedFeature,
|
||||||
|
// Snapshot undo helpers (no-op if snapshotUndo not provided)
|
||||||
|
setSnapshotEntities: setSnapshotEntitiesUndoable,
|
||||||
|
setSnapshotWikis: setSnapshotWikisUndoable,
|
||||||
|
setSnapshotEntityWikiLinks: setSnapshotEntityWikiLinksUndoable,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ export type GeometrySnapshot = {
|
|||||||
export type GeometryEntitySnapshot = {
|
export type GeometryEntitySnapshot = {
|
||||||
geometry_id: string;
|
geometry_id: string;
|
||||||
entity_id: string;
|
entity_id: string;
|
||||||
|
// Relationship semantics (geometry ↔ entity).
|
||||||
|
// - reference/binding: the link exists (assigned)
|
||||||
|
// - delete: the link is removed
|
||||||
|
operation?: "reference" | "binding" | "delete";
|
||||||
base_links_hash?: string;
|
base_links_hash?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user