diff --git a/.storybook/preview.js b/.storybook/preview.js index 764545437..eb3c48e5d 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -9,6 +9,7 @@ import getStore from '../src/bundles/index.js' import i18n from '../src/i18n.js' import DndBackend from '../src/lib/dnd-backend.js' import { HeliaProvider, ExploreProvider } from 'ipld-explorer-components/providers' +import { ThemeProvider } from '../src/context/theme-provider.tsx' /** * @type {import('@storybook/addons').BaseAnnotations} @@ -16,17 +17,19 @@ import { HeliaProvider, ExploreProvider } from 'ipld-explorer-components/provide const baseAnnotations = { decorators: [ (Story) => ( - - - - - - - - - - - + + + + + + + + + + + + + ) ], /** diff --git a/src/App.js b/src/App.js index af0f0d854..794af91f2 100644 --- a/src/App.js +++ b/src/App.js @@ -18,6 +18,8 @@ import Notify from './components/notify/Notify.js' import Connected from './components/connected/Connected.js' import TourHelper from './components/tour/TourHelper.js' import FilesExploreForm from './files/explore-form/files-explore-form.tsx' +import { ThemeProvider, ThemeContext } from './context/theme-provider.tsx' +import { ThemeToggle } from './components/theme-toggle/toggle.tsx' export class App extends Component { static propTypes = { @@ -32,6 +34,8 @@ export class App extends Component { isOver: PropTypes.bool.isRequired } + static contextType = ThemeContext + constructor (props) { super(props) props.doSetupLocalStorage() @@ -63,44 +67,49 @@ 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 -
- {/* Tinted overlay that appears when dragging and dropping an item */} - { canDrop && isOver &&
} -
-
-
-
- +
+ + {/* 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 &&
} +
+
+
+
+ +
+
+ + +
+ +
+
+
+
+ { (ipfsReady || url === '/welcome' || url.startsWith('/settings')) + ? + : + } +
-
- - +
+
-
- { (ipfsReady || url === '/welcome' || url.startsWith('/settings')) - ? - : - } -
-
-
- + +
-
- - - - +
) } diff --git a/src/components/api-address-form/ApiAddressForm.js b/src/components/api-address-form/ApiAddressForm.js index da30643ce..8c1047636 100644 --- a/src/components/api-address-form/ApiAddressForm.js +++ b/src/components/api-address-form/ApiAddressForm.js @@ -3,12 +3,14 @@ import { connect } from 'redux-bundler-react' import { withTranslation } from 'react-i18next' import Button from '../button/button.tsx' import { checkValidAPIAddress } from '../../bundles/ipfs-provider.js' +import { useTheme } from '../../hooks/theme' const ApiAddressForm = ({ t, doUpdateIpfsApiAddress, ipfsApiAddress, ipfsInitFailed }) => { const [value, setValue] = useState(asAPIString(ipfsApiAddress)) const initialIsValidApiAddress = !checkValidAPIAddress(value) const [showFailState, setShowFailState] = useState(initialIsValidApiAddress || ipfsInitFailed) const [isValidApiAddress, setIsValidApiAddress] = useState(initialIsValidApiAddress) + const { isDarkTheme } = useTheme() // Updates the border of the input to indicate validity useEffect(() => { @@ -46,12 +48,14 @@ const ApiAddressForm = ({ t, doUpdateIpfsApiAddress, ipfsApiAddress, ipfsInitFai onChange={onChange} onKeyPress={onKeyPress} value={value} + style={{ background: isDarkTheme ? 'var(--filter-peers-dark)' : '', border: isDarkTheme ? '0.4px solid var(--border-color)' : '' }} />
diff --git a/src/components/box/Box.js b/src/components/box/Box.js index 2233b5fea..c2794af9d 100644 --- a/src/components/box/Box.js +++ b/src/components/box/Box.js @@ -1,14 +1,17 @@ import React from 'react' +import { useTheme } from '../../hooks/theme' import ErrorBoundary from '../error/ErrorBoundary.js' export const Box = ({ className = 'pa4', style, + themed, children, ...props }) => { + const { isDarkTheme } = useTheme() return ( -
+
{children} diff --git a/src/components/public-gateway-form/PublicGatewayForm.js b/src/components/public-gateway-form/PublicGatewayForm.js index 35a0fff0d..af4d2b130 100644 --- a/src/components/public-gateway-form/PublicGatewayForm.js +++ b/src/components/public-gateway-form/PublicGatewayForm.js @@ -3,13 +3,14 @@ import { connect } from 'redux-bundler-react' import { withTranslation } from 'react-i18next' import Button from '../button/button.tsx' import { checkValidHttpUrl, checkViaImgSrc, DEFAULT_PATH_GATEWAY } from '../../bundles/gateway.js' +import { useTheme } from '../../hooks/theme' const PublicGatewayForm = ({ t, doUpdatePublicGateway, publicGateway }) => { const [value, setValue] = useState(publicGateway) const initialIsValidGatewayUrl = !checkValidHttpUrl(value) const [showFailState, setShowFailState] = useState(initialIsValidGatewayUrl) const [isValidGatewayUrl, setIsValidGatewayUrl] = useState(initialIsValidGatewayUrl) - + const { isDarkTheme } = useTheme() // Updates the border of the input to indicate validity useEffect(() => { setShowFailState(!isValidGatewayUrl) @@ -60,6 +61,7 @@ const PublicGatewayForm = ({ t, doUpdatePublicGateway, publicGateway }) => { onChange={onChange} onKeyPress={onKeyPress} value={value} + style={{ background: isDarkTheme ? 'var(--filter-peers-dark)' : '', border: isDarkTheme ? '0.4px solid var(--border-color)' : '' }} />
diff --git a/src/components/public-subdomain-gateway-form/PublicSubdomainGatewayForm.js b/src/components/public-subdomain-gateway-form/PublicSubdomainGatewayForm.js index 92620c23c..df33a0284 100644 --- a/src/components/public-subdomain-gateway-form/PublicSubdomainGatewayForm.js +++ b/src/components/public-subdomain-gateway-form/PublicSubdomainGatewayForm.js @@ -3,11 +3,13 @@ import { connect } from 'redux-bundler-react' import { withTranslation } from 'react-i18next' import Button from '../button/button.tsx' import { checkValidHttpUrl, checkSubdomainGateway, DEFAULT_SUBDOMAIN_GATEWAY } from '../../bundles/gateway.js' +import { useTheme } from '../../hooks/theme' const PublicSubdomainGatewayForm = ({ t, doUpdatePublicSubdomainGateway, publicSubdomainGateway }) => { const [value, setValue] = useState(publicSubdomainGateway) const initialIsValidGatewayUrl = !checkValidHttpUrl(value) const [isValidGatewayUrl, setIsValidGatewayUrl] = useState(initialIsValidGatewayUrl) + const { isDarkTheme } = useTheme() // Updates the border of the input to indicate validity useEffect(() => { @@ -64,6 +66,7 @@ const PublicSubdomainGatewayForm = ({ t, doUpdatePublicSubdomainGateway, publicS onChange={onChange} onKeyPress={onKeyPress} value={value} + style={{ background: isDarkTheme ? 'var(--filter-peers-dark)' : '', border: isDarkTheme ? '0.4px solid var(--border-color)' : '' }} />
+ ) +} diff --git a/src/context/theme-provider.tsx b/src/context/theme-provider.tsx new file mode 100644 index 000000000..d249e6bdd --- /dev/null +++ b/src/context/theme-provider.tsx @@ -0,0 +1,79 @@ +import React, { useEffect } from 'react' + +export interface ThemeProviderProps { + children: React.ReactNode +} + +export type Theme = 'light' | 'dark' + +export type ThemeContextValues = { + isDarkTheme: boolean, + toggleTheme: (event?: React.KeyboardEvent | React.MouseEvent | undefined) => void; +} + +export const ThemeContext = React.createContext(null) + +export const ThemeProvider = ({ children }: ThemeProviderProps) => { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') + + const [isDarkTheme, setDarkTheme] = React.useState(() => { + const savedTheme = localStorage.getItem('theme') + if (savedTheme) return savedTheme === 'dark' + return mediaQuery.matches + }) + + useEffect(() => { + const handleChange = (event: MediaQueryListEvent) => { + console.log('changed theme?') + const savedTheme = localStorage.getItem('theme') + if (!savedTheme) { + setDarkTheme(event.matches) + } + } + mediaQuery.addEventListener('change', handleChange) + return () => { + mediaQuery.removeEventListener('change', handleChange) + } + }, [mediaQuery]) + + useEffect(() => { + const htmlElem = document.documentElement + const currentTheme = isDarkTheme ? 'dark' : 'light' + htmlElem.setAttribute('data-theme', currentTheme) + htmlElem.setAttribute('aria-label', `Current theme: ${currentTheme}`) + + if (isDarkTheme === mediaQuery.matches) { + // delete localstorage item when dark=dark or light=light + localStorage.removeItem('theme') + } else { + // save to local storage when it differs from local settings + localStorage.setItem('theme', isDarkTheme ? 'dark' : 'light') + } + }, [isDarkTheme, mediaQuery]) + + const toggleTheme = (event: React.KeyboardEvent | React.MouseEvent | undefined) => { + if (event?.type === 'keydown') { + const isKeyboardEvent = (event: React.KeyboardEvent | React.MouseEvent): event is React.KeyboardEvent => { + return event.type === 'keydown' + } + if (isKeyboardEvent(event)) { + if (event.key === ' ') { + event.preventDefault() + setDarkTheme((prevTheme) => !prevTheme) + } + } + } else { + setDarkTheme((prevTheme) => !prevTheme) + } + } + + const values: ThemeContextValues = { + isDarkTheme, + toggleTheme + } + return ( + + {children} + + ) +} diff --git a/src/files/explore-form/files-explore-form.tsx b/src/files/explore-form/files-explore-form.tsx index d45f1c236..63f28cb80 100644 --- a/src/files/explore-form/files-explore-form.tsx +++ b/src/files/explore-form/files-explore-form.tsx @@ -7,6 +7,7 @@ import Button from '../../components/button/button' import './files-explore-form.css' // @ts-expect-error - need to fix types for ipfs-webui since we are a CJS consumer... import { useExplore } from 'ipld-explorer-components/providers' +import { useTheme } from '../../hooks/theme' /** * @type {React.FC<{ onBrowse: (evt: { path: string }) => void }>} * @@ -15,6 +16,7 @@ const FilesExploreForm = ({ onBrowse: onBrowseProp }) => { const [path, setPath] = useState('') const { doExploreUserProvidedPath } = useExplore() const { t } = useTranslation('files') + const { isDarkTheme } = useTheme() const trimmedPath = useMemo(() => { return path.trim() @@ -71,7 +73,7 @@ const FilesExploreForm = ({ onBrowse: onBrowseProp }) => {
- + Paste in a CID or IPFS path
@@ -81,7 +83,7 @@ const FilesExploreForm = ({ onBrowse: onBrowseProp }) => { disabled={!isValid} danger={!isValid} title={t('app:actions.inspect')} - style={{ borderRadius: '0 3px 3px 0' }} + style={{ borderRadius: '0 3px 3px 0', background: isDarkTheme ? 'var(--input-btn-bg)' : '' }} onClick={onInspect} bg='bg-teal' className='ExploreFormButton button-reset pv1 ph2 ba f7 fw4 white overflow-hidden tc' > @@ -92,7 +94,7 @@ const FilesExploreForm = ({ onBrowse: onBrowseProp }) => { minWidth={0} disabled={!isValid} danger={!isValid} - style={{ borderRadius: '0' }} + style={{ borderRadius: '0', background: isDarkTheme ? 'var(--input-btn-bg)' : '' }} title={t('app:actions.browse')} onClick={onBrowse} className='ExploreFormButton button-reset pv1 ph2 ba f7 fw4 white bg-gray overflow-hidden tc' > diff --git a/src/files/info-boxes/add-files-info/AddFilesInfo.js b/src/files/info-boxes/add-files-info/AddFilesInfo.js index 4b021f7fb..ed438d02f 100644 --- a/src/files/info-boxes/add-files-info/AddFilesInfo.js +++ b/src/files/info-boxes/add-files-info/AddFilesInfo.js @@ -1,15 +1,19 @@ import React from 'react' import { withTranslation, Trans } from 'react-i18next' import Box from '../../../components/box/Box.js' +import { useTheme } from '../../../hooks/theme' -const AddFilesInfo = ({ t }) => ( -
- - -

No files here yet! Add files to your local IPFS node by clicking the Import button above.

-
-
-
-) +const AddFilesInfo = ({ t }) => { + const { isDarkTheme } = useTheme() + return ( +
+ + +

No files here yet! Add files to your local IPFS node by clicking the Import button above.

+
+
+
+ ) +} export default withTranslation('files')(AddFilesInfo) diff --git a/src/hooks/theme.ts b/src/hooks/theme.ts new file mode 100644 index 000000000..78d4b1155 --- /dev/null +++ b/src/hooks/theme.ts @@ -0,0 +1,10 @@ +import React from 'react' +import { ThemeContext, ThemeContextValues } from '../context/theme-provider' + +export const useTheme = (): ThemeContextValues => { + const context = React.useContext(ThemeContext) + if (context == null) { + throw new Error('Theme context is missing You probably forgot to wrap the component depending on theme in ') + } + return context +} diff --git a/src/index.css b/src/index.css index 87793d8c3..4497268d4 100644 --- a/src/index.css +++ b/src/index.css @@ -1,15 +1,87 @@ -@import './reset.css'; +@import "./reset.css"; @import "../node_modules/tachyons"; /* They forgot to include word break: https://github.com/tachyons-css/tachyons/issues/563 */ @import "../node_modules/tachyons/src/_word-break.css"; @import "../node_modules/ipfs-css"; +:root { + --input-bg-dark: #121212; + --input-btn-bg: #2b2e30; + --teal-dark: #2b79801a; + --box-dark: #1a1c1e; + --world-map-text-dark: #e8e6e3; + --charcoal-muted: #9d9488; + --filter-peers-dark: #121212; + --element-bg-light: #fbfbfb; + --dataset-name: #d3dce4; +} + body { overflow-y: scroll; } -.placeholder-light::placeholder{ - color: #CAD3D8; +[data-theme="dark"] { + --background: #181a1b; + --text: #e8e6e3; + --element-bg: #1a1c1e; + --navy-dark: #0b3a53; + --navy-text-color: rgb(202, 198, 191); + --snow-muted: rgb(28, 30, 31); + --border-color: rgba(140, 130, 115, 0.5); + + body { + transition: background 0.3s ease-in; + } + + h2, .black, .montserrat { + color: var(--world-map-text-dark) !important; + } + + /* from inspecting the DOM, most of the classes here are from tachyon and ipfs-css + * so when the data-theme attr is set to dark, we'll have them updated + */ + .bg-snow-muted { + background: var(--snow-muted); + } + + .charcoal { + color: rgb(196, 191, 183); + } + + section { + background: var(--element-bg); + } + + .navy { + color: var(--navy-text-color); + } + + .joyride-app-explore < div { + border: 1px solid red; + } + + .webui-header { + background: var(--snow-muted) !important; + } + + .ReactVirtualized__Table__headerRow { + background: var(--box-dark); + border-bottom: 1px solid var(--border-color); + } + + .ReactVirtualized__Table__row { + border-bottom: 1px solid var(--border-color); + } +} + +html #root { + background-color: var(--background); + color: var(--text); + transition: background-color 0.3s ease, color 0.3s ease; +} + +.placeholder-light::placeholder { + color: #cad3d8; } .bg-near-white { @@ -26,7 +98,7 @@ html, body, #root { @media only screen and (min-width: 60em) { .appOverlay { - width: calc(100% - 148px) + width: calc(100% - 148px); } } diff --git a/src/peers/PeersPage.js b/src/peers/PeersPage.js index 4f2bcc266..ecef13079 100644 --- a/src/peers/PeersPage.js +++ b/src/peers/PeersPage.js @@ -14,34 +14,37 @@ import PeersTable from './PeersTable/PeersTable.js' import AddConnection from './AddConnection/AddConnection.js' import CliTutorMode from '../components/cli-tutor-mode/CliTutorMode.js' import { cliCmdKeys, cliCommandList } from '../bundles/files/consts.js' +import { useTheme } from '../hooks/theme' -const PeersPage = ({ t, toursEnabled, handleJoyrideCallback }) => ( -
- - {t('title')} | IPFS - +const PeersPage = ({ t, toursEnabled, handleJoyrideCallback }) => { + const { isDarkTheme } = useTheme() + return ( +
+ + {t('title')} | IPFS + -
- - +
+ + +
+ + + + +
- - - - - - - -
-) + ) +} export default connect( 'selectToursEnabled', diff --git a/src/peers/PeersTable/PeersTable.js b/src/peers/PeersTable/PeersTable.js index 93de11880..a2c54fd10 100644 --- a/src/peers/PeersTable/PeersTable.js +++ b/src/peers/PeersTable/PeersTable.js @@ -10,6 +10,7 @@ import Cid from '../../components/cid/Cid.js' import { sortByProperty } from '../../lib/sort.js' import './PeersTable.css' +import { useTheme } from '../../hooks/theme' const flagRenderer = (flagCode, isPrivate) => { // Check if the OS is Windows to render the flags as SVGs @@ -102,6 +103,7 @@ const rowClassRenderer = ({ index }, peers = [], selectedPeers) => { } const FilterInput = ({ setFilter, t, filteredCount }) => { + const { isDarkTheme } = useTheme() return (
{ type='text' placeholder='Filter peers' onChange={(e) => setFilter(e.target.value)} + style={{ background: isDarkTheme ? 'var(--filter-peers-dark)' : '', border: isDarkTheme ? '0.4px solid var(--border-color)' : '' }} /> {/* Now to display the total number of peers filtered out on the right side of the inside of the input */}
{filteredCount}
@@ -122,6 +125,7 @@ export const PeersTable = ({ className, t, peerLocationsForSwarm, selectedPeers const [sortBy, setSortBy] = useState('latency') const [sortDirection, setSortDirection] = useState(SortDirection.ASC) const [filter, setFilter] = useState('') + const { isDarkTheme } = useTheme() const sort = useCallback(({ sortBy, sortDirection }) => { setSortBy(sortBy) @@ -168,7 +172,7 @@ export const PeersTable = ({ className, t, peerLocationsForSwarm, selectedPeers ) return ( -
+
{ awaitedPeerLocationsForSwarm && {({ width }) => ( @@ -185,6 +189,7 @@ export const PeersTable = ({ className, t, peerLocationsForSwarm, selectedPeers rowGetter={({ index }) => sortedList[index]} sort={sort} sortBy={sortBy} + background={isDarkTheme ? 'var(--box-dark)' : '' } sortDirection={sortDirection}> diff --git a/src/peers/WorldMap/WorldMap.js b/src/peers/WorldMap/WorldMap.js index b4b8b2c92..969c8dbf1 100644 --- a/src/peers/WorldMap/WorldMap.js +++ b/src/peers/WorldMap/WorldMap.js @@ -14,6 +14,7 @@ import Popover from '../../components/popover/Popover.js' // Styles import './WorldMap.css' import Cid from '../../components/cid/Cid.js' +import { useTheme } from '../../hooks/theme' const calculateWidth = (windowWidth) => { // the d3 generated svg width includes a lot of ocean, that we crop for now, as it looks weird. @@ -49,6 +50,7 @@ const WorldMap = ({ t, className, selectedPeers, doSetSelectedPeers }) => { const [width, setWidth] = useState(calculateWidth(window.innerWidth)) const [height, setHeight] = useState(calculateHeight(width)) const [selectedTimeout, setSelectedTimeout] = useState(null) + const { isDarkTheme } = useTheme() useEffect(() => { const debouncedHandleResize = debounce(() => { @@ -100,8 +102,8 @@ const WorldMap = ({ t, className, selectedPeers, doSetSelectedPeers }) => {
-
-
{t('app:terms.peers')}
+
+
{t('app:terms.peers')}
diff --git a/src/reset.css b/src/reset.css index 90f447a1c..bd6864097 100644 --- a/src/reset.css +++ b/src/reset.css @@ -10,7 +10,7 @@ button { button:not(.disabled) { cursor: pointer; } - + button:not(.default):focus-visible { outline: 1px solid #bbb; outline-offset: -1px; @@ -32,3 +32,12 @@ button.hoverable-button:hover { button.hoverable-button:focus { outline: none; } + +/* override tachyons */ +[data-theme='dark'] .bg-white { + background-color: var(--background) !important; +} + +[data-theme='dark'] .near-black { + color: var(--text) !important; +} diff --git a/tsconfig.json b/tsconfig.json index ec69689fc..42e62a1b4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -57,6 +57,8 @@ "include": [ "**/*.ts", "@types", + "src/context", + "src/hooks", // "src/**/*.js", // TODO: Include all js files when typecheck passes "src/bundles/files/**/*.js", "src/bundles/analytics.js",