diff --git a/databox/client/package.json b/databox/client/package.json index 7f4cd1d12..957988b86 100644 --- a/databox/client/package.json +++ b/databox/client/package.json @@ -8,17 +8,18 @@ "@alchemy/core": "workspace:*", "@alchemy/react-auth": "workspace:*", "@alchemy/react-ps": "workspace:*", + "@alchemy/theme-editor": "workspace:*", "@alchemy/visual-workflow": "workspace:*", "@alchemy/react-hooks": "workspace:*", "@alchemy/navigation": "workspace:*", "@dnd-kit/core": "^6.0.5", "@dnd-kit/sortable": "^7.0.1", "@dnd-kit/utilities": "^3.2.0", - "@emotion/react": "^11.9.0", - "@emotion/styled": "^11.8.1", - "@mui/icons-material": "^5.6.2", - "@mui/lab": "^5.0.0-alpha.80", - "@mui/material": "^5.10.13", + "@emotion/react": "^11.11.1", + "@emotion/styled": "^11.11.0", + "@mui/icons-material": "^5.15.0", + "@mui/lab": "^5.0.0-alpha.156", + "@mui/material": "^5.15.0", "@toast-ui/react-image-editor": "^3.15.2", "ace-builds": "^1.14.0", "axios": "^1.6.2", diff --git a/databox/client/src/components/Layout/ChangeTheme.tsx b/databox/client/src/components/Layout/ChangeTheme.tsx index 78f8781e5..81d122a65 100644 --- a/databox/client/src/components/Layout/ChangeTheme.tsx +++ b/databox/client/src/components/Layout/ChangeTheme.tsx @@ -11,23 +11,27 @@ import {useTranslation} from 'react-i18next'; import themes from '../../themes'; import {ThemeName} from '../../lib/theme'; import {UserPreferencesContext} from '../User/Preferences/UserPreferencesContext'; +import {StackedModalProps, useModals} from '@alchemy/navigation'; -type Props = { - onClose: () => void; -}; +type Props = {} & StackedModalProps; -export default function ChangeTheme({onClose}: Props) { +export default function ChangeTheme({ + open, +}: Props) { const {t} = useTranslation(); const prefContext = useContext(UserPreferencesContext); const {preferences, updatePreference} = prefContext; + const {closeModal} = useModals(); const handleClick = (name: ThemeName) => { updatePreference('theme', name); }; + const onClose = () => closeModal(); + return ( <> - + {t('change_theme.title', 'Choose a theme')} diff --git a/databox/client/src/components/Layout/MainAppBar.tsx b/databox/client/src/components/Layout/MainAppBar.tsx index e81728fcf..bcbe0baf7 100644 --- a/databox/client/src/components/Layout/MainAppBar.tsx +++ b/databox/client/src/components/Layout/MainAppBar.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import {useContext, useState} from 'react'; +import {useContext} from 'react'; import AppBar from '@mui/material/AppBar'; import Box from '@mui/material/Box'; import Toolbar from '@mui/material/Toolbar'; @@ -17,13 +17,16 @@ import LogoutIcon from '@mui/icons-material/Logout'; import {SearchContext} from '../Media/Search/SearchContext'; import ColorLensIcon from '@mui/icons-material/ColorLens'; import AccountBoxIcon from '@mui/icons-material/AccountBox'; -import ChangeTheme from './ChangeTheme'; import {zIndex} from '../../themes/zIndex'; import {useKeycloakUrls} from '@alchemy/react-auth'; +import {ThemeEditorContext} from '@alchemy/theme-editor'; import config from '../../config.ts'; import {keycloakClient} from '../../api/api-client.ts'; import {useUser} from '../../lib/auth.ts'; import {DashboardMenu} from '@alchemy/react-ps'; +import {useModals} from '@alchemy/navigation'; +import ChangeTheme from "./ChangeTheme.tsx"; +import ThemeEditor from "./ThemeEditor.tsx"; export const menuHeight = 42; @@ -34,7 +37,8 @@ type Props = { export default function MainAppBar({onToggleLeftPanel}: Props) { const {t} = useTranslation(); - const [changeTheme, setChangeTheme] = useState(false); + const {openModal} = useModals(); + const themeEditorContext = useContext(ThemeEditorContext); const userContext = useUser(); const [anchorElUser, setAnchorElUser] = React.useState( null @@ -70,9 +74,6 @@ export default function MainAppBar({onToggleLeftPanel}: Props) { }} position="static" > - {changeTheme && ( - setChangeTheme(false)} /> - )} { - setChangeTheme(true); + openModal(ChangeTheme); handleCloseUserMenu(); }} > @@ -215,6 +216,29 @@ export default function MainAppBar({onToggleLeftPanel}: Props) { )} /> + { + openModal(ThemeEditor, {}, { + forwardedContexts: [ + { + context: ThemeEditorContext, + value: themeEditorContext, + } + ] + }); + handleCloseUserMenu(); + }} + > + + + + + closeModal(); + + return + + +} diff --git a/databox/client/src/components/Root.tsx b/databox/client/src/components/Root.tsx index f8ac56764..42e7170a8 100644 --- a/databox/client/src/components/Root.tsx +++ b/databox/client/src/components/Root.tsx @@ -1,9 +1,8 @@ import React from 'react'; -import {ModalStack} from '@alchemy/navigation'; +import {ModalStack, RouterProvider} from '@alchemy/navigation'; import UserPreferencesProvider from './User/Preferences/UserPreferencesProvider'; import {keycloakClient, oauthClient} from '../api/api-client'; import {AuthenticationProvider, MatomoUser} from '@alchemy/react-auth'; -import {RouterProvider} from '@alchemy/navigation'; import {routes} from '../routes.ts'; import RouteProxy from './Routing/RouteProxy.tsx'; @@ -17,16 +16,16 @@ export default function Root({}: Props) { return ( - - + + - - + + ); } diff --git a/databox/client/src/components/User/Preferences/UserPreferencesProvider.tsx b/databox/client/src/components/User/Preferences/UserPreferencesProvider.tsx index 66efad4e1..7976f367f 100644 --- a/databox/client/src/components/User/Preferences/UserPreferencesProvider.tsx +++ b/databox/client/src/components/User/Preferences/UserPreferencesProvider.tsx @@ -6,9 +6,10 @@ import { UserPreferencesContext, } from './UserPreferencesContext'; import {getUserPreferences, putUserPreferences} from '../../../api/user'; -import {createCachedTheme} from '../../../lib/theme'; -import {CssBaseline, GlobalStyles, ThemeProvider} from '@mui/material'; +import {createCachedThemeOptions} from '../../../lib/theme'; +import {CssBaseline, GlobalStyles} from '@mui/material'; import {useAuth} from '@alchemy/react-auth'; +import {ThemeEditorProvider} from '@alchemy/theme-editor'; const sessionStorageKey = 'userPrefs'; @@ -77,8 +78,8 @@ export default function UserPreferencesProvider({children}: Props) { return ( - {children} - + ); } diff --git a/databox/client/src/lib/theme.ts b/databox/client/src/lib/theme.ts index 75115bfd2..f2e00e470 100644 --- a/databox/client/src/lib/theme.ts +++ b/databox/client/src/lib/theme.ts @@ -1,19 +1,16 @@ -import {Theme, ThemeOptions} from '@mui/material'; -import {createTheme} from '@mui/material/styles'; +import {ThemeOptions} from '@mui/material'; import {mergeDeep} from './merge'; import baseTheme from '../themes/base'; import themes from '../themes'; -const themeCache: Record = {}; +const themeCache: Record = {}; export type ThemeName = keyof typeof themes; -export function createCachedTheme(name: ThemeName): Theme { +export function createCachedThemeOptions(name: ThemeName): ThemeOptions { if (themeCache[name]) { return themeCache[name]; } - return (themeCache[name] = createTheme( - mergeDeep({}, baseTheme, themes[name]) as ThemeOptions - )); + return (themeCache[name] = mergeDeep({}, baseTheme, themes[name]) as ThemeOptions); } diff --git a/lib/js/core/index.ts b/lib/js/core/index.ts index 5128ed350..c9d48e0fb 100644 --- a/lib/js/core/index.ts +++ b/lib/js/core/index.ts @@ -1,8 +1,10 @@ import {initSentry, logError} from "./src/sentry"; +import {ErrorBoundary} from "@sentry/react"; export { initSentry, logError, + ErrorBoundary, }; export * from './src/types'; diff --git a/lib/js/navigation/package.json b/lib/js/navigation/package.json index ce14f672f..469684c3b 100644 --- a/lib/js/navigation/package.json +++ b/lib/js/navigation/package.json @@ -9,6 +9,7 @@ "dependencies": { "@remix-run/router": "1.11.0", "react-router-dom": "6.18.0", + "@alchemy/core": "workspace:*", "@alchemy/phrasea-ui": "workspace:*" }, "peerDependencies": { diff --git a/lib/js/navigation/src/DefaultErrorBoundary.tsx b/lib/js/navigation/src/DefaultErrorBoundary.tsx deleted file mode 100644 index 41f2287c9..000000000 --- a/lib/js/navigation/src/DefaultErrorBoundary.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React, {ErrorInfo, PropsWithChildren} from 'react'; - -export type ErrorFallbackProps = { error: any }; -export type TErrorFallbackComponent = (props: ErrorFallbackProps) => React.JSX.Element; - -export type TErrorBoundaryComponent = React.JSXElementConstructor>; - -export default class DefaultErrorBoundary extends React.Component, { - error?: any; -}> { - state: { - error?: any; - } = {}; - - static getDerivedStateFromError(error: any) { - // Update state so the next render will show the fallback UI. - return {error}; - } - - componentDidCatch(error: Error, errorInfo: ErrorInfo) { - console.error(error); - console.debug(errorInfo); - } - - render() { - const {error} = this.state; - if (error) { - return <>{this.props.fallback({error})} - } - - return <>{this.props.children} - } -} diff --git a/lib/js/navigation/src/Router.tsx b/lib/js/navigation/src/Router.tsx index 534bb1825..e0c6cfb2a 100644 --- a/lib/js/navigation/src/Router.tsx +++ b/lib/js/navigation/src/Router.tsx @@ -1,13 +1,17 @@ -import {RouteDefinition, RouteParameters, Routes, RouteProxyProps, RouteProxyComponent, ErrorComponent} from "./types"; +import { + RouteDefinition, + RouteParameters, + Routes, + RouteProxyProps, + RouteProxyComponent, + TErrorBoundaryComponent, TErrorFallbackComponent, +} from "./types"; import {getFullPath, getLocationPrefix} from "./utils"; import {Outlet, RouteObject} from "react-router-dom"; import React, {PropsWithChildren} from "react"; -import DefaultErrorBoundary, { - ErrorFallbackProps, TErrorBoundaryComponent -} from "./DefaultErrorBoundary"; import DefaultRouteProxy from "./proxy/DefaultRouteProxy"; -import {DefaultErrorComponent} from "./DefaultErrorComponent"; -import {NotFoundPage} from "@alchemy/phrasea-ui"; +import {NotFoundPage, ErrorPage} from "@alchemy/phrasea-ui"; +import {ErrorBoundary} from "@alchemy/core"; export function compileRoutes(routes: Routes, rootUrl?: string): Routes { @@ -113,7 +117,7 @@ export function createRouteComponent(route: RouteDefinition, RouteProxyComponent export type RouterProviderOptions = { RouteProxyComponent?: RouteProxyComponent, - ErrorComponent?: ErrorComponent, + ErrorComponent?: TErrorFallbackComponent, ErrorBoundaryComponent?: TErrorBoundaryComponent, WrapperComponent?: React.FC>; } @@ -126,8 +130,8 @@ export function createRouterProviderRoutes( const { RouteProxyComponent: RouteProxyComponent = DefaultRouteProxy, - ErrorComponent = DefaultErrorComponent, - ErrorBoundaryComponent = DefaultErrorBoundary, + ErrorComponent = ErrorPage, + ErrorBoundaryComponent = ErrorBoundary, WrapperComponent } = options; @@ -157,7 +161,7 @@ export function createRouterProviderRoutes( return [ { - Component: () => }> + Component: () => {WrapperComponent ? React.createElement(WrapperComponent, { children: }) : } diff --git a/lib/js/navigation/src/types.ts b/lib/js/navigation/src/types.ts index 85f36c859..686885043 100644 --- a/lib/js/navigation/src/types.ts +++ b/lib/js/navigation/src/types.ts @@ -26,3 +26,10 @@ export type RouteParameters = Record; export type RouteProxyComponent = FunctionComponent; export type ErrorComponent = ElementType; + +export type ErrorFallbackProps = { error: any }; +export type TErrorFallbackComponent = (props: ErrorFallbackProps) => React.JSX.Element; + +export type TErrorBoundaryComponent = React.JSXElementConstructor>; diff --git a/lib/js/theme-editor/index.ts b/lib/js/theme-editor/index.ts new file mode 100644 index 000000000..bc2fa195d --- /dev/null +++ b/lib/js/theme-editor/index.ts @@ -0,0 +1,11 @@ +import ThemeEditorProvider from "./src/ThemeEditorProvider"; +import MuiThemeEditor from "./src/MuiThemeEditor"; +import ThemeEditorContext from "./src/ThemeEditorContext"; +export { + ThemeEditorProvider, + MuiThemeEditor, + ThemeEditorContext, +}; + +export * from './src/types'; + diff --git a/lib/js/theme-editor/package.json b/lib/js/theme-editor/package.json new file mode 100644 index 000000000..203310f94 --- /dev/null +++ b/lib/js/theme-editor/package.json @@ -0,0 +1,28 @@ +{ + "name": "@alchemy/theme-editor", + "version": "0.0.1", + "public": true, + "description": "MUI Theme editor", + "keywords": [], + "main": "index.ts", + "license": "MIT", + "peerDependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "@mui/material": "^5.15.0", + "@emotion/react": "^11.11.1", + "@emotion/styled": "^11.11.0" + }, + "dependencies": { + "@alchemy/storage": "workspace:*" + }, + "devDependencies": { + "@types/react": "^18.2.38", + "@types/react-dom": "^18.2.15", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "@mui/material": "^5.15.0", + "@emotion/react": "^11.11.1", + "@emotion/styled": "^11.11.0" + } +} diff --git a/lib/js/theme-editor/src/MuiThemeEditor.tsx b/lib/js/theme-editor/src/MuiThemeEditor.tsx new file mode 100644 index 000000000..5d2fa7b4f --- /dev/null +++ b/lib/js/theme-editor/src/MuiThemeEditor.tsx @@ -0,0 +1,78 @@ +import React, {ChangeEventHandler, useContext} from "react"; +import ThemeEditorContext from "./ThemeEditorContext"; +import {Button, ThemeOptions, Typography, TextField, Box} from "@mui/material"; +import {getSessionStorage} from '@alchemy/storage'; + +type Props = { + onClose: () => void; +}; + +export default function MuiThemeEditor({ + onClose, +}: Props) { + const themeEditorKey = 'theme-editor'; + const storage = getSessionStorage(); + const { + setThemeOptions, + } = useContext(ThemeEditorContext)!; + const [value, setValue] = React.useState(storage.getItem(themeEditorKey) || ''); + const timeoutRef = React.useRef>(); + + const onChange = React.useCallback>((e) => { + const v = e.target.value; + setValue(v); + + storage.setItem(themeEditorKey, v); + + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(() => { + const code = v.trim().replace(/^\s*(export\s+)?const\s+[^\s]+\s*=\s*\{/, '{'); + + console.log('code', code); + + setThemeOptions(eval(`(function () { + return ${code} + })();`) as ThemeOptions); + }, 200); + + }, [setThemeOptions, timeoutRef]); + + return <> + + Theme Editor + + + e.stopPropagation()} + label={`Theme Options`} + maxRows={20} + helperText={<> + Check Playground + } + multiline={true} + style={{ + width: '100%', + }} + value={value} + onChange={onChange} + /> + + + +} diff --git a/lib/js/theme-editor/src/ThemeEditorContext.ts b/lib/js/theme-editor/src/ThemeEditorContext.ts new file mode 100644 index 000000000..9d7e3676e --- /dev/null +++ b/lib/js/theme-editor/src/ThemeEditorContext.ts @@ -0,0 +1,4 @@ +import {TThemeEditorContext} from "./types"; +import React from "react"; + +export default React.createContext(undefined); diff --git a/lib/js/theme-editor/src/ThemeEditorProvider.tsx b/lib/js/theme-editor/src/ThemeEditorProvider.tsx new file mode 100644 index 000000000..555d34d98 --- /dev/null +++ b/lib/js/theme-editor/src/ThemeEditorProvider.tsx @@ -0,0 +1,38 @@ +import React, {PropsWithChildren} from 'react'; +import ThemeEditorContext from "./ThemeEditorContext"; +import {createTheme, ThemeOptions, ThemeProvider} from "@mui/material"; +import {TThemeEditorContext} from "./types"; +import {mergeDeep} from "./merge"; + +type Props = PropsWithChildren<{ + defaultTheme: ThemeOptions; +}>; + +export default function ThemeEditorProvider({ + defaultTheme, + children +}: Props) { + const [themeOptions, setThemeOptions] = React.useState({}); + + const value = React.useMemo(() => { + const theme = createTheme( + mergeDeep({}, defaultTheme, themeOptions) as ThemeOptions + ); + + return { + theme, + themeOptions, + setThemeOptions: (options) => setThemeOptions(options), + } + }, [defaultTheme, themeOptions]); + + return + + {children} + + +} diff --git a/lib/js/theme-editor/src/merge.ts b/lib/js/theme-editor/src/merge.ts new file mode 100644 index 000000000..330cec3c1 --- /dev/null +++ b/lib/js/theme-editor/src/merge.ts @@ -0,0 +1,29 @@ +export function isObject(item: any): boolean { + return item && typeof item === 'object' && !Array.isArray(item); +} + +export function mergeDeep(target: object, ...sources: object[]): object { + if (!sources.length) { + return target; + } + + const source: any = sources.shift(); + + if (isObject(target) && isObject(source)) { + for (const key in source! as object) { + const e = source[key]; + if (isObject(e)) { + // @ts-expect-error ? + if (!target[key]) { + Object.assign(target, {[key]: {}}); + } + // @ts-expect-error ? + mergeDeep(target[key], e!); + } else { + Object.assign(target, {[key]: e!}); + } + } + } + + return mergeDeep(target, ...sources); +} diff --git a/lib/js/theme-editor/src/types.ts b/lib/js/theme-editor/src/types.ts new file mode 100644 index 000000000..368394af4 --- /dev/null +++ b/lib/js/theme-editor/src/types.ts @@ -0,0 +1,7 @@ +import type {Theme, ThemeOptions} from '@mui/material'; + +export type TThemeEditorContext = { + theme: Theme; + themeOptions: ThemeOptions; + setThemeOptions: (theme: ThemeOptions) => void; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eff47d631..fbbeab119 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -553,6 +553,9 @@ importers: lib/js/navigation: dependencies: + '@alchemy/core': + specifier: workspace:* + version: link:../core '@alchemy/phrasea-ui': specifier: workspace:* version: link:../phrasea-ui @@ -824,9 +827,6 @@ importers: '@alchemy/storage': specifier: workspace:* version: link:../storage - acorn: - specifier: ^8.11.2 - version: 8.11.2 devDependencies: '@emotion/react': specifier: ^11.11.1