Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions public/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 11 additions & 4 deletions src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
<div className='sans-serif h-100 relative' onClick={getNavHelper(this.props.doUpdateUrl)}>
return (
<ThemeProvider>
{connectDropTarget(
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div className='sans-serif h-100 relative' onClick={getNavHelper(this.props.doUpdateUrl)}>
{/* Tinted overlay that appears when dragging and dropping an item */}
{ canDrop && isOver && <div className='h-100 top-0 right-0 fixed appOverlay' style={{ background: 'rgba(99, 202, 210, 0.2)' }} /> }
<div className='flex flex-row-reverse-l flex-column-reverse justify-end justify-start-l' style={{ minHeight: '100vh' }}>
<div className='flex-auto-l'>
<div className='flex items-center ph3 ph4-l' style={{ WebkitAppRegion: 'drag', height: 75, background: '#F0F6FA', paddingTop: '20px', paddingBottom: '15px' }}>
<div className='flex items-center ph3 ph4-l webui-header' style={{ WebkitAppRegion: 'drag', height: 75, paddingTop: '20px', paddingBottom: '15px' }}>
<div className='joyride-app-explore' style={{ width: 560 }}>
<FilesExploreForm onBrowse={doFilesNavigateTo} />
</div>
<div className='dn flex-ns flex-auto items-center justify-end'>
<TopBarThemeToggle />
{!url.startsWith('/diagnostics') && <TourHelper />}
<Connected className='joyride-app-status' />
</div>
Expand Down Expand Up @@ -102,6 +107,8 @@ export class App extends Component {

<Notify />
</div>
)}
</ThemeProvider>
)
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/components/box/Box.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const Box = ({
children
}) => {
return (
<section className={className} style={{ background: '#fbfbfb', ...style }}>
<section className={`bg-white ${className}`} style={style}>
<ErrorBoundary resetKeys={[globalThis.location?.pathname || '']}>
{children}
</ErrorBoundary>
Expand Down
2 changes: 1 addition & 1 deletion src/components/button/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export interface ButtonProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonE

const getButtonClassName = ({ fill, bg, color, danger, disabled }: Pick<ButtonProps, 'fill' | 'bg' | 'color' | 'danger' | 'disabled'>, 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'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

iiuc this changes disabled buttons in light mode too, and bg-black-10 is close to invisible on a dark background. Consider keeping the old classes and adding a dark override for .bg-gray-muted in index.css instead.

if (type === 'link') return 'link bg-transparent'
return `${fill} ${bg} ${color}`
}
Expand Down
4 changes: 2 additions & 2 deletions src/components/is-not-connected/is-not-connected.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ const IsNotConnected = () => {
{ !isSameOrigin && (
<div>
<Trans i18nKey='notConnected.paragraph3' t={t}>
<li className='mb3 mt4'>Is your Kubo RPC API configured to allow <a className='link blue' href='https://github.com/ipfs/ipfs-webui#configure-kubo-rpc-api-cors-headers'>cross-origin (CORS) requests</a>? If not, run these commands and then start your daemon from the terminal:</li>
<li className='mb3'>Is your Kubo RPC API configured to allow <a className='link blue' href='https://github.com/ipfs/ipfs-webui#configure-kubo-rpc-api-cors-headers'>cross-origin (CORS) requests</a>? If not, run these commands and then start your daemon from the terminal:</li>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The margin changes in this file look unrelated to the theme work. Mind reverting them, or explaining why they are needed?

</Trans>
<div className='br1 overflow-hidden'>
<div className='f7 mb0 sans-serif charcoal pv1 pl2 bg-black-20 flex items-center overflow-x-auto'>
Expand Down Expand Up @@ -80,7 +80,7 @@ const IsNotConnected = () => {
</div>
)}
<Trans i18nKey='notConnected.paragraph4' t={t}>
<li className='mt4 mb3'>Is your Kubo RPC on a port other than 5001? If your node is configured with a <a className='link blue' href='https://github.com/ipfs/kubo/blob/master/docs/config.md#addresses' target='_blank' rel='noopener noreferrer'>custom RPC API address</a>, enter it here.</li>
<li className='mb3'>Is your Kubo RPC on a port other than 5001? If your node is configured with a <a className='link blue' href='https://github.com/ipfs/kubo/blob/master/docs/config.md#addresses' target='_blank' rel='noopener noreferrer'>custom RPC API address</a>, enter it here.</li>
</Trans>
<ApiAddressForm />
</ol>
Expand Down
11 changes: 5 additions & 6 deletions src/components/modal/modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,22 +84,21 @@ describe('Modal', () => {
it('renders with default props', () => {
const { container } = render(<ModalActions />)
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(<ModalActions justify="center" />)
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', () => {
const customClass = 'custom-actions-class'
const { container } = render(<ModalActions className={customClass} />)
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', () => {
Expand All @@ -121,8 +120,8 @@ describe('Modal', () => {
it('applies default styling classes and background color', () => {
const { container } = render(<ModalActions />)
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 ')
})
})

Expand Down
3 changes: 1 addition & 2 deletions src/components/modal/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,7 @@ export const ModalActions: React.FC<ModalActionsProps> = ({
...props
}) => (
<div
className={`flex justify-${justify} pa2 ${className}`}
style={{ backgroundColor: '#f4f6f8' }}
className={`flex justify-${justify} pa2 bg-snow ${className}`}
{...props}
>
{children}
Expand Down
2 changes: 1 addition & 1 deletion src/components/progress-bar/ProgressBar.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import './ProgressBar.css'

const ProgressBar = ({ bg, br, className, style, width, height, progress, time, ...props }) => {
return (
<div className={`ProgressBar sans-serif overflow-hidden ${br} dib ${className} ${width} ${height}`} style={{ background: '#DDE6EB', ...style }} {...props}>
<div className={`ProgressBar sans-serif overflow-hidden ${br} dib bg-light-gray ${className} ${width} ${height}`} style={style} {...props}>
{time
? <div className={`${br} h-100 ${bg}`} style={{ animation: `progressBar ${time}s ease-in-out` }} />
: <div className={`${br} h-100 ${bg}`} style={{ width: `${progress}%` }} />}
Expand Down
34 changes: 34 additions & 0 deletions src/components/theme-selector/ThemeSelector.js
Original file line number Diff line number Diff line change
@@ -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 (
<div className='joyride-settings-theme'>
<Title>{t('theme') || 'Theme'}</Title>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no theme key in settings.json, and i18next returns the key itself when one is missing, so this title renders as literal "theme" (the || 'Theme' fallback never runs; same for the themeDescription fallback below). Please add the key, and use t() for the Auto/Light/Dark labels too so they can be translated.

<div className='flex items-center'>
<select
className='w-100 mw4 pa2 ba b--black-20 br1 charcoal bg-white focus-outline focus-outline-blue'
value={theme}
onChange={onChange}
>
<option value='auto'>Auto</option>
<option value='light'>Light</option>
<option value='dark'>Dark</option>
</select>
<span className='ml3 f6 charcoal-muted'>
{t('themeDescription') || 'Choose a theme or sync with your system preference.'}
</span>
</div>
</div>
)
}

export default withTranslation('settings')(ThemeSelector)
25 changes: 25 additions & 0 deletions src/components/theme-selector/ThemeSelector.stories.js
Original file line number Diff line number Diff line change
@@ -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) => (
<ThemeProvider>
<div className="pa4 bg-white" style={{ maxWidth: '400px' }}>
<Story />
</div>
</ThemeProvider>
)
]
}

/**
* @type {import('@storybook/react').StoryObj}
*/
export const Default = {}
24 changes: 24 additions & 0 deletions src/components/theme-selector/ThemeSelector.test.js
Original file line number Diff line number Diff line change
@@ -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(
<I18nextProvider i18n={i18n}>
<ThemeProvider>
<ThemeSelector />
</ThemeProvider>
</I18nextProvider>
)

expect(screen.getByRole('combobox')).toBeInTheDocument()
expect(screen.getByText('Auto')).toBeInTheDocument()
expect(screen.getByText('Light')).toBeInTheDocument()
expect(screen.getByText('Dark')).toBeInTheDocument()
})
})
52 changes: 52 additions & 0 deletions src/components/theme-selector/TopBarThemeToggle.js
Original file line number Diff line number Diff line change
@@ -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 }) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className} width={width} height={height}>
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
)

