diff --git a/packages/insomnia-app/app/models/settings.ts b/packages/insomnia-app/app/models/settings.ts index ab58abc3c9..edbeda6df5 100644 --- a/packages/insomnia-app/app/models/settings.ts +++ b/packages/insomnia-app/app/models/settings.ts @@ -68,17 +68,14 @@ interface BaseSettings { } export type Settings = BaseModel & BaseSettings; - export const name = 'Settings'; - export const type = 'Settings'; - export const prefix = 'set'; - export const canDuplicate = false; - export const canSync = false; +export type ThemeSettings = Pick; + export const isSettings = (model: Pick): model is Settings => ( model.type === type ); diff --git a/packages/insomnia-app/app/plugins/misc.ts b/packages/insomnia-app/app/plugins/misc.ts index 051239d404..2b65057ec5 100644 --- a/packages/insomnia-app/app/plugins/misc.ts +++ b/packages/insomnia-app/app/plugins/misc.ts @@ -1,9 +1,10 @@ import Color from 'color'; import { render, THROW_ON_ERROR } from '../common/render'; -import { getThemes } from './index'; +import { ColorScheme, getThemes } from './index'; import type { Theme } from './index'; import { getAppDefaultTheme } from '../common/constants'; -import { Settings } from '../models/settings'; +import { ThemeSettings } from '../models/settings'; +import { unreachableCase } from 'ts-assert-unreachable'; interface ThemeBlock { background?: { @@ -222,8 +223,8 @@ function wrapStyles(theme: string, selector: string, styles: string) { ].join('\n'); } -export function getColorScheme(settings: Settings) { - if (!settings.autoDetectColorScheme) { +export function getColorScheme({ autoDetectColorScheme }: ThemeSettings): ColorScheme { + if (!autoDetectColorScheme) { return 'default'; } @@ -238,15 +239,24 @@ export function getColorScheme(settings: Settings) { return 'default'; } -export async function applyColorScheme(settings: Settings) { +export async function applyColorScheme(settings: ThemeSettings) { const scheme = getColorScheme(settings); - if (scheme === 'light') { - await setTheme(settings.lightTheme); - } else if (scheme === 'dark') { - await setTheme(settings.darkTheme); - } else { - await setTheme(settings.theme); + switch (scheme) { + case 'light': + await setTheme(settings.lightTheme); + break; + + case 'dark': + await setTheme(settings.darkTheme); + break; + + case 'default': + await setTheme(settings.theme); + break; + + default: + unreachableCase(scheme); } } diff --git a/packages/insomnia-app/app/ui/components/base/button.tsx b/packages/insomnia-app/app/ui/components/base/button.tsx index 359aea5bce..ea27b457d3 100644 --- a/packages/insomnia-app/app/ui/components/base/button.tsx +++ b/packages/insomnia-app/app/ui/components/base/button.tsx @@ -1,4 +1,4 @@ -import React, { ButtonHTMLAttributes, PureComponent, ReactNode } from 'react'; +import React, { ButtonHTMLAttributes, CSSProperties, PureComponent, ReactNode } from 'react'; import { autoBindMethodsForReact } from 'class-autobind-decorator'; import { AUTOBIND_CFG } from '../../../common/constants'; @@ -14,6 +14,7 @@ export interface ButtonProps { id?: string, // TODO(TSCONVERSION) figure out why so many components pass this yet it isn't used title?: string; + style?: CSSProperties; } @autoBindMethodsForReact(AUTOBIND_CFG) @@ -32,7 +33,7 @@ class Button extends PureComponent> { } render() { - const { children, disabled, tabIndex, className, type, id } = this.props; + const { children, disabled, tabIndex, className, type, id, style } = this.props; return ( diff --git a/packages/insomnia-app/app/ui/components/modals/settings-modal.tsx b/packages/insomnia-app/app/ui/components/modals/settings-modal.tsx index 163f2948a6..fe9daa4711 100644 --- a/packages/insomnia-app/app/ui/components/modals/settings-modal.tsx +++ b/packages/insomnia-app/app/ui/components/modals/settings-modal.tsx @@ -10,14 +10,14 @@ import SettingsShortcuts from '../settings/shortcuts'; import General from '../settings/general'; import { ImportExport } from '../settings/import-export'; import Plugins from '../settings/plugins'; -import Theme from '../settings/theme'; +import { ThemePanel } from '../settings/theme-panel'; import * as models from '../../../models/index'; import { Curl } from 'node-libcurl'; import Tooltip from '../tooltip'; -import { applyColorScheme } from '../../../plugins/misc'; import * as session from '../../../account/session'; import Account from '../settings/account'; import { showModal } from './index'; +import { Settings } from '../../../models/settings'; export const TAB_INDEX_EXPORT = 1; export const TAB_INDEX_SHORTCUTS = 3; @@ -25,7 +25,7 @@ export const TAB_INDEX_THEMES = 2; export const TAB_INDEX_PLUGINS = 5; interface Props { - settings: any; + settings: Settings; } interface State { @@ -50,53 +50,6 @@ class SettingsModal extends PureComponent { }); } - hideSettingsModal() { - this.modal?.hide(); - } - - async _handleChangeTheme(themeName, colorScheme, persist = true) { - const { settings } = this.props; - let patch; - - switch (colorScheme) { - case 'light': - patch = { - lightTheme: themeName, - }; - break; - - case 'dark': - patch = { - darkTheme: themeName, - }; - break; - - case 'default': - default: - patch = { - theme: themeName, - }; - break; - } - - applyColorScheme({ ...settings, ...patch }); - - if (persist) { - await models.settings.update(settings, patch); - } - } - - async _handleAutoDetectColorSchemeChange(autoDetectColorScheme, persist = true) { - const { settings } = this.props; - applyColorScheme({ ...settings, autoDetectColorScheme }); - - if (persist) { - models.settings.update(settings, { - autoDetectColorScheme, - }); - } - } - async _handleUpdateKeyBindings(hotKeyRegistry) { models.settings.update(this.props.settings, { hotKeyRegistry, @@ -165,18 +118,11 @@ class SettingsModal extends PureComponent { - + mode === 'dark'; +const isLight = (mode: 'dark' | 'light') => mode === 'light'; + +const RootWrapper = styled.div({}); + +const CheckboxWrapper = styled.div({ + marginLeft: 'var(--padding-md)', +}); + +const Themes = styled.div({ + display: 'flex', + flexWrap: 'wrap', +}); + +const ThemeButton = styled.div<{ $isActive: boolean; $isInOsThemeMode: boolean }>(({ $isActive, $isInOsThemeMode }) => ({ + position: 'relative', + margin: 'var(--padding-md) var(--padding-md)', + fontSize: 0, + borderRadius: 'var(--radius-md)', + overflow: 'hidden', + boxShadow: '0 0 0 1px var(--hl-sm)', + transition: 'all 150ms ease-out', + ...($isActive ? { + boxShadow: '0 0 0 var(--padding-xs) var(--color-surprise)', + transform: 'scale(1.05)', + } : {}), + '&:hover': { + transform: 'scale(1.05)', + ...($isInOsThemeMode ? { boxShadow: 'none' } : {}), + }, + ...($isInOsThemeMode ? { + '&:hover .overlay-wrapper': { + display: 'flex', + }, + '&:hover .theme-preview': { + visibility: 'hidden', // this prevents alpha-blending problems with the underlying svg bleeding through + }, + } : {}), +})); + +const ThemeTitle = styled.h2({ + marginTop: 0, + marginBottom: 'var(--padding-xs)', + fontSize: 'var(--font-size-md)', +}); + +const ThemeWrapper = styled.div({ + maxWidth: `${100 / THEMES_PER_ROW}%`, + minWidth: 110, + marginBottom: 'var(--padding-md)', + marginTop: 'var(--padding-md)', + textAlign: 'center', +}); + +const ColorSchemeBadge = styled.div<{ $theme: 'dark' | 'light'}>(({ $theme }) => ({ + position: 'absolute', + top: 0, + width: 12, + height: 12, + fill: 'white', + padding: 4, + background: 'var(--color-surprise)', + ...(isDark($theme) ? { + right: 0, + borderBottomLeftRadius: 'var(--radius-md)', + } : {}), + ...(isLight($theme) ? { + left: 0, + borderBottomRightRadius: 'var(--radius-md)', + } : {}), +})); + +const OverlayWrapper = styled.div({ + position: 'absolute', + height: '100%', + width: '100%', + alignItems: 'center', + flexWrap: 'nowrap', + justifyContent: 'space-evenly', + boxSizing: 'content-box', + + display: 'none', // controlled by hover on the ThemeWrapper +}); + +const OverlaySide = styled.div<{ $theme: 'dark' | 'light' }>(({ $theme }) => ({ + display: 'flex', + cursor: 'pointer', + flexGrow: 1, + alignItems: 'center', + justifyContent: 'center', + height: '100%', + background: 'var(--color-bg)', + '& svg': { + opacity: 0.4, + fill: 'var(--color-fg)', + height: 20, + }, + '&:hover svg': { + opacity: 1, + fill: 'var(--color-surprise)', + }, + boxSizing: 'border-box', + border: '1px solid var(--hl-sm)', + ...(isDark($theme) ? { + borderTopRightRadius: 'var(--radius-md)', + borderBottomRightRadius: 'var(--radius-md)', + borderLeftStyle: 'none', + } : {}), + ...(isLight($theme) ? { + borderTopLeftRadius: 'var(--radius-md)', + borderBottomLeftRadius: 'var(--radius-md)', + borderRightStyle: 'none', + } : {}), + '&:hover': { + border: '1px solid var(--color-surprise)', + }, +})); + +const SunSvg = () => ( + + + + + + + + + + + +); + +const MoonSvg = () => ( + + + +); + +const ThemePreview: FC<{ theme: PluginTheme }> = ({ theme: { name: themeName } }) => ( + + {/* + A WORD TO THE WISE: If you, dear traveler from the future, are here + for the purpose of theming things due to changes in the app structure, + please remember to add `--sub` to your classes or else the selected class' + theme variables will apply to all theme previews. Search your codebase + for `--sub` to see more. + */} + + {/* @ts-expect-error -- TSCONVERSION */} + + {/* App Header */} + + + + + {/* Panes */} + + {/* Response Area */} + + + {/* URL Bars */} + + {/* Send Button */} + + + + + + {/* Sidebar */} + + + + + {/* Lines */} + + + + + + {/* Color Squares */} + + + + + + + + +); + +const IndividualTheme: FC<{ + isActive: boolean; + isDark: boolean; + isLight: boolean; + isInOsThemeMode: boolean; + onChangeTheme: (name: string, mode: ColorScheme) => Promise; + theme: PluginTheme +}> = ({ + isActive, + isDark, + isLight, + isInOsThemeMode, + onChangeTheme, + theme, +}) => { + const { displayName, name } = theme; + + const onClickThemeButton = () => { + if (isInOsThemeMode) { + // The overlays handle this behavior in OS theme mode. + // React's event bubbling means that this will be fired when you click on an overlay, so we need to turn it off when in this mode. + // Even still, we don't want to risk some potnetial subpixel or z-index nonsense accidentally setting the default when know we shouldn't. + return; + } + return onChangeTheme(name, 'default'); + }; + + return ( + + {displayName} + + + {isInOsThemeMode ? ( + <> + + { onChangeTheme(name, 'light'); }}> + { onChangeTheme(name, 'dark'); }}> + + + {isActive && isDark ? ( + + ) : null} + + {isActive && isLight ? ( + + ) : null} + + ) : null} + + + + + ); +}; + +export const ThemePanel: FC = () => { + const { + themes, + activate, + changeAutoDetect, + isActive, + isActiveDark, + isActiveLight, + autoDetectColorScheme, + } = useThemes(); + + return ( + + + + + + + {themes.map(theme => ( + + ))} + + + ); +}; diff --git a/packages/insomnia-app/app/ui/components/settings/theme.tsx b/packages/insomnia-app/app/ui/components/settings/theme.tsx deleted file mode 100644 index 851007a1d9..0000000000 --- a/packages/insomnia-app/app/ui/components/settings/theme.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import React, { PureComponent } from 'react'; -import { autoBindMethodsForReact } from 'class-autobind-decorator'; -import { AUTOBIND_CFG } from '../../../common/constants'; -import Button from '../base/button'; -import type { Theme as ThemeType, ColorScheme } from '../../../plugins'; -import { getThemes } from '../../../plugins'; -import HelpTooltip from '../help-tooltip'; -const THEMES_PER_ROW = 5; - -interface Props { - handleChangeTheme: (arg0: string, arg1: ColorScheme) => void; - activeTheme: string; - handleAutoDetectColorSchemeChange: (arg0: boolean) => void; - autoDetectColorScheme: boolean; - activeLightTheme: string; - activeDarkTheme: string; -} - -interface State { - themes: ThemeType[]; -} - -@autoBindMethodsForReact(AUTOBIND_CFG) -class Theme extends PureComponent { - state: State = { - themes: [], - } - - componentDidMount() { - this._loadThemes(); - } - - async _loadThemes() { - const themes = await getThemes(); - this.setState({ - themes, - }); - } - - renderThemePreview(themeName: string) { - return ( - /* @ts-expect-error -- TSCONVERSION */ - - {/* - A WORD TO THE WISE: If you, dear traveler from the future, are here - for the purpose of theming things due to changes in the app structure, - please remember to add `--sub` to your classes or else the selected class' - theme variables will apply to all theme previews. Search your codebase - for `--sub` to see more. - */} - - {/* @ts-expect-error -- TSCONVERSION */} - - {/* App Header */} - - - - - {/* Panes */} - - {/* Response Area */} - - - {/* URL Bars */} - - {/* Send Button */} - - - - - - {/* Sidebar */} - - - - - {/* Lines */} - - - - - - {/* Color Squares */} - - - - - - - - - ); - } - - renderTheme(theme: ThemeType) { - const { handleChangeTheme, activeTheme, autoDetectColorScheme } = this.props; - const isActive = activeTheme === theme.theme.name; - const disabled = autoDetectColorScheme; - return ( -
-

{theme.theme.displayName}

- -
- ); - } - - renderThemeRows() { - const { themes } = this.state; - const rows: ThemeType[][] = []; - let row: ThemeType[] = []; - - for (const theme of themes) { - row.push(theme); - - if (row.length === THEMES_PER_ROW) { - rows.push(row); - row = []; - } - } - - // Push the last row if it wasn't finished - if (row.length) { - rows.push(row); - } - - return rows.map((row, i) => ( -
- {row.map(this.renderTheme)} -
- )); - } - - renderThemeSelect(scheme: 'light' | 'dark') { - const { - activeLightTheme, - activeDarkTheme, - handleChangeTheme, - autoDetectColorScheme, - } = this.props; - const { themes } = this.state; - const activeColorTheme = - scheme === 'light' ? activeLightTheme : activeDarkTheme; - return ( -
-
- -
-
-
- -
-
-
- ); - } - - render() { - const { autoDetectColorScheme, handleAutoDetectColorSchemeChange } = this.props; - return ( - <> -
{this.renderThemeRows()}
-
- -
-
- {this.renderThemeSelect('light')} - {this.renderThemeSelect('dark')} -
- - ); - } -} - -export default Theme; diff --git a/packages/insomnia-app/app/ui/css/components/themes.less b/packages/insomnia-app/app/ui/css/components/themes.less deleted file mode 100644 index 597b07a280..0000000000 --- a/packages/insomnia-app/app/ui/css/components/themes.less +++ /dev/null @@ -1,109 +0,0 @@ -.themes { - &.general__preview { - padding-top: var(--padding-lg); - - } - - &.select__preview { - padding-top: var(--padding-xxs); - padding-bottom: var(--padding-xxs); - display: flex; - align-items: center; - justify-content: center; - } - - .themes__row { - display: flex; - flex-direction: row; - margin-bottom: var(--padding-lg); - margin-left: calc(var(--padding-md) * -1); - margin-right: calc(var(--padding-md) * -1); - } - - .themes__theme { - text-align: center; - - h2 { - margin-top: 0; - margin-bottom: var(--padding-xs); - font-size: var(--font-size-md) !important; - } - - svg { - .bg-fill { - fill: var(--color-bg); - } - - .success-fill { - fill: var(--color-success); - } - - .notice-fill { - fill: var(--color-notice); - } - - .warning-fill { - fill: var(--color-warning); - } - - .danger-fill { - fill: var(--color-danger); - } - - .surprise-fill { - fill: var(--color-surprise); - } - - .info-fill { - fill: var(--color-info); - } - - .fg-fill { - fill: var(--color-fg); - } - - .hl-stroke { - stroke: var(--hl-md); - } - } - } - - button { - position: relative; - margin: var(--padding-md) var(--padding-md); - font-size: 0; - border-radius: var(--radius-md); - overflow: hidden; - box-shadow: 0 0 0 1px var(--hl-sm); - - &.active:enabled { - box-shadow: 0 0 0 var(--padding-xs) var(--color-surprise); - } - - &.active:enabled, - &:hover:enabled { - transform: scale(1.05); - } - - &:active { - transition: all 50ms ease-out; - } - - &::after { - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; - font-weight: bold; - position: absolute; - font-size: var(--font-size-md); - text-transform: uppercase; - color: var(--hl-xl); - left: 0; - right: 0; - top: 0; - bottom: 0; - visibility: hidden; - } - } -} diff --git a/packages/insomnia-app/app/ui/css/index.less b/packages/insomnia-app/app/ui/css/index.less index 1b469527f7..a405982b1f 100644 --- a/packages/insomnia-app/app/ui/css/index.less +++ b/packages/insomnia-app/app/ui/css/index.less @@ -55,7 +55,6 @@ @import 'components/unit-tests'; @import 'components/tabs'; @import 'components/tag'; -@import 'components/themes'; @import 'components/toast'; @import 'components/request-url-bar'; @import 'components/workspace-dropdown'; diff --git a/packages/insomnia-app/app/ui/hooks/theme.ts b/packages/insomnia-app/app/ui/hooks/theme.ts new file mode 100644 index 0000000000..f2eeb904d8 --- /dev/null +++ b/packages/insomnia-app/app/ui/hooks/theme.ts @@ -0,0 +1,84 @@ +import { ChangeEvent, useCallback, useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { unreachableCase } from 'ts-assert-unreachable'; +import * as models from '../../models'; +import { ThemeSettings } from '../../models/settings'; +import { ColorScheme, getThemes } from '../../plugins'; +import { applyColorScheme, PluginTheme } from '../../plugins/misc'; +import { selectSettings } from '../redux/selectors'; + +export const useThemes = () => { + const { + lightTheme, + darkTheme, + autoDetectColorScheme, + theme, + pluginConfig, + } = useSelector(selectSettings); + + const [themes, setThemes] = useState([]); + + // Reload themes if pluginConfig changes + useEffect(() => { + const func = async () => { + const pluginThemes = await getThemes(); + setThemes(pluginThemes.map(({ theme }) => theme)); + }; + func(); + }, [pluginConfig]); + + // Check if the theme is active + const isActiveDark = useCallback(({ name }: PluginTheme) => name === darkTheme, [darkTheme]); + const isActiveLight = useCallback(({ name }: PluginTheme) => name === lightTheme, [lightTheme]); + + const isActive = useCallback((pluginTheme : PluginTheme) => { + if (autoDetectColorScheme) { + return isActiveLight(pluginTheme) || isActiveDark(pluginTheme); + } + return pluginTheme.name === theme; + }, [autoDetectColorScheme, isActiveDark, isActiveLight, theme]); + + // Apply the theme and update settings + const apply = useCallback(async (patch: Partial) => { + applyColorScheme({ + theme, + autoDetectColorScheme, + darkTheme, + lightTheme, + ...patch, + }); + await models.settings.patch(patch); + }, [autoDetectColorScheme, darkTheme, lightTheme, theme]); + + const changeAutoDetect = useCallback(({ target: { checked } }: ChangeEvent) => apply({ autoDetectColorScheme: checked }), [apply]); + + // Activate the theme for the selected color scheme + const activate = useCallback(async (themeName: string, colorScheme: ColorScheme) => { + switch (colorScheme) { + case 'light': + await apply({ lightTheme: themeName }); + break; + + case 'dark': + await apply({ darkTheme: themeName }); + break; + + case 'default': + await apply({ theme: themeName }); + break; + + default: + unreachableCase(colorScheme); + } + }, [apply]); + + return { + themes, + isActive, + isActiveLight, + isActiveDark, + activate, + changeAutoDetect, + autoDetectColorScheme, + }; +};