reduce api | version control
This commit is contained in:
@@ -5,10 +5,9 @@ export const API_BASE_URL =
|
||||
|
||||
export const API_ENDPOINTS = {
|
||||
geometries: `${API_BASE_URL}/geometries`,
|
||||
geometriesBatch: `${API_BASE_URL}/geometries/batch`,
|
||||
geometriesBatchCombined: `${API_BASE_URL}/geometries/batch/combined`,
|
||||
entities: `${API_BASE_URL}/entities`,
|
||||
entitiesBatch: `${API_BASE_URL}/entities/batch`,
|
||||
sections: `${API_BASE_URL}/sections`,
|
||||
submissions: `${API_BASE_URL}/submissions`,
|
||||
vectorTiles: `${API_BASE_URL}/tiles/{z}/{x}/{y}`,
|
||||
rasterTiles: `${API_BASE_URL}/raster-tiles/{z}/{x}/{y}`,
|
||||
vectorTilesMetadata: `${API_BASE_URL}/tiles/metadata/info`,
|
||||
|
||||
@@ -13,51 +13,6 @@ export type Entity = {
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export type CreateEntityPayload = {
|
||||
name: string;
|
||||
slug?: string | null;
|
||||
description?: string | null;
|
||||
type_id?: string | null;
|
||||
status?: number | null;
|
||||
};
|
||||
|
||||
export type EntityBatchCreateChange =
|
||||
| ({
|
||||
action: "create";
|
||||
entity: CreateEntityPayload & { id?: string };
|
||||
})
|
||||
| ({
|
||||
action: "create";
|
||||
id?: string;
|
||||
} & CreateEntityPayload);
|
||||
|
||||
export type EntityBatchUpdateChange =
|
||||
| ({
|
||||
action: "update";
|
||||
id: string;
|
||||
entity: Partial<CreateEntityPayload> & { id?: string };
|
||||
})
|
||||
| ({
|
||||
action: "update";
|
||||
id: string;
|
||||
} & Partial<CreateEntityPayload>);
|
||||
|
||||
export type EntityBatchDeleteChange = {
|
||||
action: "delete";
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type EntityBatchChange =
|
||||
| EntityBatchCreateChange
|
||||
| EntityBatchUpdateChange
|
||||
| EntityBatchDeleteChange;
|
||||
|
||||
export type EntityBatchSaveResponse = {
|
||||
success: boolean;
|
||||
applied: number;
|
||||
created_entity_ids: string[];
|
||||
};
|
||||
|
||||
export async function fetchEntities(query?: { q?: string }): Promise<Entity[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (query?.q) {
|
||||
@@ -82,27 +37,3 @@ export async function searchEntitiesByName(
|
||||
|
||||
return requestJson<Entity[]>(`${API_ENDPOINTS.entities}/search?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function createEntity(payload: CreateEntityPayload): Promise<Entity> {
|
||||
return requestJson<Entity>(API_ENDPOINTS.entities, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateEntity(id: string, payload: CreateEntityPayload): Promise<Entity> {
|
||||
return requestJson<Entity>(`${API_ENDPOINTS.entities}/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveEntityBatchChanges(changes: EntityBatchChange[]): Promise<EntityBatchSaveResponse> {
|
||||
return requestJson<EntityBatchSaveResponse>(API_ENDPOINTS.entitiesBatch, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ changes }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { API_ENDPOINTS } from "@/api/config";
|
||||
import { EntityBatchChange } from "@/api/entities";
|
||||
import { requestJson } from "@/api/http";
|
||||
import { Change, FeatureCollection, Geometry } from "@/lib/useEditorState";
|
||||
import { FeatureCollection } from "@/lib/useEditorState";
|
||||
|
||||
export type GeometriesBBoxQuery = {
|
||||
minLng: number;
|
||||
@@ -12,43 +11,6 @@ export type GeometriesBBoxQuery = {
|
||||
entity_id?: string;
|
||||
};
|
||||
|
||||
export type GeometryCreatePayload = {
|
||||
geometry: Geometry;
|
||||
type?: string | null;
|
||||
time_start?: number | null;
|
||||
time_end?: number | null;
|
||||
binding?: string[];
|
||||
entity_id?: string | null;
|
||||
entity_ids?: string[];
|
||||
};
|
||||
|
||||
export type GeometryUpdatePayload = {
|
||||
geometry: Geometry;
|
||||
type?: string | null;
|
||||
time_start?: number | null;
|
||||
time_end?: number | null;
|
||||
binding?: string[];
|
||||
entity_id?: string | null;
|
||||
entity_ids?: string[];
|
||||
};
|
||||
|
||||
export type GeometryCreateResponse = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type BatchSaveResponse = {
|
||||
success: boolean;
|
||||
applied: number;
|
||||
};
|
||||
|
||||
export type CombinedBatchSaveResponse = {
|
||||
success: boolean;
|
||||
applied: number;
|
||||
entity_applied: number;
|
||||
geometry_applied: number;
|
||||
created_entity_ids: string[];
|
||||
};
|
||||
|
||||
function buildBBoxQueryString(params: GeometriesBBoxQuery): string {
|
||||
const query = new URLSearchParams({
|
||||
minLng: String(params.minLng),
|
||||
@@ -72,47 +34,3 @@ export async function fetchGeometriesByBBox(params: GeometriesBBoxQuery): Promis
|
||||
const url = `${API_ENDPOINTS.geometries}?${buildBBoxQueryString(params)}`;
|
||||
return requestJson<FeatureCollection>(url);
|
||||
}
|
||||
|
||||
export async function saveGeometryBatchChanges(changes: Change[]): Promise<BatchSaveResponse> {
|
||||
return requestJson<BatchSaveResponse>(API_ENDPOINTS.geometriesBatch, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ changes }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveCombinedGeometryEntityBatchChanges(
|
||||
entityChanges: EntityBatchChange[],
|
||||
geometryChanges: Change[]
|
||||
): Promise<CombinedBatchSaveResponse> {
|
||||
return requestJson<CombinedBatchSaveResponse>(API_ENDPOINTS.geometriesBatchCombined, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
entity_changes: entityChanges,
|
||||
geometry_changes: geometryChanges,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createGeometry(payload: GeometryCreatePayload): Promise<GeometryCreateResponse> {
|
||||
return requestJson<GeometryCreateResponse>(API_ENDPOINTS.geometries, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateGeometry(id: string | number, payload: GeometryUpdatePayload): Promise<{ success: boolean }> {
|
||||
return requestJson<{ success: boolean }>(`${API_ENDPOINTS.geometries}/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteGeometry(id: string | number): Promise<{ success: boolean }> {
|
||||
return requestJson<{ success: boolean }>(`${API_ENDPOINTS.geometries}/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,3 +20,11 @@ export async function requestJson<T>(input: RequestInfo | URL, init?: RequestIni
|
||||
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export function jsonRequestInit(method: string, body: unknown): RequestInit {
|
||||
return {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
}
|
||||
|
||||
195
api/sections.ts
Normal file
195
api/sections.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { API_ENDPOINTS } from "@/api/config";
|
||||
import { jsonRequestInit, requestJson } from "@/api/http";
|
||||
|
||||
export type SectionState = {
|
||||
section_id?: string;
|
||||
status: "editing" | "submitted" | "approved" | "rejected";
|
||||
head_commit_id: string | null;
|
||||
version: number;
|
||||
locked_by: string | null;
|
||||
locked_at: string | null;
|
||||
lock_expires_at: string | null;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export type Section = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
user_id: string | null;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
state: Omit<SectionState, "section_id" | "updated_at">;
|
||||
};
|
||||
|
||||
export type SectionCommit = {
|
||||
id: string;
|
||||
section_id: string;
|
||||
parent_commit_id: string | null;
|
||||
commit_no: number;
|
||||
kind: "manual" | "restore";
|
||||
restored_from_commit_id: string | null;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
title: string | null;
|
||||
note: string | null;
|
||||
snapshot_hash: string | null;
|
||||
snapshot?: unknown;
|
||||
};
|
||||
|
||||
export type SectionSubmission = {
|
||||
id: string;
|
||||
section_id: string;
|
||||
commit_id: string;
|
||||
submitted_by: string;
|
||||
submitted_at: string;
|
||||
status: "pending" | "approved" | "rejected" | "conflicted";
|
||||
reviewed_by: string | null;
|
||||
reviewed_at: string | null;
|
||||
review_note: string | null;
|
||||
snapshot_hash: string | null;
|
||||
snapshot?: unknown;
|
||||
};
|
||||
|
||||
export type EditorLoadResponse = {
|
||||
section: Section;
|
||||
state: SectionState;
|
||||
commit: SectionCommit | null;
|
||||
snapshot: unknown;
|
||||
};
|
||||
|
||||
export type CreateSectionInput = {
|
||||
id?: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
user_id?: string;
|
||||
created_by?: string;
|
||||
};
|
||||
|
||||
export type CreateCommitInput = {
|
||||
snapshot: unknown;
|
||||
created_by?: string;
|
||||
user_id?: string;
|
||||
expected_version?: number;
|
||||
expected_head_commit_id?: string | null;
|
||||
title?: string | null;
|
||||
note?: string | null;
|
||||
};
|
||||
|
||||
export async function fetchSections(): Promise<Section[]> {
|
||||
return requestJson<Section[]>(API_ENDPOINTS.sections);
|
||||
}
|
||||
|
||||
export async function createSection(input: CreateSectionInput): Promise<Section> {
|
||||
return requestJson<Section>(API_ENDPOINTS.sections, jsonRequestInit("POST", input));
|
||||
}
|
||||
|
||||
export async function openSectionEditor(sectionId: string, userId?: string): Promise<EditorLoadResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (userId) params.set("user_id", userId);
|
||||
return requestJson<EditorLoadResponse>(sectionUrl(sectionId, "editor", params));
|
||||
}
|
||||
|
||||
export async function lockSection(sectionId: string, userId: string): Promise<{ state: SectionState }> {
|
||||
return requestJson<{ state: SectionState }>(
|
||||
sectionUrl(sectionId, "lock"),
|
||||
jsonRequestInit("POST", { user_id: userId })
|
||||
);
|
||||
}
|
||||
|
||||
export async function unlockSection(sectionId: string, userId: string): Promise<{ success: boolean }> {
|
||||
return requestJson<{ success: boolean }>(
|
||||
sectionUrl(sectionId, "unlock"),
|
||||
jsonRequestInit("POST", { user_id: userId })
|
||||
);
|
||||
}
|
||||
|
||||
export async function createSectionCommit(
|
||||
sectionId: string,
|
||||
input: CreateCommitInput
|
||||
): Promise<{ commit: SectionCommit; state: SectionState }> {
|
||||
return requestJson<{ commit: SectionCommit; state: SectionState }>(
|
||||
sectionUrl(sectionId, "commits"),
|
||||
jsonRequestInit("POST", input)
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchSectionCommits(
|
||||
sectionId: string,
|
||||
options?: { includeSnapshot?: boolean }
|
||||
): Promise<SectionCommit[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.includeSnapshot) params.set("include_snapshot", "1");
|
||||
return requestJson<SectionCommit[]>(sectionUrl(sectionId, "commits", params));
|
||||
}
|
||||
|
||||
export async function restoreSectionCommit(
|
||||
sectionId: string,
|
||||
input: {
|
||||
commit_id: string;
|
||||
created_by?: string;
|
||||
user_id?: string;
|
||||
expected_version?: number;
|
||||
expected_head_commit_id?: string | null;
|
||||
title?: string | null;
|
||||
note?: string | null;
|
||||
}
|
||||
): Promise<{ commit: SectionCommit; state: SectionState }> {
|
||||
return requestJson<{ commit: SectionCommit; state: SectionState }>(
|
||||
sectionUrl(sectionId, "restore"),
|
||||
jsonRequestInit("POST", input)
|
||||
);
|
||||
}
|
||||
|
||||
export async function submitSection(
|
||||
sectionId: string,
|
||||
input: { commit_id?: string; submitted_by?: string; user_id?: string }
|
||||
): Promise<SectionSubmission> {
|
||||
return requestJson<SectionSubmission>(sectionUrl(sectionId, "submit"), jsonRequestInit("POST", input));
|
||||
}
|
||||
|
||||
export async function fetchSectionSubmissions(
|
||||
sectionId: string,
|
||||
options?: { includeSnapshot?: boolean }
|
||||
): Promise<SectionSubmission[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.includeSnapshot) params.set("include_snapshot", "1");
|
||||
return requestJson<SectionSubmission[]>(sectionUrl(sectionId, "submissions", params));
|
||||
}
|
||||
|
||||
export async function approveSubmission(
|
||||
submissionId: string,
|
||||
input: { reviewed_by?: string; user_id?: string; review_note?: string | null }
|
||||
): Promise<SectionSubmission> {
|
||||
return requestJson<SectionSubmission>(
|
||||
submissionUrl(submissionId, "approve"),
|
||||
jsonRequestInit("POST", input)
|
||||
);
|
||||
}
|
||||
|
||||
export async function rejectSubmission(
|
||||
submissionId: string,
|
||||
input: { reviewed_by?: string; user_id?: string; review_note?: string | null }
|
||||
): Promise<SectionSubmission> {
|
||||
return requestJson<SectionSubmission>(
|
||||
submissionUrl(submissionId, "reject"),
|
||||
jsonRequestInit("POST", input)
|
||||
);
|
||||
}
|
||||
|
||||
function sectionUrl(sectionId: string, path?: string, params?: URLSearchParams): string {
|
||||
return appendQuery(
|
||||
`${API_ENDPOINTS.sections}/${encodeURIComponent(sectionId)}${path ? `/${path}` : ""}`,
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
function submissionUrl(submissionId: string, path: "approve" | "reject"): string {
|
||||
return `${API_ENDPOINTS.submissions}/${encodeURIComponent(submissionId)}/${path}`;
|
||||
}
|
||||
|
||||
function appendQuery(url: string, params?: URLSearchParams): string {
|
||||
const suffix = params?.toString();
|
||||
return suffix ? `${url}?${suffix}` : url;
|
||||
}
|
||||
@@ -1,15 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Map from "@/components/Map";
|
||||
import Editor from "@/components/Editor";
|
||||
import BackgroundLayersPanel from "@/components/BackgroundLayersPanel";
|
||||
import TimelineBar from "@/components/TimelineBar";
|
||||
import SelectedGeometryPanel from "@/components/SelectedGeometryPanel";
|
||||
import { Entity, EntityBatchChange, fetchEntities, searchEntitiesByName } from "@/api/entities";
|
||||
import { Entity, fetchEntities, searchEntitiesByName } from "@/api/entities";
|
||||
import { ApiError } from "@/api/http";
|
||||
import { fetchGeometriesByBBox, saveCombinedGeometryEntityBatchChanges, updateGeometry } from "@/api/geometries";
|
||||
import { fetchGeometriesByBBox } from "@/api/geometries";
|
||||
import {
|
||||
createSection,
|
||||
createSectionCommit,
|
||||
fetchSections,
|
||||
fetchSectionCommits,
|
||||
openSectionEditor,
|
||||
restoreSectionCommit,
|
||||
Section,
|
||||
SectionCommit,
|
||||
SectionState,
|
||||
submitSection,
|
||||
} from "@/api/sections";
|
||||
import {
|
||||
Change,
|
||||
Feature,
|
||||
FeatureCollection,
|
||||
useEditorState,
|
||||
@@ -42,6 +55,8 @@ const FALLBACK_TIMELINE_RANGE: TimelineRange = {
|
||||
};
|
||||
const TIMELINE_DEBOUNCE_MS = 180;
|
||||
const BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY = "uhm.backgroundLayerVisibility.v1";
|
||||
const DEFAULT_SECTION_TITLE = "Main editor section";
|
||||
const DEFAULT_EDITOR_USER_ID = "local-editor";
|
||||
|
||||
type TimelineRange = {
|
||||
min: number;
|
||||
@@ -60,11 +75,6 @@ type GeometryMetaFormState = {
|
||||
binding: string;
|
||||
};
|
||||
|
||||
type BindingGeometrySearchOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type PendingEntityCreate = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -73,10 +83,34 @@ type PendingEntityCreate = {
|
||||
status: number;
|
||||
};
|
||||
|
||||
type EditorSnapshot = {
|
||||
schema_version: number;
|
||||
section: {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
editor_feature_collection?: FeatureCollection;
|
||||
entities?: Array<Record<string, unknown>>;
|
||||
geometries?: Array<Record<string, unknown>>;
|
||||
link_scopes?: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const [mode, setMode] = useState<"idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle">("idle");
|
||||
const [initialData, setInitialData] = useState<FeatureCollection>(EMPTY_FC);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isOpeningSection, setIsOpeningSection] = useState(false);
|
||||
const [availableSections, setAvailableSections] = useState<Section[]>([]);
|
||||
const [selectedSectionId, setSelectedSectionId] = useState("");
|
||||
const [newSectionTitle, setNewSectionTitle] = useState("");
|
||||
const [commitTitle, setCommitTitle] = useState("");
|
||||
const [commitNote, setCommitNote] = useState("");
|
||||
const [editorUserIdInput, setEditorUserIdInput] = useState(DEFAULT_EDITOR_USER_ID);
|
||||
const [activeSection, setActiveSection] = useState<Section | null>(null);
|
||||
const [sectionState, setSectionState] = useState<SectionState | null>(null);
|
||||
const [sectionCommits, setSectionCommits] = useState<SectionCommit[]>([]);
|
||||
const [lastSectionSnapshot, setLastSectionSnapshot] = useState<EditorSnapshot | null>(null);
|
||||
const [persistedEntities, setPersistedEntities] = useState<Entity[]>([]);
|
||||
const [pendingEntityCreates, setPendingEntityCreates] = useState<PendingEntityCreate[]>([]);
|
||||
const [createdEntities, setCreatedEntities] = useState<Array<{
|
||||
@@ -103,9 +137,6 @@ export default function Page() {
|
||||
const [entitySearchResults, setEntitySearchResults] = useState<Entity[]>([]);
|
||||
const [selectedSearchEntityId, setSelectedSearchEntityId] = useState<string | null>(null);
|
||||
const [isEntitySearchLoading, setIsEntitySearchLoading] = useState(false);
|
||||
const [bindingGeometrySearchQuery, setBindingGeometrySearchQuery] = useState("");
|
||||
const [bindingGeometrySearchResults, setBindingGeometrySearchResults] = useState<BindingGeometrySearchOption[]>([]);
|
||||
const [selectedBindingGeometryId, setSelectedBindingGeometryId] = useState<string | null>(null);
|
||||
const [timelineRange, setTimelineRange] = useState<TimelineRange>(FALLBACK_TIMELINE_RANGE);
|
||||
const [timelineWindowStart, setTimelineWindowStart] = useState<number>(FALLBACK_TIMELINE_RANGE.min);
|
||||
const [timelineWindowEnd, setTimelineWindowEnd] = useState<number>(FALLBACK_TIMELINE_RANGE.max);
|
||||
@@ -124,8 +155,11 @@ export default function Page() {
|
||||
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
|
||||
const timelineFetchRequestRef = useRef(0);
|
||||
const entitySearchRequestRef = useRef(0);
|
||||
const timelineYearRef = useRef(timelineYear);
|
||||
const editorUserIdRef = useRef(DEFAULT_EDITOR_USER_ID);
|
||||
|
||||
const editor = useEditorState(initialData);
|
||||
const editorUserId = normalizeEditorUserId(editorUserIdInput);
|
||||
const entities = useMemo(
|
||||
() => mergeEntitiesWithPending(persistedEntities, pendingEntityCreates),
|
||||
[persistedEntities, pendingEntityCreates]
|
||||
@@ -162,6 +196,102 @@ export default function Page() {
|
||||
return rows;
|
||||
}, [editor.changes, entities]);
|
||||
|
||||
useEffect(() => {
|
||||
timelineYearRef.current = timelineYear;
|
||||
}, [timelineYear]);
|
||||
|
||||
useEffect(() => {
|
||||
editorUserIdRef.current = editorUserId;
|
||||
}, [editorUserId]);
|
||||
|
||||
const openSectionForEditing = useCallback(async (sectionId: string) => {
|
||||
const editorPayload = await openSectionEditor(sectionId, editorUserIdRef.current);
|
||||
const snapshot = normalizeEditorSnapshot(editorPayload.snapshot);
|
||||
const commits = await fetchSectionCommits(sectionId);
|
||||
|
||||
let nextInitialData = snapshot?.editor_feature_collection || null;
|
||||
if (!nextInitialData) {
|
||||
nextInitialData = await fetchGeometriesByBBox({
|
||||
...WORLD_BBOX,
|
||||
time: timelineYearRef.current,
|
||||
});
|
||||
}
|
||||
|
||||
setActiveSection(editorPayload.section);
|
||||
setSelectedSectionId(editorPayload.section.id);
|
||||
setSectionState(editorPayload.state);
|
||||
setLastSectionSnapshot(snapshot);
|
||||
setInitialData(nextInitialData);
|
||||
setSectionCommits(commits);
|
||||
setPendingEntityCreates([]);
|
||||
setCreatedEntities([]);
|
||||
setSelectedFeatureId(null);
|
||||
setEntityFormStatus(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
|
||||
async function loadSection() {
|
||||
try {
|
||||
setIsOpeningSection(true);
|
||||
const sections = await fetchSections();
|
||||
if (disposed) return;
|
||||
setAvailableSections(sections);
|
||||
|
||||
const section = await resolveDefaultSection(sections, editorUserIdRef.current);
|
||||
if (disposed) return;
|
||||
setAvailableSections((prev) =>
|
||||
prev.some((item) => item.id === section.id)
|
||||
? prev
|
||||
: [section, ...prev]
|
||||
);
|
||||
|
||||
try {
|
||||
await openSectionForEditing(section.id);
|
||||
} catch (err) {
|
||||
if (!(err instanceof ApiError) || err.status !== 409) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const fallbackSection = await resolveOpenableSectionAfterConflict(
|
||||
sections,
|
||||
section.id,
|
||||
editorUserIdRef.current
|
||||
);
|
||||
if (disposed) return;
|
||||
setAvailableSections((prev) =>
|
||||
prev.some((item) => item.id === fallbackSection.id)
|
||||
? prev
|
||||
: [fallbackSection, ...prev]
|
||||
);
|
||||
await openSectionForEditing(fallbackSection.id);
|
||||
if (!disposed) {
|
||||
setEntityStatus("Section mặc định đang bị lock, đã mở section khác để chỉnh sửa.");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (disposed) return;
|
||||
if (err instanceof ApiError) {
|
||||
setEntityStatus(`Không tải được section editor: ${err.body || err.message}`);
|
||||
} else {
|
||||
console.error("Load section editor failed", err);
|
||||
setEntityStatus("Không tải được section editor.");
|
||||
}
|
||||
} finally {
|
||||
if (!disposed) {
|
||||
setIsOpeningSection(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadSection();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [openSectionForEditing]);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
|
||||
@@ -171,9 +301,7 @@ export default function Page() {
|
||||
if (disposed) return;
|
||||
|
||||
setPersistedEntities(rows);
|
||||
setEntityStatus(rows.length
|
||||
? null
|
||||
: "Chưa có entity. Cần tạo entity trước khi Save geometry.");
|
||||
setEntityStatus(null);
|
||||
} catch (err) {
|
||||
if (disposed) return;
|
||||
console.error("Load entities failed", err);
|
||||
@@ -264,51 +392,6 @@ export default function Page() {
|
||||
};
|
||||
}, [entitySearchQuery, selectedFeature, pendingEntityCreates]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedFeature) {
|
||||
setBindingGeometrySearchResults([]);
|
||||
setSelectedBindingGeometryId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const keyword = bindingGeometrySearchQuery.trim().toLowerCase();
|
||||
if (!keyword.length) {
|
||||
setBindingGeometrySearchResults([]);
|
||||
setSelectedBindingGeometryId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentBindingIds = new Set(parseBindingInput(geometryMetaForm.binding));
|
||||
const rows = editor.draft.features
|
||||
.filter((feature) => String(feature.properties.id) !== String(selectedFeature.properties.id))
|
||||
.map((feature) => {
|
||||
const id = String(feature.properties.id);
|
||||
const typeLabel = feature.properties.type || feature.geometry.type;
|
||||
const entityLabel = Array.isArray(feature.properties.entity_names) && feature.properties.entity_names.length
|
||||
? feature.properties.entity_names.join(", ")
|
||||
: (feature.properties.entity_name || "");
|
||||
const label = `#${id} [${feature.geometry.type}] ${typeLabel}${entityLabel ? ` - ${entityLabel}` : ""}`;
|
||||
const searchable = `${id} ${feature.geometry.type} ${typeLabel} ${entityLabel}`.toLowerCase();
|
||||
return { id, label, searchable };
|
||||
})
|
||||
.filter((item) => !currentBindingIds.has(item.id))
|
||||
.filter((item) => item.searchable.includes(keyword))
|
||||
.slice(0, 40)
|
||||
.map((item) => ({ id: item.id, label: item.label }));
|
||||
|
||||
setBindingGeometrySearchResults(rows);
|
||||
setSelectedBindingGeometryId((prev) =>
|
||||
prev && rows.some((item) => item.id === prev)
|
||||
? prev
|
||||
: rows[0]?.id || null
|
||||
);
|
||||
}, [
|
||||
bindingGeometrySearchQuery,
|
||||
geometryMetaForm.binding,
|
||||
editor.draft,
|
||||
selectedFeature,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedFeatureId === null) return;
|
||||
const stillExists = editor.draft.features.some((feature) =>
|
||||
@@ -330,9 +413,6 @@ export default function Page() {
|
||||
setEntitySearchQuery("");
|
||||
setEntitySearchResults([]);
|
||||
setSelectedSearchEntityId(null);
|
||||
setBindingGeometrySearchQuery("");
|
||||
setBindingGeometrySearchResults([]);
|
||||
setSelectedBindingGeometryId(null);
|
||||
setEntityFormStatus(null);
|
||||
return;
|
||||
}
|
||||
@@ -351,14 +431,7 @@ export default function Page() {
|
||||
setEntitySearchQuery("");
|
||||
setEntitySearchResults([]);
|
||||
setSelectedSearchEntityId(null);
|
||||
setBindingGeometrySearchQuery("");
|
||||
setBindingGeometrySearchResults([]);
|
||||
setSelectedBindingGeometryId(null);
|
||||
if (!featureEntityIds.length) {
|
||||
setEntityFormStatus("Geometry mới phải được gắn ít nhất 1 entity trước khi Save.");
|
||||
} else {
|
||||
setEntityFormStatus(null);
|
||||
}
|
||||
}, [selectedFeature]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -470,7 +543,9 @@ export default function Page() {
|
||||
});
|
||||
|
||||
if (disposed || requestId !== timelineFetchRequestRef.current) return;
|
||||
if (!lastSectionSnapshot?.editor_feature_collection) {
|
||||
setInitialData(data);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
console.error("Load timeline data failed", err.body);
|
||||
@@ -493,65 +568,54 @@ export default function Page() {
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [timelineYear, isTimelineReady]);
|
||||
}, [timelineYear, isTimelineReady, lastSectionSnapshot]);
|
||||
|
||||
const handleSave = async () => {
|
||||
const geometryChanges = editor.buildPayload();
|
||||
const entityChanges: EntityBatchChange[] = pendingEntityCreates.map((entity) => ({
|
||||
action: "create",
|
||||
entity: {
|
||||
id: entity.id,
|
||||
name: entity.name,
|
||||
slug: entity.slug,
|
||||
type_id: entity.type_id,
|
||||
status: entity.status,
|
||||
},
|
||||
}));
|
||||
|
||||
if (!geometryChanges.length && !entityChanges.length) return;
|
||||
|
||||
const invalid = geometryChanges.find((change) => {
|
||||
if (change.action === "delete") return false;
|
||||
const draftFeature = change.action === "create"
|
||||
? change.feature
|
||||
: editor.draft.features.find((feature) =>
|
||||
String(feature.properties.id) === String(change.id)
|
||||
);
|
||||
if (!draftFeature) return false;
|
||||
const entityIds = normalizeFeatureEntityIds(draftFeature);
|
||||
return entityIds.length === 0;
|
||||
});
|
||||
|
||||
if (invalid) {
|
||||
const invalidId = invalid.action === "create"
|
||||
? invalid.feature.properties.id
|
||||
: invalid.id;
|
||||
setSelectedFeatureId(invalidId);
|
||||
setEntityStatus("Không thể Save: mỗi geometry phải có ít nhất 1 entity.");
|
||||
const handleCommitSection = async () => {
|
||||
if (!activeSection || !sectionState) {
|
||||
setEntityStatus("Chưa mở được section editor.");
|
||||
return;
|
||||
}
|
||||
|
||||
const geometryChanges = editor.buildPayload();
|
||||
|
||||
setIsSaving(true);
|
||||
setEntityStatus(null);
|
||||
try {
|
||||
await saveCombinedGeometryEntityBatchChanges(entityChanges, geometryChanges);
|
||||
if (geometryChanges.length) {
|
||||
const snapshot = buildEditorSnapshot({
|
||||
section: activeSection,
|
||||
draft: editor.draft,
|
||||
changes: geometryChanges,
|
||||
pendingEntities: pendingEntityCreates,
|
||||
previousSnapshot: lastSectionSnapshot,
|
||||
hasPersistedFeature: editor.hasPersistedFeature,
|
||||
});
|
||||
const result = await createSectionCommit(activeSection.id, {
|
||||
snapshot,
|
||||
created_by: editorUserId,
|
||||
expected_version: sectionState.version,
|
||||
expected_head_commit_id: sectionState.head_commit_id,
|
||||
title: commitTitle.trim() || `Commit ${new Date().toLocaleString()}`,
|
||||
note: commitNote.trim() || null,
|
||||
});
|
||||
|
||||
setSectionState(result.state);
|
||||
setLastSectionSnapshot(snapshot);
|
||||
setInitialData(editor.draft);
|
||||
editor.clearChanges();
|
||||
await reloadCurrentTimelineData();
|
||||
}
|
||||
if (entityChanges.length) {
|
||||
setPendingEntityCreates([]);
|
||||
await reloadEntities();
|
||||
setEntityFormStatus("Đã lưu batch entities + geometries.");
|
||||
}
|
||||
setCreatedEntities([]);
|
||||
setCommitTitle("");
|
||||
setCommitNote("");
|
||||
setSectionCommits(await fetchSectionCommits(activeSection.id));
|
||||
setEntityFormStatus(`Đã tạo commit #${result.commit.commit_no}.`);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
console.error("Save failed", err.body);
|
||||
setEntityStatus(`Save thất bại: ${err.body}`);
|
||||
console.error("Commit failed", err.body);
|
||||
setEntityStatus(`Commit thất bại: ${err.body}`);
|
||||
return;
|
||||
}
|
||||
console.error("Save error", err);
|
||||
setEntityStatus("Save thất bại.");
|
||||
console.error("Commit error", err);
|
||||
setEntityStatus("Commit thất bại.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -623,42 +687,6 @@ export default function Page() {
|
||||
setEntityFormStatus(null);
|
||||
};
|
||||
|
||||
const handleAddSelectedBindingGeometry = () => {
|
||||
const geometryId = selectedBindingGeometryId ? selectedBindingGeometryId.trim() : "";
|
||||
if (!geometryId.length) {
|
||||
setEntityFormStatus("Hãy chọn một geometry từ kết quả search binding trước.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedFeature && String(selectedFeature.properties.id) === geometryId) {
|
||||
setEntityFormStatus("Không thể tự bind geometry với chính nó.");
|
||||
return;
|
||||
}
|
||||
|
||||
const currentBindingIds = parseBindingInput(geometryMetaForm.binding);
|
||||
const nextBindingIds = uniqueEntityIds([...currentBindingIds, geometryId]);
|
||||
setGeometryMetaForm((prev) => ({
|
||||
...prev,
|
||||
binding: nextBindingIds.join(", "),
|
||||
}));
|
||||
setSelectedBindingGeometryId(null);
|
||||
setEntityFormStatus(null);
|
||||
};
|
||||
|
||||
const reloadEntities = async () => {
|
||||
const rows = await fetchEntities();
|
||||
setPersistedEntities(rows);
|
||||
return rows;
|
||||
};
|
||||
|
||||
const reloadCurrentTimelineData = async () => {
|
||||
const data = await fetchGeometriesByBBox({
|
||||
...WORLD_BBOX,
|
||||
time: timelineYear,
|
||||
});
|
||||
setInitialData(data);
|
||||
};
|
||||
|
||||
const parseGeometryMetaFormRange = () => {
|
||||
const timeStart = parseOptionalYearInput(geometryMetaForm.time_start, "time_start");
|
||||
const timeEnd = parseOptionalYearInput(geometryMetaForm.time_end, "time_end");
|
||||
@@ -678,12 +706,28 @@ export default function Page() {
|
||||
return primaryEntity?.type_id || null;
|
||||
};
|
||||
|
||||
const patchSelectedFeatureLocally = (
|
||||
const patchSelectedGeometryMetadataLocally = (
|
||||
feature: Feature,
|
||||
entityIds: string[],
|
||||
timeStart: number | null,
|
||||
timeEnd: number | null,
|
||||
bindingIds: string[],
|
||||
bindingIds: string[]
|
||||
) => {
|
||||
editor.patchFeatureProperties(feature.properties.id, {
|
||||
time_start: timeStart,
|
||||
time_end: timeEnd,
|
||||
binding: bindingIds,
|
||||
});
|
||||
|
||||
setGeometryMetaForm({
|
||||
time_start: timeStart != null ? String(timeStart) : "",
|
||||
time_end: timeEnd != null ? String(timeEnd) : "",
|
||||
binding: bindingIds.join(", "),
|
||||
});
|
||||
};
|
||||
|
||||
const patchSelectedFeatureEntitiesLocally = (
|
||||
feature: Feature,
|
||||
entityIds: string[],
|
||||
entityRows: Entity[] = entities
|
||||
) => {
|
||||
const primaryEntityId = entityIds[0] || null;
|
||||
@@ -702,31 +746,17 @@ export default function Page() {
|
||||
entity_name: primaryEntity?.name || null,
|
||||
entity_names: entityNames,
|
||||
entity_type_id: primaryEntity?.type_id || null,
|
||||
time_start: timeStart,
|
||||
time_end: timeEnd,
|
||||
binding: bindingIds,
|
||||
});
|
||||
|
||||
setSelectedGeometryEntityIds(entityIds);
|
||||
setGeometryMetaForm({
|
||||
time_start: timeStart != null ? String(timeStart) : "",
|
||||
time_end: timeEnd != null ? String(timeEnd) : "",
|
||||
binding: bindingIds.join(", "),
|
||||
});
|
||||
};
|
||||
|
||||
const handleApplyEntitiesForSelectedGeometry = async () => {
|
||||
const handleApplyGeometryMetadata = async () => {
|
||||
if (!selectedFeature) {
|
||||
setEntityFormStatus("Hãy chọn một geometry trước.");
|
||||
return;
|
||||
}
|
||||
|
||||
const entityIds = uniqueEntityIds(selectedGeometryEntityIds);
|
||||
if (!entityIds.length) {
|
||||
setEntityFormStatus("Geometry phải có ít nhất 1 entity.");
|
||||
return;
|
||||
}
|
||||
|
||||
let timeStart: number | null;
|
||||
let timeEnd: number | null;
|
||||
let bindingIds: string[];
|
||||
@@ -741,34 +771,25 @@ export default function Page() {
|
||||
setIsEntitySubmitting(true);
|
||||
setEntityFormStatus(null);
|
||||
try {
|
||||
const nextGeometryType = resolveGeometryTypeFromEntityIds(entityIds) || selectedFeature.properties.type || null;
|
||||
if (editor.hasPersistedFeature(selectedFeature.properties.id)) {
|
||||
const pendingEntityIdSet = new Set(pendingEntityCreates.map((entity) => entity.id));
|
||||
const hasPendingEntity = entityIds.some((entityId) => pendingEntityIdSet.has(entityId));
|
||||
if (hasPendingEntity) {
|
||||
setEntityFormStatus("Danh sách gắn có entity chưa lưu. Hãy bấm Save trước, rồi áp dụng lại.");
|
||||
return;
|
||||
patchSelectedGeometryMetadataLocally(selectedFeature, timeStart, timeEnd, bindingIds);
|
||||
setEntityFormStatus("Đã cập nhật thuộc tính GEO. Commit khi sẵn sàng.");
|
||||
} finally {
|
||||
setIsEntitySubmitting(false);
|
||||
}
|
||||
if (editor.changeCount > 0) {
|
||||
setEntityFormStatus("Hãy Save/Undo hết thay đổi bản đồ trước khi cập nhật geometry đã tồn tại.");
|
||||
};
|
||||
|
||||
const handleApplyEntitiesForSelectedGeometry = async () => {
|
||||
if (!selectedFeature) {
|
||||
setEntityFormStatus("Hãy chọn một geometry trước.");
|
||||
return;
|
||||
}
|
||||
|
||||
await updateGeometry(selectedFeature.properties.id, {
|
||||
geometry: selectedFeature.geometry,
|
||||
type: nextGeometryType ?? undefined,
|
||||
time_start: timeStart,
|
||||
time_end: timeEnd,
|
||||
binding: bindingIds,
|
||||
entity_ids: entityIds,
|
||||
});
|
||||
|
||||
await reloadCurrentTimelineData();
|
||||
setEntityFormStatus("Đã cập nhật entities + metadata geometry.");
|
||||
} else {
|
||||
patchSelectedFeatureLocally(selectedFeature, entityIds, timeStart, timeEnd, bindingIds);
|
||||
setEntityFormStatus("Đã cập nhật local. Bấm Save để lưu geometry mới.");
|
||||
}
|
||||
const entityIds = uniqueEntityIds(selectedGeometryEntityIds);
|
||||
setIsEntitySubmitting(true);
|
||||
setEntityFormStatus(null);
|
||||
try {
|
||||
patchSelectedFeatureEntitiesLocally(selectedFeature, entityIds);
|
||||
setEntityFormStatus("Đã cập nhật danh sách entity. Commit khi sẵn sàng.");
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setEntityFormStatus(`Lưu thất bại: ${err.body}`);
|
||||
@@ -837,7 +858,7 @@ export default function Page() {
|
||||
slug: "",
|
||||
}));
|
||||
setEntityStatus(null);
|
||||
setEntityFormStatus("Đã thêm entity mới vào danh sách chờ Save.");
|
||||
setEntityFormStatus("Đã thêm entity mới vào danh sách chờ Commit.");
|
||||
|
||||
if (selectedFeature) {
|
||||
setEntitySearchQuery(pendingCreate.name);
|
||||
@@ -848,11 +869,142 @@ export default function Page() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenSelectedSection = async () => {
|
||||
const sectionId = selectedSectionId.trim();
|
||||
if (!sectionId) {
|
||||
setEntityStatus("Hãy chọn section để mở.");
|
||||
return;
|
||||
}
|
||||
if (pendingSaveCount > 0) {
|
||||
const confirmed = window.confirm("Section hiện tại có thay đổi chưa Commit. Mở section khác sẽ bỏ các thay đổi này. Tiếp tục?");
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
setIsOpeningSection(true);
|
||||
setEntityStatus(null);
|
||||
try {
|
||||
await openSectionForEditing(sectionId);
|
||||
setEntityStatus("Đã mở section để chỉnh sửa.");
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setEntityStatus(`Mở section thất bại: ${err.body}`);
|
||||
} else {
|
||||
setEntityStatus("Mở section thất bại.");
|
||||
}
|
||||
} finally {
|
||||
setIsOpeningSection(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateAndOpenSection = async () => {
|
||||
const title = newSectionTitle.trim();
|
||||
if (!title) {
|
||||
setEntityStatus("Tên section là bắt buộc.");
|
||||
return;
|
||||
}
|
||||
if (pendingSaveCount > 0) {
|
||||
const confirmed = window.confirm("Section hiện tại có thay đổi chưa Commit. Tạo section mới sẽ bỏ các thay đổi này. Tiếp tục?");
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
setIsOpeningSection(true);
|
||||
setEntityStatus(null);
|
||||
try {
|
||||
const section = await createSection({
|
||||
title,
|
||||
user_id: editorUserId,
|
||||
created_by: editorUserId,
|
||||
});
|
||||
const sections = await fetchSections();
|
||||
setAvailableSections(sections);
|
||||
setNewSectionTitle("");
|
||||
await openSectionForEditing(section.id);
|
||||
setEntityStatus("Đã tạo và mở section mới.");
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setEntityStatus(`Tạo section thất bại: ${err.body}`);
|
||||
} else {
|
||||
setEntityStatus("Tạo section thất bại.");
|
||||
}
|
||||
} finally {
|
||||
setIsOpeningSection(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitSection = async () => {
|
||||
if (!activeSection || !sectionState?.head_commit_id) {
|
||||
setEntityStatus("Chưa có commit để submit.");
|
||||
return;
|
||||
}
|
||||
if (pendingSaveCount > 0) {
|
||||
setEntityStatus("Hãy Commit các thay đổi trước khi Submit.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setEntityStatus(null);
|
||||
try {
|
||||
const submission = await submitSection(activeSection.id, {
|
||||
submitted_by: editorUserId,
|
||||
commit_id: sectionState.head_commit_id,
|
||||
});
|
||||
setSectionState((prev) => prev ? { ...prev, status: "submitted" } : prev);
|
||||
setEntityStatus(`Đã submit section, submission ${submission.id}.`);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setEntityStatus(`Submit thất bại: ${err.body}`);
|
||||
} else {
|
||||
setEntityStatus("Submit thất bại.");
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreCommit = async (commitId: string) => {
|
||||
if (!activeSection || !sectionState) {
|
||||
setEntityStatus("Chưa mở được section editor.");
|
||||
return;
|
||||
}
|
||||
if (pendingSaveCount > 0) {
|
||||
setEntityStatus("Hãy Commit hoặc Undo thay đổi hiện tại trước khi Restore.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setEntityStatus(null);
|
||||
try {
|
||||
const result = await restoreSectionCommit(activeSection.id, {
|
||||
commit_id: commitId,
|
||||
created_by: editorUserId,
|
||||
expected_version: sectionState.version,
|
||||
expected_head_commit_id: sectionState.head_commit_id,
|
||||
});
|
||||
const editorPayload = await openSectionEditor(activeSection.id, editorUserId);
|
||||
const snapshot = normalizeEditorSnapshot(editorPayload.snapshot);
|
||||
setSectionState(result.state);
|
||||
setLastSectionSnapshot(snapshot);
|
||||
if (snapshot?.editor_feature_collection) {
|
||||
setInitialData(snapshot.editor_feature_collection);
|
||||
}
|
||||
setSectionCommits(await fetchSectionCommits(activeSection.id));
|
||||
setEntityFormStatus(`Đã restore thành commit #${result.commit.commit_no}.`);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setEntityStatus(`Restore thất bại: ${err.body}`);
|
||||
} else {
|
||||
setEntityStatus("Restore thất bại.");
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pendingSaveCount = editor.changeCount + pendingEntityCreates.length;
|
||||
const timelineDisabled = !isTimelineReady || isSaving || pendingSaveCount > 0;
|
||||
const timelineStatusText =
|
||||
pendingSaveCount > 0
|
||||
? "Lưu hết thay đổi trước khi đổi mốc thời gian."
|
||||
? "Commit hoặc Undo hết thay đổi trước khi đổi mốc thời gian."
|
||||
: isSaving
|
||||
? "Đang lưu thay đổi..."
|
||||
: timelineStatus;
|
||||
@@ -869,8 +1021,30 @@ export default function Page() {
|
||||
setMode={setMode}
|
||||
entityStatus={entityStatus}
|
||||
onUndo={editor.undo}
|
||||
onSave={handleSave}
|
||||
onCommit={handleCommitSection}
|
||||
onSubmit={handleSubmitSection}
|
||||
onRestoreCommit={handleRestoreCommit}
|
||||
isSaving={isSaving}
|
||||
isSubmitting={isSubmitting}
|
||||
isOpeningSection={isOpeningSection}
|
||||
sectionTitle={activeSection?.title || "Đang tải section"}
|
||||
sectionStatus={sectionState?.status || "editing"}
|
||||
selectedSectionId={selectedSectionId}
|
||||
editorUserId={editorUserIdInput}
|
||||
sectionOptions={availableSections}
|
||||
newSectionTitle={newSectionTitle}
|
||||
commitTitle={commitTitle}
|
||||
commitNote={commitNote}
|
||||
onEditorUserIdChange={setEditorUserIdInput}
|
||||
onSelectedSectionIdChange={setSelectedSectionId}
|
||||
onNewSectionTitleChange={setNewSectionTitle}
|
||||
onCommitTitleChange={setCommitTitle}
|
||||
onCommitNoteChange={setCommitNote}
|
||||
onOpenSection={handleOpenSelectedSection}
|
||||
onCreateSection={handleCreateAndOpenSection}
|
||||
commitCount={sectionCommits.length}
|
||||
latestCommitLabel={sectionCommits[0] ? `Head #${sectionCommits[0].commit_no}` : null}
|
||||
commits={sectionCommits}
|
||||
changesCount={pendingSaveCount}
|
||||
undoStack={editor.undoStack}
|
||||
createdEntities={createdEntities}
|
||||
@@ -940,14 +1114,9 @@ export default function Page() {
|
||||
entityTypeOptions={ENTITY_TYPE_OPTIONS}
|
||||
geometryMetaForm={geometryMetaForm}
|
||||
onGeometryMetaFormChange={handleGeometryMetaFormChange}
|
||||
bindingGeometrySearchQuery={bindingGeometrySearchQuery}
|
||||
onBindingGeometrySearchQueryChange={setBindingGeometrySearchQuery}
|
||||
bindingGeometrySearchResults={bindingGeometrySearchResults}
|
||||
selectedBindingGeometryId={selectedBindingGeometryId}
|
||||
onSelectBindingGeometryId={setSelectedBindingGeometryId}
|
||||
onAddSelectedBindingGeometry={handleAddSelectedBindingGeometry}
|
||||
isEntitySubmitting={isEntitySubmitting}
|
||||
onCreateEntityOnly={handleCreateEntityOnly}
|
||||
onApplyGeometryMetadata={handleApplyGeometryMetadata}
|
||||
onApplyEntitiesForSelectedGeometry={handleApplyEntitiesForSelectedGeometry}
|
||||
changeCount={editor.changeCount}
|
||||
entityFormStatus={entityFormStatus}
|
||||
@@ -958,6 +1127,233 @@ export default function Page() {
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveDefaultSection(existingSections?: Section[], userId = DEFAULT_EDITOR_USER_ID): Promise<Section> {
|
||||
const sections = existingSections || await fetchSections();
|
||||
|
||||
const existingDefault = sections.find((section) => section.title === DEFAULT_SECTION_TITLE);
|
||||
if (existingDefault) {
|
||||
return existingDefault;
|
||||
}
|
||||
|
||||
return createSection({
|
||||
title: DEFAULT_SECTION_TITLE,
|
||||
user_id: userId,
|
||||
created_by: userId,
|
||||
description: "Default section used by the map editor UI.",
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveOpenableSectionAfterConflict(
|
||||
sections: Section[],
|
||||
blockedSectionId: string,
|
||||
userId: string
|
||||
): Promise<Section> {
|
||||
const now = Date.now();
|
||||
const fallback = sections.find((section) =>
|
||||
section.id !== blockedSectionId &&
|
||||
section.state.status === "editing" &&
|
||||
(
|
||||
!section.state.locked_by ||
|
||||
section.state.locked_by === userId ||
|
||||
(
|
||||
section.state.lock_expires_at !== null &&
|
||||
Date.parse(section.state.lock_expires_at) <= now
|
||||
)
|
||||
)
|
||||
);
|
||||
if (fallback) return fallback;
|
||||
|
||||
return createSection({
|
||||
title: `${DEFAULT_SECTION_TITLE} (${userId})`,
|
||||
user_id: userId,
|
||||
created_by: userId,
|
||||
description: "Fallback section created because the default section is locked.",
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeEditorUserId(value: string): string {
|
||||
const normalized = value.trim();
|
||||
return normalized || DEFAULT_EDITOR_USER_ID;
|
||||
}
|
||||
|
||||
function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const snapshot = raw as EditorSnapshot;
|
||||
if (
|
||||
snapshot.editor_feature_collection &&
|
||||
snapshot.editor_feature_collection.type === "FeatureCollection" &&
|
||||
Array.isArray(snapshot.editor_feature_collection.features)
|
||||
) {
|
||||
return snapshot;
|
||||
}
|
||||
return {
|
||||
...snapshot,
|
||||
editor_feature_collection: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEditorSnapshot(options: {
|
||||
section: Section;
|
||||
draft: FeatureCollection;
|
||||
changes: Change[];
|
||||
pendingEntities: PendingEntityCreate[];
|
||||
previousSnapshot: EditorSnapshot | null;
|
||||
hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean;
|
||||
}): EditorSnapshot {
|
||||
const changedIds = new Set(options.changes.map((change) => String(change.action === "create" ? change.feature.properties.id : change.id)));
|
||||
const deletedIds = new Set(
|
||||
options.changes
|
||||
.filter((change): change is Extract<Change, { action: "delete" }> => change.action === "delete")
|
||||
.map((change) => String(change.id))
|
||||
);
|
||||
const currentDraftIds = new Set(options.draft.features.map((feature) => String(feature.properties.id)));
|
||||
const previousFeatures = new globalThis.Map<string, Feature>();
|
||||
for (const feature of options.previousSnapshot?.editor_feature_collection?.features || []) {
|
||||
previousFeatures.set(String(feature.properties.id), feature);
|
||||
if (!currentDraftIds.has(String(feature.properties.id))) {
|
||||
deletedIds.add(String(feature.properties.id));
|
||||
}
|
||||
}
|
||||
const previousGeometryOps = new globalThis.Map<string, string>();
|
||||
for (const item of options.previousSnapshot?.geometries || []) {
|
||||
const id = typeof item.id === "string" || typeof item.id === "number" ? String(item.id) : "";
|
||||
const operation = typeof item.operation === "string" ? item.operation : "";
|
||||
if (id && operation) previousGeometryOps.set(id, operation);
|
||||
}
|
||||
|
||||
const pendingEntityIds = new Set(options.pendingEntities.map((entity) => entity.id));
|
||||
const entityRows = new globalThis.Map<string, Record<string, unknown>>();
|
||||
for (const item of options.previousSnapshot?.entities || []) {
|
||||
const id = typeof item.id === "string" || typeof item.id === "number" ? String(item.id) : "";
|
||||
if (id) entityRows.set(id, { ...item });
|
||||
}
|
||||
for (const entity of options.pendingEntities) {
|
||||
entityRows.set(entity.id, {
|
||||
id: entity.id,
|
||||
operation: "create",
|
||||
name: entity.name,
|
||||
slug: entity.slug,
|
||||
description: null,
|
||||
type_id: entity.type_id,
|
||||
status: entity.status,
|
||||
is_deleted: 0,
|
||||
});
|
||||
}
|
||||
|
||||
for (const feature of options.draft.features) {
|
||||
for (const entityId of normalizeFeatureEntityIds(feature)) {
|
||||
if (entityRows.has(entityId)) continue;
|
||||
entityRows.set(entityId, {
|
||||
id: entityId,
|
||||
operation: "reference",
|
||||
name: feature.properties.entity_names?.[0] || feature.properties.entity_name || entityId,
|
||||
slug: null,
|
||||
description: null,
|
||||
type_id: feature.properties.entity_type_id || feature.properties.type || DEFAULT_ENTITY_TYPE_ID,
|
||||
status: 1,
|
||||
is_deleted: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const geometries: Array<Record<string, unknown>> = options.draft.features.map((feature) => {
|
||||
const id = String(feature.properties.id);
|
||||
const previousOperation = previousGeometryOps.get(id);
|
||||
const previousFeature = previousFeatures.get(id);
|
||||
const changedFromPreviousSnapshot = previousFeature
|
||||
? JSON.stringify(previousFeature) !== JSON.stringify(feature)
|
||||
: false;
|
||||
const operation = previousOperation === "create"
|
||||
? "create"
|
||||
: !previousFeature && (options.previousSnapshot || !options.hasPersistedFeature(feature.properties.id))
|
||||
? "create"
|
||||
: changedIds.has(id) || changedFromPreviousSnapshot
|
||||
? "update"
|
||||
: "reference";
|
||||
const bbox = getFeatureBBox(feature);
|
||||
return {
|
||||
id,
|
||||
operation,
|
||||
type: feature.properties.type || getDefaultTypeIdForFeature(feature),
|
||||
draw_geometry: feature.geometry,
|
||||
binding: normalizeFeatureBindingIds(feature),
|
||||
time_start: feature.properties.time_start ?? null,
|
||||
time_end: feature.properties.time_end ?? null,
|
||||
bbox: bbox
|
||||
? {
|
||||
min_lng: bbox.minLng,
|
||||
min_lat: bbox.minLat,
|
||||
max_lng: bbox.maxLng,
|
||||
max_lat: bbox.maxLat,
|
||||
}
|
||||
: null,
|
||||
is_deleted: 0,
|
||||
};
|
||||
});
|
||||
|
||||
for (const id of deletedIds) {
|
||||
geometries.push({
|
||||
id,
|
||||
operation: "delete",
|
||||
is_deleted: 1,
|
||||
});
|
||||
}
|
||||
|
||||
const linkScopes = options.draft.features
|
||||
.map((feature) => ({
|
||||
geometry_id: String(feature.properties.id),
|
||||
operation: "replace",
|
||||
entity_ids: normalizeFeatureEntityIds(feature),
|
||||
}))
|
||||
.filter((scope) => scope.entity_ids.length > 0);
|
||||
|
||||
return {
|
||||
schema_version: 1,
|
||||
section: {
|
||||
id: options.section.id,
|
||||
title: options.section.title,
|
||||
},
|
||||
editor_feature_collection: JSON.parse(JSON.stringify(options.draft)) as FeatureCollection,
|
||||
entities: Array.from(entityRows.values()).map((entity) => {
|
||||
const id = String(entity.id || "");
|
||||
if (pendingEntityIds.has(id)) return entity;
|
||||
return entity;
|
||||
}),
|
||||
geometries,
|
||||
link_scopes: linkScopes,
|
||||
};
|
||||
}
|
||||
|
||||
function getFeatureBBox(feature: Feature): { minLng: number; minLat: number; maxLng: number; maxLat: number } | null {
|
||||
const points = collectCoordinatePairs(feature.geometry.coordinates);
|
||||
if (!points.length) return null;
|
||||
let minLng = Number.POSITIVE_INFINITY;
|
||||
let minLat = Number.POSITIVE_INFINITY;
|
||||
let maxLng = Number.NEGATIVE_INFINITY;
|
||||
let maxLat = Number.NEGATIVE_INFINITY;
|
||||
for (const [lng, lat] of points) {
|
||||
minLng = Math.min(minLng, lng);
|
||||
minLat = Math.min(minLat, lat);
|
||||
maxLng = Math.max(maxLng, lng);
|
||||
maxLat = Math.max(maxLat, lat);
|
||||
}
|
||||
return { minLng, minLat, maxLng, maxLat };
|
||||
}
|
||||
|
||||
function collectCoordinatePairs(value: unknown): Array<[number, number]> {
|
||||
if (!Array.isArray(value)) return [];
|
||||
if (
|
||||
value.length >= 2 &&
|
||||
typeof value[0] === "number" &&
|
||||
typeof value[1] === "number" &&
|
||||
Number.isFinite(value[0]) &&
|
||||
Number.isFinite(value[1])
|
||||
) {
|
||||
return [[value[0], value[1]]];
|
||||
}
|
||||
return value.flatMap((item) => collectCoordinatePairs(item));
|
||||
}
|
||||
|
||||
function deriveTimelineRange(collection: FeatureCollection): TimelineRange {
|
||||
let min = Number.POSITIVE_INFINITY;
|
||||
let max = Number.NEGATIVE_INFINITY;
|
||||
|
||||
@@ -9,8 +9,42 @@ type Props = {
|
||||
setMode: (mode: Mode) => void;
|
||||
entityStatus?: string | null;
|
||||
onUndo: () => void;
|
||||
onSave: () => void;
|
||||
onCommit: () => void;
|
||||
onSubmit: () => void;
|
||||
onRestoreCommit: (commitId: string) => void;
|
||||
isSaving: boolean;
|
||||
isSubmitting: boolean;
|
||||
isOpeningSection: boolean;
|
||||
sectionTitle: string;
|
||||
sectionStatus: string;
|
||||
selectedSectionId: string;
|
||||
editorUserId: string;
|
||||
sectionOptions: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
state?: {
|
||||
status?: string;
|
||||
};
|
||||
}>;
|
||||
newSectionTitle: string;
|
||||
commitTitle: string;
|
||||
commitNote: string;
|
||||
onEditorUserIdChange: (userId: string) => void;
|
||||
onSelectedSectionIdChange: (sectionId: string) => void;
|
||||
onNewSectionTitleChange: (title: string) => void;
|
||||
onCommitTitleChange: (title: string) => void;
|
||||
onCommitNoteChange: (note: string) => void;
|
||||
onOpenSection: () => void;
|
||||
onCreateSection: () => void;
|
||||
commitCount: number;
|
||||
latestCommitLabel: string | null;
|
||||
commits: Array<{
|
||||
id: string;
|
||||
commit_no: number;
|
||||
kind: string;
|
||||
created_at: string;
|
||||
title: string | null;
|
||||
}>;
|
||||
changesCount: number;
|
||||
undoStack: UndoAction[];
|
||||
createdEntities: Array<{
|
||||
@@ -31,8 +65,30 @@ export default function Editor({
|
||||
setMode,
|
||||
entityStatus,
|
||||
onUndo,
|
||||
onSave,
|
||||
onCommit,
|
||||
onSubmit,
|
||||
onRestoreCommit,
|
||||
isSaving,
|
||||
isSubmitting,
|
||||
isOpeningSection,
|
||||
sectionTitle,
|
||||
sectionStatus,
|
||||
selectedSectionId,
|
||||
editorUserId,
|
||||
sectionOptions,
|
||||
newSectionTitle,
|
||||
commitTitle,
|
||||
commitNote,
|
||||
onEditorUserIdChange,
|
||||
onSelectedSectionIdChange,
|
||||
onNewSectionTitleChange,
|
||||
onCommitTitleChange,
|
||||
onCommitNoteChange,
|
||||
onOpenSection,
|
||||
onCreateSection,
|
||||
commitCount,
|
||||
latestCommitLabel,
|
||||
commits,
|
||||
changesCount,
|
||||
undoStack,
|
||||
createdEntities,
|
||||
@@ -83,6 +139,124 @@ export default function Editor({
|
||||
}}
|
||||
>
|
||||
<h3 style={{ marginBottom: "10px" }}>Editor</h3>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "12px",
|
||||
padding: "10px",
|
||||
background: "#0b1220",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #1f2937",
|
||||
fontSize: "12px",
|
||||
color: "#cbd5e1",
|
||||
}}
|
||||
>
|
||||
<div style={{ color: "white", fontWeight: 600 }}>{sectionTitle}</div>
|
||||
<div style={{ marginTop: "4px" }}>Status: {sectionStatus}</div>
|
||||
<div>Commits: {commitCount}</div>
|
||||
<div>{latestCommitLabel || "Chưa có commit"}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "12px",
|
||||
padding: "10px",
|
||||
background: "#0b1220",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #1f2937",
|
||||
fontSize: "12px",
|
||||
color: "#cbd5e1",
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: "8px", fontWeight: 600, color: "white" }}>
|
||||
Section
|
||||
</div>
|
||||
<input
|
||||
value={editorUserId}
|
||||
onChange={(event) => onEditorUserIdChange(event.target.value)}
|
||||
placeholder="User ID"
|
||||
style={{
|
||||
width: "100%",
|
||||
marginBottom: "8px",
|
||||
padding: "7px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #334155",
|
||||
background: "#111827",
|
||||
color: "white",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
disabled={isOpeningSection}
|
||||
/>
|
||||
<select
|
||||
value={selectedSectionId}
|
||||
onChange={(event) => onSelectedSectionIdChange(event.target.value)}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "7px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #334155",
|
||||
background: "#111827",
|
||||
color: "white",
|
||||
}}
|
||||
disabled={isOpeningSection}
|
||||
>
|
||||
{sectionOptions.length === 0 ? (
|
||||
<option value="">Chưa có section</option>
|
||||
) : null}
|
||||
{sectionOptions.map((section) => (
|
||||
<option key={section.id} value={section.id}>
|
||||
{section.title} {section.state?.status ? `(${section.state.status})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
style={{
|
||||
width: "100%",
|
||||
marginTop: "8px",
|
||||
padding: "8px",
|
||||
borderRadius: "4px",
|
||||
border: "none",
|
||||
cursor: isOpeningSection || !selectedSectionId ? "not-allowed" : "pointer",
|
||||
background: isOpeningSection || !selectedSectionId ? "#555" : "#2563eb",
|
||||
color: "white",
|
||||
}}
|
||||
onClick={onOpenSection}
|
||||
disabled={isOpeningSection || !selectedSectionId}
|
||||
>
|
||||
Mở section
|
||||
</button>
|
||||
<input
|
||||
value={newSectionTitle}
|
||||
onChange={(event) => onNewSectionTitleChange(event.target.value)}
|
||||
placeholder="Tên section mới"
|
||||
style={{
|
||||
width: "100%",
|
||||
marginTop: "10px",
|
||||
padding: "7px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #334155",
|
||||
background: "#111827",
|
||||
color: "white",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
disabled={isOpeningSection}
|
||||
/>
|
||||
<button
|
||||
style={{
|
||||
width: "100%",
|
||||
marginTop: "8px",
|
||||
padding: "8px",
|
||||
borderRadius: "4px",
|
||||
border: "none",
|
||||
cursor: isOpeningSection || !newSectionTitle.trim() ? "not-allowed" : "pointer",
|
||||
background: isOpeningSection || !newSectionTitle.trim() ? "#555" : "#0f766e",
|
||||
color: "white",
|
||||
}}
|
||||
onClick={onCreateSection}
|
||||
disabled={isOpeningSection || !newSectionTitle.trim()}
|
||||
>
|
||||
Tạo và mở section
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
style={getButtonStyle("draw")}
|
||||
@@ -168,10 +342,10 @@ export default function Editor({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={{ marginTop: "12px", display: "flex", gap: "8px" }}>
|
||||
<div style={{ marginTop: "12px" }}>
|
||||
<button
|
||||
style={{
|
||||
flex: 1,
|
||||
width: "100%",
|
||||
padding: "8px",
|
||||
borderRadius: "4px",
|
||||
border: "none",
|
||||
@@ -183,22 +357,125 @@ export default function Editor({
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
value={commitTitle}
|
||||
onChange={(event) => onCommitTitleChange(event.target.value)}
|
||||
placeholder="Commit title"
|
||||
disabled={isSaving || isSubmitting || sectionStatus === "submitted"}
|
||||
style={{
|
||||
width: "100%",
|
||||
marginTop: "8px",
|
||||
padding: "7px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #334155",
|
||||
background: "#111827",
|
||||
color: "white",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
<textarea
|
||||
value={commitNote}
|
||||
onChange={(event) => onCommitNoteChange(event.target.value)}
|
||||
placeholder="Commit note"
|
||||
disabled={isSaving || isSubmitting || sectionStatus === "submitted"}
|
||||
rows={3}
|
||||
style={{
|
||||
width: "100%",
|
||||
marginTop: "8px",
|
||||
padding: "7px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #334155",
|
||||
background: "#111827",
|
||||
color: "white",
|
||||
boxSizing: "border-box",
|
||||
resize: "vertical",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
style={{
|
||||
flex: 1,
|
||||
width: "100%",
|
||||
marginTop: "8px",
|
||||
padding: "8px",
|
||||
borderRadius: "4px",
|
||||
border: "none",
|
||||
cursor: isSaving ? "not-allowed" : "pointer",
|
||||
background: isSaving ? "#555" : "#3b82f6",
|
||||
cursor: isSaving || isSubmitting || sectionStatus === "submitted" ? "not-allowed" : "pointer",
|
||||
background: isSaving || isSubmitting || sectionStatus === "submitted" ? "#555" : "#0f766e",
|
||||
color: "white",
|
||||
opacity: changesCount === 0 ? 0.6 : 1,
|
||||
}}
|
||||
onClick={onSave}
|
||||
disabled={isSaving || changesCount === 0}
|
||||
onClick={onCommit}
|
||||
disabled={isSaving || isSubmitting || sectionStatus === "submitted"}
|
||||
>
|
||||
Save ({changesCount})
|
||||
Commit ({changesCount})
|
||||
</button>
|
||||
<button
|
||||
style={{
|
||||
width: "100%",
|
||||
marginTop: "8px",
|
||||
padding: "8px",
|
||||
borderRadius: "4px",
|
||||
border: "none",
|
||||
cursor: isSubmitting || commitCount === 0 || sectionStatus === "submitted" ? "not-allowed" : "pointer",
|
||||
background: isSubmitting || commitCount === 0 || sectionStatus === "submitted" ? "#555" : "#16a34a",
|
||||
color: "white",
|
||||
opacity: commitCount === 0 ? 0.6 : 1,
|
||||
}}
|
||||
onClick={onSubmit}
|
||||
disabled={isSubmitting || commitCount === 0 || sectionStatus === "submitted"}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: "16px",
|
||||
padding: "10px",
|
||||
background: "#0b1220",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #1f2937",
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: "8px", fontWeight: 600, fontSize: "14px" }}>
|
||||
Commit history
|
||||
</div>
|
||||
{commits.length === 0 ? (
|
||||
<div style={{ color: "#64748b", fontSize: "12px" }}>
|
||||
Chưa có commit
|
||||
</div>
|
||||
) : (
|
||||
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: "12px" }}>
|
||||
{commits.slice(0, 8).map((commit) => (
|
||||
<li
|
||||
key={commit.id}
|
||||
style={{
|
||||
padding: "6px 0",
|
||||
borderBottom: "1px solid #1f2937",
|
||||
color: "#e2e8f0",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
#{commit.commit_no} {commit.kind}
|
||||
</div>
|
||||
<button
|
||||
style={{
|
||||
marginTop: "4px",
|
||||
padding: "4px 6px",
|
||||
borderRadius: "4px",
|
||||
border: "none",
|
||||
background: "#334155",
|
||||
color: "white",
|
||||
cursor: isSaving || isSubmitting ? "not-allowed" : "pointer",
|
||||
}}
|
||||
onClick={() => onRestoreCommit(commit.id)}
|
||||
disabled={isSaving || isSubmitting}
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -265,11 +542,11 @@ export default function Editor({
|
||||
)}
|
||||
|
||||
<div style={{ fontSize: "13px", color: "#cbd5e1", marginBottom: "6px" }}>
|
||||
Geometries mới chưa lưu ({createdGeometries.length})
|
||||
Geometries mới chưa commit ({createdGeometries.length})
|
||||
</div>
|
||||
{createdGeometries.length === 0 ? (
|
||||
<div style={{ color: "#64748b", fontSize: "12px" }}>
|
||||
Chưa có geometry mới chờ save
|
||||
Chưa có geometry mới chờ commit
|
||||
</div>
|
||||
) : (
|
||||
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: "12px" }}>
|
||||
|
||||
@@ -42,17 +42,9 @@ type Props = {
|
||||
entityTypeOptions: EntityTypeOption[];
|
||||
geometryMetaForm: GeometryMetaFormState;
|
||||
onGeometryMetaFormChange: (key: keyof GeometryMetaFormState, value: string) => void;
|
||||
bindingGeometrySearchQuery: string;
|
||||
onBindingGeometrySearchQueryChange: (value: string) => void;
|
||||
bindingGeometrySearchResults: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
}>;
|
||||
selectedBindingGeometryId: string | null;
|
||||
onSelectBindingGeometryId: (value: string | null) => void;
|
||||
onAddSelectedBindingGeometry: () => void;
|
||||
isEntitySubmitting: boolean;
|
||||
onCreateEntityOnly: () => void;
|
||||
onApplyGeometryMetadata: () => void;
|
||||
onApplyEntitiesForSelectedGeometry: () => void;
|
||||
changeCount: number;
|
||||
entityFormStatus: string | null;
|
||||
@@ -77,14 +69,9 @@ export default function SelectedGeometryPanel({
|
||||
entityTypeOptions,
|
||||
geometryMetaForm,
|
||||
onGeometryMetaFormChange,
|
||||
bindingGeometrySearchQuery,
|
||||
onBindingGeometrySearchQueryChange,
|
||||
bindingGeometrySearchResults,
|
||||
selectedBindingGeometryId,
|
||||
onSelectBindingGeometryId,
|
||||
onAddSelectedBindingGeometry,
|
||||
isEntitySubmitting,
|
||||
onCreateEntityOnly,
|
||||
onApplyGeometryMetadata,
|
||||
onApplyEntitiesForSelectedGeometry,
|
||||
changeCount,
|
||||
entityFormStatus,
|
||||
@@ -187,10 +174,6 @@ export default function SelectedGeometryPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
|
||||
Geometry phải có ít nhất 1 entity để Save.
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
@@ -202,10 +185,10 @@ export default function SelectedGeometryPanel({
|
||||
}}
|
||||
>
|
||||
<div style={{ color: "#e2e8f0", fontWeight: 700, fontSize: "12px" }}>
|
||||
Metadata geometry (chỉ áp dụng khi bind entity)
|
||||
Thuộc tính GEO
|
||||
</div>
|
||||
<div style={{ color: "#94a3b8", fontSize: "11px" }}>
|
||||
`time_start`, `time_end`, `binding` chỉ được áp dụng khi bấm nút bind entity cho geometry.
|
||||
Các giá trị này thuộc về GEO đang chọn, không phụ thuộc entity.
|
||||
</div>
|
||||
<input
|
||||
value={geometryMetaForm.time_start}
|
||||
@@ -221,44 +204,13 @@ export default function SelectedGeometryPanel({
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
<input
|
||||
value={geometryMetaForm.binding}
|
||||
onChange={(event) => onGeometryMetaFormChange("binding", event.target.value)}
|
||||
placeholder="binding ids (vd: geo-id-1, geo-id-2)"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
<input
|
||||
value={bindingGeometrySearchQuery}
|
||||
onChange={(event) =>
|
||||
onBindingGeometrySearchQueryChange(event.target.value)
|
||||
}
|
||||
placeholder="Search geometry để thêm vào binding..."
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
<select
|
||||
value={selectedBindingGeometryId || ""}
|
||||
onChange={(event) =>
|
||||
onSelectBindingGeometryId(event.target.value ? event.target.value : null)
|
||||
}
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
>
|
||||
<option value="">-- Chọn geometry từ kết quả search binding --</option>
|
||||
{bindingGeometrySearchResults.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddSelectedBindingGeometry}
|
||||
onClick={onApplyGeometryMetadata}
|
||||
disabled={isEntitySubmitting}
|
||||
style={secondaryActionButtonStyle}
|
||||
style={primaryGeometryButtonStyle}
|
||||
>
|
||||
Thêm geometry đã chọn vào binding
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -327,13 +279,13 @@ export default function SelectedGeometryPanel({
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Áp dụng danh sách entity + metadata
|
||||
Áp dụng danh sách entity
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{changeCount > 0 ? (
|
||||
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
|
||||
Geometry mới sẽ lưu entity khi bấm Save.
|
||||
Thay đổi sẽ vào lịch sử khi Commit.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -470,6 +422,16 @@ const secondaryActionButtonStyle: CSSProperties = {
|
||||
color: "#ffffff",
|
||||
};
|
||||
|
||||
const primaryGeometryButtonStyle: CSSProperties = {
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
padding: "7px 8px",
|
||||
cursor: "pointer",
|
||||
background: "#0f766e",
|
||||
color: "#ffffff",
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
function resolveFeatureGeometryPreset(feature: Feature): EntityGeometryPreset {
|
||||
const explicitPreset = normalizeGeometryPreset(feature.properties.geometry_preset);
|
||||
if (explicitPreset) return explicitPreset;
|
||||
|
||||
@@ -55,11 +55,17 @@ function geometryEquals(a: Geometry | undefined, b: Geometry | undefined): boole
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
function featureEquals(a: Feature | undefined, b: Feature | undefined): boolean {
|
||||
if (!a || !b) return false;
|
||||
return JSON.stringify(a.geometry) === JSON.stringify(b.geometry) &&
|
||||
JSON.stringify(a.properties) === JSON.stringify(b.properties);
|
||||
}
|
||||
|
||||
// Tạo baseline map id -> geometry từ dữ liệu đã lưu.
|
||||
function buildInitialMap(fc: FeatureCollection) {
|
||||
const map = new Map<FeatureProperties["id"], Geometry>();
|
||||
const map = new Map<FeatureProperties["id"], Feature>();
|
||||
for (const f of fc.features) {
|
||||
map.set(f.properties.id, deepClone(f.geometry));
|
||||
map.set(f.properties.id, deepClone(f));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@@ -67,7 +73,7 @@ function buildInitialMap(fc: FeatureCollection) {
|
||||
// Tính diff giữa draft hiện tại và baseline để sinh payload thay đổi.
|
||||
function diffDraftToInitial(
|
||||
draft: FeatureCollection,
|
||||
initialMap: Map<FeatureProperties["id"], Geometry>
|
||||
initialMap: Map<FeatureProperties["id"], Feature>
|
||||
) {
|
||||
const next = new Map<FeatureProperties["id"], Change>();
|
||||
|
||||
@@ -78,10 +84,10 @@ function diffDraftToInitial(
|
||||
for (const f of draft.features) {
|
||||
const id = f.properties.id;
|
||||
seen.add(id);
|
||||
const initialGeom = initialMap.get(id);
|
||||
if (!initialGeom) {
|
||||
const initialFeature = initialMap.get(id);
|
||||
if (!initialFeature) {
|
||||
next.set(id, { action: "create", feature: deepClone(f) });
|
||||
} else if (!geometryEquals(initialGeom, f.geometry)) {
|
||||
} else if (!featureEquals(initialFeature, f)) {
|
||||
next.set(id, { action: "update", id, geometry: deepClone(f.geometry) });
|
||||
}
|
||||
}
|
||||
@@ -133,7 +139,7 @@ export function useEditorState(initialData: FeatureCollection) {
|
||||
const [undoStack, setUndoStack] = useState<UndoAction[]>([]);
|
||||
|
||||
// baseline to know what is "saved" state
|
||||
const initialMapRef = useRef<Map<FeatureProperties["id"], Geometry>>(
|
||||
const initialMapRef = useRef<Map<FeatureProperties["id"], Feature>>(
|
||||
buildInitialMap(initialData)
|
||||
);
|
||||
const draftRef = useRef<FeatureCollection>(deepClone(initialData));
|
||||
|
||||
Reference in New Issue
Block a user