const MoonIcon = ({ className, width, height }) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className} width={width} height={height}>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
)

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 (
<button
className="button-reset bg-transparent bn p0 m0 mr3 flex items-center justify-center pointer charcoal-muted hover-navy transition-all glow"
onClick={cycleTheme}
title={t('themeDescription')}
style={{ width: 28, height: 28, outline: 'none' }}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ux nit: outline: 'none' hides the focus ring for keyboard users, and the button has no text for screen readers.

Please use the existing focus-outline classes instead, and add an aria-label naming the current theme.

>
<Icon width={24} height={24} className="fill-current-color" />
</button>
)
}

export default TopBarThemeToggle
58 changes: 58 additions & 0 deletions src/contexts/theme-context.js
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any string in localStorage becomes theme unchecked, and the Settings select renders blank for unknown values. localStorage access can also throw in private browsing / tor browser under certain modes

Please validate against ['auto', 'light', 'dark'] and wrap localStorage access in try/catch that defaults to auto on unknown value

})

useEffect(() => {
const mediaQuery = window.matchMedia
? window.matchMedia('(prefers-color-scheme: dark)')
: { matches: false, addEventListener: () => {}, removeEventListener: () => {} }

const applyTheme = () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: when the theme is auto and the OS scheme changes, only the DOM attribute updates; the context value stays 'auto', so consumers do not re-render. Speedometer.js and NodeBandwidthChart.js read the attribute during render, so their chart colors go stale until the next data update.

Perhaps expose the resolved light/dark value through the ontext; that fixes both?

const isDark = theme === 'dark' || (theme === 'auto' && mediaQuery.matches)
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light')
}

applyTheme()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The theme is applied only after React mounts, so dark-mode users see a white flash on every load.

One way to fix it is a tiny script in public/index.html, ahead of the bundle, can set data-theme from localStorage and matchMedia (to avoid that white flash on slow machines).


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 (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
)
}
2 changes: 1 addition & 1 deletion src/files/breadcrumbs/Breadcrumbs.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ const Breadcrumbs = ({ t, tReady, path, onClick, className, onContextMenuHandle,
return (
<nav aria-label={t('breadcrumbs')} className={classNames('Breadcrumbs flex items-center sans-serif overflow-hidden sticky top-0', className)} {...props}>
<div className='nowrap overflow-hidden relative flex flex-wrap' ref={ anchors }>
<div className={`absolute left-0 top-0 h-100 w1 ${overflows ? '' : 'dn'}`} style={{ background: 'linear-gradient(to right, #ffffff 0%, transparent 100%)' }} />
<div className={`absolute left-0 top-0 h-100 w1 breadcrumb-gradient ${overflows ? '' : 'dn'}`} />

{ bread.map((link, index) => (
<div key={`${index}link`}>
Expand Down
10 changes: 5 additions & 5 deletions src/files/dropdown/Dropdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@ import React, { forwardRef } from 'react'
import { Dropdown as Drop, DropdownMenu as Menu } from '@tableflip/react-dropdown'
import StrokeCode from '../../icons/StrokeCode.js'

export const Option = ({ children, onClick, className = '', isCliTutorModeEnabled, onCliTutorMode, ...props }) => (
export const Option = ({ children, onClick, className = '', isCliTutorModeEnabled, onCliTutorMode, disabled, ...props }) => (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Nothing in this PR passes disabled, so I think this slipped in from other work (the two branches also differ: real disabled vs aria-disabled, and not-allowed is not a tachyons/ipfs-css class).

Would you mind moving it to its own PR and explain there why you do this? Keeps this one focused on theming.

isCliTutorModeEnabled
? <div className='flex items-center justify-between'>
<button role='menuitem' className={`bg-animate hover-bg-near-white pa2 pointer flex items-center flex-grow-1 ${className}`} onClick={onClick} {...props}>
<button role='menuitem' className={`bg-animate hover-bg-near-white pa2 pointer flex items-center flex-grow-1 ${className}`} onClick={onClick} disabled={disabled} {...props}>
{children}
</button>
<button {...props} className={`bg-animate hover-bg-near-white pa2 pointer flex items-center ${className}`}>
<button {...props} className={`bg-animate hover-bg-near-white pa2 pointer flex items-center ${className}`} disabled={disabled}>
<StrokeCode {...props} onClick={() => onCliTutorMode(true)} className='dib fill-link pointer' style={{ height: 38 }}/>
</button>
</div>
: <button role="menuitem" className={`bg-animate hover-bg-near-white pa2 pointer flex items-center ${className}`} onClick={onClick} {...props}>
: <button role="menuitem" className={`bg-animate hover-bg-near-white pa2 pointer flex items-center ${disabled ? 'o-40 not-allowed' : ''} ${className}`} onClick={!disabled ? onClick : undefined} aria-disabled={disabled} {...props}>
{children}
</button>
)
Expand All @@ -22,7 +22,7 @@ export const DropdownMenu = forwardRef((props, ref) => {

return (
<Menu
className='sans-serif br2 charcoal'
className='sans-serif br2 charcoal dropdown-menu'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The library fills the menu arrow with inline background: 'white' by default, so in dark mode menus show a white triangle above the dark panel.

Image

Passing background='var(--element-bg)' here should theme both the panel and the arrow for everything using this wrapper, and then the !important rule in index.css can go too.

boxShadow='rgba(105, 196, 205, 0.5) 0px 1px 10px 0px'
width={width}
arrowAlign='right'
Expand Down
3 changes: 1 addition & 2 deletions src/files/file-import-status/FileImportStatus.js
Original file line number Diff line number Diff line change
Expand Up @@ -160,9 +160,8 @@ export const FileImportStatus = ({ filesFinished, filesPending, filesErrors, doF
onClick={() => setExpanded(!expanded)}
onKeyPress={handleExpandByKeyboard}
role="button"
className="fileImportStatusButton pv2 ph3 relative flex items-center no-select pointer charcoal w-100 justify-between"
className="fileImportStatusButton pv2 ph3 relative flex items-center no-select pointer charcoal w-100 justify-between bg-snow"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, white triangle should be fixed too:

Image

aria-expanded={expanded}
style={{ background: '#F0F6FA' }}
>
<span>
{ filesPending.length
Expand Down
Loading