455 lines
12 KiB
TypeScript
455 lines
12 KiB
TypeScript
import type { StoreApi } from 'zustand';
|
|
|
|
import { shallow } from 'zustand/shallow';
|
|
import { useStoreWithEqualityFn } from 'zustand/traditional';
|
|
import { createStore } from 'zustand/vanilla';
|
|
|
|
import type {
|
|
BarState,
|
|
ExtractState,
|
|
GlobalState,
|
|
GlobalStateStoreType,
|
|
LayoutState,
|
|
NavigationState,
|
|
OwnerState,
|
|
ProgramState,
|
|
SessionState,
|
|
UserState,
|
|
} from './GlobalStateStore.types';
|
|
|
|
import { loadStorage, saveStorage } from './GlobalStateStore.utils';
|
|
|
|
const initialState: GlobalState = {
|
|
initialized: false,
|
|
layout: {
|
|
bottomBar: { open: false },
|
|
leftBar: { open: false },
|
|
rightBar: { open: false },
|
|
topBar: { open: false },
|
|
},
|
|
navigation: {
|
|
menu: [],
|
|
},
|
|
owner: {
|
|
guid: '',
|
|
id: 0,
|
|
name: '',
|
|
},
|
|
program: {
|
|
controls: {},
|
|
environment: 'production',
|
|
guid: '',
|
|
name: '',
|
|
slug: '',
|
|
},
|
|
session: {
|
|
apiURL: '',
|
|
authToken: '',
|
|
connected: true,
|
|
loading: false,
|
|
loggedIn: false,
|
|
},
|
|
user: {
|
|
guid: '',
|
|
username: '',
|
|
},
|
|
};
|
|
|
|
type GetState = () => GlobalStateStoreType;
|
|
type SetState = (
|
|
partial: ((state: GlobalState) => Partial<GlobalState>) | Partial<GlobalState>
|
|
) => void;
|
|
|
|
const createProgramSlice = (set: SetState) => ({
|
|
setProgram: (updates: Partial<ProgramState>) =>
|
|
set((state: GlobalState) => ({
|
|
program: { ...state.program, ...updates },
|
|
})),
|
|
});
|
|
|
|
const createSessionSlice = (set: SetState) => ({
|
|
setApiURL: (url: string) =>
|
|
set((state: GlobalState) => ({
|
|
session: { ...state.session, apiURL: url },
|
|
})),
|
|
|
|
setAuthToken: (token: string) =>
|
|
set((state: GlobalState) => ({
|
|
session: { ...state.session, authToken: token },
|
|
})),
|
|
|
|
setSession: (updates: Partial<SessionState>) =>
|
|
set((state: GlobalState) => ({
|
|
session: { ...state.session, ...updates },
|
|
})),
|
|
});
|
|
|
|
const createOwnerSlice = (set: SetState) => ({
|
|
setOwner: (updates: Partial<OwnerState>) =>
|
|
set((state: GlobalState) => ({
|
|
owner: { ...state.owner, ...updates },
|
|
})),
|
|
});
|
|
|
|
const createUserSlice = (set: SetState) => ({
|
|
setUser: (updates: Partial<UserState>) =>
|
|
set((state: GlobalState) => ({
|
|
user: { ...state.user, ...updates },
|
|
})),
|
|
});
|
|
|
|
const createLayoutSlice = (set: SetState) => ({
|
|
setBottomBar: (updates: Partial<BarState>) =>
|
|
set((state: GlobalState) => ({
|
|
layout: { ...state.layout, bottomBar: { ...state.layout.bottomBar, ...updates } },
|
|
})),
|
|
|
|
setLayout: (updates: Partial<LayoutState>) =>
|
|
set((state: GlobalState) => ({
|
|
layout: { ...state.layout, ...updates },
|
|
})),
|
|
|
|
setLeftBar: (updates: Partial<BarState>) =>
|
|
set((state: GlobalState) => ({
|
|
layout: { ...state.layout, leftBar: { ...state.layout.leftBar, ...updates } },
|
|
})),
|
|
|
|
setRightBar: (updates: Partial<BarState>) =>
|
|
set((state: GlobalState) => ({
|
|
layout: { ...state.layout, rightBar: { ...state.layout.rightBar, ...updates } },
|
|
})),
|
|
|
|
setTopBar: (updates: Partial<BarState>) =>
|
|
set((state: GlobalState) => ({
|
|
layout: { ...state.layout, topBar: { ...state.layout.topBar, ...updates } },
|
|
})),
|
|
});
|
|
|
|
const createNavigationSlice = (set: SetState) => ({
|
|
setCurrentPage: (page: NavigationState['currentPage']) =>
|
|
set((state: GlobalState) => ({
|
|
navigation: { ...state.navigation, currentPage: page },
|
|
})),
|
|
|
|
setMenu: (menu: NavigationState['menu']) =>
|
|
set((state: GlobalState) => ({
|
|
navigation: { ...state.navigation, menu },
|
|
})),
|
|
|
|
setNavigation: (updates: Partial<NavigationState>) =>
|
|
set((state: GlobalState) => ({
|
|
navigation: { ...state.navigation, ...updates },
|
|
})),
|
|
});
|
|
|
|
const createComplexActions = (set: SetState, get: GetState) => {
|
|
// Internal implementation without lock
|
|
const fetchDataInternal = async (url?: string) => {
|
|
try {
|
|
set((state: GlobalState) => ({
|
|
session: {
|
|
...state.session,
|
|
apiURL: url || state.session.apiURL,
|
|
loading: true,
|
|
},
|
|
}));
|
|
|
|
const currentState = get();
|
|
const result = await currentState.onFetchSession?.(currentState);
|
|
|
|
set((state: GlobalState) => ({
|
|
...state,
|
|
...result,
|
|
layout: { ...state.layout, ...result?.layout },
|
|
navigation: { ...state.navigation, ...result?.navigation },
|
|
owner: {
|
|
...state.owner,
|
|
...result?.owner,
|
|
},
|
|
program: {
|
|
...state.program,
|
|
...result?.program,
|
|
updatedAt: new Date().toISOString(),
|
|
},
|
|
session: {
|
|
...state.session,
|
|
...result?.session,
|
|
connected: true,
|
|
loading: false,
|
|
},
|
|
user: {
|
|
...state.user,
|
|
...result?.user,
|
|
},
|
|
}));
|
|
} catch (e) {
|
|
set((state: GlobalState) => ({
|
|
session: {
|
|
...state.session,
|
|
connected: false,
|
|
error: `Load Exception: ${String(e)}`,
|
|
loading: false,
|
|
},
|
|
}));
|
|
}
|
|
};
|
|
|
|
return {
|
|
fetchData: async (url?: string) => {
|
|
// Wait for initialization to complete
|
|
await waitForInitialization();
|
|
|
|
// Use lock to prevent concurrent fetchData calls
|
|
return withOperationLock(() => fetchDataInternal(url));
|
|
},
|
|
|
|
isLoggedIn: (): boolean => {
|
|
const session = get().session;
|
|
if (!session.loggedIn || !session.authToken) {
|
|
return false;
|
|
}
|
|
if (session.expiryDate && new Date(session.expiryDate) < new Date()) {
|
|
return false;
|
|
}
|
|
return true;
|
|
},
|
|
|
|
login: async (authToken?: string, user?: Partial<UserState>) => {
|
|
// Wait for initialization to complete
|
|
await waitForInitialization();
|
|
|
|
// Use lock to prevent concurrent auth operations
|
|
return withOperationLock(async () => {
|
|
try {
|
|
set((state: GlobalState) => ({
|
|
session: {
|
|
...state.session,
|
|
authToken: authToken ?? '',
|
|
expiryDate: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
|
loading: true,
|
|
loggedIn: true,
|
|
},
|
|
user: {
|
|
...state.user,
|
|
...user,
|
|
},
|
|
}));
|
|
|
|
const currentState = get();
|
|
const result = await currentState.onLogin?.(currentState);
|
|
if (result) {
|
|
set((state: GlobalState) => ({
|
|
...state,
|
|
owner: result.owner ? { ...state.owner, ...result.owner } : state.owner,
|
|
program: result.program ? { ...state.program, ...result.program } : state.program,
|
|
session: result.session ? { ...state.session, ...result.session } : state.session,
|
|
user: result.user ? { ...state.user, ...result.user } : state.user,
|
|
}));
|
|
}
|
|
// Call internal version to avoid nested lock
|
|
await fetchDataInternal();
|
|
} catch (e) {
|
|
set((state: GlobalState) => ({
|
|
session: {
|
|
...state.session,
|
|
connected: false,
|
|
error: `Login Exception: ${String(e)}`,
|
|
loading: false,
|
|
loggedIn: false,
|
|
},
|
|
}));
|
|
} finally {
|
|
set((state: GlobalState) => ({
|
|
session: {
|
|
...state.session,
|
|
loading: false,
|
|
},
|
|
}));
|
|
}
|
|
});
|
|
},
|
|
|
|
logout: async () => {
|
|
// Wait for initialization to complete
|
|
await waitForInitialization();
|
|
|
|
// Use lock to prevent concurrent auth operations
|
|
return withOperationLock(async () => {
|
|
try {
|
|
set((state: GlobalState) => ({
|
|
...initialState,
|
|
session: {
|
|
...initialState.session,
|
|
apiURL: state.session.apiURL,
|
|
expiryDate: undefined,
|
|
loading: true,
|
|
loggedIn: false,
|
|
},
|
|
}));
|
|
|
|
const currentState = get();
|
|
const result = await currentState.onLogout?.(currentState);
|
|
if (result) {
|
|
set((state: GlobalState) => ({
|
|
...state,
|
|
owner: result.owner ? { ...state.owner, ...result.owner } : state.owner,
|
|
program: result.program ? { ...state.program, ...result.program } : state.program,
|
|
session: result.session ? { ...state.session, ...result.session } : state.session,
|
|
user: result.user ? { ...state.user, ...result.user } : state.user,
|
|
}));
|
|
}
|
|
// Call internal version to avoid nested lock
|
|
await fetchDataInternal();
|
|
} catch (e) {
|
|
set((state: GlobalState) => ({
|
|
session: {
|
|
...state.session,
|
|
connected: false,
|
|
error: `Logout Exception: ${String(e)}`,
|
|
loading: false,
|
|
},
|
|
}));
|
|
} finally {
|
|
set((state: GlobalState) => ({
|
|
session: {
|
|
...state.session,
|
|
loading: false,
|
|
},
|
|
}));
|
|
}
|
|
});
|
|
},
|
|
};
|
|
};
|
|
|
|
// State management flags and locks - must be defined before store creation
|
|
let isStorageInitialized = false;
|
|
let initializationPromise: null | Promise<void> = null;
|
|
let operationLock: Promise<void> = Promise.resolve();
|
|
|
|
// Helper to wait for initialization - must be defined before store creation
|
|
const waitForInitialization = async (): Promise<void> => {
|
|
if (initializationPromise) {
|
|
await initializationPromise;
|
|
}
|
|
};
|
|
|
|
// Helper to ensure async operations run sequentially
|
|
const withOperationLock = async <T>(operation: () => Promise<T>): Promise<T> => {
|
|
const currentLock = operationLock;
|
|
let releaseLock: () => void;
|
|
|
|
// Create new lock promise
|
|
operationLock = new Promise<void>((resolve) => {
|
|
releaseLock = resolve;
|
|
});
|
|
|
|
try {
|
|
// Wait for previous operation to complete
|
|
await currentLock;
|
|
// Run the operation
|
|
return await operation();
|
|
} finally {
|
|
// Release the lock
|
|
releaseLock!();
|
|
}
|
|
};
|
|
|
|
const GlobalStateStore = createStore<GlobalStateStoreType>((set, get) => ({
|
|
...initialState,
|
|
...createProgramSlice(set),
|
|
...createSessionSlice(set),
|
|
...createOwnerSlice(set),
|
|
...createUserSlice(set),
|
|
...createLayoutSlice(set),
|
|
...createNavigationSlice(set),
|
|
...createComplexActions(set, get),
|
|
}));
|
|
|
|
// Initialize storage and load saved state
|
|
initializationPromise = loadStorage()
|
|
.then((state) => {
|
|
// Merge loaded state with initial state
|
|
GlobalStateStore.setState(
|
|
(current) => ({
|
|
...current,
|
|
...state,
|
|
initialized: true,
|
|
session: {
|
|
...current.session,
|
|
...state.session,
|
|
connected: true,
|
|
loading: false,
|
|
},
|
|
}),
|
|
true // Replace state completely to avoid triggering subscription during init
|
|
);
|
|
})
|
|
.catch((e) => {
|
|
console.error('Error loading storage:', e);
|
|
// Mark as initialized even on error so app doesn't hang
|
|
GlobalStateStore.setState({ initialized: true });
|
|
})
|
|
.finally(() => {
|
|
// Mark initialization as complete
|
|
isStorageInitialized = true;
|
|
initializationPromise = null;
|
|
});
|
|
|
|
// Subscribe to state changes and persist to storage
|
|
// Only saves after initialization is complete
|
|
GlobalStateStore.subscribe((state) => {
|
|
if (!isStorageInitialized) {
|
|
return;
|
|
}
|
|
|
|
saveStorage(state).catch((e) => {
|
|
console.error('Error saving storage:', e);
|
|
});
|
|
});
|
|
|
|
const createTypeBoundedUseStore = ((store) => (selector) =>
|
|
useStoreWithEqualityFn(store, selector, shallow)) as <S extends StoreApi<unknown>>(
|
|
store: S
|
|
) => {
|
|
(): ExtractState<S>;
|
|
<T>(selector: (state: ExtractState<S>) => T): T;
|
|
};
|
|
|
|
const useGlobalStateStore = createTypeBoundedUseStore(GlobalStateStore);
|
|
|
|
const setApiURL = (url: string) => {
|
|
GlobalStateStore.getState().setApiURL(url);
|
|
};
|
|
|
|
const getApiURL = (): string => {
|
|
return GlobalStateStore.getState().session.apiURL ?? '';
|
|
};
|
|
|
|
const getAuthToken = (): string => {
|
|
return GlobalStateStore.getState().session.authToken ?? '';
|
|
};
|
|
|
|
const isLoggedIn = (): boolean => {
|
|
return GlobalStateStore.getState().isLoggedIn();
|
|
};
|
|
|
|
const setAuthToken = (token: string) => {
|
|
GlobalStateStore.getState().setAuthToken(token);
|
|
};
|
|
|
|
const GetGlobalState = (): GlobalStateStoreType => {
|
|
return GlobalStateStore.getState();
|
|
};
|
|
|
|
export {
|
|
getApiURL,
|
|
getAuthToken,
|
|
GetGlobalState,
|
|
GlobalStateStore,
|
|
isLoggedIn,
|
|
setApiURL,
|
|
setAuthToken,
|
|
useGlobalStateStore,
|
|
};
|