Files
History-user/src/uhm/store/editorStore.tsx
T

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;
}