feat: preselected theme (#8354)
This PR implements the ability to attempt to force the use of light/dark theme in the admin panel. While I am a big advocate for the benefits that dark mode can bring to UX, it does not always suit a clients branding needs. Open to discussion on whether we consider this a suitable feature for the platform. Please feel free to add to this PR as needed. TODO: - [x] Implement tests (I'm open to guidance on this from the Payload team as currently it doesn't look like it's possible to adjust the payload config file on the fly - meaning it can't be easily placed in the admin folder tests). --------- Co-authored-by: Germán Jabloñski <43938777+GermanJablo@users.noreply.github.com>
This commit is contained in:
@@ -98,6 +98,7 @@ The following options are available:
|
|||||||
| **`livePreview`** | Enable real-time editing for instant visual feedback of your front-end application. [More details](../live-preview/overview). |
|
| **`livePreview`** | Enable real-time editing for instant visual feedback of your front-end application. [More details](../live-preview/overview). |
|
||||||
| **`meta`** | Base metadata to use for the Admin Panel. [More details](./metadata). |
|
| **`meta`** | Base metadata to use for the Admin Panel. [More details](./metadata). |
|
||||||
| **`routes`** | Replace built-in Admin Panel routes with your own custom routes. [More details](#customizing-routes). |
|
| **`routes`** | Replace built-in Admin Panel routes with your own custom routes. [More details](#customizing-routes). |
|
||||||
|
| **`theme`** | Restrict the Admin Panel theme to use only one of your choice. Default is `all`.
|
||||||
| **`user`** | The `slug` of the Collection that you want to allow to login to the Admin Panel. [More details](#the-admin-user-collection). |
|
| **`user`** | The `slug` of the Collection that you want to allow to login to the Admin Panel. [More details](#the-admin-user-collection). |
|
||||||
|
|
||||||
<Banner type="success">
|
<Banner type="success">
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ type GetRequestLanguageArgs = {
|
|||||||
const acceptedThemes: Theme[] = ['dark', 'light']
|
const acceptedThemes: Theme[] = ['dark', 'light']
|
||||||
|
|
||||||
export const getRequestTheme = ({ config, cookies, headers }: GetRequestLanguageArgs): Theme => {
|
export const getRequestTheme = ({ config, cookies, headers }: GetRequestLanguageArgs): Theme => {
|
||||||
|
if (config.admin.theme !== 'all' && acceptedThemes.includes(config.admin.theme)) {
|
||||||
|
return config.admin.theme
|
||||||
|
}
|
||||||
|
|
||||||
const themeCookie = cookies.get(`${config.cookiePrefix || 'payload'}-theme`)
|
const themeCookie = cookies.get(`${config.cookiePrefix || 'payload'}-theme`)
|
||||||
|
|
||||||
const themeFromCookie: Theme = (
|
const themeFromCookie: Theme = (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { I18n } from '@payloadcms/translations'
|
import type { I18n } from '@payloadcms/translations'
|
||||||
import type { LanguageOptions } from 'payload'
|
import type { Config, LanguageOptions } from 'payload'
|
||||||
|
|
||||||
import { FieldLabel } from '@payloadcms/ui'
|
import { FieldLabel } from '@payloadcms/ui'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
@@ -14,8 +14,9 @@ export const Settings: React.FC<{
|
|||||||
readonly className?: string
|
readonly className?: string
|
||||||
readonly i18n: I18n
|
readonly i18n: I18n
|
||||||
readonly languageOptions: LanguageOptions
|
readonly languageOptions: LanguageOptions
|
||||||
|
readonly theme: Config['admin']['theme']
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const { className, i18n, languageOptions } = props
|
const { className, i18n, languageOptions, theme } = props
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={[baseClass, className].filter(Boolean).join(' ')}>
|
<div className={[baseClass, className].filter(Boolean).join(' ')}>
|
||||||
@@ -24,7 +25,7 @@ export const Settings: React.FC<{
|
|||||||
<FieldLabel field={null} htmlFor="language-select" label={i18n.t('general:language')} />
|
<FieldLabel field={null} htmlFor="language-select" label={i18n.t('general:language')} />
|
||||||
<LanguageSelector languageOptions={languageOptions} />
|
<LanguageSelector languageOptions={languageOptions} />
|
||||||
</div>
|
</div>
|
||||||
<ToggleTheme />
|
{theme === 'all' && <ToggleTheme />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,11 @@ export const Account: React.FC<AdminViewProps> = async ({
|
|||||||
} = initPageResult
|
} = initPageResult
|
||||||
|
|
||||||
const {
|
const {
|
||||||
admin: { components: { views: { Account: CustomAccountComponent } = {} } = {}, user: userSlug },
|
admin: {
|
||||||
|
components: { views: { Account: CustomAccountComponent } = {} } = {},
|
||||||
|
theme,
|
||||||
|
user: userSlug,
|
||||||
|
},
|
||||||
routes: { api },
|
routes: { api },
|
||||||
serverURL,
|
serverURL,
|
||||||
} = config
|
} = config
|
||||||
@@ -85,7 +89,7 @@ export const Account: React.FC<AdminViewProps> = async ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentInfoProvider
|
<DocumentInfoProvider
|
||||||
AfterFields={<Settings i18n={i18n} languageOptions={languageOptions} />}
|
AfterFields={<Settings i18n={i18n} languageOptions={languageOptions} theme={theme} />}
|
||||||
apiURL={`${serverURL}${api}/${userSlug}${user?.id ? `/${user.id}` : ''}`}
|
apiURL={`${serverURL}${api}/${userSlug}${user?.id ? `/${user.id}` : ''}`}
|
||||||
collectionSlug={userSlug}
|
collectionSlug={userSlug}
|
||||||
docPermissions={docPermissions}
|
docPermissions={docPermissions}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const defaults: Omit<Config, 'db' | 'editor' | 'secret'> = {
|
|||||||
reset: '/reset',
|
reset: '/reset',
|
||||||
unauthorized: '/unauthorized',
|
unauthorized: '/unauthorized',
|
||||||
},
|
},
|
||||||
|
theme: 'all',
|
||||||
},
|
},
|
||||||
bin: [],
|
bin: [],
|
||||||
collections: [],
|
collections: [],
|
||||||
|
|||||||
@@ -818,6 +818,12 @@ export type Config = {
|
|||||||
/** The route for the unauthorized page. */
|
/** The route for the unauthorized page. */
|
||||||
unauthorized?: string
|
unauthorized?: string
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Restrict the Admin Panel theme to use only one of your choice
|
||||||
|
*
|
||||||
|
* @default 'all' // The theme can be configured by users
|
||||||
|
*/
|
||||||
|
theme?: 'all' | 'dark' | 'light'
|
||||||
/** The slug of a Collection that you want to be used to log in to the Admin dashboard. */
|
/** The slug of a Collection that you want to be used to log in to the Admin dashboard. */
|
||||||
user?: string
|
user?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export const RootProvider: React.FC<Props> = ({
|
|||||||
<ModalProvider classPrefix="payload" transTime={0} zIndex="var(--z-modal)">
|
<ModalProvider classPrefix="payload" transTime={0} zIndex="var(--z-modal)">
|
||||||
<AuthProvider permissions={permissions} user={user}>
|
<AuthProvider permissions={permissions} user={user}>
|
||||||
<PreferencesProvider>
|
<PreferencesProvider>
|
||||||
<ThemeProvider cookiePrefix={config.cookiePrefix} theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<ParamsProvider>
|
<ParamsProvider>
|
||||||
<LocaleProvider>
|
<LocaleProvider>
|
||||||
<StepNavProvider>
|
<StepNavProvider>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'
|
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import { useConfig } from '../Config/index.js'
|
||||||
|
|
||||||
export type Theme = 'dark' | 'light'
|
export type Theme = 'dark' | 'light'
|
||||||
|
|
||||||
export type ThemeContext = {
|
export type ThemeContext = {
|
||||||
@@ -55,20 +57,26 @@ export const defaultTheme = 'light'
|
|||||||
|
|
||||||
export const ThemeProvider: React.FC<{
|
export const ThemeProvider: React.FC<{
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
cookiePrefix?: string
|
|
||||||
theme?: Theme
|
theme?: Theme
|
||||||
}> = ({ children, cookiePrefix, theme: initialTheme }) => {
|
}> = ({ children, theme: initialTheme }) => {
|
||||||
const cookieKey = `${cookiePrefix || 'payload'}-theme`
|
const { config } = useConfig()
|
||||||
|
|
||||||
|
const preselectedTheme = config.admin.theme
|
||||||
|
const cookieKey = `${config.cookiePrefix || 'payload'}-theme`
|
||||||
|
|
||||||
const [theme, setThemeState] = useState<Theme>(initialTheme || defaultTheme)
|
const [theme, setThemeState] = useState<Theme>(initialTheme || defaultTheme)
|
||||||
|
|
||||||
const [autoMode, setAutoMode] = useState<boolean>()
|
const [autoMode, setAutoMode] = useState<boolean>()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (preselectedTheme !== 'all') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const { theme, themeFromCookies } = getTheme(cookieKey)
|
const { theme, themeFromCookies } = getTheme(cookieKey)
|
||||||
setThemeState(theme)
|
setThemeState(theme)
|
||||||
setAutoMode(!themeFromCookies)
|
setAutoMode(!themeFromCookies)
|
||||||
}, [cookieKey])
|
}, [preselectedTheme, cookieKey])
|
||||||
|
|
||||||
const setTheme = useCallback(
|
const setTheme = useCallback(
|
||||||
(themeToSet: 'auto' | Theme) => {
|
(themeToSet: 'auto' | Theme) => {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export default buildConfigWithDefaults({
|
|||||||
importMap: {
|
importMap: {
|
||||||
baseDir: path.resolve(dirname),
|
baseDir: path.resolve(dirname),
|
||||||
},
|
},
|
||||||
|
theme: 'dark',
|
||||||
},
|
},
|
||||||
cors: ['http://localhost:3000', 'http://localhost:3001'],
|
cors: ['http://localhost:3000', 'http://localhost:3001'],
|
||||||
globals: [MenuGlobal],
|
globals: [MenuGlobal],
|
||||||
|
|||||||
@@ -98,4 +98,11 @@ test.describe('Admin Panel (Root)', () => {
|
|||||||
await expect(favicons.nth(1)).toHaveAttribute('media', '(prefers-color-scheme: dark)')
|
await expect(favicons.nth(1)).toHaveAttribute('media', '(prefers-color-scheme: dark)')
|
||||||
await expect(favicons.nth(1)).toHaveAttribute('href', /\/payload-favicon-light\.[a-z\d]+\.png/)
|
await expect(favicons.nth(1)).toHaveAttribute('href', /\/payload-favicon-light\.[a-z\d]+\.png/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('config.admin.theme should restrict the theme', async () => {
|
||||||
|
await page.goto(url.account)
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
|
||||||
|
await expect(page.locator('#field-theme')).toBeHidden()
|
||||||
|
await expect(page.locator('#field-theme-auto')).toBeHidden()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user