diff --git a/public/locales/en/settings.json b/public/locales/en/settings.json index 9c320f9cd..d0203528e 100644 --- a/public/locales/en/settings.json +++ b/public/locales/en/settings.json @@ -13,6 +13,7 @@ "removeAutoUpload": "Disable Auto Upload" }, "language": "Language", + "themeDescription": "Choose your preferred color theme.", "analytics": "Analytics", "cliTutorMode": "CLI Tutor Mode", "config": "Kubo Config", diff --git a/src/App.js b/src/App.js index 46fc1c677..a612bf375 100644 --- a/src/App.js +++ b/src/App.js @@ -17,7 +17,9 @@ import ComponentLoader from './loader/ComponentLoader.js' import Notify from './components/notify/Notify.js' import Connected from './components/connected/Connected.js' import TourHelper from './components/tour/TourHelper.js' +import TopBarThemeToggle from './components/theme-selector/TopBarThemeToggle.js' import FilesExploreForm from './files/explore-form/files-explore-form.tsx' +import { ThemeProvider } from './contexts/theme-context.js' export class App extends Component { static propTypes = { @@ -62,18 +64,21 @@ export class App extends Component { render () { const { t, route: Page, ipfsReady, doFilesNavigateTo, routeInfo: { url }, connectDropTarget, canDrop, isOver, showTooltip } = this.props - return connectDropTarget( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -
+ return ( + + {connectDropTarget( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
{/* Tinted overlay that appears when dragging and dropping an item */} { canDrop && isOver &&
}
-
+
+ {!url.startsWith('/diagnostics') && }
@@ -102,6 +107,8 @@ export class App extends Component {
+ )} + ) } } diff --git a/src/components/box/Box.js b/src/components/box/Box.js index a7f81f0f0..161c711f9 100644 --- a/src/components/box/Box.js +++ b/src/components/box/Box.js @@ -13,7 +13,7 @@ export const Box = ({ children }) => { return ( -
+
{children} diff --git a/src/components/button/button.tsx b/src/components/button/button.tsx index aaab0a1f7..7ca23b9f7 100644 --- a/src/components/button/button.tsx +++ b/src/components/button/button.tsx @@ -15,7 +15,7 @@ export interface ButtonProps extends Omit, type: ButtonProps['type']) => { if (danger) return 'bg-red fill-white white' - if (disabled) return 'bg-gray-muted fill-snow light-gray' + if (disabled) return 'bg-black-10 fill-charcoal-muted charcoal-muted' if (type === 'link') return 'link bg-transparent' return `${fill} ${bg} ${color}` } diff --git a/src/components/is-not-connected/is-not-connected.tsx b/src/components/is-not-connected/is-not-connected.tsx index 0bb1e4a05..f397b92a5 100644 --- a/src/components/is-not-connected/is-not-connected.tsx +++ b/src/components/is-not-connected/is-not-connected.tsx @@ -42,7 +42,7 @@ const IsNotConnected = () => { { !isSameOrigin && (
-
  • Is your Kubo RPC API configured to allow cross-origin (CORS) requests? If not, run these commands and then start your daemon from the terminal:
  • +
  • Is your Kubo RPC API configured to allow cross-origin (CORS) requests? If not, run these commands and then start your daemon from the terminal:
  • @@ -80,7 +80,7 @@ const IsNotConnected = () => {
    )} -
  • Is your Kubo RPC on a port other than 5001? If your node is configured with a custom RPC API address, enter it here.
  • +
  • Is your Kubo RPC on a port other than 5001? If your node is configured with a custom RPC API address, enter it here.
  • diff --git a/src/components/modal/modal.test.tsx b/src/components/modal/modal.test.tsx index d260b7ed9..56c7ed14b 100644 --- a/src/components/modal/modal.test.tsx +++ b/src/components/modal/modal.test.tsx @@ -84,14 +84,13 @@ describe('Modal', () => { it('renders with default props', () => { const { container } = render() const actionsElement = container.firstChild as HTMLElement - expect(actionsElement).toHaveClass('flex', 'justify-between', 'pa2') - expect(actionsElement).toHaveStyle({ backgroundColor: '#f4f6f8' }) + expect(actionsElement).toHaveClass('flex', 'justify-between', 'pa2', 'bg-snow') }) it('renders with custom justify prop', () => { const { container } = render() const actionsElement = container.firstChild as HTMLElement - expect(actionsElement).toHaveClass('flex', 'justify-center', 'pa2') + expect(actionsElement).toHaveClass('flex', 'justify-center', 'pa2', 'bg-snow') }) it('renders with custom className', () => { @@ -99,7 +98,7 @@ describe('Modal', () => { const { container } = render() const actionsElement = container.firstChild as HTMLElement expect(actionsElement).toHaveClass(customClass) - expect(actionsElement).toHaveClass('flex', 'justify-between', 'pa2') + expect(actionsElement).toHaveClass('flex', 'justify-between', 'pa2', 'bg-snow') }) it('renders children correctly', () => { @@ -121,8 +120,8 @@ describe('Modal', () => { it('applies default styling classes and background color', () => { const { container } = render() const actionsElement = container.firstChild as HTMLElement - expect(actionsElement).toHaveClass('flex', 'justify-between', 'pa2') - expect(actionsElement.className).toBe('flex justify-between pa2 ') + expect(actionsElement).toHaveClass('flex', 'justify-between', 'pa2', 'bg-snow') + expect(actionsElement.className).toBe('flex justify-between pa2 bg-snow ') }) }) diff --git a/src/components/modal/modal.tsx b/src/components/modal/modal.tsx index 270841005..b36cb6b60 100644 --- a/src/components/modal/modal.tsx +++ b/src/components/modal/modal.tsx @@ -31,8 +31,7 @@ export const ModalActions: React.FC = ({ ...props }) => (
    {children} diff --git a/src/components/progress-bar/ProgressBar.js b/src/components/progress-bar/ProgressBar.js index 4af66158c..b5d8a4072 100644 --- a/src/components/progress-bar/ProgressBar.js +++ b/src/components/progress-bar/ProgressBar.js @@ -4,7 +4,7 @@ import './ProgressBar.css' const ProgressBar = ({ bg, br, className, style, width, height, progress, time, ...props }) => { return ( -
    +
    {time ?
    :
    } diff --git a/src/components/theme-selector/ThemeSelector.js b/src/components/theme-selector/ThemeSelector.js new file mode 100644 index 000000000..ed56ce744 --- /dev/null +++ b/src/components/theme-selector/ThemeSelector.js @@ -0,0 +1,34 @@ +import React from 'react' +import { withTranslation } from 'react-i18next' +import { useTheme } from '../../contexts/theme-context.js' +import Title from '../../settings/Title.js' + +const ThemeSelector = ({ t }) => { + const { theme, setTheme } = useTheme() + + const onChange = (e) => { + setTheme(e.target.value) + } + + return ( +
    + {t('theme') || 'Theme'} +
    + + + {t('themeDescription') || 'Choose a theme or sync with your system preference.'} + +
    +
    + ) +} + +export default withTranslation('settings')(ThemeSelector) diff --git a/src/components/theme-selector/ThemeSelector.stories.js b/src/components/theme-selector/ThemeSelector.stories.js new file mode 100644 index 000000000..97c7acb72 --- /dev/null +++ b/src/components/theme-selector/ThemeSelector.stories.js @@ -0,0 +1,25 @@ +// @ts-check +import ThemeSelector from './ThemeSelector.js' +import { ThemeProvider } from '../../contexts/theme-context.js' + +/** + * @type {import('@storybook/react').Meta} + */ +export default { + title: 'Settings/Theme Selector', + component: ThemeSelector, + decorators: [ + (Story) => ( + +
    + +
    +
    + ) + ] +} + +/** + * @type {import('@storybook/react').StoryObj} + */ +export const Default = {} diff --git a/src/components/theme-selector/ThemeSelector.test.js b/src/components/theme-selector/ThemeSelector.test.js new file mode 100644 index 000000000..b6cc36518 --- /dev/null +++ b/src/components/theme-selector/ThemeSelector.test.js @@ -0,0 +1,24 @@ +import React from 'react' +import '@testing-library/jest-dom' +import { render, screen } from '@testing-library/react' +import ThemeSelector from './ThemeSelector.js' +import { ThemeProvider } from '../../contexts/theme-context.js' +import { I18nextProvider } from 'react-i18next' +import i18n from '../../i18n.js' + +describe('ThemeSelector', () => { + it('renders correctly', () => { + render( + + + + + + ) + + expect(screen.getByRole('combobox')).toBeInTheDocument() + expect(screen.getByText('Auto')).toBeInTheDocument() + expect(screen.getByText('Light')).toBeInTheDocument() + expect(screen.getByText('Dark')).toBeInTheDocument() + }) +}) diff --git a/src/components/theme-selector/TopBarThemeToggle.js b/src/components/theme-selector/TopBarThemeToggle.js new file mode 100644 index 000000000..e81b5ba36 --- /dev/null +++ b/src/components/theme-selector/TopBarThemeToggle.js @@ -0,0 +1,52 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { useTheme } from '../../contexts/theme-context.js' +import StrokeMonitor from '../../icons/StrokeMonitor.js' + +const SunIcon = ({ className, width, height }) => ( + + + + + + + + + + + +) + +const MoonIcon = ({ className, width, height }) => ( + + + +) + +export const TopBarThemeToggle = () => { + const { theme, setTheme } = useTheme() + const { t } = useTranslation('settings') + + const cycleTheme = () => { + if (theme === 'auto') setTheme('light') + else if (theme === 'light') setTheme('dark') + else setTheme('auto') + } + + let Icon = StrokeMonitor + if (theme === 'light') Icon = SunIcon + if (theme === 'dark') Icon = MoonIcon + + return ( + + ) +} + +export default TopBarThemeToggle diff --git a/src/contexts/theme-context.js b/src/contexts/theme-context.js new file mode 100644 index 000000000..eb171dd84 --- /dev/null +++ b/src/contexts/theme-context.js @@ -0,0 +1,58 @@ +import React, { createContext, useContext, useEffect, useState } from 'react' + +export const ThemeContext = createContext({ + theme: 'auto', + setTheme: (/** @type {string} */ _newTheme) => {} +}) + +export const useTheme = () => useContext(ThemeContext) + +// Default theme is auto +const DEFAULT_THEME = 'auto' + +/** + * @param {Object} props + * @param {import('react').ReactNode} props.children + */ +export const ThemeProvider = ({ children }) => { + const [theme, setThemeState] = useState(() => { + return localStorage.getItem('ipfs-webui-theme') || DEFAULT_THEME + }) + + useEffect(() => { + const mediaQuery = window.matchMedia + ? window.matchMedia('(prefers-color-scheme: dark)') + : { matches: false, addEventListener: () => {}, removeEventListener: () => {} } + + const applyTheme = () => { + const isDark = theme === 'dark' || (theme === 'auto' && mediaQuery.matches) + document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light') + } + + applyTheme() + + const handleChange = () => { + if (theme === 'auto') { + applyTheme() + } + } + + mediaQuery.addEventListener('change', handleChange) + return () => { + mediaQuery.removeEventListener('change', handleChange) + } + }, [theme]) + /** + * @param {string} newTheme + */ + const setTheme = (newTheme) => { + localStorage.setItem('ipfs-webui-theme', newTheme) + setThemeState(newTheme) + } + + return ( + + {children} + + ) +} diff --git a/src/files/breadcrumbs/Breadcrumbs.js b/src/files/breadcrumbs/Breadcrumbs.js index 630ecd81b..08994a33f 100644 --- a/src/files/breadcrumbs/Breadcrumbs.js +++ b/src/files/breadcrumbs/Breadcrumbs.js @@ -87,7 +87,7 @@ const Breadcrumbs = ({ t, tReady, path, onClick, className, onContextMenuHandle, return (