refactor(important): reduce editor drill state
This commit is contained in:
Generated
+31
-1
@@ -40,7 +40,8 @@
|
|||||||
"swiper": "^11.2.10",
|
"swiper": "^11.2.10",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"yet-another-react-lightbox": "^3.30.1"
|
"yet-another-react-lightbox": "^3.30.1",
|
||||||
|
"zustand": "^5.0.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
@@ -10107,6 +10108,35 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^3.25.0 || ^4.0.0"
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zustand": {
|
||||||
|
"version": "5.0.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz",
|
||||||
|
"integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=18.0.0",
|
||||||
|
"immer": ">=9.0.6",
|
||||||
|
"react": ">=18.0.0",
|
||||||
|
"use-sync-external-store": ">=1.2.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"immer": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"use-sync-external-store": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -41,7 +41,8 @@
|
|||||||
"swiper": "^11.2.10",
|
"swiper": "^11.2.10",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"yet-another-react-lightbox": "^3.30.1"
|
"yet-another-react-lightbox": "^3.30.1",
|
||||||
|
"zustand": "^5.0.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
|
|||||||
+158
-194
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction, type PointerEvent as ReactPointerEvent } from "react";
|
import { useCallback, useEffect, useMemo, useRef, type SetStateAction, type PointerEvent as ReactPointerEvent } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import Map from "@/uhm/components/Map";
|
import Map from "@/uhm/components/Map";
|
||||||
import Editor from "@/uhm/components/Editor";
|
import Editor from "@/uhm/components/Editor";
|
||||||
import BackgroundLayersPanel from "@/uhm/components/editor/BackgroundLayersPanel";
|
import BackgroundLayersPanel from "@/uhm/components/editor/BackgroundLayersPanel";
|
||||||
@@ -24,22 +25,7 @@ import {
|
|||||||
Geometry,
|
Geometry,
|
||||||
useEditorState,
|
useEditorState,
|
||||||
} from "@/uhm/lib/editor/state/useEditorState";
|
} from "@/uhm/lib/editor/state/useEditorState";
|
||||||
import { GEO_TYPE_KEYS } from "@/uhm/lib/map/geo/geoTypeMap";
|
import { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
import {
|
|
||||||
BackgroundLayerId,
|
|
||||||
BackgroundLayerVisibility,
|
|
||||||
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
|
||||||
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
|
||||||
} from "@/uhm/lib/map/styles/backgroundLayers";
|
|
||||||
import {
|
|
||||||
GEOMETRY_TYPE_OPTIONS,
|
|
||||||
} from "@/uhm/lib/map/geo/geometryTypeOptions";
|
|
||||||
import {
|
|
||||||
EntityFormState,
|
|
||||||
EditorMode,
|
|
||||||
GeometryMetaFormState,
|
|
||||||
useEditorSessionState,
|
|
||||||
} from "@/uhm/lib/editor/state/useEditorSessionState";
|
|
||||||
import {
|
import {
|
||||||
getDefaultTypeIdForFeature,
|
getDefaultTypeIdForFeature,
|
||||||
normalizeFeatureBindingIds,
|
normalizeFeatureBindingIds,
|
||||||
@@ -53,7 +39,6 @@ import {
|
|||||||
import { buildFeatureEntityPatch } from "@/uhm/lib/editor/entity/entityBinding";
|
import { buildFeatureEntityPatch } from "@/uhm/lib/editor/entity/entityBinding";
|
||||||
import {
|
import {
|
||||||
loadBackgroundLayerVisibilityFromStorage,
|
loadBackgroundLayerVisibilityFromStorage,
|
||||||
persistBackgroundLayerVisibility,
|
|
||||||
} from "@/uhm/lib/editor/background/backgroundVisibilityStorage";
|
} from "@/uhm/lib/editor/background/backgroundVisibilityStorage";
|
||||||
import { useProjectCommands } from "@/uhm/lib/editor/project/useProjectCommands";
|
import { useProjectCommands } from "@/uhm/lib/editor/project/useProjectCommands";
|
||||||
import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants";
|
import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants";
|
||||||
@@ -62,69 +47,55 @@ import { useFeatureCommands } from "./featureCommands";
|
|||||||
import { deleteSubmission } from "@/uhm/api/projects";
|
import { deleteSubmission } from "@/uhm/api/projects";
|
||||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
||||||
import UnifiedSearchBar, { type UnifiedSearchKind } from "@/uhm/components/ui/UnifiedSearchBar";
|
import UnifiedSearchBar from "@/uhm/components/ui/UnifiedSearchBar";
|
||||||
|
import {
|
||||||
|
EditorStoreProvider,
|
||||||
|
useEditorStore,
|
||||||
|
useEditorStoreApi,
|
||||||
|
} from "@/uhm/store/editorStore";
|
||||||
|
|
||||||
const CURRENT_YEAR = new Date().getUTCFullYear();
|
const CURRENT_YEAR = new Date().getUTCFullYear();
|
||||||
const DEFAULT_EDITOR_USER_ID = "local-editor";
|
const DEFAULT_EDITOR_USER_ID = "local-editor";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<EditorStoreProvider
|
||||||
|
options={{
|
||||||
|
emptyFeatureCollection: EMPTY_FEATURE_COLLECTION,
|
||||||
|
defaultEditorUserId: DEFAULT_EDITOR_USER_ID,
|
||||||
|
fallbackTimelineRange: FIXED_TIMELINE_RANGE,
|
||||||
|
currentYear: CURRENT_YEAR,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditorPageContent />
|
||||||
|
</EditorStoreProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditorPageContent() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const editorStoreApi = useEditorStoreApi();
|
||||||
const projectId = String(params.id || "");
|
const projectId = String(params.id || "");
|
||||||
const openedProjectIdRef = useRef<string | null>(null);
|
const openedProjectIdRef = useRef<string | null>(null);
|
||||||
const [blockedPendingSubmissionId, setBlockedPendingSubmissionId] = useState<string | null>(null);
|
|
||||||
const [searchKind, setSearchKind] = useState<UnifiedSearchKind>("entity");
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const [searchQueryDraft, setSearchQueryDraft] = useState("");
|
|
||||||
const [wikiSearchResults, setWikiSearchResults] = useState<Wiki[]>([]);
|
|
||||||
const [isWikiSearching, setIsWikiSearching] = useState(false);
|
|
||||||
const [geoSearchResults, setGeoSearchResults] = useState<EntityGeometriesSearchItem[]>([]);
|
|
||||||
const [isGeoSearching, setIsGeoSearching] = useState(false);
|
|
||||||
const [requestedActiveWikiId, setRequestedActiveWikiId] = useState<string | null>(null);
|
|
||||||
const [leftPanelWidth, setLeftPanelWidth] = useState(280);
|
|
||||||
const [rightPanelWidth, setRightPanelWidth] = useState(420);
|
|
||||||
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 geoBindingStatusTimeoutRef = useRef<number | null>(null);
|
||||||
const [geoBindingStatus, setGeoBindingStatus] = useState<string | null>(null);
|
|
||||||
const [geometryFocusRequest, setGeometryFocusRequest] = useState<{
|
|
||||||
key: number;
|
|
||||||
collection: FeatureCollection;
|
|
||||||
} | null>(null);
|
|
||||||
const localCreatedEntityIdsRef = useRef<Set<string>>(new Set());
|
const localCreatedEntityIdsRef = useRef<Set<string>>(new Set());
|
||||||
const lastSelectedFeatureIdRef = useRef<string | null>(null);
|
const lastSelectedFeatureIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const [replayFeatureId, setReplayFeatureId] = useState<string | number | null>(null);
|
|
||||||
const [hideOutside, setHideOutside] = useState(false);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mode,
|
mode,
|
||||||
setMode: internalSetMode,
|
internalSetMode,
|
||||||
initialData,
|
initialData,
|
||||||
setInitialData,
|
|
||||||
isSaving,
|
isSaving,
|
||||||
setIsSaving,
|
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
setIsSubmitting,
|
|
||||||
isOpeningSection,
|
isOpeningSection,
|
||||||
setIsOpeningSection,
|
setIsOpeningSection,
|
||||||
setAvailableSections,
|
|
||||||
selectedProjectId,
|
|
||||||
setSelectedProjectId,
|
|
||||||
newSectionTitle,
|
|
||||||
setNewSectionTitle,
|
|
||||||
commitTitle,
|
commitTitle,
|
||||||
setCommitTitle,
|
setCommitTitle,
|
||||||
editorUserIdInput,
|
|
||||||
activeSection,
|
activeSection,
|
||||||
setActiveSection,
|
|
||||||
projectState,
|
projectState,
|
||||||
setProjectState,
|
|
||||||
sectionCommits,
|
sectionCommits,
|
||||||
setProjectCommits,
|
|
||||||
baselineSnapshot,
|
baselineSnapshot,
|
||||||
setBaselineSnapshot,
|
|
||||||
entityCatalog,
|
entityCatalog,
|
||||||
setEntityCatalog,
|
setEntityCatalog,
|
||||||
snapshotEntities,
|
snapshotEntities,
|
||||||
@@ -139,9 +110,7 @@ export default function Page() {
|
|||||||
setSelectedGeometryEntityIds,
|
setSelectedGeometryEntityIds,
|
||||||
geometryMetaForm,
|
geometryMetaForm,
|
||||||
setGeometryMetaForm,
|
setGeometryMetaForm,
|
||||||
isEntitySubmitting,
|
|
||||||
setIsEntitySubmitting,
|
setIsEntitySubmitting,
|
||||||
entityFormStatus,
|
|
||||||
setEntityFormStatus,
|
setEntityFormStatus,
|
||||||
entitySearchResults,
|
entitySearchResults,
|
||||||
setEntitySearchResults,
|
setEntitySearchResults,
|
||||||
@@ -157,23 +126,122 @@ export default function Page() {
|
|||||||
setSnapshotWikis,
|
setSnapshotWikis,
|
||||||
snapshotEntityWikiLinks,
|
snapshotEntityWikiLinks,
|
||||||
setSnapshotEntityWikiLinks,
|
setSnapshotEntityWikiLinks,
|
||||||
} = useEditorSessionState({
|
blockedPendingSubmissionId,
|
||||||
emptyFeatureCollection: EMPTY_FEATURE_COLLECTION,
|
setBlockedPendingSubmissionId,
|
||||||
defaultEditorUserId: DEFAULT_EDITOR_USER_ID,
|
searchKind,
|
||||||
fallbackTimelineRange: FIXED_TIMELINE_RANGE,
|
setSearchKind,
|
||||||
currentYear: CURRENT_YEAR,
|
searchQuery,
|
||||||
});
|
setSearchQuery,
|
||||||
|
searchQueryDraft,
|
||||||
|
setSearchQueryDraft,
|
||||||
|
wikiSearchResults,
|
||||||
|
setWikiSearchResults,
|
||||||
|
isWikiSearching,
|
||||||
|
setIsWikiSearching,
|
||||||
|
geoSearchResults,
|
||||||
|
setGeoSearchResults,
|
||||||
|
isGeoSearching,
|
||||||
|
setIsGeoSearching,
|
||||||
|
setRequestedActiveWikiId,
|
||||||
|
leftPanelWidth,
|
||||||
|
setLeftPanelWidth,
|
||||||
|
rightPanelWidth,
|
||||||
|
setRightPanelWidth,
|
||||||
|
timelineFilterEnabled,
|
||||||
|
setTimelineFilterEnabled,
|
||||||
|
geometryBindingFilterEnabled,
|
||||||
|
setGeoBindingStatus,
|
||||||
|
hoveredGeometryId,
|
||||||
|
geometryFocusRequest,
|
||||||
|
setGeometryFocusRequest,
|
||||||
|
replayFeatureId,
|
||||||
|
setReplayFeatureId,
|
||||||
|
hideOutside,
|
||||||
|
setHideOutside,
|
||||||
|
geometryVisibility,
|
||||||
|
} = useEditorStore(useShallow((state) => ({
|
||||||
|
mode: state.mode,
|
||||||
|
internalSetMode: state.setMode,
|
||||||
|
initialData: state.initialData,
|
||||||
|
isSaving: state.isSaving,
|
||||||
|
isSubmitting: state.isSubmitting,
|
||||||
|
isOpeningSection: state.isOpeningSection,
|
||||||
|
setIsOpeningSection: state.setIsOpeningSection,
|
||||||
|
commitTitle: state.commitTitle,
|
||||||
|
setCommitTitle: state.setCommitTitle,
|
||||||
|
activeSection: state.activeSection,
|
||||||
|
projectState: state.projectState,
|
||||||
|
sectionCommits: state.sectionCommits,
|
||||||
|
baselineSnapshot: state.baselineSnapshot,
|
||||||
|
entityCatalog: state.entityCatalog,
|
||||||
|
setEntityCatalog: state.setEntityCatalog,
|
||||||
|
snapshotEntities: state.snapshotEntities,
|
||||||
|
setSnapshotEntities: state.setSnapshotEntities,
|
||||||
|
entityStatus: state.entityStatus,
|
||||||
|
setEntityStatus: state.setEntityStatus,
|
||||||
|
selectedFeatureIds: state.selectedFeatureIds,
|
||||||
|
setSelectedFeatureIds: state.setSelectedFeatureIds,
|
||||||
|
entityForm: state.entityForm,
|
||||||
|
setEntityForm: state.setEntityForm,
|
||||||
|
selectedGeometryEntityIds: state.selectedGeometryEntityIds,
|
||||||
|
setSelectedGeometryEntityIds: state.setSelectedGeometryEntityIds,
|
||||||
|
geometryMetaForm: state.geometryMetaForm,
|
||||||
|
setGeometryMetaForm: state.setGeometryMetaForm,
|
||||||
|
setIsEntitySubmitting: state.setIsEntitySubmitting,
|
||||||
|
setEntityFormStatus: state.setEntityFormStatus,
|
||||||
|
entitySearchResults: state.entitySearchResults,
|
||||||
|
setEntitySearchResults: state.setEntitySearchResults,
|
||||||
|
isEntitySearchLoading: state.isEntitySearchLoading,
|
||||||
|
setIsEntitySearchLoading: state.setIsEntitySearchLoading,
|
||||||
|
timelineDraftYear: state.timelineDraftYear,
|
||||||
|
setTimelineDraftYear: state.setTimelineDraftYear,
|
||||||
|
backgroundVisibility: state.backgroundVisibility,
|
||||||
|
setBackgroundVisibility: state.setBackgroundVisibility,
|
||||||
|
isBackgroundVisibilityReady: state.isBackgroundVisibilityReady,
|
||||||
|
setIsBackgroundVisibilityReady: state.setIsBackgroundVisibilityReady,
|
||||||
|
snapshotWikis: state.snapshotWikis,
|
||||||
|
setSnapshotWikis: state.setSnapshotWikis,
|
||||||
|
snapshotEntityWikiLinks: state.snapshotEntityWikiLinks,
|
||||||
|
setSnapshotEntityWikiLinks: state.setSnapshotEntityWikiLinks,
|
||||||
|
blockedPendingSubmissionId: state.blockedPendingSubmissionId,
|
||||||
|
setBlockedPendingSubmissionId: state.setBlockedPendingSubmissionId,
|
||||||
|
searchKind: state.searchKind,
|
||||||
|
setSearchKind: state.setSearchKind,
|
||||||
|
searchQuery: state.searchQuery,
|
||||||
|
setSearchQuery: state.setSearchQuery,
|
||||||
|
searchQueryDraft: state.searchQueryDraft,
|
||||||
|
setSearchQueryDraft: state.setSearchQueryDraft,
|
||||||
|
wikiSearchResults: state.wikiSearchResults,
|
||||||
|
setWikiSearchResults: state.setWikiSearchResults,
|
||||||
|
isWikiSearching: state.isWikiSearching,
|
||||||
|
setIsWikiSearching: state.setIsWikiSearching,
|
||||||
|
geoSearchResults: state.geoSearchResults,
|
||||||
|
setGeoSearchResults: state.setGeoSearchResults,
|
||||||
|
isGeoSearching: state.isGeoSearching,
|
||||||
|
setIsGeoSearching: state.setIsGeoSearching,
|
||||||
|
setRequestedActiveWikiId: state.setRequestedActiveWikiId,
|
||||||
|
leftPanelWidth: state.leftPanelWidth,
|
||||||
|
setLeftPanelWidth: state.setLeftPanelWidth,
|
||||||
|
rightPanelWidth: state.rightPanelWidth,
|
||||||
|
setRightPanelWidth: state.setRightPanelWidth,
|
||||||
|
timelineFilterEnabled: state.timelineFilterEnabled,
|
||||||
|
setTimelineFilterEnabled: state.setTimelineFilterEnabled,
|
||||||
|
geometryBindingFilterEnabled: state.geometryBindingFilterEnabled,
|
||||||
|
setGeoBindingStatus: state.setGeoBindingStatus,
|
||||||
|
hoveredGeometryId: state.hoveredGeometryId,
|
||||||
|
geometryFocusRequest: state.geometryFocusRequest,
|
||||||
|
setGeometryFocusRequest: state.setGeometryFocusRequest,
|
||||||
|
replayFeatureId: state.replayFeatureId,
|
||||||
|
setReplayFeatureId: state.setReplayFeatureId,
|
||||||
|
hideOutside: state.hideOutside,
|
||||||
|
setHideOutside: state.setHideOutside,
|
||||||
|
geometryVisibility: state.geometryVisibility,
|
||||||
|
})));
|
||||||
// Counter để bỏ qua response cũ khi user gõ search entity liên tục.
|
// Counter để bỏ qua response cũ khi user gõ search entity liên tục.
|
||||||
const entitySearchRequestRef = useRef(0);
|
const entitySearchRequestRef = useRef(0);
|
||||||
const wikiSearchRequestRef = useRef(0);
|
const wikiSearchRequestRef = useRef(0);
|
||||||
const geoSearchRequestRef = useRef(0);
|
const geoSearchRequestRef = useRef(0);
|
||||||
|
|
||||||
const [geometryVisibility, setGeometryVisibility] = useState<Record<string, boolean>>(() => {
|
|
||||||
const init: Record<string, boolean> = {};
|
|
||||||
for (const key of GEO_TYPE_KEYS) init[key] = true;
|
|
||||||
return init;
|
|
||||||
});
|
|
||||||
|
|
||||||
const snapshotEntitiesRef = useRef(snapshotEntities);
|
const snapshotEntitiesRef = useRef(snapshotEntities);
|
||||||
const snapshotWikisRef = useRef(snapshotWikis);
|
const snapshotWikisRef = useRef(snapshotWikis);
|
||||||
const snapshotEntityWikiLinksRef = useRef(snapshotEntityWikiLinks);
|
const snapshotEntityWikiLinksRef = useRef(snapshotEntityWikiLinks);
|
||||||
@@ -211,7 +279,6 @@ export default function Page() {
|
|||||||
},
|
},
|
||||||
[editor]
|
[editor]
|
||||||
);
|
);
|
||||||
const editorUserId = normalizeEditorUserId(editorUserIdInput);
|
|
||||||
const snapshotEntitiesAsEntities = useMemo(() => {
|
const snapshotEntitiesAsEntities = useMemo(() => {
|
||||||
const rows = snapshotEntities || [];
|
const rows = snapshotEntities || [];
|
||||||
return rows
|
return rows
|
||||||
@@ -235,17 +302,6 @@ export default function Page() {
|
|||||||
entitiesRef.current = entities;
|
entitiesRef.current = entities;
|
||||||
}, [entities]);
|
}, [entities]);
|
||||||
|
|
||||||
const snapshotEntitiesVisible = useMemo(() => {
|
|
||||||
const byId = new globalThis.Map<string, EntitySnapshot>();
|
|
||||||
for (const ref of snapshotEntities || []) {
|
|
||||||
const id = String(ref?.id || "").trim();
|
|
||||||
if (!id || byId.has(id)) continue;
|
|
||||||
if (ref.operation === "delete") continue;
|
|
||||||
byId.set(id, ref);
|
|
||||||
}
|
|
||||||
return Array.from(byId.values());
|
|
||||||
}, [snapshotEntities]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const localCreatedIds = localCreatedEntityIdsRef.current;
|
const localCreatedIds = localCreatedEntityIdsRef.current;
|
||||||
if (!localCreatedIds.size) return;
|
if (!localCreatedIds.size) return;
|
||||||
@@ -284,21 +340,6 @@ export default function Page() {
|
|||||||
};
|
};
|
||||||
}, [editor, mode, timelineDraftYear, timelineFilterEnabled]);
|
}, [editor, mode, timelineDraftYear, timelineFilterEnabled]);
|
||||||
|
|
||||||
const projectEntityChoices = useMemo(() => {
|
|
||||||
const ids = new Set<string>();
|
|
||||||
for (const ref of snapshotEntitiesVisible) ids.add(String(ref.id));
|
|
||||||
const rows = Array.from(ids).map((id) => {
|
|
||||||
const ref = snapshotEntitiesVisible.find((entity) => String(entity.id) === id) || null;
|
|
||||||
const found = entities.find((e) => e.id === id) || null;
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name: found?.name || id,
|
|
||||||
isNew: ref?.source === "inline" && ref?.operation === "create",
|
|
||||||
};
|
|
||||||
});
|
|
||||||
rows.sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
return rows;
|
|
||||||
}, [entities, snapshotEntitiesVisible]);
|
|
||||||
const selectedFeatures = useMemo(() => {
|
const selectedFeatures = useMemo(() => {
|
||||||
if (!selectedFeatureIds || selectedFeatureIds.length === 0) return [];
|
if (!selectedFeatureIds || selectedFeatureIds.length === 0) return [];
|
||||||
return selectedFeatureIds
|
return selectedFeatureIds
|
||||||
@@ -341,6 +382,18 @@ export default function Page() {
|
|||||||
return normalizeFeatureBindingIds(selectedFeature);
|
return normalizeFeatureBindingIds(selectedFeature);
|
||||||
}, [selectedFeature]);
|
}, [selectedFeature]);
|
||||||
|
|
||||||
|
const hoveredGeometryHighlight = useMemo(() => {
|
||||||
|
if (!hoveredGeometryId) return null;
|
||||||
|
const feature = editor.draft.features.find(
|
||||||
|
(item) => String(item.properties.id) === hoveredGeometryId
|
||||||
|
);
|
||||||
|
if (!feature) return null;
|
||||||
|
return {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [feature],
|
||||||
|
} as FeatureCollection;
|
||||||
|
}, [editor.draft.features, hoveredGeometryId]);
|
||||||
|
|
||||||
const wikiDirty = useMemo(() => {
|
const wikiDirty = useMemo(() => {
|
||||||
const prev = normalizeWikisForCompare(baselineSnapshot?.wikis);
|
const prev = normalizeWikisForCompare(baselineSnapshot?.wikis);
|
||||||
const next = normalizeWikisForCompare(snapshotWikis);
|
const next = normalizeWikisForCompare(snapshotWikis);
|
||||||
@@ -379,36 +432,9 @@ export default function Page() {
|
|||||||
|
|
||||||
const sectionCommands = useProjectCommands({
|
const sectionCommands = useProjectCommands({
|
||||||
editor,
|
editor,
|
||||||
editorUserId,
|
store: editorStoreApi,
|
||||||
emptyFeatureCollection: EMPTY_FEATURE_COLLECTION,
|
emptyFeatureCollection: EMPTY_FEATURE_COLLECTION,
|
||||||
activeSection,
|
|
||||||
projectState,
|
|
||||||
selectedProjectId,
|
|
||||||
newSectionTitle,
|
|
||||||
pendingSaveCount,
|
pendingSaveCount,
|
||||||
snapshotEntities,
|
|
||||||
snapshotWikis,
|
|
||||||
snapshotEntityWikiLinks,
|
|
||||||
baselineSnapshot,
|
|
||||||
commitTitle,
|
|
||||||
setActiveSection,
|
|
||||||
setSelectedProjectId,
|
|
||||||
setProjectState,
|
|
||||||
setBaselineSnapshot,
|
|
||||||
setInitialData,
|
|
||||||
setProjectCommits,
|
|
||||||
setSnapshotEntities,
|
|
||||||
setSnapshotWikis,
|
|
||||||
setSnapshotEntityWikiLinks,
|
|
||||||
setEntityFormStatus,
|
|
||||||
setSelectedFeatureIds,
|
|
||||||
setEntityStatus,
|
|
||||||
setIsSaving,
|
|
||||||
setIsSubmitting,
|
|
||||||
setIsOpeningSection,
|
|
||||||
setAvailableSections,
|
|
||||||
setNewSectionTitle,
|
|
||||||
setCommitTitle,
|
|
||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
openSectionForEditing,
|
openSectionForEditing,
|
||||||
@@ -432,7 +458,7 @@ export default function Page() {
|
|||||||
setHideOutside(false);
|
setHideOutside(false);
|
||||||
}
|
}
|
||||||
internalSetMode(m);
|
internalSetMode(m);
|
||||||
}, [internalSetMode, mode, editor, selectedFeatureIds]);
|
}, [internalSetMode, mode, editor, selectedFeatureIds, setHideOutside, setReplayFeatureId, setSelectedFeatureIds]);
|
||||||
|
|
||||||
const effectiveGeometryVisibility = useMemo(() => {
|
const effectiveGeometryVisibility = useMemo(() => {
|
||||||
const visibility: Record<string, boolean> = { ...geometryVisibility };
|
const visibility: Record<string, boolean> = { ...geometryVisibility };
|
||||||
@@ -461,7 +487,7 @@ export default function Page() {
|
|||||||
|
|
||||||
const onToggleHideOutside = useCallback(() => {
|
const onToggleHideOutside = useCallback(() => {
|
||||||
setHideOutside((prev) => !prev);
|
setHideOutside((prev) => !prev);
|
||||||
}, []);
|
}, [setHideOutside]);
|
||||||
|
|
||||||
const openProject = useCallback(async () => {
|
const openProject = useCallback(async () => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
@@ -500,7 +526,7 @@ export default function Page() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsOpeningSection(false);
|
setIsOpeningSection(false);
|
||||||
}
|
}
|
||||||
}, [openSectionForEditing, projectId, router, setEntityStatus, setIsOpeningSection]);
|
}, [openSectionForEditing, projectId, router, setBlockedPendingSubmissionId, setEntityStatus, setIsOpeningSection]);
|
||||||
|
|
||||||
const unlockByDeletingPendingSubmission = useCallback(async () => {
|
const unlockByDeletingPendingSubmission = useCallback(async () => {
|
||||||
if (!blockedPendingSubmissionId) return;
|
if (!blockedPendingSubmissionId) return;
|
||||||
@@ -521,7 +547,7 @@ export default function Page() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsOpeningSection(false);
|
setIsOpeningSection(false);
|
||||||
}
|
}
|
||||||
}, [blockedPendingSubmissionId, openProject, setEntityStatus, setIsOpeningSection]);
|
}, [blockedPendingSubmissionId, openProject, setBlockedPendingSubmissionId, setEntityStatus, setIsOpeningSection]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
@@ -704,7 +730,7 @@ export default function Page() {
|
|||||||
disposed = true;
|
disposed = true;
|
||||||
window.clearTimeout(timeoutId);
|
window.clearTimeout(timeoutId);
|
||||||
};
|
};
|
||||||
}, [searchKind, searchQuery]);
|
}, [searchKind, searchQuery, setIsWikiSearching, setWikiSearchResults]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searchKind !== "geo") {
|
if (searchKind !== "geo") {
|
||||||
@@ -743,7 +769,7 @@ export default function Page() {
|
|||||||
disposed = true;
|
disposed = true;
|
||||||
window.clearTimeout(timeoutId);
|
window.clearTimeout(timeoutId);
|
||||||
};
|
};
|
||||||
}, [geoSearchRequestRef, searchKind, searchQuery]);
|
}, [searchKind, searchQuery, setGeoSearchResults, setIsGeoSearching]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedFeatureIds || selectedFeatureIds.length === 0) return;
|
if (!selectedFeatureIds || selectedFeatureIds.length === 0) return;
|
||||||
@@ -831,43 +857,10 @@ export default function Page() {
|
|||||||
setIsBackgroundVisibilityReady(true);
|
setIsBackgroundVisibilityReady(true);
|
||||||
}, [setBackgroundVisibility, setIsBackgroundVisibilityReady]);
|
}, [setBackgroundVisibility, setIsBackgroundVisibilityReady]);
|
||||||
|
|
||||||
const updateBackgroundVisibility = (
|
|
||||||
updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility
|
|
||||||
) => {
|
|
||||||
setBackgroundVisibility((prev) => {
|
|
||||||
const next = updater(prev);
|
|
||||||
persistBackgroundLayerVisibility(next);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleBackgroundLayer = (id: BackgroundLayerId) => {
|
|
||||||
updateBackgroundVisibility((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[id]: !prev[id],
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleShowAllBackgroundLayers = () => {
|
|
||||||
updateBackgroundVisibility(() => ({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleHideAllBackgroundLayers = () => {
|
|
||||||
updateBackgroundVisibility(() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTimelineYearChange = (nextYear: number) => {
|
const handleTimelineYearChange = (nextYear: number) => {
|
||||||
setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear)));
|
setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEntityFormChange = (key: keyof EntityFormState, value: string) => {
|
|
||||||
setEntityForm((prev) => ({ ...prev, [key]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGeometryMetaFormChange = (key: keyof GeometryMetaFormState, value: string) => {
|
|
||||||
setGeometryMetaForm((prev) => ({ ...prev, [key]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
||||||
@@ -1073,6 +1066,7 @@ export default function Page() {
|
|||||||
}, [
|
}, [
|
||||||
editor.draft.features,
|
editor.draft.features,
|
||||||
flashGeoBindingStatus,
|
flashGeoBindingStatus,
|
||||||
|
setGeometryFocusRequest,
|
||||||
setSelectedFeatureIds,
|
setSelectedFeatureIds,
|
||||||
setTimelineFilterEnabled,
|
setTimelineFilterEnabled,
|
||||||
timelineFilterEnabled,
|
timelineFilterEnabled,
|
||||||
@@ -1380,6 +1374,7 @@ export default function Page() {
|
|||||||
backgroundVisibility={backgroundVisibility}
|
backgroundVisibility={backgroundVisibility}
|
||||||
geometryVisibility={effectiveGeometryVisibility}
|
geometryVisibility={effectiveGeometryVisibility}
|
||||||
respectBindingFilter={geometryBindingFilterEnabled}
|
respectBindingFilter={geometryBindingFilterEnabled}
|
||||||
|
highlightFeatures={hoveredGeometryHighlight}
|
||||||
focusFeatureCollection={geometryFocusRequest?.collection || null}
|
focusFeatureCollection={geometryFocusRequest?.collection || null}
|
||||||
focusRequestKey={geometryFocusRequest?.key ?? null}
|
focusRequestKey={geometryFocusRequest?.key ?? null}
|
||||||
focusPadding={96}
|
focusPadding={96}
|
||||||
@@ -1414,14 +1409,6 @@ export default function Page() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<BackgroundLayersPanel
|
<BackgroundLayersPanel
|
||||||
visibility={backgroundVisibility}
|
|
||||||
onToggleLayer={handleToggleBackgroundLayer}
|
|
||||||
onShowAll={handleShowAllBackgroundLayers}
|
|
||||||
onHideAll={handleHideAllBackgroundLayers}
|
|
||||||
geometryVisibility={geometryVisibility}
|
|
||||||
onToggleGeometryType={(typeKey) => {
|
|
||||||
setGeometryVisibility((prev) => ({ ...prev, [typeKey]: prev[typeKey] === false }));
|
|
||||||
}}
|
|
||||||
width={rightPanelWidth}
|
width={rightPanelWidth}
|
||||||
topContent={
|
topContent={
|
||||||
<div style={{ display: "grid", gap: "12px" }}>
|
<div style={{ display: "grid", gap: "12px" }}>
|
||||||
@@ -1655,44 +1642,26 @@ export default function Page() {
|
|||||||
selectedGeometryBindingIds={selectedGeometryBindingIds}
|
selectedGeometryBindingIds={selectedGeometryBindingIds}
|
||||||
onToggleBindGeometryForSelectedGeometry={handleToggleBindGeometryForSelectedGeometry}
|
onToggleBindGeometryForSelectedGeometry={handleToggleBindGeometryForSelectedGeometry}
|
||||||
onFocusGeometry={handleFocusGeometryFromBindingPanel}
|
onFocusGeometry={handleFocusGeometryFromBindingPanel}
|
||||||
statusText={geoBindingStatus}
|
|
||||||
bindingFilterEnabled={geometryBindingFilterEnabled}
|
|
||||||
onBindingFilterEnabledChange={setGeometryBindingFilterEnabled}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ProjectEntityRefsPanel
|
<ProjectEntityRefsPanel
|
||||||
entityRefs={snapshotEntitiesVisible}
|
|
||||||
entityForm={entityForm}
|
|
||||||
onEntityFormChange={handleEntityFormChange}
|
|
||||||
isEntitySubmitting={isEntitySubmitting}
|
|
||||||
onCreateEntityOnly={handleCreateEntityOnly}
|
onCreateEntityOnly={handleCreateEntityOnly}
|
||||||
onUpdateEntity={handleUpdateEntityInProject}
|
onUpdateEntity={handleUpdateEntityInProject}
|
||||||
entityFormStatus={entityFormStatus}
|
|
||||||
hasSelectedGeometry={Boolean(selectedFeature)}
|
hasSelectedGeometry={Boolean(selectedFeature)}
|
||||||
selectedGeometryEntityIds={selectedGeometryEntityIds}
|
|
||||||
onToggleBindEntityForSelectedGeometry={handleToggleBindEntityForSelectedGeometry}
|
onToggleBindEntityForSelectedGeometry={handleToggleBindEntityForSelectedGeometry}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WikiSidebarPanel
|
<WikiSidebarPanel
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
wikis={snapshotWikis}
|
|
||||||
setWikis={setSnapshotWikisUndoable}
|
setWikis={setSnapshotWikisUndoable}
|
||||||
requestedActiveId={requestedActiveWikiId}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<EntityWikiBindingsPanel
|
<EntityWikiBindingsPanel
|
||||||
entities={projectEntityChoices}
|
|
||||||
wikis={snapshotWikis}
|
|
||||||
links={snapshotEntityWikiLinks}
|
|
||||||
setLinks={setSnapshotEntityWikiLinksUndoable}
|
setLinks={setSnapshotEntityWikiLinksUndoable}
|
||||||
/>
|
/>
|
||||||
{selectedFeature ? (
|
{selectedFeature ? (
|
||||||
<SelectedGeometryPanel
|
<SelectedGeometryPanel
|
||||||
selectedFeatures={selectedFeatures}
|
selectedFeatures={selectedFeatures}
|
||||||
entityTypeOptions={GEOMETRY_TYPE_OPTIONS}
|
|
||||||
geometryMetaForm={geometryMetaForm}
|
|
||||||
onGeometryMetaFormChange={handleGeometryMetaFormChange}
|
|
||||||
isEntitySubmitting={isEntitySubmitting}
|
|
||||||
onApplyGeometryMetadata={featureCommands.applyGeometryMetadata}
|
onApplyGeometryMetadata={featureCommands.applyGeometryMetadata}
|
||||||
changeCount={editor.changeCount}
|
changeCount={editor.changeCount}
|
||||||
onReplayEdit={(id) => setMode("replay", id)}
|
onReplayEdit={(id) => setMode("replay", id)}
|
||||||
@@ -1770,11 +1739,6 @@ function clampNumber(value: number, min: number, max: number): number {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeEditorUserId(value: string): string {
|
|
||||||
const normalized = value.trim();
|
|
||||||
return normalized || DEFAULT_EDITOR_USER_ID;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCommitTitle(commit: ProjectCommit): string {
|
function formatCommitTitle(commit: ProjectCommit): string {
|
||||||
return commit.edit_summary?.trim() || `Commit ${commit.id.slice(0, 8)}`;
|
return commit.edit_summary?.trim() || `Commit ${commit.id.slice(0, 8)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,65 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import { persistBackgroundLayerVisibility } from "@/uhm/lib/editor/background/backgroundVisibilityStorage";
|
||||||
import {
|
import {
|
||||||
BACKGROUND_LAYER_OPTIONS,
|
BACKGROUND_LAYER_OPTIONS,
|
||||||
BackgroundLayerId,
|
BackgroundLayerId,
|
||||||
BackgroundLayerVisibility,
|
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
||||||
|
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
||||||
} from "@/uhm/lib/map/styles/backgroundLayers";
|
} from "@/uhm/lib/map/styles/backgroundLayers";
|
||||||
import { GEO_TYPE_KEYS } from "@/uhm/lib/map/geo/geoTypeMap";
|
import { GEO_TYPE_KEYS } from "@/uhm/lib/map/geo/geoTypeMap";
|
||||||
|
import { useEditorStore } from "@/uhm/store/editorStore";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
visibility: BackgroundLayerVisibility;
|
|
||||||
onToggleLayer: (id: BackgroundLayerId) => void;
|
|
||||||
onShowAll: () => void;
|
|
||||||
onHideAll: () => void;
|
|
||||||
geometryVisibility?: Record<string, boolean>;
|
|
||||||
onToggleGeometryType?: (typeKey: string) => void;
|
|
||||||
topContent?: ReactNode;
|
topContent?: ReactNode;
|
||||||
width?: number;
|
width?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function BackgroundLayersPanel({
|
export default function BackgroundLayersPanel({
|
||||||
visibility,
|
|
||||||
onToggleLayer,
|
|
||||||
onShowAll,
|
|
||||||
onHideAll,
|
|
||||||
geometryVisibility,
|
|
||||||
onToggleGeometryType,
|
|
||||||
topContent,
|
topContent,
|
||||||
width = 240,
|
width = 240,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const {
|
||||||
|
visibility,
|
||||||
|
setBackgroundVisibility,
|
||||||
|
geometryVisibility,
|
||||||
|
setGeometryVisibility,
|
||||||
|
} = useEditorStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
visibility: state.backgroundVisibility,
|
||||||
|
setBackgroundVisibility: state.setBackgroundVisibility,
|
||||||
|
geometryVisibility: state.geometryVisibility,
|
||||||
|
setGeometryVisibility: state.setGeometryVisibility,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateBackgroundVisibility = (
|
||||||
|
updater: (prev: typeof visibility) => typeof visibility
|
||||||
|
) => {
|
||||||
|
setBackgroundVisibility((prev) => {
|
||||||
|
const next = updater(prev);
|
||||||
|
persistBackgroundLayerVisibility(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleLayer = (id: BackgroundLayerId) => {
|
||||||
|
updateBackgroundVisibility((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[id]: !prev[id],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShowAll = () => {
|
||||||
|
updateBackgroundVisibility(() => ({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHideAll = () => {
|
||||||
|
updateBackgroundVisibility(() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
style={{
|
style={{
|
||||||
@@ -52,7 +83,7 @@ export default function BackgroundLayersPanel({
|
|||||||
<button
|
<button
|
||||||
key={layer.id}
|
key={layer.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onToggleLayer(layer.id)}
|
onClick={() => handleToggleLayer(layer.id)}
|
||||||
style={{
|
style={{
|
||||||
border: "none",
|
border: "none",
|
||||||
background: "transparent",
|
background: "transparent",
|
||||||
@@ -75,7 +106,23 @@ export default function BackgroundLayersPanel({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{geometryVisibility && onToggleGeometryType ? (
|
<div style={{ marginTop: 10, display: "flex", gap: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleShowAll}
|
||||||
|
style={secondaryButtonStyle}
|
||||||
|
>
|
||||||
|
Show all
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleHideAll}
|
||||||
|
style={secondaryButtonStyle}
|
||||||
|
>
|
||||||
|
Hide all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<>
|
<>
|
||||||
<div style={{ height: 1, background: "#1f2937", margin: "12px 0" }} />
|
<div style={{ height: 1, background: "#1f2937", margin: "12px 0" }} />
|
||||||
<div style={{ margin: 0, marginBottom: 10, fontWeight: 800, fontSize: 13, color: "#e5e7eb" }}>
|
<div style={{ margin: 0, marginBottom: 10, fontWeight: 800, fontSize: 13, color: "#e5e7eb" }}>
|
||||||
@@ -88,7 +135,12 @@ export default function BackgroundLayersPanel({
|
|||||||
<button
|
<button
|
||||||
key={typeKey}
|
key={typeKey}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onToggleGeometryType(typeKey)}
|
onClick={() => {
|
||||||
|
setGeometryVisibility((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[typeKey]: prev[typeKey] === false,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
border: "none",
|
border: "none",
|
||||||
background: "transparent",
|
background: "transparent",
|
||||||
@@ -112,7 +164,17 @@ export default function BackgroundLayersPanel({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const secondaryButtonStyle = {
|
||||||
|
border: "1px solid #334155",
|
||||||
|
borderRadius: 6,
|
||||||
|
background: "#0b1220",
|
||||||
|
color: "#cbd5e1",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
padding: "6px 8px",
|
||||||
|
} as const;
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
|
||||||
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
||||||
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import NewBadge from "@/uhm/components/editor/NewBadge";
|
import NewBadge from "@/uhm/components/editor/NewBadge";
|
||||||
|
import { useEditorStore } from "@/uhm/store/editorStore";
|
||||||
|
|
||||||
type EntityChoice = { id: string; name: string; isNew?: boolean };
|
type EntityChoice = { id: string; name: string; isNew?: boolean };
|
||||||
type WikiChoice = { id: string; title: string; isNew?: boolean };
|
type WikiChoice = { id: string; title: string; isNew?: boolean };
|
||||||
@@ -18,9 +20,6 @@ type BindingRow = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
entities: EntityChoice[];
|
|
||||||
wikis: WikiSnapshot[];
|
|
||||||
links: EntityWikiLinkSnapshot[];
|
|
||||||
setLinks: React.Dispatch<React.SetStateAction<EntityWikiLinkSnapshot[]>>;
|
setLinks: React.Dispatch<React.SetStateAction<EntityWikiLinkSnapshot[]>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,7 +28,20 @@ function wikiTitle(w: WikiSnapshot): string {
|
|||||||
return t.length ? t : "Untitled wiki";
|
return t.length ? t : "Untitled wiki";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EntityWikiBindingsPanel({ entities, wikis, links, setLinks }: Props) {
|
export default function EntityWikiBindingsPanel({ setLinks }: Props) {
|
||||||
|
const {
|
||||||
|
entityCatalog,
|
||||||
|
snapshotEntities,
|
||||||
|
wikis,
|
||||||
|
links,
|
||||||
|
} = useEditorStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
entityCatalog: state.entityCatalog,
|
||||||
|
snapshotEntities: state.snapshotEntities,
|
||||||
|
wikis: state.snapshotWikis,
|
||||||
|
links: state.snapshotEntityWikiLinks,
|
||||||
|
}))
|
||||||
|
);
|
||||||
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 [collapsed, setCollapsed] = useState(false);
|
||||||
@@ -46,11 +58,29 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
|
|||||||
[wikis]
|
[wikis]
|
||||||
);
|
);
|
||||||
|
|
||||||
const entityChoices = useMemo(() => {
|
const entityChoices = useMemo<EntityChoice[]>(() => {
|
||||||
const cleaned = (entities || []).filter((e) => e && typeof e.id === "string" && e.id.trim().length > 0);
|
const visibleSnapshotEntities = new globalThis.Map<string, { id: string; name: string; isNew: boolean }>();
|
||||||
cleaned.sort((a, b) => a.name.localeCompare(b.name));
|
for (const ref of snapshotEntities || []) {
|
||||||
return cleaned;
|
const id = String(ref?.id || "").trim();
|
||||||
}, [entities]);
|
if (!id || ref?.operation === "delete" || visibleSnapshotEntities.has(id)) continue;
|
||||||
|
visibleSnapshotEntities.set(id, {
|
||||||
|
id,
|
||||||
|
name: String(ref?.name || id),
|
||||||
|
isNew: ref?.source === "inline" && ref?.operation === "create",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = Array.from(visibleSnapshotEntities.values()).map((entity) => {
|
||||||
|
const found = entityCatalog.find((item) => String(item.id) === entity.id) || null;
|
||||||
|
return {
|
||||||
|
id: entity.id,
|
||||||
|
name: String(found?.name || entity.name || entity.id),
|
||||||
|
isNew: entity.isNew,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
rows.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
return rows;
|
||||||
|
}, [entityCatalog, snapshotEntities]);
|
||||||
|
|
||||||
const activeLinks = useMemo(() => {
|
const activeLinks = useMemo(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState, type CSSProperties, type KeyboardEvent } from "react";
|
import { useMemo, useState, type CSSProperties, type KeyboardEvent } from "react";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import NewBadge from "@/uhm/components/editor/NewBadge";
|
import NewBadge from "@/uhm/components/editor/NewBadge";
|
||||||
|
import { useEditorStore } from "@/uhm/store/editorStore";
|
||||||
|
|
||||||
type GeometryChoice = {
|
type GeometryChoice = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -11,13 +13,10 @@ type GeometryChoice = {
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
geometries: GeometryChoice[];
|
geometries: GeometryChoice[];
|
||||||
selectedGeometryId: string | null;
|
selectedGeometryId?: string | null;
|
||||||
selectedGeometryBindingIds: string[];
|
selectedGeometryBindingIds: string[];
|
||||||
onToggleBindGeometryForSelectedGeometry?: (geometryId: string, nextChecked: boolean) => void;
|
onToggleBindGeometryForSelectedGeometry?: (geometryId: string, nextChecked: boolean) => void;
|
||||||
onFocusGeometry?: (geometryId: string) => void;
|
onFocusGeometry?: (geometryId: string) => void;
|
||||||
statusText?: string | null;
|
|
||||||
bindingFilterEnabled: boolean;
|
|
||||||
onBindingFilterEnabledChange: (next: boolean) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function GeometryBindingPanel({
|
export default function GeometryBindingPanel({
|
||||||
@@ -26,12 +25,29 @@ export default function GeometryBindingPanel({
|
|||||||
selectedGeometryBindingIds,
|
selectedGeometryBindingIds,
|
||||||
onToggleBindGeometryForSelectedGeometry,
|
onToggleBindGeometryForSelectedGeometry,
|
||||||
onFocusGeometry,
|
onFocusGeometry,
|
||||||
|
}: Props) {
|
||||||
|
const {
|
||||||
|
selectedFeatureIds,
|
||||||
statusText,
|
statusText,
|
||||||
bindingFilterEnabled,
|
bindingFilterEnabled,
|
||||||
onBindingFilterEnabledChange,
|
setGeometryBindingFilterEnabled,
|
||||||
}: Props) {
|
hoveredGeometryId,
|
||||||
|
setHoveredGeometryId,
|
||||||
|
} = useEditorStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
selectedFeatureIds: state.selectedFeatureIds,
|
||||||
|
statusText: state.geoBindingStatus,
|
||||||
|
bindingFilterEnabled: state.geometryBindingFilterEnabled,
|
||||||
|
setGeometryBindingFilterEnabled: state.setGeometryBindingFilterEnabled,
|
||||||
|
hoveredGeometryId: state.hoveredGeometryId,
|
||||||
|
setHoveredGeometryId: state.setHoveredGeometryId,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const effectiveSelectedGeometryId =
|
||||||
|
selectedGeometryId ??
|
||||||
|
(selectedFeatureIds.length > 0 ? String(selectedFeatureIds[0]) : null);
|
||||||
const canBindToggle =
|
const canBindToggle =
|
||||||
Boolean(selectedGeometryId) && typeof onToggleBindGeometryForSelectedGeometry === "function";
|
Boolean(effectiveSelectedGeometryId) && typeof onToggleBindGeometryForSelectedGeometry === "function";
|
||||||
const canFocusGeometry = typeof onFocusGeometry === "function";
|
const canFocusGeometry = typeof onFocusGeometry === "function";
|
||||||
|
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
@@ -46,24 +62,30 @@ export default function GeometryBindingPanel({
|
|||||||
|
|
||||||
const bindingSet = useMemo(() => new Set(selectedGeometryBindingIds || []), [selectedGeometryBindingIds]);
|
const bindingSet = useMemo(() => new Set(selectedGeometryBindingIds || []), [selectedGeometryBindingIds]);
|
||||||
const selectedGeometry = useMemo(() => {
|
const selectedGeometry = useMemo(() => {
|
||||||
if (!selectedGeometryId) return null;
|
if (!effectiveSelectedGeometryId) return null;
|
||||||
return rows.find((g) => g.id === selectedGeometryId) || null;
|
return rows.find((g) => g.id === effectiveSelectedGeometryId) || null;
|
||||||
}, [rows, selectedGeometryId]);
|
}, [effectiveSelectedGeometryId, rows]);
|
||||||
const visibleRows = useMemo(() => {
|
const visibleRows = useMemo(() => {
|
||||||
return rows
|
return rows
|
||||||
.filter((g) => g.id !== selectedGeometryId)
|
.filter((g) => g.id !== effectiveSelectedGeometryId)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const aBound = bindingSet.has(a.id);
|
const aBound = bindingSet.has(a.id);
|
||||||
const bBound = bindingSet.has(b.id);
|
const bBound = bindingSet.has(b.id);
|
||||||
if (aBound !== bBound) return aBound ? -1 : 1;
|
if (aBound !== bBound) return aBound ? -1 : 1;
|
||||||
return a.id.localeCompare(b.id);
|
return a.id.localeCompare(b.id);
|
||||||
});
|
});
|
||||||
}, [bindingSet, rows, selectedGeometryId]);
|
}, [bindingSet, effectiveSelectedGeometryId, rows]);
|
||||||
|
|
||||||
const handleFocusKeyDown = (event: KeyboardEvent<HTMLDivElement>, geometryId: string) => {
|
const handleFocusKeyDown = (event: KeyboardEvent<HTMLDivElement>, geometryId: string) => {
|
||||||
if (!canFocusGeometry) return;
|
if (!canFocusGeometry) return;
|
||||||
if (event.key !== "Enter" && event.key !== " ") return;
|
if (event.key !== "Enter" && event.key !== " ") return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
setHoveredGeometryId((current) => (current === geometryId ? null : current));
|
||||||
|
onFocusGeometry?.(geometryId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFocusGeometry = (geometryId: string) => {
|
||||||
|
setHoveredGeometryId((current) => (current === geometryId ? null : current));
|
||||||
onFocusGeometry?.(geometryId);
|
onFocusGeometry?.(geometryId);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -75,6 +97,7 @@ export default function GeometryBindingPanel({
|
|||||||
borderRadius: "8px",
|
borderRadius: "8px",
|
||||||
border: "1px solid #1f2937",
|
border: "1px solid #1f2937",
|
||||||
}}
|
}}
|
||||||
|
onMouseLeave={() => setHoveredGeometryId(null)}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
|
||||||
@@ -92,7 +115,7 @@ export default function GeometryBindingPanel({
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={bindingFilterEnabled}
|
checked={bindingFilterEnabled}
|
||||||
onChange={(e) => onBindingFilterEnabledChange(e.target.checked)}
|
onChange={(e) => setGeometryBindingFilterEnabled(e.target.checked)}
|
||||||
style={{ width: 14, height: 14 }}
|
style={{ width: 14, height: 14 }}
|
||||||
/>
|
/>
|
||||||
<span style={{ fontSize: 12, color: "#94a3b8", whiteSpace: "nowrap" }}>Filter</span>
|
<span style={{ fontSize: 12, color: "#94a3b8", whiteSpace: "nowrap" }}>Filter</span>
|
||||||
@@ -130,15 +153,26 @@ export default function GeometryBindingPanel({
|
|||||||
marginTop: 10,
|
marginTop: 10,
|
||||||
padding: "8px",
|
padding: "8px",
|
||||||
borderRadius: "6px",
|
borderRadius: "6px",
|
||||||
border: "1px solid rgba(59, 130, 246, 0.45)",
|
border:
|
||||||
background: "rgba(37, 99, 235, 0.12)",
|
hoveredGeometryId === selectedGeometry.id
|
||||||
|
? "1px solid rgba(245, 158, 11, 0.95)"
|
||||||
|
: "1px solid rgba(59, 130, 246, 0.45)",
|
||||||
|
background:
|
||||||
|
hoveredGeometryId === selectedGeometry.id
|
||||||
|
? "rgba(245, 158, 11, 0.18)"
|
||||||
|
: "rgba(37, 99, 235, 0.12)",
|
||||||
cursor: canFocusGeometry ? "pointer" : "default",
|
cursor: canFocusGeometry ? "pointer" : "default",
|
||||||
|
boxShadow:
|
||||||
|
hoveredGeometryId === selectedGeometry.id
|
||||||
|
? "0 0 0 2px rgba(251, 191, 36, 0.18)"
|
||||||
|
: "none",
|
||||||
}}
|
}}
|
||||||
title={selectedGeometry.id}
|
title={selectedGeometry.id}
|
||||||
role={canFocusGeometry ? "button" : undefined}
|
role={canFocusGeometry ? "button" : undefined}
|
||||||
tabIndex={canFocusGeometry ? 0 : undefined}
|
tabIndex={canFocusGeometry ? 0 : undefined}
|
||||||
onClick={() => onFocusGeometry?.(selectedGeometry.id)}
|
onClick={() => handleFocusGeometry(selectedGeometry.id)}
|
||||||
onKeyDown={(event) => handleFocusKeyDown(event, selectedGeometry.id)}
|
onKeyDown={(event) => handleFocusKeyDown(event, selectedGeometry.id)}
|
||||||
|
onMouseEnter={() => setHoveredGeometryId(selectedGeometry.id)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -187,25 +221,36 @@ export default function GeometryBindingPanel({
|
|||||||
{visibleRows
|
{visibleRows
|
||||||
.map((g) => {
|
.map((g) => {
|
||||||
const isBound = bindingSet.has(g.id);
|
const isBound = bindingSet.has(g.id);
|
||||||
|
const isHovered = hoveredGeometryId === g.id;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={g.id}
|
key={g.id}
|
||||||
style={{
|
style={{
|
||||||
padding: "8px",
|
padding: "8px",
|
||||||
borderRadius: "6px",
|
borderRadius: "6px",
|
||||||
border: isBound ? "1px solid rgba(20, 184, 166, 0.65)" : "1px solid #1f2937",
|
border: isHovered
|
||||||
background: isBound ? "rgba(20, 184, 166, 0.12)" : "transparent",
|
? "1px solid rgba(245, 158, 11, 0.95)"
|
||||||
|
: isBound
|
||||||
|
? "1px solid rgba(20, 184, 166, 0.65)"
|
||||||
|
: "1px solid #1f2937",
|
||||||
|
background: isHovered
|
||||||
|
? "rgba(245, 158, 11, 0.18)"
|
||||||
|
: isBound
|
||||||
|
? "rgba(20, 184, 166, 0.12)"
|
||||||
|
: "transparent",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 10,
|
gap: 10,
|
||||||
cursor: canFocusGeometry ? "pointer" : "default",
|
cursor: canFocusGeometry ? "pointer" : "default",
|
||||||
opacity: canBindToggle ? 1 : 0.75,
|
opacity: canBindToggle ? 1 : 0.75,
|
||||||
|
boxShadow: isHovered ? "0 0 0 2px rgba(251, 191, 36, 0.18)" : "none",
|
||||||
}}
|
}}
|
||||||
title={g.id}
|
title={g.id}
|
||||||
role={canFocusGeometry ? "button" : undefined}
|
role={canFocusGeometry ? "button" : undefined}
|
||||||
tabIndex={canFocusGeometry ? 0 : undefined}
|
tabIndex={canFocusGeometry ? 0 : undefined}
|
||||||
onClick={() => onFocusGeometry?.(g.id)}
|
onClick={() => handleFocusGeometry(g.id)}
|
||||||
onKeyDown={(event) => handleFocusKeyDown(event, g.id)}
|
onKeyDown={(event) => handleFocusKeyDown(event, g.id)}
|
||||||
|
onMouseEnter={() => setHoveredGeometryId(g.id)}
|
||||||
>
|
>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -2,34 +2,40 @@
|
|||||||
|
|
||||||
import { useMemo, useState, type CSSProperties } from "react";
|
import { 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 { useShallow } from "zustand/react/shallow";
|
||||||
import NewBadge from "@/uhm/components/editor/NewBadge";
|
import NewBadge from "@/uhm/components/editor/NewBadge";
|
||||||
|
import { useEditorStore } from "@/uhm/store/editorStore";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
entityRefs: EntitySnapshot[];
|
|
||||||
entityForm: EntityFormState;
|
|
||||||
onEntityFormChange: (key: keyof EntityFormState, value: string) => void;
|
|
||||||
isEntitySubmitting: boolean;
|
|
||||||
onCreateEntityOnly: () => void;
|
onCreateEntityOnly: () => void;
|
||||||
onUpdateEntity?: (entityId: string, payload: { name: string; description: string | null }) => void;
|
onUpdateEntity?: (entityId: string, payload: { name: string; description: string | null }) => void;
|
||||||
entityFormStatus: string | null;
|
|
||||||
selectedGeometryEntityIds?: string[];
|
|
||||||
hasSelectedGeometry?: boolean;
|
hasSelectedGeometry?: boolean;
|
||||||
onToggleBindEntityForSelectedGeometry?: (entityId: string, nextChecked: boolean) => void;
|
onToggleBindEntityForSelectedGeometry?: (entityId: string, nextChecked: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ProjectEntityRefsPanel({
|
export default function ProjectEntityRefsPanel({
|
||||||
entityRefs,
|
|
||||||
entityForm,
|
|
||||||
onEntityFormChange,
|
|
||||||
isEntitySubmitting,
|
|
||||||
onCreateEntityOnly,
|
onCreateEntityOnly,
|
||||||
onUpdateEntity,
|
onUpdateEntity,
|
||||||
entityFormStatus,
|
|
||||||
selectedGeometryEntityIds,
|
|
||||||
hasSelectedGeometry,
|
hasSelectedGeometry,
|
||||||
onToggleBindEntityForSelectedGeometry,
|
onToggleBindEntityForSelectedGeometry,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const {
|
||||||
|
snapshotEntities,
|
||||||
|
entityForm,
|
||||||
|
setEntityForm,
|
||||||
|
isEntitySubmitting,
|
||||||
|
entityFormStatus,
|
||||||
|
selectedGeometryEntityIds,
|
||||||
|
} = useEditorStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
snapshotEntities: state.snapshotEntities,
|
||||||
|
entityForm: state.entityForm,
|
||||||
|
setEntityForm: state.setEntityForm,
|
||||||
|
isEntitySubmitting: state.isEntitySubmitting,
|
||||||
|
entityFormStatus: state.entityFormStatus,
|
||||||
|
selectedGeometryEntityIds: state.selectedGeometryEntityIds,
|
||||||
|
}))
|
||||||
|
);
|
||||||
const canBindToggle =
|
const canBindToggle =
|
||||||
Boolean(hasSelectedGeometry) &&
|
Boolean(hasSelectedGeometry) &&
|
||||||
Array.isArray(selectedGeometryEntityIds) &&
|
Array.isArray(selectedGeometryEntityIds) &&
|
||||||
@@ -43,6 +49,16 @@ export default function ProjectEntityRefsPanel({
|
|||||||
() => new Set((selectedGeometryEntityIds || []).map(String)),
|
() => new Set((selectedGeometryEntityIds || []).map(String)),
|
||||||
[selectedGeometryEntityIds]
|
[selectedGeometryEntityIds]
|
||||||
);
|
);
|
||||||
|
const entityRefs = useMemo(() => {
|
||||||
|
const byId = new globalThis.Map<string, EntitySnapshot>();
|
||||||
|
for (const ref of snapshotEntities || []) {
|
||||||
|
const id = String(ref?.id || "").trim();
|
||||||
|
if (!id || byId.has(id)) continue;
|
||||||
|
if (ref.operation === "delete") continue;
|
||||||
|
byId.set(id, ref);
|
||||||
|
}
|
||||||
|
return Array.from(byId.values());
|
||||||
|
}, [snapshotEntities]);
|
||||||
const sortedEntityRefs = useMemo(() => {
|
const sortedEntityRefs = useMemo(() => {
|
||||||
const rows = [...(entityRefs || [])];
|
const rows = [...(entityRefs || [])];
|
||||||
rows.sort((a, b) => {
|
rows.sort((a, b) => {
|
||||||
@@ -68,6 +84,9 @@ export default function ProjectEntityRefsPanel({
|
|||||||
setEditName(typeof entity.name === "string" ? entity.name : "");
|
setEditName(typeof entity.name === "string" ? entity.name : "");
|
||||||
setEditDescription(entity.description == null ? "" : String(entity.description));
|
setEditDescription(entity.description == null ? "" : String(entity.description));
|
||||||
};
|
};
|
||||||
|
const handleEntityFormChange = (key: "name" | "description", value: string) => {
|
||||||
|
setEntityForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -325,14 +344,14 @@ export default function ProjectEntityRefsPanel({
|
|||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
value={entityForm.name}
|
value={entityForm.name}
|
||||||
onChange={(event) => onEntityFormChange("name", event.target.value)}
|
onChange={(event) => handleEntityFormChange("name", event.target.value)}
|
||||||
placeholder="Tên entity mới"
|
placeholder="Tên entity mới"
|
||||||
disabled={isEntitySubmitting}
|
disabled={isEntitySubmitting}
|
||||||
style={entityInputStyle}
|
style={entityInputStyle}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
value={entityForm.description}
|
value={entityForm.description}
|
||||||
onChange={(event) => onEntityFormChange("description", event.target.value)}
|
onChange={(event) => handleEntityFormChange("description", event.target.value)}
|
||||||
placeholder="Description"
|
placeholder="Description"
|
||||||
disabled={isEntitySubmitting}
|
disabled={isEntitySubmitting}
|
||||||
style={entityInputStyle}
|
style={entityInputStyle}
|
||||||
|
|||||||
@@ -1,23 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { type CSSProperties, useMemo, useState } from "react";
|
import { type CSSProperties, useMemo, useState } from "react";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { Feature } from "@/uhm/lib/editor/state/useEditorState";
|
import { Feature } from "@/uhm/lib/editor/state/useEditorState";
|
||||||
import {
|
import {
|
||||||
|
GEOMETRY_TYPE_OPTIONS,
|
||||||
GeometryPreset,
|
GeometryPreset,
|
||||||
GeometryTypeGroupId,
|
GeometryTypeGroupId,
|
||||||
GeometryTypeOption,
|
|
||||||
findGeometryTypeOption,
|
findGeometryTypeOption,
|
||||||
groupGeometryTypeOptions,
|
groupGeometryTypeOptions,
|
||||||
} from "@/uhm/lib/map/geo/geometryTypeOptions";
|
} from "@/uhm/lib/map/geo/geometryTypeOptions";
|
||||||
import { normalizeGeoTypeKey } from "@/uhm/lib/map/geo/geoTypeMap";
|
import { normalizeGeoTypeKey } from "@/uhm/lib/map/geo/geoTypeMap";
|
||||||
import type { GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionTypes";
|
import { useEditorStore } from "@/uhm/store/editorStore";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
selectedFeatures: Feature[];
|
selectedFeatures: Feature[];
|
||||||
entityTypeOptions: GeometryTypeOption[];
|
|
||||||
geometryMetaForm: GeometryMetaFormState;
|
|
||||||
onGeometryMetaFormChange: (key: keyof GeometryMetaFormState, value: string) => void;
|
|
||||||
isEntitySubmitting: boolean;
|
|
||||||
onApplyGeometryMetadata: () => Promise<{ ok: boolean; error?: string }>;
|
onApplyGeometryMetadata: () => Promise<{ ok: boolean; error?: string }>;
|
||||||
changeCount: number;
|
changeCount: number;
|
||||||
onReplayEdit?: (id: string | number) => void;
|
onReplayEdit?: (id: string | number) => void;
|
||||||
@@ -25,14 +22,21 @@ type Props = {
|
|||||||
|
|
||||||
export default function SelectedGeometryPanel({
|
export default function SelectedGeometryPanel({
|
||||||
selectedFeatures,
|
selectedFeatures,
|
||||||
entityTypeOptions,
|
|
||||||
geometryMetaForm,
|
|
||||||
onGeometryMetaFormChange,
|
|
||||||
isEntitySubmitting,
|
|
||||||
onApplyGeometryMetadata,
|
onApplyGeometryMetadata,
|
||||||
changeCount,
|
changeCount,
|
||||||
onReplayEdit,
|
onReplayEdit,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const {
|
||||||
|
geometryMetaForm,
|
||||||
|
setGeometryMetaForm,
|
||||||
|
isEntitySubmitting,
|
||||||
|
} = useEditorStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
geometryMetaForm: state.geometryMetaForm,
|
||||||
|
setGeometryMetaForm: state.setGeometryMetaForm,
|
||||||
|
isEntitySubmitting: state.isEntitySubmitting,
|
||||||
|
}))
|
||||||
|
);
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const [geoApplyFeedback, setGeoApplyFeedback] = useState<
|
const [geoApplyFeedback, setGeoApplyFeedback] = useState<
|
||||||
| {
|
| {
|
||||||
@@ -73,7 +77,7 @@ export default function SelectedGeometryPanel({
|
|||||||
if (!selectedFeatures || selectedFeatures.length === 0) return null;
|
if (!selectedFeatures || selectedFeatures.length === 0) return null;
|
||||||
const representativeFeature = selectedFeatures[0];
|
const representativeFeature = selectedFeatures[0];
|
||||||
|
|
||||||
const groupedGeometryTypeOptions = groupGeometryTypeOptions(entityTypeOptions);
|
const groupedGeometryTypeOptions = groupGeometryTypeOptions(GEOMETRY_TYPE_OPTIONS);
|
||||||
const featureGeometryPreset = resolveFeatureGeometryPreset(representativeFeature);
|
const featureGeometryPreset = resolveFeatureGeometryPreset(representativeFeature);
|
||||||
const allowedGroupIds = getAllowedGroupIdsForPreset(featureGeometryPreset);
|
const allowedGroupIds = getAllowedGroupIdsForPreset(featureGeometryPreset);
|
||||||
const groupedGeoTypeOptions = groupedGeometryTypeOptions.filter((group) =>
|
const groupedGeoTypeOptions = groupedGeometryTypeOptions.filter((group) =>
|
||||||
@@ -143,7 +147,12 @@ export default function SelectedGeometryPanel({
|
|||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
value={geometryMetaForm.type_key}
|
value={geometryMetaForm.type_key}
|
||||||
onChange={(event) => onGeometryMetaFormChange("type_key", event.target.value)}
|
onChange={(event) =>
|
||||||
|
setGeometryMetaForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
type_key: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
disabled={isEntitySubmitting}
|
disabled={isEntitySubmitting}
|
||||||
style={entityInputStyle}
|
style={entityInputStyle}
|
||||||
>
|
>
|
||||||
@@ -176,14 +185,24 @@ export default function SelectedGeometryPanel({
|
|||||||
) : null}
|
) : null}
|
||||||
<input
|
<input
|
||||||
value={geometryMetaForm.time_start}
|
value={geometryMetaForm.time_start}
|
||||||
onChange={(event) => onGeometryMetaFormChange("time_start", event.target.value)}
|
onChange={(event) =>
|
||||||
|
setGeometryMetaForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
time_start: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
placeholder="time_start"
|
placeholder="time_start"
|
||||||
disabled={isEntitySubmitting}
|
disabled={isEntitySubmitting}
|
||||||
style={entityInputStyle}
|
style={entityInputStyle}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
value={geometryMetaForm.time_end}
|
value={geometryMetaForm.time_end}
|
||||||
onChange={(event) => onGeometryMetaFormChange("time_end", event.target.value)}
|
onChange={(event) =>
|
||||||
|
setGeometryMetaForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
time_end: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
placeholder="time_end"
|
placeholder="time_end"
|
||||||
disabled={isEntitySubmitting}
|
disabled={isEntitySubmitting}
|
||||||
style={entityInputStyle}
|
style={entityInputStyle}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { initLine } from "@/uhm/lib/map/engines/lineEngine";
|
|||||||
import { initPath } from "@/uhm/lib/map/engines/pathEngine";
|
import { initPath } from "@/uhm/lib/map/engines/pathEngine";
|
||||||
import { initCircle } from "@/uhm/lib/map/engines/circleEngine";
|
import { initCircle } from "@/uhm/lib/map/engines/circleEngine";
|
||||||
import { createEditingEngine } from "@/uhm/lib/map/engines/editingEngine";
|
import { createEditingEngine } from "@/uhm/lib/map/engines/editingEngine";
|
||||||
import { Feature, FeatureCollection, Geometry } from "@/uhm/lib/editor/state/useEditorState";
|
import { FeatureCollection, Geometry } from "@/uhm/lib/editor/state/useEditorState";
|
||||||
import { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
|
import { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
import { buildClientFeatureId, getSelectableLayers } from "./mapUtils";
|
import { buildClientFeatureId, getSelectableLayers } from "./mapUtils";
|
||||||
import { MapHoverPayload } from "../Map";
|
import { MapHoverPayload } from "../Map";
|
||||||
@@ -15,7 +15,7 @@ import { MapHoverPayload } from "../Map";
|
|||||||
type EngineBinding = {
|
type EngineBinding = {
|
||||||
cleanup: () => void;
|
cleanup: () => void;
|
||||||
cancel?: () => void;
|
cancel?: () => void;
|
||||||
clearSelection?: () => void;
|
clearSelection?: (skipNotify?: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UseMapInteractionProps = {
|
type UseMapInteractionProps = {
|
||||||
@@ -143,7 +143,9 @@ export function useMapInteraction({
|
|||||||
const originalFeature = draftRef.current.features.find(
|
const originalFeature = draftRef.current.features.find(
|
||||||
(item) => String(item.properties.id) === String(rawId)
|
(item) => String(item.properties.id) === String(rawId)
|
||||||
);
|
);
|
||||||
editingEngineRef.current?.beginEditing((originalFeature || feature) as any);
|
editingEngineRef.current?.beginEditing(
|
||||||
|
(originalFeature || feature) as unknown as maplibregl.MapGeoJSONFeature
|
||||||
|
);
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
(ids) => onSelectFeatureIdsRef.current?.(ids),
|
(ids) => onSelectFeatureIdsRef.current?.(ids),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import "react-quill-new/dist/quill.snow.css";
|
import "react-quill-new/dist/quill.snow.css";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
|
||||||
import { Modal } from "@/components/ui/modal";
|
import { Modal } from "@/components/ui/modal";
|
||||||
import Button from "@/components/ui/button/Button";
|
import Button from "@/components/ui/button/Button";
|
||||||
@@ -13,6 +14,7 @@ import { newId } from "@/uhm/lib/utils/id";
|
|||||||
import type ReactQuill from "react-quill-new";
|
import type ReactQuill from "react-quill-new";
|
||||||
import { checkWikiSlugExists, fetchWikiBySlug, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
|
import { checkWikiSlugExists, fetchWikiBySlug, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
|
||||||
import NewBadge from "@/uhm/components/editor/NewBadge";
|
import NewBadge from "@/uhm/components/editor/NewBadge";
|
||||||
|
import { useEditorStore } from "@/uhm/store/editorStore";
|
||||||
|
|
||||||
type ReactQuillProps = ComponentProps<typeof ReactQuill>;
|
type ReactQuillProps = ComponentProps<typeof ReactQuill>;
|
||||||
type QuillRange = { index: number; length: number };
|
type QuillRange = { index: number; length: number };
|
||||||
@@ -39,7 +41,7 @@ type QuillLinkFormat = {
|
|||||||
type QuillImageFormatCtor = {
|
type QuillImageFormatCtor = {
|
||||||
new (): {
|
new (): {
|
||||||
domNode: Element;
|
domNode: Element;
|
||||||
format: (name: string, value: string) => void;
|
format(name: string, value: string): void;
|
||||||
};
|
};
|
||||||
formats: (domNode: Element) => Record<string, string>;
|
formats: (domNode: Element) => Record<string, string>;
|
||||||
};
|
};
|
||||||
@@ -53,9 +55,7 @@ let quillLinkSanitizePatched = false;
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
wikis: WikiSnapshot[];
|
|
||||||
setWikis: React.Dispatch<React.SetStateAction<WikiSnapshot[]>>;
|
setWikis: React.Dispatch<React.SetStateAction<WikiSnapshot[]>>;
|
||||||
requestedActiveId?: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function clampTitle(title: string) {
|
function clampTitle(title: string) {
|
||||||
@@ -63,7 +63,13 @@ function clampTitle(title: string) {
|
|||||||
return t.length ? t.slice(0, 120) : "Untitled wiki";
|
return t.length ? t.slice(0, 120) : "Untitled wiki";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WikiSidebarPanel({ projectId, wikis, setWikis, requestedActiveId }: Props) {
|
export default function WikiSidebarPanel({ projectId, setWikis }: Props) {
|
||||||
|
const { wikis, requestedActiveId } = useEditorStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
wikis: state.snapshotWikis,
|
||||||
|
requestedActiveId: state.requestedActiveWikiId,
|
||||||
|
}))
|
||||||
|
);
|
||||||
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 [collapsed, setCollapsed] = useState(false);
|
||||||
@@ -122,13 +128,18 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, requested
|
|||||||
|
|
||||||
const ImageFormat = Quill.import?.("formats/image") as QuillImageFormatCtor | undefined;
|
const ImageFormat = Quill.import?.("formats/image") as QuillImageFormatCtor | undefined;
|
||||||
if (ImageFormat) {
|
if (ImageFormat) {
|
||||||
class CustomImage extends ImageFormat {
|
const BaseImageFormat = ImageFormat;
|
||||||
|
class CustomImage extends BaseImageFormat {
|
||||||
static formats(domNode: Element) {
|
static formats(domNode: Element) {
|
||||||
const formats = ImageFormat.formats(domNode) || {};
|
const formats = BaseImageFormat.formats(domNode) || {};
|
||||||
if (domNode.hasAttribute("style")) formats.style = domNode.getAttribute("style");
|
const style = domNode.getAttribute("style");
|
||||||
if (domNode.hasAttribute("width")) formats.width = domNode.getAttribute("width");
|
const width = domNode.getAttribute("width");
|
||||||
if (domNode.hasAttribute("height")) formats.height = domNode.getAttribute("height");
|
const height = domNode.getAttribute("height");
|
||||||
if (domNode.hasAttribute("class")) formats.class = domNode.getAttribute("class");
|
const className = domNode.getAttribute("class");
|
||||||
|
if (style) formats.style = style;
|
||||||
|
if (width) formats.width = width;
|
||||||
|
if (height) formats.height = height;
|
||||||
|
if (className) formats.class = className;
|
||||||
return formats;
|
return formats;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,11 +302,8 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, requested
|
|||||||
if (!activeId) return;
|
if (!activeId) return;
|
||||||
|
|
||||||
const fmt = detectWikiDocStorageFormat(wikiDocHtml);
|
const fmt = detectWikiDocStorageFormat(wikiDocHtml);
|
||||||
const label = fmt === "json" ? "json" : fmt === "text" ? "txt" : "html";
|
const label = fmt === "text" ? "txt" : "html";
|
||||||
const mime =
|
const mime = fmt === "text"
|
||||||
fmt === "json"
|
|
||||||
? "application/json;charset=utf-8"
|
|
||||||
: fmt === "text"
|
|
||||||
? "text/plain;charset=utf-8"
|
? "text/plain;charset=utf-8"
|
||||||
: "text/html;charset=utf-8";
|
: "text/html;charset=utf-8";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import type { Dispatch, SetStateAction } from "react";
|
|
||||||
import { ApiError } from "@/uhm/api/http";
|
import { ApiError } from "@/uhm/api/http";
|
||||||
import {
|
import {
|
||||||
createProject,
|
createProject,
|
||||||
@@ -11,10 +10,11 @@ import {
|
|||||||
} from "@/uhm/api/projects";
|
} from "@/uhm/api/projects";
|
||||||
import { buildEditorSnapshot, normalizeEditorSnapshot, toApiEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
import { buildEditorSnapshot, normalizeEditorSnapshot, toApiEditorSnapshot } 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, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
|
import type { Feature, FeatureCollection, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
|
||||||
import type { BattleReplay, EditorSnapshot, Project, ProjectCommit, ProjectState, EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
import type { BattleReplay, EditorSnapshot, ProjectCommit, EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
||||||
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";
|
||||||
|
import type { EditorStoreApi } from "@/uhm/store/editorStore";
|
||||||
|
|
||||||
type EditorDraftApi = {
|
type EditorDraftApi = {
|
||||||
draft: FeatureCollection;
|
draft: FeatureCollection;
|
||||||
@@ -27,40 +27,14 @@ type EditorDraftApi = {
|
|||||||
|
|
||||||
type Options = {
|
type Options = {
|
||||||
editor: EditorDraftApi;
|
editor: EditorDraftApi;
|
||||||
editorUserId: string;
|
store: EditorStoreApi;
|
||||||
emptyFeatureCollection: FeatureCollection;
|
emptyFeatureCollection: FeatureCollection;
|
||||||
activeSection: Project | null;
|
|
||||||
projectState: ProjectState | null;
|
|
||||||
selectedProjectId: string;
|
|
||||||
newSectionTitle: string;
|
|
||||||
pendingSaveCount: number;
|
pendingSaveCount: number;
|
||||||
snapshotEntities: EntitySnapshot[];
|
|
||||||
snapshotWikis: WikiSnapshot[];
|
|
||||||
snapshotEntityWikiLinks: EntityWikiLinkSnapshot[];
|
|
||||||
baselineSnapshot: EditorSnapshot | null;
|
|
||||||
commitTitle: string;
|
|
||||||
setActiveSection: Dispatch<SetStateAction<Project | null>>;
|
|
||||||
setSelectedProjectId: Dispatch<SetStateAction<string>>;
|
|
||||||
setProjectState: Dispatch<SetStateAction<ProjectState | null>>;
|
|
||||||
setBaselineSnapshot: Dispatch<SetStateAction<EditorSnapshot | null>>;
|
|
||||||
setInitialData: Dispatch<SetStateAction<FeatureCollection>>;
|
|
||||||
setProjectCommits: Dispatch<SetStateAction<ProjectCommit[]>>;
|
|
||||||
setSnapshotEntities: Dispatch<SetStateAction<EntitySnapshot[]>>;
|
|
||||||
setSnapshotWikis: Dispatch<SetStateAction<WikiSnapshot[]>>;
|
|
||||||
setSnapshotEntityWikiLinks: Dispatch<SetStateAction<EntityWikiLinkSnapshot[]>>;
|
|
||||||
setSelectedFeatureIds: Dispatch<SetStateAction<FeatureId[]>>;
|
|
||||||
setEntityFormStatus: Dispatch<SetStateAction<string | null>>;
|
|
||||||
setEntityStatus: Dispatch<SetStateAction<string | null>>;
|
|
||||||
setIsSaving: Dispatch<SetStateAction<boolean>>;
|
|
||||||
setIsSubmitting: Dispatch<SetStateAction<boolean>>;
|
|
||||||
setIsOpeningSection: Dispatch<SetStateAction<boolean>>;
|
|
||||||
setAvailableSections: Dispatch<SetStateAction<Project[]>>;
|
|
||||||
setNewSectionTitle: Dispatch<SetStateAction<string>>;
|
|
||||||
setCommitTitle: Dispatch<SetStateAction<string>>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useProjectCommands(options: Options) {
|
export function useProjectCommands(options: Options) {
|
||||||
const openSectionForEditing = useCallback(async (projectId: string) => {
|
const openSectionForEditing = useCallback(async (projectId: string) => {
|
||||||
|
const state = options.store.getState();
|
||||||
const editorPayload = await openSectionEditor(projectId);
|
const editorPayload = await openSectionEditor(projectId);
|
||||||
const snapshot = normalizeEditorSnapshot(editorPayload.snapshot);
|
const snapshot = normalizeEditorSnapshot(editorPayload.snapshot);
|
||||||
// When starting a fresh editor session from a commit snapshot, treat all rows as baseline state:
|
// When starting a fresh editor session from a commit snapshot, treat all rows as baseline state:
|
||||||
@@ -69,45 +43,46 @@ export function useProjectCommands(options: Options) {
|
|||||||
const commits = await fetchProjectCommits(projectId);
|
const commits = await fetchProjectCommits(projectId);
|
||||||
const nextInitialData = sessionSnapshot?.editor_feature_collection || options.emptyFeatureCollection;
|
const nextInitialData = sessionSnapshot?.editor_feature_collection || options.emptyFeatureCollection;
|
||||||
|
|
||||||
options.setActiveSection(editorPayload.project);
|
state.setActiveSection(editorPayload.project);
|
||||||
options.setSelectedProjectId(editorPayload.project.id);
|
state.setSelectedProjectId(editorPayload.project.id);
|
||||||
options.setProjectState(editorPayload.state);
|
state.setProjectState(editorPayload.state);
|
||||||
options.setBaselineSnapshot(sessionSnapshot);
|
state.setBaselineSnapshot(sessionSnapshot);
|
||||||
options.setInitialData(nextInitialData);
|
state.setInitialData(nextInitialData);
|
||||||
options.setProjectCommits(commits);
|
state.setProjectCommits(commits);
|
||||||
options.setSnapshotEntities(sessionSnapshot?.entities || []);
|
state.setSnapshotEntities(sessionSnapshot?.entities || []);
|
||||||
options.setSnapshotWikis(sessionSnapshot?.wikis || []);
|
state.setSnapshotWikis(sessionSnapshot?.wikis || []);
|
||||||
options.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []);
|
state.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []);
|
||||||
options.setSelectedFeatureIds([]);
|
state.setSelectedFeatureIds([]);
|
||||||
options.setEntityFormStatus(null);
|
state.setEntityFormStatus(null);
|
||||||
}, [options]);
|
}, [options.emptyFeatureCollection, options.store]);
|
||||||
|
|
||||||
const commitSection = useCallback(async () => {
|
const commitSection = useCallback(async () => {
|
||||||
if (!options.activeSection || !options.projectState) {
|
const state = options.store.getState();
|
||||||
options.setEntityStatus("Chưa mở được project editor.");
|
if (!state.activeSection || !state.projectState) {
|
||||||
|
state.setEntityStatus("Chưa mở được project editor.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (options.pendingSaveCount <= 0) {
|
if (options.pendingSaveCount <= 0) {
|
||||||
options.setEntityStatus("Không có thay đổi để Commit.");
|
state.setEntityStatus("Không có thay đổi để Commit.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const geometryChanges = options.editor.buildPayload();
|
const geometryChanges = options.editor.buildPayload();
|
||||||
options.setIsSaving(true);
|
state.setIsSaving(true);
|
||||||
options.setEntityStatus(null);
|
state.setEntityStatus(null);
|
||||||
try {
|
try {
|
||||||
const snapshot = buildEditorSnapshot({
|
const snapshot = buildEditorSnapshot({
|
||||||
project: options.activeSection,
|
project: state.activeSection,
|
||||||
draft: options.editor.mainDraft,
|
draft: options.editor.mainDraft,
|
||||||
changes: geometryChanges,
|
changes: geometryChanges,
|
||||||
snapshotEntities: options.snapshotEntities,
|
snapshotEntities: state.snapshotEntities,
|
||||||
snapshotWikis: options.snapshotWikis,
|
snapshotWikis: state.snapshotWikis,
|
||||||
snapshotEntityWikiLinks: options.snapshotEntityWikiLinks,
|
snapshotEntityWikiLinks: state.snapshotEntityWikiLinks,
|
||||||
replays: options.editor.replays,
|
replays: options.editor.replays,
|
||||||
previousSnapshot: options.baselineSnapshot,
|
previousSnapshot: state.baselineSnapshot,
|
||||||
hasPersistedFeature: options.editor.hasPersistedFeature,
|
hasPersistedFeature: options.editor.hasPersistedFeature,
|
||||||
});
|
});
|
||||||
const editSummary = options.commitTitle.trim()
|
const editSummary = state.commitTitle.trim()
|
||||||
|| `Edit ${new Date().toLocaleString()}`;
|
|| `Edit ${new Date().toLocaleString()}`;
|
||||||
|
|
||||||
// Guardrail: commit payload can get large and some deployments reject/close connections for big bodies.
|
// Guardrail: commit payload can get large and some deployments reject/close connections for big bodies.
|
||||||
@@ -117,7 +92,7 @@ export function useProjectCommands(options: Options) {
|
|||||||
const bytes = typeof Blob !== "undefined" ? new Blob([payloadText]).size : payloadText.length;
|
const bytes = typeof Blob !== "undefined" ? new Blob([payloadText]).size : payloadText.length;
|
||||||
const limitBytes = 3_500_000; // ~3.5MB (conservative vs common default body limits)
|
const limitBytes = 3_500_000; // ~3.5MB (conservative vs common default body limits)
|
||||||
if (bytes > limitBytes) {
|
if (bytes > limitBytes) {
|
||||||
options.setEntityStatus(
|
state.setEntityStatus(
|
||||||
`Commit payload quá lớn (~${(bytes / (1024 * 1024)).toFixed(2)}MB). ` +
|
`Commit payload quá lớn (~${(bytes / (1024 * 1024)).toFixed(2)}MB). ` +
|
||||||
`Hãy giảm bớt nội dung snapshot/changes hoặc chạy BE local với body limit lớn hơn.`
|
`Hãy giảm bớt nội dung snapshot/changes hoặc chạy BE local với body limit lớn hơn.`
|
||||||
);
|
);
|
||||||
@@ -127,39 +102,40 @@ export function useProjectCommands(options: Options) {
|
|||||||
// If stringify fails, let API call throw a more actionable error downstream.
|
// If stringify fails, let API call throw a more actionable error downstream.
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await createProjectCommit(options.activeSection.id, {
|
const result = await createProjectCommit(state.activeSection.id, {
|
||||||
snapshot,
|
snapshot,
|
||||||
edit_summary: editSummary,
|
edit_summary: editSummary,
|
||||||
});
|
});
|
||||||
|
|
||||||
const sessionSnapshot = toEditorSessionSnapshot(snapshot);
|
const sessionSnapshot = toEditorSessionSnapshot(snapshot);
|
||||||
options.setProjectState(result.state);
|
state.setProjectState(result.state);
|
||||||
options.setBaselineSnapshot(sessionSnapshot);
|
state.setBaselineSnapshot(sessionSnapshot);
|
||||||
options.setSnapshotEntities(sessionSnapshot.entities || []);
|
state.setSnapshotEntities(sessionSnapshot.entities || []);
|
||||||
options.setSnapshotWikis(sessionSnapshot.wikis || []);
|
state.setSnapshotWikis(sessionSnapshot.wikis || []);
|
||||||
options.setSnapshotEntityWikiLinks(sessionSnapshot.entity_wiki || []);
|
state.setSnapshotEntityWikiLinks(sessionSnapshot.entity_wiki || []);
|
||||||
options.setInitialData(options.editor.draft);
|
state.setInitialData(options.editor.draft);
|
||||||
options.editor.clearChanges();
|
options.editor.clearChanges();
|
||||||
options.setCommitTitle("");
|
state.setCommitTitle("");
|
||||||
options.setProjectCommits(await fetchProjectCommits(options.activeSection.id));
|
state.setProjectCommits(await fetchProjectCommits(state.activeSection.id));
|
||||||
options.setEntityFormStatus("Đã tạo commit.");
|
state.setEntityFormStatus("Đã tạo commit.");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
console.error("Commit failed", err.body);
|
console.error("Commit failed", err.body);
|
||||||
options.setEntityStatus(`Commit thất bại: ${err.body}`);
|
state.setEntityStatus(`Commit thất bại: ${err.body}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.error("Commit error", err);
|
console.error("Commit error", err);
|
||||||
options.setEntityStatus("Commit thất bại.");
|
state.setEntityStatus("Commit thất bại.");
|
||||||
} finally {
|
} finally {
|
||||||
options.setIsSaving(false);
|
state.setIsSaving(false);
|
||||||
}
|
}
|
||||||
}, [options]);
|
}, [options.editor, options.pendingSaveCount, options.store]);
|
||||||
|
|
||||||
const openSelectedSection = useCallback(async () => {
|
const openSelectedSection = useCallback(async () => {
|
||||||
const projectId = options.selectedProjectId.trim();
|
const state = options.store.getState();
|
||||||
|
const projectId = state.selectedProjectId.trim();
|
||||||
if (!projectId) {
|
if (!projectId) {
|
||||||
options.setEntityStatus("Hãy chọn project để mở.");
|
state.setEntityStatus("Hãy chọn project để mở.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (options.pendingSaveCount > 0) {
|
if (options.pendingSaveCount > 0) {
|
||||||
@@ -167,26 +143,27 @@ export function useProjectCommands(options: Options) {
|
|||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
options.setIsOpeningSection(true);
|
state.setIsOpeningSection(true);
|
||||||
options.setEntityStatus(null);
|
state.setEntityStatus(null);
|
||||||
try {
|
try {
|
||||||
await openSectionForEditing(projectId);
|
await openSectionForEditing(projectId);
|
||||||
options.setEntityStatus("Đã mở project để chỉnh sửa.");
|
state.setEntityStatus("Đã mở project để chỉnh sửa.");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
options.setEntityStatus(`Mở project thất bại: ${err.body}`);
|
state.setEntityStatus(`Mở project thất bại: ${err.body}`);
|
||||||
} else {
|
} else {
|
||||||
options.setEntityStatus("Mở project thất bại.");
|
state.setEntityStatus("Mở project thất bại.");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
options.setIsOpeningSection(false);
|
state.setIsOpeningSection(false);
|
||||||
}
|
}
|
||||||
}, [openSectionForEditing, options]);
|
}, [openSectionForEditing, options.pendingSaveCount, options.store]);
|
||||||
|
|
||||||
const createAndOpenSection = useCallback(async () => {
|
const createAndOpenSection = useCallback(async () => {
|
||||||
const title = options.newSectionTitle.trim();
|
const state = options.store.getState();
|
||||||
|
const title = state.newSectionTitle.trim();
|
||||||
if (!title) {
|
if (!title) {
|
||||||
options.setEntityStatus("Tên project là bắt buộc.");
|
state.setEntityStatus("Tên project là bắt buộc.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (options.pendingSaveCount > 0) {
|
if (options.pendingSaveCount > 0) {
|
||||||
@@ -194,74 +171,76 @@ export function useProjectCommands(options: Options) {
|
|||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
options.setIsOpeningSection(true);
|
state.setIsOpeningSection(true);
|
||||||
options.setEntityStatus(null);
|
state.setEntityStatus(null);
|
||||||
try {
|
try {
|
||||||
const project = await createProject({
|
const project = await createProject({
|
||||||
title,
|
title,
|
||||||
description: null,
|
description: null,
|
||||||
});
|
});
|
||||||
const projects = await fetchProjects();
|
const projects = await fetchProjects();
|
||||||
options.setAvailableSections(projects);
|
state.setAvailableSections(projects);
|
||||||
options.setNewSectionTitle("");
|
state.setNewSectionTitle("");
|
||||||
await openSectionForEditing(project.id);
|
await openSectionForEditing(project.id);
|
||||||
options.setEntityStatus("Đã tạo và mở project mới.");
|
state.setEntityStatus("Đã tạo và mở project mới.");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
options.setEntityStatus(`Tạo project thất bại: ${err.body}`);
|
state.setEntityStatus(`Tạo project thất bại: ${err.body}`);
|
||||||
} else {
|
} else {
|
||||||
options.setEntityStatus("Tạo project thất bại.");
|
state.setEntityStatus("Tạo project thất bại.");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
options.setIsOpeningSection(false);
|
state.setIsOpeningSection(false);
|
||||||
}
|
}
|
||||||
}, [openSectionForEditing, options]);
|
}, [openSectionForEditing, options.pendingSaveCount, options.store]);
|
||||||
|
|
||||||
const submitCurrentSection = useCallback(async (content: string) => {
|
const submitCurrentSection = useCallback(async (content: string) => {
|
||||||
if (!options.activeSection || !options.projectState?.head_commit_id) {
|
const state = options.store.getState();
|
||||||
options.setEntityStatus("Project hiện tại chưa có head để submit.");
|
if (!state.activeSection || !state.projectState?.head_commit_id) {
|
||||||
|
state.setEntityStatus("Project hiện tại chưa có head để submit.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (options.pendingSaveCount > 0) {
|
if (options.pendingSaveCount > 0) {
|
||||||
options.setEntityStatus("Hãy Commit các thay đổi trước khi Submit.");
|
state.setEntityStatus("Hãy Commit các thay đổi trước khi Submit.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
options.setIsSubmitting(true);
|
state.setIsSubmitting(true);
|
||||||
options.setEntityStatus(null);
|
state.setEntityStatus(null);
|
||||||
try {
|
try {
|
||||||
const submission = await submitSection(options.activeSection.id, content);
|
const submission = await submitSection(state.activeSection.id, content);
|
||||||
options.setEntityStatus(`Đã submit, submission ${submission.id}.`);
|
state.setEntityStatus(`Đã submit, submission ${submission.id}.`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
options.setEntityStatus(`Submit thất bại: ${err.body}`);
|
state.setEntityStatus(`Submit thất bại: ${err.body}`);
|
||||||
} else {
|
} else {
|
||||||
options.setEntityStatus("Submit thất bại.");
|
state.setEntityStatus("Submit thất bại.");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
options.setIsSubmitting(false);
|
state.setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
}, [options]);
|
}, [options.pendingSaveCount, options.store]);
|
||||||
|
|
||||||
const restoreCommit = useCallback(async (commitId: string) => {
|
const restoreCommit = useCallback(async (commitId: string) => {
|
||||||
if (!options.activeSection || !options.projectState) {
|
const state = options.store.getState();
|
||||||
options.setEntityStatus("Chưa mở được project editor.");
|
if (!state.activeSection || !state.projectState) {
|
||||||
|
state.setEntityStatus("Chưa mở được project editor.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (options.pendingSaveCount > 0) {
|
if (options.pendingSaveCount > 0) {
|
||||||
options.setEntityStatus("Hãy Commit hoặc Undo thay đổi hiện tại trước khi Restore.");
|
state.setEntityStatus("Hãy Commit hoặc Undo thay đổi hiện tại trước khi Restore.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
options.setIsSaving(true);
|
state.setIsSaving(true);
|
||||||
options.setEntityStatus(null);
|
state.setEntityStatus(null);
|
||||||
try {
|
try {
|
||||||
// FE-only restore: load snapshot from selected commit and apply to editor state.
|
// FE-only restore: load snapshot from selected commit and apply to editor state.
|
||||||
// Do NOT move project's head commit on backend.
|
// Do NOT move project's head commit on backend.
|
||||||
const commits = await fetchProjectCommits(options.activeSection.id);
|
const commits = await fetchProjectCommits(state.activeSection.id);
|
||||||
const target = commits.find((c: ProjectCommit) => c.id === commitId) || null;
|
const target = commits.find((c: ProjectCommit) => c.id === commitId) || null;
|
||||||
if (!target) {
|
if (!target) {
|
||||||
options.setEntityStatus("Không tìm thấy commit để restore.");
|
state.setEntityStatus("Không tìm thấy commit để restore.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,27 +248,27 @@ export function useProjectCommands(options: Options) {
|
|||||||
const sessionSnapshot = snapshot ? toEditorSessionSnapshot(snapshot) : null;
|
const sessionSnapshot = snapshot ? toEditorSessionSnapshot(snapshot) : null;
|
||||||
const nextInitialData = sessionSnapshot?.editor_feature_collection || options.emptyFeatureCollection;
|
const nextInitialData = sessionSnapshot?.editor_feature_collection || options.emptyFeatureCollection;
|
||||||
|
|
||||||
options.setBaselineSnapshot(sessionSnapshot);
|
state.setBaselineSnapshot(sessionSnapshot);
|
||||||
options.setInitialData(nextInitialData);
|
state.setInitialData(nextInitialData);
|
||||||
options.setSnapshotEntities(sessionSnapshot?.entities || []);
|
state.setSnapshotEntities(sessionSnapshot?.entities || []);
|
||||||
options.setSnapshotWikis(sessionSnapshot?.wikis || []);
|
state.setSnapshotWikis(sessionSnapshot?.wikis || []);
|
||||||
options.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []);
|
state.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []);
|
||||||
options.setSelectedFeatureIds([]);
|
state.setSelectedFeatureIds([]);
|
||||||
options.setEntityFormStatus(null);
|
state.setEntityFormStatus(null);
|
||||||
|
|
||||||
// Refresh commits list for UI, but keep projectState/head as-is.
|
// Refresh commits list for UI, but keep projectState/head as-is.
|
||||||
options.setProjectCommits(commits);
|
state.setProjectCommits(commits);
|
||||||
options.setEntityFormStatus("Đã load snapshot từ commit (không đổi head trên BE).");
|
state.setEntityFormStatus("Đã load snapshot từ commit (không đổi head trên BE).");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
options.setEntityStatus(`Restore thất bại: ${err.body}`);
|
state.setEntityStatus(`Restore thất bại: ${err.body}`);
|
||||||
} else {
|
} else {
|
||||||
options.setEntityStatus("Restore thất bại.");
|
state.setEntityStatus("Restore thất bại.");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
options.setIsSaving(false);
|
state.setIsSaving(false);
|
||||||
}
|
}
|
||||||
}, [options]);
|
}, [options.emptyFeatureCollection, options.pendingSaveCount, options.store]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
openSectionForEditing,
|
openSectionForEditing,
|
||||||
@@ -312,17 +291,21 @@ function toEditorSessionSnapshot(snapshot: EditorSnapshot): EditorSnapshot {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EditorEntityRow = NonNullable<EditorSnapshot["entities"]>[number];
|
||||||
|
type EditorGeometryRow = NonNullable<EditorSnapshot["geometries"]>[number];
|
||||||
|
type EditorGeometryEntityRow = NonNullable<EditorSnapshot["geometry_entity"]>[number];
|
||||||
|
type EditorWikiRow = NonNullable<EditorSnapshot["wikis"]>[number];
|
||||||
|
|
||||||
function toEditorSessionEntities(input: EditorSnapshot["entities"]): EntitySnapshot[] {
|
function toEditorSessionEntities(input: EditorSnapshot["entities"]): EntitySnapshot[] {
|
||||||
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 is EditorEntityRow => Boolean(e) && (typeof e.id === "string" || typeof e.id === "number"))
|
||||||
.filter((e) => (e as any).operation !== "delete")
|
.filter((e) => e.operation !== "delete")
|
||||||
.map((e) => {
|
.map((e) => {
|
||||||
const { operation: _op, ...rest } = e;
|
|
||||||
const id = String(e.id);
|
const id = String(e.id);
|
||||||
const source: EntitySnapshot["source"] = e.source === "inline" ? "inline" : "ref";
|
const source: EntitySnapshot["source"] = e.source === "inline" ? "inline" : "ref";
|
||||||
return {
|
return {
|
||||||
...(rest as Omit<EntitySnapshot, "id" | "source" | "operation">),
|
...e,
|
||||||
id,
|
id,
|
||||||
source,
|
source,
|
||||||
operation: "reference",
|
operation: "reference",
|
||||||
@@ -333,14 +316,13 @@ function toEditorSessionEntities(input: EditorSnapshot["entities"]): EntitySnaps
|
|||||||
function toEditorSessionGeometries(input: EditorSnapshot["geometries"]): GeometrySnapshot[] {
|
function toEditorSessionGeometries(input: EditorSnapshot["geometries"]): GeometrySnapshot[] {
|
||||||
const rows = Array.isArray(input) ? input : [];
|
const rows = Array.isArray(input) ? input : [];
|
||||||
return rows
|
return rows
|
||||||
.filter((g) => g && (typeof (g as any).id === "string" || typeof (g as any).id === "number"))
|
.filter((g): g is EditorGeometryRow => Boolean(g) && (typeof g.id === "string" || typeof g.id === "number"))
|
||||||
.filter((g) => (g as any).operation !== "delete")
|
.filter((g) => g.operation !== "delete")
|
||||||
.map((g) => {
|
.map((g) => {
|
||||||
const { operation: _op, ...rest } = g as any;
|
const id = String(g.id);
|
||||||
const id = String((g as any).id);
|
const source: GeometrySnapshot["source"] = g.source === "inline" ? "inline" : "ref";
|
||||||
const source: GeometrySnapshot["source"] = (g as any).source === "inline" ? "inline" : "ref";
|
|
||||||
return {
|
return {
|
||||||
...(rest as Omit<GeometrySnapshot, "id" | "source" | "operation">),
|
...g,
|
||||||
id,
|
id,
|
||||||
source,
|
source,
|
||||||
operation: "reference",
|
operation: "reference",
|
||||||
@@ -353,16 +335,22 @@ function toEditorSessionGeometryEntity(input: EditorSnapshot["geometry_entity"])
|
|||||||
const deduped = new globalThis.Map<string, GeometryEntitySnapshot>();
|
const deduped = new globalThis.Map<string, GeometryEntitySnapshot>();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (!row) continue;
|
if (!row) continue;
|
||||||
if ((row as any).operation === "delete") continue;
|
const safeRow = row as EditorGeometryEntityRow;
|
||||||
const geometry_id = typeof (row as any).geometry_id === "string" || typeof (row as any).geometry_id === "number"
|
if (safeRow.operation === "delete") continue;
|
||||||
? String((row as any).geometry_id).trim()
|
const geometry_id = typeof safeRow.geometry_id === "string" || typeof safeRow.geometry_id === "number"
|
||||||
|
? String(safeRow.geometry_id).trim()
|
||||||
: "";
|
: "";
|
||||||
const entity_id = typeof (row as any).entity_id === "string" || typeof (row as any).entity_id === "number"
|
const entity_id = typeof safeRow.entity_id === "string" || typeof safeRow.entity_id === "number"
|
||||||
? String((row as any).entity_id).trim()
|
? String(safeRow.entity_id).trim()
|
||||||
: "";
|
: "";
|
||||||
if (!geometry_id || !entity_id) continue;
|
if (!geometry_id || !entity_id) continue;
|
||||||
const key = `${geometry_id}::${entity_id}`;
|
const key = `${geometry_id}::${entity_id}`;
|
||||||
deduped.set(key, { geometry_id, entity_id, operation: "reference", base_links_hash: (row as any).base_links_hash });
|
deduped.set(key, {
|
||||||
|
geometry_id,
|
||||||
|
entity_id,
|
||||||
|
operation: "reference",
|
||||||
|
base_links_hash: safeRow.base_links_hash,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return Array.from(deduped.values()).sort((a, b) => {
|
return Array.from(deduped.values()).sort((a, b) => {
|
||||||
const g = a.geometry_id.localeCompare(b.geometry_id);
|
const g = a.geometry_id.localeCompare(b.geometry_id);
|
||||||
@@ -374,13 +362,12 @@ function toEditorSessionGeometryEntity(input: EditorSnapshot["geometry_entity"])
|
|||||||
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 is EditorWikiRow => Boolean(w) && typeof w.id === "string" && w.id.trim().length > 0)
|
||||||
.filter((w) => (w as any).operation !== "delete")
|
.filter((w) => w.operation !== "delete")
|
||||||
.map((w) => {
|
.map((w) => {
|
||||||
const { operation: _op, ...rest } = w;
|
|
||||||
const source: WikiSnapshot["source"] = w.source === "inline" ? "inline" : "ref";
|
const source: WikiSnapshot["source"] = w.source === "inline" ? "inline" : "ref";
|
||||||
return {
|
return {
|
||||||
...(rest as Omit<WikiSnapshot, "source" | "operation">),
|
...w,
|
||||||
source,
|
source,
|
||||||
operation: "reference",
|
operation: "reference",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,355 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
type SetStateAction,
|
||||||
|
} from "react";
|
||||||
|
import { useStore } from "zustand";
|
||||||
|
import { createStore, type StoreApi } from "zustand/vanilla";
|
||||||
|
import type { EntityGeometriesSearchItem } from "@/uhm/api/geometries";
|
||||||
|
import type { Wiki } from "@/uhm/api/wikis";
|
||||||
|
import type { FeatureCollection, FeatureId } from "@/uhm/types/geo";
|
||||||
|
import type { Entity, EntitySnapshot } from "@/uhm/types/entities";
|
||||||
|
import type { EntityWikiLinkSnapshot, EditorSnapshot, Project, ProjectCommit, ProjectState } from "@/uhm/types/projects";
|
||||||
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
|
import type {
|
||||||
|
EditorMode,
|
||||||
|
EntityFormState,
|
||||||
|
GeometryMetaFormState,
|
||||||
|
TimelineRange,
|
||||||
|
} from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
import type { UnifiedSearchKind } from "@/uhm/components/ui/UnifiedSearchBar";
|
||||||
|
import {
|
||||||
|
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
||||||
|
type BackgroundLayerVisibility,
|
||||||
|
} from "@/uhm/lib/map/styles/backgroundLayers";
|
||||||
|
import { GEO_TYPE_KEYS } from "@/uhm/lib/map/geo/geoTypeMap";
|
||||||
|
import { clampYearValue } from "@/uhm/lib/utils/timeline";
|
||||||
|
|
||||||
|
export type GeometryFocusRequest = {
|
||||||
|
key: number;
|
||||||
|
collection: FeatureCollection;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EditorStoreValues = {
|
||||||
|
mode: EditorMode;
|
||||||
|
initialData: FeatureCollection;
|
||||||
|
isSaving: boolean;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
isOpeningSection: boolean;
|
||||||
|
availableSections: Project[];
|
||||||
|
selectedProjectId: string;
|
||||||
|
newSectionTitle: string;
|
||||||
|
commitTitle: string;
|
||||||
|
editorUserIdInput: string;
|
||||||
|
activeSection: Project | null;
|
||||||
|
projectState: ProjectState | null;
|
||||||
|
sectionCommits: ProjectCommit[];
|
||||||
|
baselineSnapshot: EditorSnapshot | null;
|
||||||
|
entityCatalog: Entity[];
|
||||||
|
snapshotEntities: EntitySnapshot[];
|
||||||
|
entityStatus: string | null;
|
||||||
|
selectedFeatureIds: FeatureId[];
|
||||||
|
entityForm: EntityFormState;
|
||||||
|
selectedGeometryEntityIds: string[];
|
||||||
|
geometryMetaForm: GeometryMetaFormState;
|
||||||
|
isEntitySubmitting: boolean;
|
||||||
|
entityFormStatus: string | null;
|
||||||
|
entitySearchResults: Entity[];
|
||||||
|
isEntitySearchLoading: boolean;
|
||||||
|
timelineDraftYear: number;
|
||||||
|
backgroundVisibility: BackgroundLayerVisibility;
|
||||||
|
isBackgroundVisibilityReady: boolean;
|
||||||
|
snapshotWikis: WikiSnapshot[];
|
||||||
|
snapshotEntityWikiLinks: EntityWikiLinkSnapshot[];
|
||||||
|
blockedPendingSubmissionId: string | null;
|
||||||
|
searchKind: UnifiedSearchKind;
|
||||||
|
searchQuery: string;
|
||||||
|
searchQueryDraft: string;
|
||||||
|
wikiSearchResults: Wiki[];
|
||||||
|
isWikiSearching: boolean;
|
||||||
|
geoSearchResults: EntityGeometriesSearchItem[];
|
||||||
|
isGeoSearching: boolean;
|
||||||
|
requestedActiveWikiId: string | null;
|
||||||
|
leftPanelWidth: number;
|
||||||
|
rightPanelWidth: number;
|
||||||
|
timelineFilterEnabled: boolean;
|
||||||
|
geometryBindingFilterEnabled: boolean;
|
||||||
|
geoBindingStatus: string | null;
|
||||||
|
hoveredGeometryId: string | null;
|
||||||
|
geometryFocusRequest: GeometryFocusRequest | null;
|
||||||
|
replayFeatureId: string | number | null;
|
||||||
|
hideOutside: boolean;
|
||||||
|
geometryVisibility: Record<string, boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EditorStoreActions = {
|
||||||
|
setMode: (next: SetStateAction<EditorMode>) => void;
|
||||||
|
setInitialData: (next: SetStateAction<FeatureCollection>) => void;
|
||||||
|
setIsSaving: (next: SetStateAction<boolean>) => void;
|
||||||
|
setIsSubmitting: (next: SetStateAction<boolean>) => void;
|
||||||
|
setIsOpeningSection: (next: SetStateAction<boolean>) => void;
|
||||||
|
setAvailableSections: (next: SetStateAction<Project[]>) => void;
|
||||||
|
setSelectedProjectId: (next: SetStateAction<string>) => void;
|
||||||
|
setNewSectionTitle: (next: SetStateAction<string>) => void;
|
||||||
|
setCommitTitle: (next: SetStateAction<string>) => void;
|
||||||
|
setEditorUserIdInput: (next: SetStateAction<string>) => void;
|
||||||
|
setActiveSection: (next: SetStateAction<Project | null>) => void;
|
||||||
|
setProjectState: (next: SetStateAction<ProjectState | null>) => void;
|
||||||
|
setProjectCommits: (next: SetStateAction<ProjectCommit[]>) => void;
|
||||||
|
setBaselineSnapshot: (next: SetStateAction<EditorSnapshot | null>) => void;
|
||||||
|
setEntityCatalog: (next: SetStateAction<Entity[]>) => void;
|
||||||
|
setSnapshotEntities: (next: SetStateAction<EntitySnapshot[]>) => void;
|
||||||
|
setEntityStatus: (next: SetStateAction<string | null>) => void;
|
||||||
|
setSelectedFeatureIds: (next: SetStateAction<FeatureId[]>) => void;
|
||||||
|
setEntityForm: (next: SetStateAction<EntityFormState>) => void;
|
||||||
|
setSelectedGeometryEntityIds: (next: SetStateAction<string[]>) => void;
|
||||||
|
setGeometryMetaForm: (next: SetStateAction<GeometryMetaFormState>) => void;
|
||||||
|
setIsEntitySubmitting: (next: SetStateAction<boolean>) => void;
|
||||||
|
setEntityFormStatus: (next: SetStateAction<string | null>) => void;
|
||||||
|
setEntitySearchResults: (next: SetStateAction<Entity[]>) => void;
|
||||||
|
setIsEntitySearchLoading: (next: SetStateAction<boolean>) => void;
|
||||||
|
setTimelineDraftYear: (next: SetStateAction<number>) => void;
|
||||||
|
setBackgroundVisibility: (next: SetStateAction<BackgroundLayerVisibility>) => void;
|
||||||
|
setIsBackgroundVisibilityReady: (next: SetStateAction<boolean>) => void;
|
||||||
|
setSnapshotWikis: (next: SetStateAction<WikiSnapshot[]>) => void;
|
||||||
|
setSnapshotEntityWikiLinks: (next: SetStateAction<EntityWikiLinkSnapshot[]>) => void;
|
||||||
|
setBlockedPendingSubmissionId: (next: SetStateAction<string | null>) => void;
|
||||||
|
setSearchKind: (next: SetStateAction<UnifiedSearchKind>) => void;
|
||||||
|
setSearchQuery: (next: SetStateAction<string>) => void;
|
||||||
|
setSearchQueryDraft: (next: SetStateAction<string>) => void;
|
||||||
|
setWikiSearchResults: (next: SetStateAction<Wiki[]>) => void;
|
||||||
|
setIsWikiSearching: (next: SetStateAction<boolean>) => void;
|
||||||
|
setGeoSearchResults: (next: SetStateAction<EntityGeometriesSearchItem[]>) => void;
|
||||||
|
setIsGeoSearching: (next: SetStateAction<boolean>) => void;
|
||||||
|
setRequestedActiveWikiId: (next: SetStateAction<string | null>) => void;
|
||||||
|
setLeftPanelWidth: (next: SetStateAction<number>) => void;
|
||||||
|
setRightPanelWidth: (next: SetStateAction<number>) => void;
|
||||||
|
setTimelineFilterEnabled: (next: SetStateAction<boolean>) => void;
|
||||||
|
setGeometryBindingFilterEnabled: (next: SetStateAction<boolean>) => void;
|
||||||
|
setGeoBindingStatus: (next: SetStateAction<string | null>) => void;
|
||||||
|
setHoveredGeometryId: (next: SetStateAction<string | null>) => void;
|
||||||
|
setGeometryFocusRequest: (next: SetStateAction<GeometryFocusRequest | null>) => void;
|
||||||
|
setReplayFeatureId: (next: SetStateAction<string | number | null>) => void;
|
||||||
|
setHideOutside: (next: SetStateAction<boolean>) => void;
|
||||||
|
setGeometryVisibility: (next: SetStateAction<Record<string, boolean>>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EditorStoreState = EditorStoreValues & EditorStoreActions;
|
||||||
|
export type EditorStoreApi = StoreApi<EditorStoreState>;
|
||||||
|
|
||||||
|
export type EditorStoreOptions = {
|
||||||
|
emptyFeatureCollection: FeatureCollection;
|
||||||
|
defaultEditorUserId: string;
|
||||||
|
fallbackTimelineRange: TimelineRange;
|
||||||
|
currentYear: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveNextState<T>(next: SetStateAction<T>, prev: T): T {
|
||||||
|
return typeof next === "function" ? (next as (prevState: T) => T)(prev) : next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInitialGeometryVisibility() {
|
||||||
|
const next: Record<string, boolean> = {};
|
||||||
|
for (const key of GEO_TYPE_KEYS) {
|
||||||
|
next[key] = true;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEditorStore(options: EditorStoreOptions): EditorStoreApi {
|
||||||
|
const initialTimelineYear = clampYearValue(
|
||||||
|
options.currentYear,
|
||||||
|
options.fallbackTimelineRange.min,
|
||||||
|
options.fallbackTimelineRange.max
|
||||||
|
);
|
||||||
|
|
||||||
|
return createStore<EditorStoreState>()((set) => {
|
||||||
|
const setValue = <K extends keyof EditorStoreValues>(
|
||||||
|
key: K,
|
||||||
|
next: SetStateAction<EditorStoreValues[K]>
|
||||||
|
) => {
|
||||||
|
set((state) => ({
|
||||||
|
[key]: resolveNextState(next, state[key]),
|
||||||
|
} as Pick<EditorStoreValues, K>));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTaskFlag = (
|
||||||
|
task: "saving" | "submitting" | "opening-project",
|
||||||
|
next: SetStateAction<boolean>
|
||||||
|
) => {
|
||||||
|
set((state) => {
|
||||||
|
const currentValue =
|
||||||
|
task === "saving"
|
||||||
|
? state.isSaving
|
||||||
|
: task === "submitting"
|
||||||
|
? state.isSubmitting
|
||||||
|
: state.isOpeningSection;
|
||||||
|
const nextValue = resolveNextState(next, currentValue);
|
||||||
|
|
||||||
|
if (nextValue) {
|
||||||
|
return {
|
||||||
|
isSaving: task === "saving",
|
||||||
|
isSubmitting: task === "submitting",
|
||||||
|
isOpeningSection: task === "opening-project",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentValue) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task === "saving") return { isSaving: false };
|
||||||
|
if (task === "submitting") return { isSubmitting: false };
|
||||||
|
return { isOpeningSection: false };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: "idle",
|
||||||
|
initialData: options.emptyFeatureCollection,
|
||||||
|
isSaving: false,
|
||||||
|
isSubmitting: false,
|
||||||
|
isOpeningSection: false,
|
||||||
|
availableSections: [],
|
||||||
|
selectedProjectId: "",
|
||||||
|
newSectionTitle: "",
|
||||||
|
commitTitle: "",
|
||||||
|
editorUserIdInput: options.defaultEditorUserId,
|
||||||
|
activeSection: null,
|
||||||
|
projectState: null,
|
||||||
|
sectionCommits: [],
|
||||||
|
baselineSnapshot: null,
|
||||||
|
entityCatalog: [],
|
||||||
|
snapshotEntities: [],
|
||||||
|
entityStatus: null,
|
||||||
|
selectedFeatureIds: [],
|
||||||
|
entityForm: {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
},
|
||||||
|
selectedGeometryEntityIds: [],
|
||||||
|
geometryMetaForm: {
|
||||||
|
type_key: "",
|
||||||
|
time_start: "",
|
||||||
|
time_end: "",
|
||||||
|
binding: "",
|
||||||
|
},
|
||||||
|
isEntitySubmitting: false,
|
||||||
|
entityFormStatus: null,
|
||||||
|
entitySearchResults: [],
|
||||||
|
isEntitySearchLoading: false,
|
||||||
|
timelineDraftYear: initialTimelineYear,
|
||||||
|
backgroundVisibility: { ...HIDDEN_BACKGROUND_LAYER_VISIBILITY },
|
||||||
|
isBackgroundVisibilityReady: false,
|
||||||
|
snapshotWikis: [],
|
||||||
|
snapshotEntityWikiLinks: [],
|
||||||
|
blockedPendingSubmissionId: null,
|
||||||
|
searchKind: "entity",
|
||||||
|
searchQuery: "",
|
||||||
|
searchQueryDraft: "",
|
||||||
|
wikiSearchResults: [],
|
||||||
|
isWikiSearching: false,
|
||||||
|
geoSearchResults: [],
|
||||||
|
isGeoSearching: false,
|
||||||
|
requestedActiveWikiId: null,
|
||||||
|
leftPanelWidth: 280,
|
||||||
|
rightPanelWidth: 420,
|
||||||
|
timelineFilterEnabled: true,
|
||||||
|
geometryBindingFilterEnabled: true,
|
||||||
|
geoBindingStatus: null,
|
||||||
|
hoveredGeometryId: null,
|
||||||
|
geometryFocusRequest: null,
|
||||||
|
replayFeatureId: null,
|
||||||
|
hideOutside: false,
|
||||||
|
geometryVisibility: buildInitialGeometryVisibility(),
|
||||||
|
setMode: (next) => setValue("mode", next),
|
||||||
|
setInitialData: (next) => setValue("initialData", next),
|
||||||
|
setIsSaving: (next) => setTaskFlag("saving", next),
|
||||||
|
setIsSubmitting: (next) => setTaskFlag("submitting", next),
|
||||||
|
setIsOpeningSection: (next) => setTaskFlag("opening-project", next),
|
||||||
|
setAvailableSections: (next) => setValue("availableSections", next),
|
||||||
|
setSelectedProjectId: (next) => setValue("selectedProjectId", next),
|
||||||
|
setNewSectionTitle: (next) => setValue("newSectionTitle", next),
|
||||||
|
setCommitTitle: (next) => setValue("commitTitle", next),
|
||||||
|
setEditorUserIdInput: (next) => setValue("editorUserIdInput", next),
|
||||||
|
setActiveSection: (next) => setValue("activeSection", next),
|
||||||
|
setProjectState: (next) => setValue("projectState", next),
|
||||||
|
setProjectCommits: (next) => setValue("sectionCommits", next),
|
||||||
|
setBaselineSnapshot: (next) => setValue("baselineSnapshot", next),
|
||||||
|
setEntityCatalog: (next) => setValue("entityCatalog", next),
|
||||||
|
setSnapshotEntities: (next) => setValue("snapshotEntities", next),
|
||||||
|
setEntityStatus: (next) => setValue("entityStatus", next),
|
||||||
|
setSelectedFeatureIds: (next) => setValue("selectedFeatureIds", next),
|
||||||
|
setEntityForm: (next) => setValue("entityForm", next),
|
||||||
|
setSelectedGeometryEntityIds: (next) => setValue("selectedGeometryEntityIds", next),
|
||||||
|
setGeometryMetaForm: (next) => setValue("geometryMetaForm", next),
|
||||||
|
setIsEntitySubmitting: (next) => setValue("isEntitySubmitting", next),
|
||||||
|
setEntityFormStatus: (next) => setValue("entityFormStatus", next),
|
||||||
|
setEntitySearchResults: (next) => setValue("entitySearchResults", next),
|
||||||
|
setIsEntitySearchLoading: (next) => setValue("isEntitySearchLoading", next),
|
||||||
|
setTimelineDraftYear: (next) => setValue("timelineDraftYear", next),
|
||||||
|
setBackgroundVisibility: (next) => setValue("backgroundVisibility", next),
|
||||||
|
setIsBackgroundVisibilityReady: (next) => setValue("isBackgroundVisibilityReady", next),
|
||||||
|
setSnapshotWikis: (next) => setValue("snapshotWikis", next),
|
||||||
|
setSnapshotEntityWikiLinks: (next) => setValue("snapshotEntityWikiLinks", next),
|
||||||
|
setBlockedPendingSubmissionId: (next) => setValue("blockedPendingSubmissionId", next),
|
||||||
|
setSearchKind: (next) => setValue("searchKind", next),
|
||||||
|
setSearchQuery: (next) => setValue("searchQuery", next),
|
||||||
|
setSearchQueryDraft: (next) => setValue("searchQueryDraft", next),
|
||||||
|
setWikiSearchResults: (next) => setValue("wikiSearchResults", next),
|
||||||
|
setIsWikiSearching: (next) => setValue("isWikiSearching", next),
|
||||||
|
setGeoSearchResults: (next) => setValue("geoSearchResults", next),
|
||||||
|
setIsGeoSearching: (next) => setValue("isGeoSearching", next),
|
||||||
|
setRequestedActiveWikiId: (next) => setValue("requestedActiveWikiId", next),
|
||||||
|
setLeftPanelWidth: (next) => setValue("leftPanelWidth", next),
|
||||||
|
setRightPanelWidth: (next) => setValue("rightPanelWidth", next),
|
||||||
|
setTimelineFilterEnabled: (next) => setValue("timelineFilterEnabled", next),
|
||||||
|
setGeometryBindingFilterEnabled: (next) => setValue("geometryBindingFilterEnabled", next),
|
||||||
|
setGeoBindingStatus: (next) => setValue("geoBindingStatus", next),
|
||||||
|
setHoveredGeometryId: (next) => setValue("hoveredGeometryId", next),
|
||||||
|
setGeometryFocusRequest: (next) => setValue("geometryFocusRequest", next),
|
||||||
|
setReplayFeatureId: (next) => setValue("replayFeatureId", next),
|
||||||
|
setHideOutside: (next) => setValue("hideOutside", next),
|
||||||
|
setGeometryVisibility: (next) => setValue("geometryVisibility", next),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditorStoreContext = createContext<EditorStoreApi | null>(null);
|
||||||
|
|
||||||
|
type EditorStoreProviderProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
options: EditorStoreOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EditorStoreProvider({ children, options }: EditorStoreProviderProps) {
|
||||||
|
const [store] = useState(() => createEditorStore(options));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EditorStoreContext.Provider value={store}>
|
||||||
|
{children}
|
||||||
|
</EditorStoreContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEditorStore<T>(selector: (state: EditorStoreState) => T) {
|
||||||
|
const store = useContext(EditorStoreContext);
|
||||||
|
if (!store) {
|
||||||
|
throw new Error("useEditorStore must be used within EditorStoreProvider.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return useStore(store, selector);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEditorStoreApi() {
|
||||||
|
const store = useContext(EditorStoreContext);
|
||||||
|
if (!store) {
|
||||||
|
throw new Error("useEditorStoreApi must be used within EditorStoreProvider.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return store;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user