Files
oranguru/src/GlobalStateStore/GlobalStateStore.ts

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