refactor(important): reduce editor drill state
This commit is contained in:
Generated
+31
-1
@@ -40,7 +40,8 @@
|
||||
"swiper": "^11.2.10",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"uuid": "^13.0.0",
|
||||
"yet-another-react-lightbox": "^3.30.1"
|
||||
"yet-another-react-lightbox": "^3.30.1",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
@@ -10107,6 +10108,35 @@
|
||||
"peerDependencies": {
|
||||
"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",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"uuid": "^13.0.0",
|
||||
"yet-another-react-lightbox": "^3.30.1"
|
||||
"yet-another-react-lightbox": "^3.30.1",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
|
||||
+158
-194
@@ -1,7 +1,8 @@
|
||||
"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 { useShallow } from "zustand/react/shallow";
|
||||
import Map from "@/uhm/components/Map";
|
||||
import Editor from "@/uhm/components/Editor";
|
||||
import BackgroundLayersPanel from "@/uhm/components/editor/BackgroundLayersPanel";
|
||||
@@ -24,22 +25,7 @@ import {
|
||||
Geometry,
|
||||
useEditorState,
|
||||
} from "@/uhm/lib/editor/state/useEditorState";
|
||||
import { GEO_TYPE_KEYS } from "@/uhm/lib/map/geo/geoTypeMap";
|
||||
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 { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
|
||||
import {
|
||||
getDefaultTypeIdForFeature,
|
||||
normalizeFeatureBindingIds,
|
||||
@@ -53,7 +39,6 @@ import {
|
||||
import { buildFeatureEntityPatch } from "@/uhm/lib/editor/entity/entityBinding";
|
||||
import {
|
||||
loadBackgroundLayerVisibilityFromStorage,
|
||||
persistBackgroundLayerVisibility,
|
||||
} from "@/uhm/lib/editor/background/backgroundVisibilityStorage";
|
||||
import { useProjectCommands } from "@/uhm/lib/editor/project/useProjectCommands";
|
||||
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 type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
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 DEFAULT_EDITOR_USER_ID = "local-editor";
|
||||
|
||||
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 router = useRouter();
|
||||
const editorStoreApi = useEditorStoreApi();
|
||||
const projectId = String(params.id || "");
|
||||
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 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 lastSelectedFeatureIdRef = useRef<string | null>(null);
|
||||
|
||||
const [replayFeatureId, setReplayFeatureId] = useState<string | number | null>(null);
|
||||
const [hideOutside, setHideOutside] = useState(false);
|
||||
|
||||
const {
|
||||
mode,
|
||||
setMode: internalSetMode,
|
||||
internalSetMode,
|
||||
initialData,
|
||||
setInitialData,
|
||||
isSaving,
|
||||
setIsSaving,
|
||||
isSubmitting,
|
||||
setIsSubmitting,
|
||||
isOpeningSection,
|
||||
setIsOpeningSection,
|
||||
setAvailableSections,
|
||||
selectedProjectId,
|
||||
setSelectedProjectId,
|
||||
newSectionTitle,
|
||||
setNewSectionTitle,
|
||||
commitTitle,
|
||||
setCommitTitle,
|
||||
editorUserIdInput,
|
||||
activeSection,
|
||||
setActiveSection,
|
||||
projectState,
|
||||
setProjectState,
|
||||
sectionCommits,
|
||||
setProjectCommits,
|
||||
baselineSnapshot,
|
||||
setBaselineSnapshot,
|
||||
entityCatalog,
|
||||
setEntityCatalog,
|
||||
snapshotEntities,
|
||||
@@ -139,9 +110,7 @@ export default function Page() {
|
||||
setSelectedGeometryEntityIds,
|
||||
geometryMetaForm,
|
||||
setGeometryMetaForm,
|
||||
isEntitySubmitting,
|
||||
setIsEntitySubmitting,
|
||||
entityFormStatus,
|
||||
setEntityFormStatus,
|
||||
entitySearchResults,
|
||||
setEntitySearchResults,
|
||||
@@ -157,23 +126,122 @@ export default function Page() {
|
||||
setSnapshotWikis,
|
||||
snapshotEntityWikiLinks,
|
||||
setSnapshotEntityWikiLinks,
|
||||
} = useEditorSessionState({
|
||||
emptyFeatureCollection: EMPTY_FEATURE_COLLECTION,
|
||||
defaultEditorUserId: DEFAULT_EDITOR_USER_ID,
|
||||
fallbackTimelineRange: FIXED_TIMELINE_RANGE,
|
||||
currentYear: CURRENT_YEAR,
|
||||
});
|
||||
blockedPendingSubmissionId,
|
||||
setBlockedPendingSubmissionId,
|
||||
searchKind,
|
||||
setSearchKind,
|
||||
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.
|
||||
const entitySearchRequestRef = useRef(0);
|
||||
const wikiSearchRequestRef = 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 snapshotWikisRef = useRef(snapshotWikis);
|
||||
const snapshotEntityWikiLinksRef = useRef(snapshotEntityWikiLinks);
|
||||
@@ -211,7 +279,6 @@ export default function Page() {
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
const editorUserId = normalizeEditorUserId(editorUserIdInput);
|
||||
const snapshotEntitiesAsEntities = useMemo(() => {
|
||||
const rows = snapshotEntities || [];
|
||||
return rows
|
||||
@@ -235,17 +302,6 @@ export default function Page() {
|
||||
entitiesRef.current = 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(() => {
|
||||
const localCreatedIds = localCreatedEntityIdsRef.current;
|
||||
if (!localCreatedIds.size) return;
|
||||
@@ -284,21 +340,6 @@ export default function Page() {
|
||||
};
|
||||
}, [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(() => {
|
||||
if (!selectedFeatureIds || selectedFeatureIds.length === 0) return [];
|
||||
return selectedFeatureIds
|
||||
@@ -341,6 +382,18 @@ export default function Page() {
|
||||
return normalizeFeatureBindingIds(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 prev = normalizeWikisForCompare(baselineSnapshot?.wikis);
|
||||
const next = normalizeWikisForCompare(snapshotWikis);
|
||||
@@ -379,36 +432,9 @@ export default function Page() {
|
||||
|
||||
const sectionCommands = useProjectCommands({
|
||||
editor,
|
||||
editorUserId,
|
||||
store: editorStoreApi,
|
||||
emptyFeatureCollection: EMPTY_FEATURE_COLLECTION,
|
||||
activeSection,
|
||||
projectState,
|
||||
selectedProjectId,
|
||||
newSectionTitle,
|
||||
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 {
|
||||
openSectionForEditing,
|
||||
@@ -432,7 +458,7 @@ export default function Page() {
|
||||
setHideOutside(false);
|
||||
}
|
||||
internalSetMode(m);
|
||||
}, [internalSetMode, mode, editor, selectedFeatureIds]);
|
||||
}, [internalSetMode, mode, editor, selectedFeatureIds, setHideOutside, setReplayFeatureId, setSelectedFeatureIds]);
|
||||
|
||||
const effectiveGeometryVisibility = useMemo(() => {
|
||||
const visibility: Record<string, boolean> = { ...geometryVisibility };
|
||||
@@ -461,7 +487,7 @@ export default function Page() {
|
||||
|
||||
const onToggleHideOutside = useCallback(() => {
|
||||
setHideOutside((prev) => !prev);
|
||||
}, []);
|
||||
}, [setHideOutside]);
|
||||
|
||||
const openProject = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
@@ -500,7 +526,7 @@ export default function Page() {
|
||||
} finally {
|
||||
setIsOpeningSection(false);
|
||||
}
|
||||
}, [openSectionForEditing, projectId, router, setEntityStatus, setIsOpeningSection]);
|
||||
}, [openSectionForEditing, projectId, router, setBlockedPendingSubmissionId, setEntityStatus, setIsOpeningSection]);
|
||||
|
||||
const unlockByDeletingPendingSubmission = useCallback(async () => {
|
||||
if (!blockedPendingSubmissionId) return;
|
||||
@@ -521,7 +547,7 @@ export default function Page() {
|
||||
} finally {
|
||||
setIsOpeningSection(false);
|
||||
}
|
||||
}, [blockedPendingSubmissionId, openProject, setEntityStatus, setIsOpeningSection]);
|
||||
}, [blockedPendingSubmissionId, openProject, setBlockedPendingSubmissionId, setEntityStatus, setIsOpeningSection]);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
@@ -704,7 +730,7 @@ export default function Page() {
|
||||
disposed = true;
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [searchKind, searchQuery]);
|
||||
}, [searchKind, searchQuery, setIsWikiSearching, setWikiSearchResults]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchKind !== "geo") {
|
||||
@@ -743,7 +769,7 @@ export default function Page() {
|
||||
disposed = true;
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [geoSearchRequestRef, searchKind, searchQuery]);
|
||||
}, [searchKind, searchQuery, setGeoSearchResults, setIsGeoSearching]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedFeatureIds || selectedFeatureIds.length === 0) return;
|
||||
@@ -831,43 +857,10 @@ export default function Page() {
|
||||
setIsBackgroundVisibilityReady(true);
|
||||
}, [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) => {
|
||||
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 id = String(entity.id || "").trim();
|
||||
if (!id) return;
|
||||
@@ -1073,6 +1066,7 @@ export default function Page() {
|
||||
}, [
|
||||
editor.draft.features,
|
||||
flashGeoBindingStatus,
|
||||
setGeometryFocusRequest,
|
||||
setSelectedFeatureIds,
|
||||
setTimelineFilterEnabled,
|
||||
timelineFilterEnabled,
|
||||
@@ -1380,6 +1374,7 @@ export default function Page() {
|
||||
backgroundVisibility={backgroundVisibility}
|
||||
geometryVisibility={effectiveGeometryVisibility}
|
||||
respectBindingFilter={geometryBindingFilterEnabled}
|
||||
highlightFeatures={hoveredGeometryHighlight}
|
||||
focusFeatureCollection={geometryFocusRequest?.collection || null}
|
||||
focusRequestKey={geometryFocusRequest?.key ?? null}
|
||||
focusPadding={96}
|
||||
@@ -1414,14 +1409,6 @@ export default function Page() {
|
||||
/>
|
||||
|
||||
<BackgroundLayersPanel
|
||||
visibility={backgroundVisibility}
|
||||
onToggleLayer={handleToggleBackgroundLayer}
|
||||
onShowAll={handleShowAllBackgroundLayers}
|
||||
onHideAll={handleHideAllBackgroundLayers}
|
||||
geometryVisibility={geometryVisibility}
|
||||
onToggleGeometryType={(typeKey) => {
|
||||
setGeometryVisibility((prev) => ({ ...prev, [typeKey]: prev[typeKey] === false }));
|
||||
}}
|
||||
width={rightPanelWidth}
|
||||
topContent={
|
||||
<div style={{ display: "grid", gap: "12px" }}>
|
||||
@@ -1655,44 +1642,26 @@ export default function Page() {
|
||||
selectedGeometryBindingIds={selectedGeometryBindingIds}
|
||||
onToggleBindGeometryForSelectedGeometry={handleToggleBindGeometryForSelectedGeometry}
|
||||
onFocusGeometry={handleFocusGeometryFromBindingPanel}
|
||||
statusText={geoBindingStatus}
|
||||
bindingFilterEnabled={geometryBindingFilterEnabled}
|
||||
onBindingFilterEnabledChange={setGeometryBindingFilterEnabled}
|
||||
/>
|
||||
|
||||
<ProjectEntityRefsPanel
|
||||
entityRefs={snapshotEntitiesVisible}
|
||||
entityForm={entityForm}
|
||||
onEntityFormChange={handleEntityFormChange}
|
||||
isEntitySubmitting={isEntitySubmitting}
|
||||
onCreateEntityOnly={handleCreateEntityOnly}
|
||||
onUpdateEntity={handleUpdateEntityInProject}
|
||||
entityFormStatus={entityFormStatus}
|
||||
hasSelectedGeometry={Boolean(selectedFeature)}
|
||||
selectedGeometryEntityIds={selectedGeometryEntityIds}
|
||||
onToggleBindEntityForSelectedGeometry={handleToggleBindEntityForSelectedGeometry}
|
||||
/>
|
||||
|
||||
<WikiSidebarPanel
|
||||
projectId={projectId}
|
||||
wikis={snapshotWikis}
|
||||
setWikis={setSnapshotWikisUndoable}
|
||||
requestedActiveId={requestedActiveWikiId}
|
||||
/>
|
||||
|
||||
<EntityWikiBindingsPanel
|
||||
entities={projectEntityChoices}
|
||||
wikis={snapshotWikis}
|
||||
links={snapshotEntityWikiLinks}
|
||||
setLinks={setSnapshotEntityWikiLinksUndoable}
|
||||
/>
|
||||
{selectedFeature ? (
|
||||
<SelectedGeometryPanel
|
||||
selectedFeatures={selectedFeatures}
|
||||
entityTypeOptions={GEOMETRY_TYPE_OPTIONS}
|
||||
geometryMetaForm={geometryMetaForm}
|
||||
onGeometryMetaFormChange={handleGeometryMetaFormChange}
|
||||
isEntitySubmitting={isEntitySubmitting}
|
||||
onApplyGeometryMetadata={featureCommands.applyGeometryMetadata}
|
||||
changeCount={editor.changeCount}
|
||||
onReplayEdit={(id) => setMode("replay", id)}
|
||||
@@ -1770,11 +1739,6 @@ function clampNumber(value: number, min: number, max: number): number {
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeEditorUserId(value: string): string {
|
||||
const normalized = value.trim();
|
||||
return normalized || DEFAULT_EDITOR_USER_ID;
|
||||
}
|
||||
|
||||
function formatCommitTitle(commit: ProjectCommit): string {
|
||||
return commit.edit_summary?.trim() || `Commit ${commit.id.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
@@ -1,34 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { persistBackgroundLayerVisibility } from "@/uhm/lib/editor/background/backgroundVisibilityStorage";
|
||||
import {
|
||||
BACKGROUND_LAYER_OPTIONS,
|
||||
BackgroundLayerId,
|
||||
BackgroundLayerVisibility,
|
||||
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
||||
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
||||
} from "@/uhm/lib/map/styles/backgroundLayers";
|
||||
import { GEO_TYPE_KEYS } from "@/uhm/lib/map/geo/geoTypeMap";
|
||||
import { useEditorStore } from "@/uhm/store/editorStore";
|
||||
|
||||
type Props = {
|
||||
visibility: BackgroundLayerVisibility;
|
||||
onToggleLayer: (id: BackgroundLayerId) => void;
|
||||
onShowAll: () => void;
|
||||
onHideAll: () => void;
|
||||
geometryVisibility?: Record<string, boolean>;
|
||||
onToggleGeometryType?: (typeKey: string) => void;
|
||||
topContent?: ReactNode;
|
||||
width?: number;
|
||||
};
|
||||
|
||||
export default function BackgroundLayersPanel({
|
||||
visibility,
|
||||
onToggleLayer,
|
||||
onShowAll,
|
||||
onHideAll,
|
||||
geometryVisibility,
|
||||
onToggleGeometryType,
|
||||
topContent,
|
||||
width = 240,
|
||||
}: 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 (
|
||||
<aside
|
||||
style={{
|
||||
@@ -52,7 +83,7 @@ export default function BackgroundLayersPanel({
|
||||
<button
|
||||
key={layer.id}
|
||||
type="button"
|
||||
onClick={() => onToggleLayer(layer.id)}
|
||||
onClick={() => handleToggleLayer(layer.id)}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
@@ -75,7 +106,23 @@ export default function BackgroundLayersPanel({
|
||||
})}
|
||||
</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={{ margin: 0, marginBottom: 10, fontWeight: 800, fontSize: 13, color: "#e5e7eb" }}>
|
||||
@@ -88,7 +135,12 @@ export default function BackgroundLayersPanel({
|
||||
<button
|
||||
key={typeKey}
|
||||
type="button"
|
||||
onClick={() => onToggleGeometryType(typeKey)}
|
||||
onClick={() => {
|
||||
setGeometryVisibility((prev) => ({
|
||||
...prev,
|
||||
[typeKey]: prev[typeKey] === false,
|
||||
}));
|
||||
}}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
@@ -112,7 +164,17 @@ export default function BackgroundLayersPanel({
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</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";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
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 { useEditorStore } from "@/uhm/store/editorStore";
|
||||
|
||||
type EntityChoice = { id: string; name: string; isNew?: boolean };
|
||||
type WikiChoice = { id: string; title: string; isNew?: boolean };
|
||||
@@ -18,9 +20,6 @@ type BindingRow = {
|
||||
};
|
||||
|
||||
type Props = {
|
||||
entities: EntityChoice[];
|
||||
wikis: WikiSnapshot[];
|
||||
links: EntityWikiLinkSnapshot[];
|
||||
setLinks: React.Dispatch<React.SetStateAction<EntityWikiLinkSnapshot[]>>;
|
||||
};
|
||||
|
||||
@@ -29,7 +28,20 @@ function wikiTitle(w: WikiSnapshot): string {
|
||||
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 [activeWikiId, setActiveWikiId] = useState<string>("");
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
@@ -46,11 +58,29 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
|
||||
[wikis]
|
||||
);
|
||||
|
||||
const entityChoices = useMemo(() => {
|
||||
const cleaned = (entities || []).filter((e) => e && typeof e.id === "string" && e.id.trim().length > 0);
|
||||
cleaned.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return cleaned;
|
||||
}, [entities]);
|
||||
const entityChoices = useMemo<EntityChoice[]>(() => {
|
||||
const visibleSnapshotEntities = new globalThis.Map<string, { id: string; name: string; isNew: boolean }>();
|
||||
for (const ref of snapshotEntities || []) {
|
||||
const id = String(ref?.id || "").trim();
|
||||
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 set = new Set<string>();
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, type CSSProperties, type KeyboardEvent } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import NewBadge from "@/uhm/components/editor/NewBadge";
|
||||
import { useEditorStore } from "@/uhm/store/editorStore";
|
||||
|
||||
type GeometryChoice = {
|
||||
id: string;
|
||||
@@ -11,13 +13,10 @@ type GeometryChoice = {
|
||||
|
||||
type Props = {
|
||||
geometries: GeometryChoice[];
|
||||
selectedGeometryId: string | null;
|
||||
selectedGeometryId?: string | null;
|
||||
selectedGeometryBindingIds: string[];
|
||||
onToggleBindGeometryForSelectedGeometry?: (geometryId: string, nextChecked: boolean) => void;
|
||||
onFocusGeometry?: (geometryId: string) => void;
|
||||
statusText?: string | null;
|
||||
bindingFilterEnabled: boolean;
|
||||
onBindingFilterEnabledChange: (next: boolean) => void;
|
||||
};
|
||||
|
||||
export default function GeometryBindingPanel({
|
||||
@@ -26,12 +25,29 @@ export default function GeometryBindingPanel({
|
||||
selectedGeometryBindingIds,
|
||||
onToggleBindGeometryForSelectedGeometry,
|
||||
onFocusGeometry,
|
||||
}: Props) {
|
||||
const {
|
||||
selectedFeatureIds,
|
||||
statusText,
|
||||
bindingFilterEnabled,
|
||||
onBindingFilterEnabledChange,
|
||||
}: Props) {
|
||||
setGeometryBindingFilterEnabled,
|
||||
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 =
|
||||
Boolean(selectedGeometryId) && typeof onToggleBindGeometryForSelectedGeometry === "function";
|
||||
Boolean(effectiveSelectedGeometryId) && typeof onToggleBindGeometryForSelectedGeometry === "function";
|
||||
const canFocusGeometry = typeof onFocusGeometry === "function";
|
||||
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
@@ -46,24 +62,30 @@ export default function GeometryBindingPanel({
|
||||
|
||||
const bindingSet = useMemo(() => new Set(selectedGeometryBindingIds || []), [selectedGeometryBindingIds]);
|
||||
const selectedGeometry = useMemo(() => {
|
||||
if (!selectedGeometryId) return null;
|
||||
return rows.find((g) => g.id === selectedGeometryId) || null;
|
||||
}, [rows, selectedGeometryId]);
|
||||
if (!effectiveSelectedGeometryId) return null;
|
||||
return rows.find((g) => g.id === effectiveSelectedGeometryId) || null;
|
||||
}, [effectiveSelectedGeometryId, rows]);
|
||||
const visibleRows = useMemo(() => {
|
||||
return rows
|
||||
.filter((g) => g.id !== selectedGeometryId)
|
||||
.filter((g) => g.id !== effectiveSelectedGeometryId)
|
||||
.sort((a, b) => {
|
||||
const aBound = bindingSet.has(a.id);
|
||||
const bBound = bindingSet.has(b.id);
|
||||
if (aBound !== bBound) return aBound ? -1 : 1;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
}, [bindingSet, rows, selectedGeometryId]);
|
||||
}, [bindingSet, effectiveSelectedGeometryId, rows]);
|
||||
|
||||
const handleFocusKeyDown = (event: KeyboardEvent<HTMLDivElement>, geometryId: string) => {
|
||||
if (!canFocusGeometry) return;
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
setHoveredGeometryId((current) => (current === geometryId ? null : current));
|
||||
onFocusGeometry?.(geometryId);
|
||||
};
|
||||
|
||||
const handleFocusGeometry = (geometryId: string) => {
|
||||
setHoveredGeometryId((current) => (current === geometryId ? null : current));
|
||||
onFocusGeometry?.(geometryId);
|
||||
};
|
||||
|
||||
@@ -75,6 +97,7 @@ export default function GeometryBindingPanel({
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #1f2937",
|
||||
}}
|
||||
onMouseLeave={() => setHoveredGeometryId(null)}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
|
||||
@@ -92,7 +115,7 @@ export default function GeometryBindingPanel({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bindingFilterEnabled}
|
||||
onChange={(e) => onBindingFilterEnabledChange(e.target.checked)}
|
||||
onChange={(e) => setGeometryBindingFilterEnabled(e.target.checked)}
|
||||
style={{ width: 14, height: 14 }}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: "#94a3b8", whiteSpace: "nowrap" }}>Filter</span>
|
||||
@@ -130,15 +153,26 @@ export default function GeometryBindingPanel({
|
||||
marginTop: 10,
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid rgba(59, 130, 246, 0.45)",
|
||||
background: "rgba(37, 99, 235, 0.12)",
|
||||
border:
|
||||
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",
|
||||
boxShadow:
|
||||
hoveredGeometryId === selectedGeometry.id
|
||||
? "0 0 0 2px rgba(251, 191, 36, 0.18)"
|
||||
: "none",
|
||||
}}
|
||||
title={selectedGeometry.id}
|
||||
role={canFocusGeometry ? "button" : undefined}
|
||||
tabIndex={canFocusGeometry ? 0 : undefined}
|
||||
onClick={() => onFocusGeometry?.(selectedGeometry.id)}
|
||||
onClick={() => handleFocusGeometry(selectedGeometry.id)}
|
||||
onKeyDown={(event) => handleFocusKeyDown(event, selectedGeometry.id)}
|
||||
onMouseEnter={() => setHoveredGeometryId(selectedGeometry.id)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
@@ -187,25 +221,36 @@ export default function GeometryBindingPanel({
|
||||
{visibleRows
|
||||
.map((g) => {
|
||||
const isBound = bindingSet.has(g.id);
|
||||
const isHovered = hoveredGeometryId === g.id;
|
||||
return (
|
||||
<div
|
||||
key={g.id}
|
||||
style={{
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
border: isBound ? "1px solid rgba(20, 184, 166, 0.65)" : "1px solid #1f2937",
|
||||
background: isBound ? "rgba(20, 184, 166, 0.12)" : "transparent",
|
||||
border: isHovered
|
||||
? "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",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
cursor: canFocusGeometry ? "pointer" : "default",
|
||||
opacity: canBindToggle ? 1 : 0.75,
|
||||
boxShadow: isHovered ? "0 0 0 2px rgba(251, 191, 36, 0.18)" : "none",
|
||||
}}
|
||||
title={g.id}
|
||||
role={canFocusGeometry ? "button" : undefined}
|
||||
tabIndex={canFocusGeometry ? 0 : undefined}
|
||||
onClick={() => onFocusGeometry?.(g.id)}
|
||||
onClick={() => handleFocusGeometry(g.id)}
|
||||
onKeyDown={(event) => handleFocusKeyDown(event, g.id)}
|
||||
onMouseEnter={() => setHoveredGeometryId(g.id)}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
|
||||
@@ -2,34 +2,40 @@
|
||||
|
||||
import { useMemo, useState, type CSSProperties } from "react";
|
||||
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 { useEditorStore } from "@/uhm/store/editorStore";
|
||||
|
||||
type Props = {
|
||||
entityRefs: EntitySnapshot[];
|
||||
entityForm: EntityFormState;
|
||||
onEntityFormChange: (key: keyof EntityFormState, value: string) => void;
|
||||
isEntitySubmitting: boolean;
|
||||
onCreateEntityOnly: () => void;
|
||||
onUpdateEntity?: (entityId: string, payload: { name: string; description: string | null }) => void;
|
||||
entityFormStatus: string | null;
|
||||
selectedGeometryEntityIds?: string[];
|
||||
hasSelectedGeometry?: boolean;
|
||||
onToggleBindEntityForSelectedGeometry?: (entityId: string, nextChecked: boolean) => void;
|
||||
};
|
||||
|
||||
export default function ProjectEntityRefsPanel({
|
||||
entityRefs,
|
||||
entityForm,
|
||||
onEntityFormChange,
|
||||
isEntitySubmitting,
|
||||
onCreateEntityOnly,
|
||||
onUpdateEntity,
|
||||
entityFormStatus,
|
||||
selectedGeometryEntityIds,
|
||||
hasSelectedGeometry,
|
||||
onToggleBindEntityForSelectedGeometry,
|
||||
}: 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 =
|
||||
Boolean(hasSelectedGeometry) &&
|
||||
Array.isArray(selectedGeometryEntityIds) &&
|
||||
@@ -43,6 +49,16 @@ export default function ProjectEntityRefsPanel({
|
||||
() => new Set((selectedGeometryEntityIds || []).map(String)),
|
||||
[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 rows = [...(entityRefs || [])];
|
||||
rows.sort((a, b) => {
|
||||
@@ -68,6 +84,9 @@ export default function ProjectEntityRefsPanel({
|
||||
setEditName(typeof entity.name === "string" ? entity.name : "");
|
||||
setEditDescription(entity.description == null ? "" : String(entity.description));
|
||||
};
|
||||
const handleEntityFormChange = (key: "name" | "description", value: string) => {
|
||||
setEntityForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -325,14 +344,14 @@ export default function ProjectEntityRefsPanel({
|
||||
<>
|
||||
<input
|
||||
value={entityForm.name}
|
||||
onChange={(event) => onEntityFormChange("name", event.target.value)}
|
||||
onChange={(event) => handleEntityFormChange("name", event.target.value)}
|
||||
placeholder="Tên entity mới"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
<input
|
||||
value={entityForm.description}
|
||||
onChange={(event) => onEntityFormChange("description", event.target.value)}
|
||||
onChange={(event) => handleEntityFormChange("description", event.target.value)}
|
||||
placeholder="Description"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { type CSSProperties, useMemo, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { Feature } from "@/uhm/lib/editor/state/useEditorState";
|
||||
import {
|
||||
GEOMETRY_TYPE_OPTIONS,
|
||||
GeometryPreset,
|
||||
GeometryTypeGroupId,
|
||||
GeometryTypeOption,
|
||||
findGeometryTypeOption,
|
||||
groupGeometryTypeOptions,
|
||||
} from "@/uhm/lib/map/geo/geometryTypeOptions";
|
||||
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 = {
|
||||
selectedFeatures: Feature[];
|
||||
entityTypeOptions: GeometryTypeOption[];
|
||||
geometryMetaForm: GeometryMetaFormState;
|
||||
onGeometryMetaFormChange: (key: keyof GeometryMetaFormState, value: string) => void;
|
||||
isEntitySubmitting: boolean;
|
||||
onApplyGeometryMetadata: () => Promise<{ ok: boolean; error?: string }>;
|
||||
changeCount: number;
|
||||
onReplayEdit?: (id: string | number) => void;
|
||||
@@ -25,14 +22,21 @@ type Props = {
|
||||
|
||||
export default function SelectedGeometryPanel({
|
||||
selectedFeatures,
|
||||
entityTypeOptions,
|
||||
geometryMetaForm,
|
||||
onGeometryMetaFormChange,
|
||||
isEntitySubmitting,
|
||||
onApplyGeometryMetadata,
|
||||
changeCount,
|
||||
onReplayEdit,
|
||||
}: Props) {
|
||||
const {
|
||||
geometryMetaForm,
|
||||
setGeometryMetaForm,
|
||||
isEntitySubmitting,
|
||||
} = useEditorStore(
|
||||
useShallow((state) => ({
|
||||
geometryMetaForm: state.geometryMetaForm,
|
||||
setGeometryMetaForm: state.setGeometryMetaForm,
|
||||
isEntitySubmitting: state.isEntitySubmitting,
|
||||
}))
|
||||
);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [geoApplyFeedback, setGeoApplyFeedback] = useState<
|
||||
| {
|
||||
@@ -73,7 +77,7 @@ export default function SelectedGeometryPanel({
|
||||
if (!selectedFeatures || selectedFeatures.length === 0) return null;
|
||||
const representativeFeature = selectedFeatures[0];
|
||||
|
||||
const groupedGeometryTypeOptions = groupGeometryTypeOptions(entityTypeOptions);
|
||||
const groupedGeometryTypeOptions = groupGeometryTypeOptions(GEOMETRY_TYPE_OPTIONS);
|
||||
const featureGeometryPreset = resolveFeatureGeometryPreset(representativeFeature);
|
||||
const allowedGroupIds = getAllowedGroupIdsForPreset(featureGeometryPreset);
|
||||
const groupedGeoTypeOptions = groupedGeometryTypeOptions.filter((group) =>
|
||||
@@ -143,7 +147,12 @@ export default function SelectedGeometryPanel({
|
||||
</div>
|
||||
<select
|
||||
value={geometryMetaForm.type_key}
|
||||
onChange={(event) => onGeometryMetaFormChange("type_key", event.target.value)}
|
||||
onChange={(event) =>
|
||||
setGeometryMetaForm((prev) => ({
|
||||
...prev,
|
||||
type_key: event.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
>
|
||||
@@ -176,14 +185,24 @@ export default function SelectedGeometryPanel({
|
||||
) : null}
|
||||
<input
|
||||
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"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
<input
|
||||
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"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { initLine } from "@/uhm/lib/map/engines/lineEngine";
|
||||
import { initPath } from "@/uhm/lib/map/engines/pathEngine";
|
||||
import { initCircle } from "@/uhm/lib/map/engines/circleEngine";
|
||||
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 { buildClientFeatureId, getSelectableLayers } from "./mapUtils";
|
||||
import { MapHoverPayload } from "../Map";
|
||||
@@ -15,7 +15,7 @@ import { MapHoverPayload } from "../Map";
|
||||
type EngineBinding = {
|
||||
cleanup: () => void;
|
||||
cancel?: () => void;
|
||||
clearSelection?: () => void;
|
||||
clearSelection?: (skipNotify?: boolean) => void;
|
||||
};
|
||||
|
||||
type UseMapInteractionProps = {
|
||||
@@ -143,7 +143,9 @@ export function useMapInteraction({
|
||||
const originalFeature = draftRef.current.features.find(
|
||||
(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,
|
||||
(ids) => onSelectFeatureIdsRef.current?.(ids),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import "react-quill-new/dist/quill.snow.css";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
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 { checkWikiSlugExists, fetchWikiBySlug, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
|
||||
import NewBadge from "@/uhm/components/editor/NewBadge";
|
||||
import { useEditorStore } from "@/uhm/store/editorStore";
|
||||
|
||||
type ReactQuillProps = ComponentProps<typeof ReactQuill>;
|
||||
type QuillRange = { index: number; length: number };
|
||||
@@ -39,7 +41,7 @@ type QuillLinkFormat = {
|
||||
type QuillImageFormatCtor = {
|
||||
new (): {
|
||||
domNode: Element;
|
||||
format: (name: string, value: string) => void;
|
||||
format(name: string, value: string): void;
|
||||
};
|
||||
formats: (domNode: Element) => Record<string, string>;
|
||||
};
|
||||
@@ -53,9 +55,7 @@ let quillLinkSanitizePatched = false;
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
wikis: WikiSnapshot[];
|
||||
setWikis: React.Dispatch<React.SetStateAction<WikiSnapshot[]>>;
|
||||
requestedActiveId?: string | null;
|
||||
};
|
||||
|
||||
function clampTitle(title: string) {
|
||||
@@ -63,7 +63,13 @@ function clampTitle(title: string) {
|
||||
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 [activeId, setActiveId] = useState<string | null>(null);
|
||||
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;
|
||||
if (ImageFormat) {
|
||||
class CustomImage extends ImageFormat {
|
||||
const BaseImageFormat = ImageFormat;
|
||||
class CustomImage extends BaseImageFormat {
|
||||
static formats(domNode: Element) {
|
||||
const formats = ImageFormat.formats(domNode) || {};
|
||||
if (domNode.hasAttribute("style")) formats.style = domNode.getAttribute("style");
|
||||
if (domNode.hasAttribute("width")) formats.width = domNode.getAttribute("width");
|
||||
if (domNode.hasAttribute("height")) formats.height = domNode.getAttribute("height");
|
||||
if (domNode.hasAttribute("class")) formats.class = domNode.getAttribute("class");
|
||||
const formats = BaseImageFormat.formats(domNode) || {};
|
||||
const style = domNode.getAttribute("style");
|
||||
const width = domNode.getAttribute("width");
|
||||
const height = domNode.getAttribute("height");
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -291,11 +302,8 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, requested
|
||||
if (!activeId) return;
|
||||
|
||||
const fmt = detectWikiDocStorageFormat(wikiDocHtml);
|
||||
const label = fmt === "json" ? "json" : fmt === "text" ? "txt" : "html";
|
||||
const mime =
|
||||
fmt === "json"
|
||||
? "application/json;charset=utf-8"
|
||||
: fmt === "text"
|
||||
const label = fmt === "text" ? "txt" : "html";
|
||||
const mime = fmt === "text"
|
||||
? "text/plain;charset=utf-8"
|
||||
: "text/html;charset=utf-8";
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { ApiError } from "@/uhm/api/http";
|
||||
import {
|
||||
createProject,
|
||||
@@ -11,10 +10,11 @@ import {
|
||||
} from "@/uhm/api/projects";
|
||||
import { buildEditorSnapshot, normalizeEditorSnapshot, toApiEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
||||
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
|
||||
import type { Feature, FeatureCollection, FeatureId, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
|
||||
import type { BattleReplay, EditorSnapshot, Project, ProjectCommit, ProjectState, EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
||||
import type { Feature, FeatureCollection, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
|
||||
import type { BattleReplay, EditorSnapshot, ProjectCommit, EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
import type { EditorStoreApi } from "@/uhm/store/editorStore";
|
||||
|
||||
type EditorDraftApi = {
|
||||
draft: FeatureCollection;
|
||||
@@ -27,40 +27,14 @@ type EditorDraftApi = {
|
||||
|
||||
type Options = {
|
||||
editor: EditorDraftApi;
|
||||
editorUserId: string;
|
||||
store: EditorStoreApi;
|
||||
emptyFeatureCollection: FeatureCollection;
|
||||
activeSection: Project | null;
|
||||
projectState: ProjectState | null;
|
||||
selectedProjectId: string;
|
||||
newSectionTitle: string;
|
||||
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) {
|
||||
const openSectionForEditing = useCallback(async (projectId: string) => {
|
||||
const state = options.store.getState();
|
||||
const editorPayload = await openSectionEditor(projectId);
|
||||
const snapshot = normalizeEditorSnapshot(editorPayload.snapshot);
|
||||
// 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 nextInitialData = sessionSnapshot?.editor_feature_collection || options.emptyFeatureCollection;
|
||||
|
||||
options.setActiveSection(editorPayload.project);
|
||||
options.setSelectedProjectId(editorPayload.project.id);
|
||||
options.setProjectState(editorPayload.state);
|
||||
options.setBaselineSnapshot(sessionSnapshot);
|
||||
options.setInitialData(nextInitialData);
|
||||
options.setProjectCommits(commits);
|
||||
options.setSnapshotEntities(sessionSnapshot?.entities || []);
|
||||
options.setSnapshotWikis(sessionSnapshot?.wikis || []);
|
||||
options.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []);
|
||||
options.setSelectedFeatureIds([]);
|
||||
options.setEntityFormStatus(null);
|
||||
}, [options]);
|
||||
state.setActiveSection(editorPayload.project);
|
||||
state.setSelectedProjectId(editorPayload.project.id);
|
||||
state.setProjectState(editorPayload.state);
|
||||
state.setBaselineSnapshot(sessionSnapshot);
|
||||
state.setInitialData(nextInitialData);
|
||||
state.setProjectCommits(commits);
|
||||
state.setSnapshotEntities(sessionSnapshot?.entities || []);
|
||||
state.setSnapshotWikis(sessionSnapshot?.wikis || []);
|
||||
state.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []);
|
||||
state.setSelectedFeatureIds([]);
|
||||
state.setEntityFormStatus(null);
|
||||
}, [options.emptyFeatureCollection, options.store]);
|
||||
|
||||
const commitSection = useCallback(async () => {
|
||||
if (!options.activeSection || !options.projectState) {
|
||||
options.setEntityStatus("Chưa mở được project editor.");
|
||||
const state = options.store.getState();
|
||||
if (!state.activeSection || !state.projectState) {
|
||||
state.setEntityStatus("Chưa mở được project editor.");
|
||||
return;
|
||||
}
|
||||
if (options.pendingSaveCount <= 0) {
|
||||
options.setEntityStatus("Không có thay đổi để Commit.");
|
||||
state.setEntityStatus("Không có thay đổi để Commit.");
|
||||
return;
|
||||
}
|
||||
|
||||
const geometryChanges = options.editor.buildPayload();
|
||||
options.setIsSaving(true);
|
||||
options.setEntityStatus(null);
|
||||
state.setIsSaving(true);
|
||||
state.setEntityStatus(null);
|
||||
try {
|
||||
const snapshot = buildEditorSnapshot({
|
||||
project: options.activeSection,
|
||||
project: state.activeSection,
|
||||
draft: options.editor.mainDraft,
|
||||
changes: geometryChanges,
|
||||
snapshotEntities: options.snapshotEntities,
|
||||
snapshotWikis: options.snapshotWikis,
|
||||
snapshotEntityWikiLinks: options.snapshotEntityWikiLinks,
|
||||
snapshotEntities: state.snapshotEntities,
|
||||
snapshotWikis: state.snapshotWikis,
|
||||
snapshotEntityWikiLinks: state.snapshotEntityWikiLinks,
|
||||
replays: options.editor.replays,
|
||||
previousSnapshot: options.baselineSnapshot,
|
||||
previousSnapshot: state.baselineSnapshot,
|
||||
hasPersistedFeature: options.editor.hasPersistedFeature,
|
||||
});
|
||||
const editSummary = options.commitTitle.trim()
|
||||
const editSummary = state.commitTitle.trim()
|
||||
|| `Edit ${new Date().toLocaleString()}`;
|
||||
|
||||
// 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 limitBytes = 3_500_000; // ~3.5MB (conservative vs common default body limits)
|
||||
if (bytes > limitBytes) {
|
||||
options.setEntityStatus(
|
||||
state.setEntityStatus(
|
||||
`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.`
|
||||
);
|
||||
@@ -127,39 +102,40 @@ export function useProjectCommands(options: Options) {
|
||||
// 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,
|
||||
edit_summary: editSummary,
|
||||
});
|
||||
|
||||
const sessionSnapshot = toEditorSessionSnapshot(snapshot);
|
||||
options.setProjectState(result.state);
|
||||
options.setBaselineSnapshot(sessionSnapshot);
|
||||
options.setSnapshotEntities(sessionSnapshot.entities || []);
|
||||
options.setSnapshotWikis(sessionSnapshot.wikis || []);
|
||||
options.setSnapshotEntityWikiLinks(sessionSnapshot.entity_wiki || []);
|
||||
options.setInitialData(options.editor.draft);
|
||||
state.setProjectState(result.state);
|
||||
state.setBaselineSnapshot(sessionSnapshot);
|
||||
state.setSnapshotEntities(sessionSnapshot.entities || []);
|
||||
state.setSnapshotWikis(sessionSnapshot.wikis || []);
|
||||
state.setSnapshotEntityWikiLinks(sessionSnapshot.entity_wiki || []);
|
||||
state.setInitialData(options.editor.draft);
|
||||
options.editor.clearChanges();
|
||||
options.setCommitTitle("");
|
||||
options.setProjectCommits(await fetchProjectCommits(options.activeSection.id));
|
||||
options.setEntityFormStatus("Đã tạo commit.");
|
||||
state.setCommitTitle("");
|
||||
state.setProjectCommits(await fetchProjectCommits(state.activeSection.id));
|
||||
state.setEntityFormStatus("Đã tạo commit.");
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
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;
|
||||
}
|
||||
console.error("Commit error", err);
|
||||
options.setEntityStatus("Commit thất bại.");
|
||||
state.setEntityStatus("Commit thất bại.");
|
||||
} finally {
|
||||
options.setIsSaving(false);
|
||||
state.setIsSaving(false);
|
||||
}
|
||||
}, [options]);
|
||||
}, [options.editor, options.pendingSaveCount, options.store]);
|
||||
|
||||
const openSelectedSection = useCallback(async () => {
|
||||
const projectId = options.selectedProjectId.trim();
|
||||
const state = options.store.getState();
|
||||
const projectId = state.selectedProjectId.trim();
|
||||
if (!projectId) {
|
||||
options.setEntityStatus("Hãy chọn project để mở.");
|
||||
state.setEntityStatus("Hãy chọn project để mở.");
|
||||
return;
|
||||
}
|
||||
if (options.pendingSaveCount > 0) {
|
||||
@@ -167,26 +143,27 @@ export function useProjectCommands(options: Options) {
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
options.setIsOpeningSection(true);
|
||||
options.setEntityStatus(null);
|
||||
state.setIsOpeningSection(true);
|
||||
state.setEntityStatus(null);
|
||||
try {
|
||||
await openSectionForEditing(projectId);
|
||||
options.setEntityStatus("Đã mở project để chỉnh sửa.");
|
||||
state.setEntityStatus("Đã mở project để chỉnh sửa.");
|
||||
} catch (err) {
|
||||
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 {
|
||||
options.setEntityStatus("Mở project thất bại.");
|
||||
state.setEntityStatus("Mở project thất bại.");
|
||||
}
|
||||
} finally {
|
||||
options.setIsOpeningSection(false);
|
||||
state.setIsOpeningSection(false);
|
||||
}
|
||||
}, [openSectionForEditing, options]);
|
||||
}, [openSectionForEditing, options.pendingSaveCount, options.store]);
|
||||
|
||||
const createAndOpenSection = useCallback(async () => {
|
||||
const title = options.newSectionTitle.trim();
|
||||
const state = options.store.getState();
|
||||
const title = state.newSectionTitle.trim();
|
||||
if (!title) {
|
||||
options.setEntityStatus("Tên project là bắt buộc.");
|
||||
state.setEntityStatus("Tên project là bắt buộc.");
|
||||
return;
|
||||
}
|
||||
if (options.pendingSaveCount > 0) {
|
||||
@@ -194,74 +171,76 @@ export function useProjectCommands(options: Options) {
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
options.setIsOpeningSection(true);
|
||||
options.setEntityStatus(null);
|
||||
state.setIsOpeningSection(true);
|
||||
state.setEntityStatus(null);
|
||||
try {
|
||||
const project = await createProject({
|
||||
title,
|
||||
description: null,
|
||||
});
|
||||
const projects = await fetchProjects();
|
||||
options.setAvailableSections(projects);
|
||||
options.setNewSectionTitle("");
|
||||
state.setAvailableSections(projects);
|
||||
state.setNewSectionTitle("");
|
||||
await openSectionForEditing(project.id);
|
||||
options.setEntityStatus("Đã tạo và mở project mới.");
|
||||
state.setEntityStatus("Đã tạo và mở project mới.");
|
||||
} catch (err) {
|
||||
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 {
|
||||
options.setEntityStatus("Tạo project thất bại.");
|
||||
state.setEntityStatus("Tạo project thất bại.");
|
||||
}
|
||||
} finally {
|
||||
options.setIsOpeningSection(false);
|
||||
state.setIsOpeningSection(false);
|
||||
}
|
||||
}, [openSectionForEditing, options]);
|
||||
}, [openSectionForEditing, options.pendingSaveCount, options.store]);
|
||||
|
||||
const submitCurrentSection = useCallback(async (content: string) => {
|
||||
if (!options.activeSection || !options.projectState?.head_commit_id) {
|
||||
options.setEntityStatus("Project hiện tại chưa có head để submit.");
|
||||
const state = options.store.getState();
|
||||
if (!state.activeSection || !state.projectState?.head_commit_id) {
|
||||
state.setEntityStatus("Project hiện tại chưa có head để submit.");
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
options.setIsSubmitting(true);
|
||||
options.setEntityStatus(null);
|
||||
state.setIsSubmitting(true);
|
||||
state.setEntityStatus(null);
|
||||
try {
|
||||
const submission = await submitSection(options.activeSection.id, content);
|
||||
options.setEntityStatus(`Đã submit, submission ${submission.id}.`);
|
||||
const submission = await submitSection(state.activeSection.id, content);
|
||||
state.setEntityStatus(`Đã submit, submission ${submission.id}.`);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
options.setEntityStatus(`Submit thất bại: ${err.body}`);
|
||||
state.setEntityStatus(`Submit thất bại: ${err.body}`);
|
||||
} else {
|
||||
options.setEntityStatus("Submit thất bại.");
|
||||
state.setEntityStatus("Submit thất bại.");
|
||||
}
|
||||
} finally {
|
||||
options.setIsSubmitting(false);
|
||||
state.setIsSubmitting(false);
|
||||
}
|
||||
}, [options]);
|
||||
}, [options.pendingSaveCount, options.store]);
|
||||
|
||||
const restoreCommit = useCallback(async (commitId: string) => {
|
||||
if (!options.activeSection || !options.projectState) {
|
||||
options.setEntityStatus("Chưa mở được project editor.");
|
||||
const state = options.store.getState();
|
||||
if (!state.activeSection || !state.projectState) {
|
||||
state.setEntityStatus("Chưa mở được project editor.");
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
options.setIsSaving(true);
|
||||
options.setEntityStatus(null);
|
||||
state.setIsSaving(true);
|
||||
state.setEntityStatus(null);
|
||||
try {
|
||||
// FE-only restore: load snapshot from selected commit and apply to editor state.
|
||||
// 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;
|
||||
if (!target) {
|
||||
options.setEntityStatus("Không tìm thấy commit để restore.");
|
||||
state.setEntityStatus("Không tìm thấy commit để restore.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -269,27 +248,27 @@ export function useProjectCommands(options: Options) {
|
||||
const sessionSnapshot = snapshot ? toEditorSessionSnapshot(snapshot) : null;
|
||||
const nextInitialData = sessionSnapshot?.editor_feature_collection || options.emptyFeatureCollection;
|
||||
|
||||
options.setBaselineSnapshot(sessionSnapshot);
|
||||
options.setInitialData(nextInitialData);
|
||||
options.setSnapshotEntities(sessionSnapshot?.entities || []);
|
||||
options.setSnapshotWikis(sessionSnapshot?.wikis || []);
|
||||
options.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []);
|
||||
options.setSelectedFeatureIds([]);
|
||||
options.setEntityFormStatus(null);
|
||||
state.setBaselineSnapshot(sessionSnapshot);
|
||||
state.setInitialData(nextInitialData);
|
||||
state.setSnapshotEntities(sessionSnapshot?.entities || []);
|
||||
state.setSnapshotWikis(sessionSnapshot?.wikis || []);
|
||||
state.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []);
|
||||
state.setSelectedFeatureIds([]);
|
||||
state.setEntityFormStatus(null);
|
||||
|
||||
// Refresh commits list for UI, but keep projectState/head as-is.
|
||||
options.setProjectCommits(commits);
|
||||
options.setEntityFormStatus("Đã load snapshot từ commit (không đổi head trên BE).");
|
||||
state.setProjectCommits(commits);
|
||||
state.setEntityFormStatus("Đã load snapshot từ commit (không đổi head trên BE).");
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
options.setEntityStatus(`Restore thất bại: ${err.body}`);
|
||||
state.setEntityStatus(`Restore thất bại: ${err.body}`);
|
||||
} else {
|
||||
options.setEntityStatus("Restore thất bại.");
|
||||
state.setEntityStatus("Restore thất bại.");
|
||||
}
|
||||
} finally {
|
||||
options.setIsSaving(false);
|
||||
state.setIsSaving(false);
|
||||
}
|
||||
}, [options]);
|
||||
}, [options.emptyFeatureCollection, options.pendingSaveCount, options.store]);
|
||||
|
||||
return {
|
||||
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[] {
|
||||
const rows = Array.isArray(input) ? input : [];
|
||||
return rows
|
||||
.filter((e) => e && (typeof e.id === "string" || typeof e.id === "number"))
|
||||
.filter((e) => (e as any).operation !== "delete")
|
||||
.filter((e): e is EditorEntityRow => Boolean(e) && (typeof e.id === "string" || typeof e.id === "number"))
|
||||
.filter((e) => e.operation !== "delete")
|
||||
.map((e) => {
|
||||
const { operation: _op, ...rest } = e;
|
||||
const id = String(e.id);
|
||||
const source: EntitySnapshot["source"] = e.source === "inline" ? "inline" : "ref";
|
||||
return {
|
||||
...(rest as Omit<EntitySnapshot, "id" | "source" | "operation">),
|
||||
...e,
|
||||
id,
|
||||
source,
|
||||
operation: "reference",
|
||||
@@ -333,14 +316,13 @@ function toEditorSessionEntities(input: EditorSnapshot["entities"]): EntitySnaps
|
||||
function toEditorSessionGeometries(input: EditorSnapshot["geometries"]): GeometrySnapshot[] {
|
||||
const rows = Array.isArray(input) ? input : [];
|
||||
return rows
|
||||
.filter((g) => g && (typeof (g as any).id === "string" || typeof (g as any).id === "number"))
|
||||
.filter((g) => (g as any).operation !== "delete")
|
||||
.filter((g): g is EditorGeometryRow => Boolean(g) && (typeof g.id === "string" || typeof g.id === "number"))
|
||||
.filter((g) => g.operation !== "delete")
|
||||
.map((g) => {
|
||||
const { operation: _op, ...rest } = g as any;
|
||||
const id = String((g as any).id);
|
||||
const source: GeometrySnapshot["source"] = (g as any).source === "inline" ? "inline" : "ref";
|
||||
const id = String(g.id);
|
||||
const source: GeometrySnapshot["source"] = g.source === "inline" ? "inline" : "ref";
|
||||
return {
|
||||
...(rest as Omit<GeometrySnapshot, "id" | "source" | "operation">),
|
||||
...g,
|
||||
id,
|
||||
source,
|
||||
operation: "reference",
|
||||
@@ -353,16 +335,22 @@ function toEditorSessionGeometryEntity(input: EditorSnapshot["geometry_entity"])
|
||||
const deduped = new globalThis.Map<string, GeometryEntitySnapshot>();
|
||||
for (const row of rows) {
|
||||
if (!row) continue;
|
||||
if ((row as any).operation === "delete") continue;
|
||||
const geometry_id = typeof (row as any).geometry_id === "string" || typeof (row as any).geometry_id === "number"
|
||||
? String((row as any).geometry_id).trim()
|
||||
const safeRow = row as EditorGeometryEntityRow;
|
||||
if (safeRow.operation === "delete") continue;
|
||||
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"
|
||||
? String((row as any).entity_id).trim()
|
||||
const entity_id = typeof safeRow.entity_id === "string" || typeof safeRow.entity_id === "number"
|
||||
? String(safeRow.entity_id).trim()
|
||||
: "";
|
||||
if (!geometry_id || !entity_id) continue;
|
||||
const key = `${geometry_id}::${entity_id}`;
|
||||
deduped.set(key, { geometry_id, entity_id, operation: "reference", base_links_hash: (row as any).base_links_hash });
|
||||
deduped.set(key, {
|
||||
geometry_id,
|
||||
entity_id,
|
||||
operation: "reference",
|
||||
base_links_hash: safeRow.base_links_hash,
|
||||
});
|
||||
}
|
||||
return Array.from(deduped.values()).sort((a, b) => {
|
||||
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[] {
|
||||
const rows = Array.isArray(input) ? input : [];
|
||||
return rows
|
||||
.filter((w) => w && typeof w.id === "string" && w.id.trim().length > 0)
|
||||
.filter((w) => (w as any).operation !== "delete")
|
||||
.filter((w): w is EditorWikiRow => Boolean(w) && typeof w.id === "string" && w.id.trim().length > 0)
|
||||
.filter((w) => w.operation !== "delete")
|
||||
.map((w) => {
|
||||
const { operation: _op, ...rest } = w;
|
||||
const source: WikiSnapshot["source"] = w.source === "inline" ? "inline" : "ref";
|
||||
return {
|
||||
...(rest as Omit<WikiSnapshot, "source" | "operation">),
|
||||
...w,
|
||||
source,
|
||||
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