380 lines
17 KiB
TypeScript
380 lines
17 KiB
TypeScript
"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 = {
|
|
// Editor mode + baseline FeatureCollection used to seed/reset useEditorState.
|
|
mode: EditorMode;
|
|
baselineFeatureCollection: FeatureCollection;
|
|
// Task flags; setTaskFlag ensures only one blocking task is active at a time.
|
|
isSaving: boolean;
|
|
isSubmitting: boolean;
|
|
isOpeningSection: boolean;
|
|
// Project/commit state loaded from backend.
|
|
availableSections: Project[];
|
|
selectedProjectId: string;
|
|
newSectionTitle: string;
|
|
commitTitle: string;
|
|
editorUserIdInput: string;
|
|
activeSection: Project | null;
|
|
projectState: ProjectState | null;
|
|
sectionCommits: ProjectCommit[];
|
|
baselineSnapshot: EditorSnapshot | null;
|
|
// Entity state: backend catalog plus snapshot-local rows and form/search status.
|
|
entityCatalog: Entity[];
|
|
snapshotEntityRows: EntitySnapshot[];
|
|
entityStatus: string | null;
|
|
selectedFeatureIds: FeatureId[];
|
|
entityForm: EntityFormState;
|
|
selectedGeometryEntityIds: string[];
|
|
geometryMetaForm: GeometryMetaFormState;
|
|
isEntitySubmitting: boolean;
|
|
entityFormStatus: string | null;
|
|
entitySearchResults: Entity[];
|
|
isEntitySearchLoading: boolean;
|
|
// Timeline + background layer state for map filtering/rendering.
|
|
timelineDraftYear: number;
|
|
backgroundVisibility: BackgroundLayerVisibility;
|
|
isBackgroundVisibilityReady: boolean;
|
|
// Wiki state and entity-wiki relationship snapshot.
|
|
snapshotWikis: WikiSnapshot[];
|
|
snapshotEntityWikiLinks: EntityWikiLinkSnapshot[];
|
|
blockedPendingSubmissionId: string | null;
|
|
// Unified search state shared by entity/wiki/geo search UI.
|
|
searchKind: UnifiedSearchKind;
|
|
searchQuery: string;
|
|
searchQueryDraft: string;
|
|
wikiSearchResults: Wiki[];
|
|
isWikiSearching: boolean;
|
|
geoSearchResults: EntityGeometriesSearchItem[];
|
|
isGeoSearching: boolean;
|
|
requestedActiveWikiId: string | null;
|
|
// Layout sizing and panel filter state.
|
|
leftPanelWidth: number;
|
|
rightPanelWidth: number;
|
|
timelineFilterEnabled: boolean;
|
|
geometryBindingFilterEnabled: boolean;
|
|
geoBindingStatus: string | null;
|
|
hoveredGeometryId: string | null;
|
|
// Map focus/replay visibility state.
|
|
geometryFocusRequest: GeometryFocusRequest | null;
|
|
replayFeatureId: string | number | null;
|
|
hideOutside: boolean;
|
|
// Map visibility overrides keyed by either a geometry id or a semantic geo type key.
|
|
geometryVisibility: Record<string, boolean>;
|
|
};
|
|
|
|
type EditorStoreActions = {
|
|
setMode: (next: SetStateAction<EditorMode>) => void;
|
|
setBaselineFeatureCollection: (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;
|
|
setSnapshotEntityRows: (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;
|
|
};
|
|
|
|
// Hỗ trợ setter nhận cả value trực tiếp và updater function giống React setState.
|
|
function resolveNextState<T>(next: SetStateAction<T>, prev: T): T {
|
|
return typeof next === "function" ? (next as (prevState: T) => T)(prev) : next;
|
|
}
|
|
|
|
// Khởi tạo visibility mặc định cho từng geo type trong map.
|
|
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 {
|
|
// Năm timeline ban đầu luôn nằm trong fixed range để slider/map nhất quán.
|
|
const initialTimelineYear = clampYearValue(
|
|
options.currentYear,
|
|
options.fallbackTimelineRange.min,
|
|
options.fallbackTimelineRange.max
|
|
);
|
|
|
|
return createStore<EditorStoreState>()((set) => {
|
|
// Setter generic để tránh lặp boilerplate cho từng field trong store.
|
|
const setValue = <K extends keyof EditorStoreValues>(
|
|
key: K,
|
|
next: SetStateAction<EditorStoreValues[K]>
|
|
) => {
|
|
set((state) => {
|
|
const nextValue = resolveNextState(next, state[key]);
|
|
if (Object.is(nextValue, state[key])) return state;
|
|
return {
|
|
[key]: nextValue,
|
|
} as Pick<EditorStoreValues, K>;
|
|
});
|
|
};
|
|
|
|
// Setter riêng cho task flags vì saving/submitting/opening không nên đồng thời true.
|
|
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",
|
|
baselineFeatureCollection: options.emptyFeatureCollection,
|
|
isSaving: false,
|
|
isSubmitting: false,
|
|
isOpeningSection: false,
|
|
availableSections: [],
|
|
selectedProjectId: "",
|
|
newSectionTitle: "",
|
|
commitTitle: "",
|
|
editorUserIdInput: options.defaultEditorUserId,
|
|
activeSection: null,
|
|
projectState: null,
|
|
sectionCommits: [],
|
|
baselineSnapshot: null,
|
|
entityCatalog: [],
|
|
snapshotEntityRows: [],
|
|
entityStatus: null,
|
|
selectedFeatureIds: [],
|
|
entityForm: {
|
|
name: "",
|
|
description: "",
|
|
time_start: "",
|
|
time_end: "",
|
|
},
|
|
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),
|
|
setBaselineFeatureCollection: (next) => setValue("baselineFeatureCollection", 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),
|
|
setSnapshotEntityRows: (next) => setValue("snapshotEntityRows", 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) {
|
|
// State giữ store instance ổn định trong suốt vòng đời provider.
|
|
const [store] = useState(() => createEditorStore(options));
|
|
|
|
return (
|
|
<EditorStoreContext.Provider value={store}>
|
|
{children}
|
|
</EditorStoreContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useEditorStore<T>(selector: (state: EditorStoreState) => T) {
|
|
// Hook đọc store bằng selector để component chỉ re-render theo slice cần thiết.
|
|
const store = useContext(EditorStoreContext);
|
|
if (!store) {
|
|
throw new Error("useEditorStore must be used within EditorStoreProvider.");
|
|
}
|
|
|
|
return useStore(store, selector);
|
|
}
|
|
|
|
export function useEditorStoreApi() {
|
|
// Hook lấy raw StoreApi cho command layer cần getState/setState ngoài render.
|
|
const store = useContext(EditorStoreContext);
|
|
if (!store) {
|
|
throw new Error("useEditorStoreApi must be used within EditorStoreProvider.");
|
|
}
|
|
|
|
return store;
|
|
}
|