diff --git a/src/App.tsx b/src/App.tsx index 209e3bc1..4b3fa155 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -48,6 +48,8 @@ import { mutatorOpenListener } from './blocks/mrc_param_container' import { TOOLBOX_UPDATE_EVENT } from './blocks/mrc_mechanism_component_holder'; import { antdThemeFromString } from './reactComponents/ThemeModal'; import { useTranslation } from 'react-i18next'; +import { UserSettingsProvider } from './reactComponents/UserSettingsProvider'; +import { useUserSettings } from './reactComponents/useUserSettings'; /** Storage key for shown toolbox categories. */ const SHOWN_TOOLBOX_CATEGORIES_KEY = 'shownPythonToolboxCategories'; @@ -87,15 +89,75 @@ const LAYOUT_BACKGROUND_COLOR = '#0F0'; * project management, and user interface layout. */ const App: React.FC = (): React.JSX.Element => { + const [storage, setStorage] = React.useState(null); + + /** Opens client-side storage asynchronously. */ + const openStorage = async (): Promise => { + try { + const clientStorage = await clientSideStorage.openClientSideStorage(); + setStorage(clientStorage); + } catch (e) { + console.error(STORAGE_ERROR_MESSAGE); + console.error(e); + } + }; + + // Initialize storage when app loads + React.useEffect(() => { + openStorage(); + }, []); + + if (!storage) { + return ( + +
+ +
+
+ ); + } + + return ; +}; + +/** + * App wrapper that manages project state and provides it to UserSettingsProvider. + */ +const AppWithUserSettings: React.FC<{ storage: commonStorage.Storage }> = ({ storage }) => { + const [project, setProject] = React.useState(null); + + return ( + + + + ); +}; + +/** + * Inner application content component that has access to UserSettings context. + */ +interface AppContentProps { + project: commonStorage.Project | null; + setProject: React.Dispatch>; +} + +const AppContent: React.FC = ({ project, setProject }): React.JSX.Element => { const { t, i18n } = useTranslation(); - + const { settings, updateLanguage, updateTheme, storage, isLoading } = useUserSettings(); + const [alertErrorMessage, setAlertErrorMessage] = React.useState(''); - const [storage, setStorage] = React.useState(null); const [currentModule, setCurrentModule] = React.useState(null); const [messageApi, contextHolder] = Antd.message.useMessage(); const [generatedCode, setGeneratedCode] = React.useState(''); const [toolboxSettingsModalIsOpen, setToolboxSettingsModalIsOpen] = React.useState(false); - const [project, setProject] = React.useState(null); const [tabItems, setTabItems] = React.useState([]); const [activeTab, setActiveTab] = React.useState(''); const [shownPythonToolboxCategories, setShownPythonToolboxCategories] = React.useState>(new Set()); @@ -103,21 +165,88 @@ const App: React.FC = (): React.JSX.Element => { const [leftCollapsed, setLeftCollapsed] = React.useState(false); const [rightCollapsed, setRightCollapsed] = React.useState(false); const [theme, setTheme] = React.useState('dark'); + const [languageInitialized, setLanguageInitialized] = React.useState(false); + const [themeInitialized, setThemeInitialized] = React.useState(false); const blocksEditor = React.useRef(null); const generatorContext = React.useRef(null); const blocklyComponent = React.useRef(null); - /** Opens client-side storage asynchronously. */ - const openStorage = async (): Promise => { - try { - const clientStorage = await clientSideStorage.openClientSideStorage(); - setStorage(clientStorage); - } catch (e) { - console.error(STORAGE_ERROR_MESSAGE); - console.error(e); + /** Initialize language from UserSettings when app first starts. */ + React.useEffect(() => { + // Only proceed if settings are loaded + if (!isLoading) { + if (!languageInitialized && settings.language && i18n.language !== settings.language) { + i18n.changeLanguage(settings.language); + setLanguageInitialized(true); + } else if (!languageInitialized) { + setLanguageInitialized(true); + } } - }; + }, [settings.language, i18n, languageInitialized, isLoading]); + + /** Initialize theme from UserSettings when app first starts. */ + React.useEffect(() => { + // Only proceed if settings are loaded + if (!isLoading) { + if (!themeInitialized && settings.theme && settings.theme !== theme) { + setTheme(settings.theme); + setThemeInitialized(true); + } else if (!themeInitialized) { + setThemeInitialized(true); + } + } + }, [settings.theme, theme, themeInitialized, isLoading]); + + /** Save language changes to UserSettings when i18n language changes. */ + React.useEffect(() => { + const handleLanguageChange = async (newLanguage: string) => { + // Save current blocks before language change + if (currentModule && areBlocksModified()) { + try { + await saveBlocks(); + } catch (e) { + console.error('Failed to save blocks before language change:', e); + } + } + + // Only save if this is not the initial load and the language is different + if (languageInitialized && newLanguage !== settings.language) { + try { + await updateLanguage(newLanguage); + } catch (error) { + console.error('Failed to save language setting:', error); + } + } + + // Update toolbox after language change + if (blocksEditor.current) { + blocksEditor.current.updateToolbox(shownPythonToolboxCategories); + } + }; + + i18n.on('languageChanged', handleLanguageChange); + + return () => { + i18n.off('languageChanged', handleLanguageChange); + }; + }, [languageInitialized, settings.language, updateLanguage, i18n, currentModule, shownPythonToolboxCategories]); + + /** Save theme changes to UserSettings when theme changes. */ + React.useEffect(() => { + const saveThemeChange = async () => { + // Only save if this is not the initial load and theme is different from settings + if (themeInitialized && theme !== settings.theme && !isLoading) { + try { + await updateTheme(theme); + } catch (error) { + console.error('Failed to save theme setting:', error); + } + } + }; + + saveThemeChange(); + }, [theme, settings.theme, updateTheme, themeInitialized, isLoading]); /** Initializes custom blocks and Python generator. */ const initializeBlocks = (): void => { @@ -274,9 +403,8 @@ const App: React.FC = (): React.JSX.Element => { }; }, [handleToolboxUpdateRequest]); - // Initialize storage and blocks when app loads + // Initialize blocks when app loads React.useEffect(() => { - openStorage(); initializeBlocks(); }, []); @@ -292,10 +420,10 @@ const App: React.FC = (): React.JSX.Element => { if (blocksEditor.current) { blocksEditor.current.loadModuleBlocks(currentModule); } - }, [currentModule]); + }, [currentModule]); const setupWorkspace = (newWorkspace: Blockly.WorkspaceSvg) => { - if (!blocklyComponent.current || !storage) { + if (!blocklyComponent.current || !storage) { return; } // Recreate workspace when Blockly component is ready @@ -303,18 +431,18 @@ const App: React.FC = (): React.JSX.Element => { newWorkspace.addChangeListener(mutatorOpenListener); newWorkspace.addChangeListener(handleBlocksChanged); generatorContext.current = createGeneratorContext(); - + if (currentModule) { generatorContext.current.setModule(currentModule); } blocksEditor.current = new editor.Editor(newWorkspace, generatorContext.current, storage); - + // Set the current module in the editor after creating it if (currentModule) { blocksEditor.current.loadModuleBlocks(currentModule); } - + blocksEditor.current.updateToolbox(shownPythonToolboxCategories); }; @@ -359,31 +487,6 @@ const App: React.FC = (): React.JSX.Element => { } }, [project]); - // Handle language changes with automatic saving - React.useEffect(() => { - const handleLanguageChange = async () => { - // Save current blocks before language change - if (currentModule && areBlocksModified()) { - try { - await saveBlocks(); - } catch (e) { - console.error('Failed to save blocks before language change:', e); - } - } - - // Update toolbox after language change - if (blocksEditor.current) { - blocksEditor.current.updateToolbox(shownPythonToolboxCategories); - } - }; - - i18n.on('languageChanged', handleLanguageChange); - - return () => { - i18n.off('languageChanged', handleLanguageChange); - }; - }, [currentModule, shownPythonToolboxCategories, i18n]); - const { Sider, Content } = Antd.Layout; return ( @@ -392,78 +495,79 @@ const App: React.FC = (): React.JSX.Element => { > {contextHolder} -
- - setLeftCollapsed(collapsed)} +
+ - setToolboxSettingsModalIsOpen(true)} - setTheme={setTheme} - /> - - - + setLeftCollapsed(collapsed)} + > + setToolboxSettingsModalIsOpen(true)} + theme={theme} + setTheme={setTheme} + /> + - - - - setRightCollapsed(collapsed)} - > - - + + + + + + setRightCollapsed(collapsed)} + > + + + - - - + + ); }; diff --git a/src/reactComponents/Menu.tsx b/src/reactComponents/Menu.tsx index 0faa960e..d5543848 100644 --- a/src/reactComponents/Menu.tsx +++ b/src/reactComponents/Menu.tsx @@ -54,6 +54,7 @@ export interface MenuProps { project: commonStorage.Project | null; openWPIToolboxSettings: () => void; setProject: (project: commonStorage.Project | null) => void; + theme: string; setTheme: (theme: string) => void; } @@ -178,11 +179,8 @@ export function Component(props: MenuProps): React.JSX.Element { const [noProjects, setNoProjects] = React.useState(false); const [aboutDialogVisible, setAboutDialogVisible] = React.useState(false); const [themeModalOpen, setThemeModalOpen] = React.useState(false); - const [currentTheme, setCurrentTheme] = React.useState('dark'); - const handleThemeChange = (newTheme: string) => { - setCurrentTheme(newTheme); props.setTheme(newTheme); }; @@ -359,7 +357,7 @@ export function Component(props: MenuProps): React.JSX.Element { setThemeModalOpen(false)} - currentTheme={currentTheme} + currentTheme={props.theme} onThemeChange={handleThemeChange} /> diff --git a/src/reactComponents/UserSettingsProvider.tsx b/src/reactComponents/UserSettingsProvider.tsx new file mode 100644 index 00000000..1dd0ea75 --- /dev/null +++ b/src/reactComponents/UserSettingsProvider.tsx @@ -0,0 +1,151 @@ +/** + * @license + * Copyright 2025 Porpoiseful LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @fileoverview React component for managing user settings data. + * This component uses the storage interface to persist user preferences. + */ + +import * as React from 'react'; +import { Storage } from '../storage/common_storage'; + +/** Storage keys for user settings. */ +const USER_LANGUAGE_KEY = 'userLanguage'; +const USER_THEME_KEY = 'userTheme'; + +/** Default values for user settings. */ +const DEFAULT_LANGUAGE = 'en'; +const DEFAULT_THEME = 'dark'; + +/** User settings interface. */ +export interface UserSettings { + language: string; + theme: string; +} + +/** User settings context interface. */ +export interface UserSettingsContextType { + settings: UserSettings; + updateLanguage: (language: string) => Promise; + updateTheme: (theme: string) => Promise; + isLoading: boolean; + error: string | null; + storage: Storage | null; +} + +/** User settings context. */ +export const UserSettingsContext = React.createContext(null); + +/** Props for UserSettingsProvider component. */ +export interface UserSettingsProviderProps { + storage?: Storage | null; // Optional storage, can be provided for testing + currentProjectName?: string | null; + children: React.ReactNode; +} + +/** User settings provider component. */ +export const UserSettingsProvider: React.FC = ({ + storage, + currentProjectName, + children, +}) => { + const [settings, setSettings] = React.useState({ + language: DEFAULT_LANGUAGE, + theme: DEFAULT_THEME, + }); + const [isLoading, setIsLoading] = React.useState(true); + const [error, setError] = React.useState(null); + + /** Load user settings from storage on component mount. */ + React.useEffect(() => { + const loadSettings = async (validStorage: Storage): Promise => { + try { + setIsLoading(true); + setError(null); + + const [language, theme] = await Promise.all([ + validStorage.fetchEntry(USER_LANGUAGE_KEY, DEFAULT_LANGUAGE), + validStorage.fetchEntry(USER_THEME_KEY, DEFAULT_THEME), + ]); + + setSettings({ + language, + theme, + }); + } catch (err) { + setError(`Failed to load user settings: ${err}`); + console.error('Error loading user settings:', err); + } finally { + setIsLoading(false); + } + }; + + if (storage) { + loadSettings(storage); + } else { + // If no storage is available, we're still "loaded" with default values + setIsLoading(false); + } + }, [storage, currentProjectName]); + + /** Update language setting. */ + const updateLanguage = async (language: string): Promise => { + try { + setError(null); + if (storage) { + await storage.saveEntry(USER_LANGUAGE_KEY, language); + setSettings(prev => ({ ...prev, language })); + } else { + console.warn('No storage available, cannot save language'); + } + } catch (err) { + setError(`Failed to save language setting: ${err}`); + console.error('Error saving language setting:', err); + throw err; + } + }; + + /** Update theme setting. */ + const updateTheme = async (theme: string): Promise => { + try { + setError(null); + if (storage) { + await storage.saveEntry(USER_THEME_KEY, theme); + setSettings(prev => ({ ...prev, theme })); + } + } catch (err) { + setError(`Failed to save theme setting: ${err}`); + console.error('Error saving theme setting:', err); + throw err; + } + }; + + const contextValue: UserSettingsContextType = { + settings, + updateLanguage, + updateTheme, + isLoading, + error, + storage: storage || null, + }; + + return ( + + {children} + + ); +}; diff --git a/src/reactComponents/useUserSettings.ts b/src/reactComponents/useUserSettings.ts new file mode 100644 index 00000000..d7fe83e0 --- /dev/null +++ b/src/reactComponents/useUserSettings.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2025 Porpoiseful LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @fileoverview Custom hooks for accessing user settings. + */ + +import * as React from 'react'; +import { UserSettings, UserSettingsContextType, UserSettingsContext } from './UserSettingsProvider'; + +/** Hook to use user settings context. */ +export const useUserSettings = (): UserSettingsContextType => { + const context = React.useContext(UserSettingsContext); + if (!context) { + throw new Error('useUserSettings must be used within a UserSettingsProvider'); + } + return context; +}; + +/** Hook to access user settings data only (without update functions). */ +export const useUserSettingsData = (): UserSettings => { + const { settings } = useUserSettings(); + return settings; +};