mirror of https://github.com/langgenius/dify.git
feat: add theme switcher
This commit is contained in:
parent
46616db689
commit
91009eaef6
|
@ -0,0 +1,97 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
RiCheckLine,
|
||||
RiComputerLine,
|
||||
RiMoonLine,
|
||||
RiSunLine,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTheme } from 'next-themes'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'system'
|
||||
|
||||
export default function ThemeSelector() {
|
||||
const { t } = useTranslation()
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const handleThemeChange = (newTheme: Theme) => {
|
||||
setTheme(newTheme)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const getCurrentIcon = () => {
|
||||
switch (theme) {
|
||||
case 'light': return <RiSunLine className='h-4 w-4 text-text-tertiary' />
|
||||
case 'dark': return <RiMoonLine className='h-4 w-4 text-text-tertiary' />
|
||||
default: return <RiComputerLine className='h-4 w-4 text-text-tertiary' />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='bottom-end'
|
||||
offset={{ mainAxis: 6 }}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<ActionButton
|
||||
className={`h-8 w-8 p-[6px] ${open && 'bg-state-base-hover'}`}
|
||||
>
|
||||
{getCurrentIcon()}
|
||||
</ActionButton>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[1000]'>
|
||||
<div className='flex w-[144px] flex-col items-start rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg'>
|
||||
<button
|
||||
className='flex w-full items-center gap-1 rounded-lg px-2 py-1.5 text-text-secondary hover:bg-state-base-hover'
|
||||
onClick={() => handleThemeChange('light')}
|
||||
>
|
||||
<RiSunLine className='h-4 w-4 text-text-tertiary' />
|
||||
<div className='flex grow items-center justify-start px-1'>
|
||||
<span className='system-md-regular'>{t('common.theme.light')}</span>
|
||||
</div>
|
||||
{theme === 'light' && <div className='flex h-4 w-4 shrink-0 items-center justify-center'>
|
||||
<RiCheckLine className='h-4 w-4 text-text-accent' />
|
||||
</div>}
|
||||
</button>
|
||||
<button
|
||||
className='flex w-full items-center gap-1 rounded-lg px-2 py-1.5 text-text-secondary hover:bg-state-base-hover'
|
||||
onClick={() => handleThemeChange('dark')}
|
||||
>
|
||||
<RiMoonLine className='h-4 w-4 text-text-tertiary' />
|
||||
<div className='flex grow items-center justify-start px-1'>
|
||||
<span className='system-md-regular'>{t('common.theme.dark')}</span>
|
||||
</div>
|
||||
{theme === 'dark' && <div className='flex h-4 w-4 shrink-0 items-center justify-center'>
|
||||
<RiCheckLine className='h-4 w-4 text-text-accent' />
|
||||
</div>}
|
||||
</button>
|
||||
<button
|
||||
className='flex w-full items-center gap-1 rounded-lg px-2 py-1.5 text-text-secondary hover:bg-state-base-hover'
|
||||
onClick={() => handleThemeChange('system')}
|
||||
>
|
||||
<RiComputerLine className='h-4 w-4 text-text-tertiary' />
|
||||
<div className='flex grow items-center justify-start px-1'>
|
||||
<span className='system-md-regular'>{t('common.theme.auto')}</span>
|
||||
</div>
|
||||
{theme === 'system' && <div className='flex h-4 w-4 shrink-0 items-center justify-center'>
|
||||
<RiCheckLine className='h-4 w-4 text-text-accent' />
|
||||
</div>}
|
||||
</button>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
'use client'
|
||||
import {
|
||||
RiComputerLine,
|
||||
RiMoonLine,
|
||||
RiSunLine,
|
||||
} from '@remixicon/react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'system'
|
||||
|
||||
export default function ThemeSwitcher() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
const handleThemeChange = (newTheme: Theme) => {
|
||||
setTheme(newTheme)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex items-center rounded-[10px] bg-components-segmented-control-bg-normal p-0.5'>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg px-2 py-1 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
||||
theme === 'system' && 'bg-components-segmented-control-item-active-bg text-text-accent-light-mode-only shadow-sm hover:bg-components-segmented-control-item-active-bg hover:text-text-accent-light-mode-only',
|
||||
)}
|
||||
onClick={() => handleThemeChange('system')}
|
||||
>
|
||||
<div className='p-0.5'>
|
||||
<RiComputerLine className='h-4 w-4' />
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn('h-[14px] w-px bg-transparent', theme === 'dark' && 'bg-divider-regular')}></div>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg px-2 py-1 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
||||
theme === 'light' && 'bg-components-segmented-control-item-active-bg text-text-accent-light-mode-only shadow-sm hover:bg-components-segmented-control-item-active-bg hover:text-text-accent-light-mode-only',
|
||||
)}
|
||||
onClick={() => handleThemeChange('light')}
|
||||
>
|
||||
<div className='p-0.5'>
|
||||
<RiSunLine className='h-4 w-4' />
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn('h-[14px] w-px bg-transparent', theme === 'system' && 'bg-divider-regular')}></div>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg px-2 py-1 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
||||
theme === 'dark' && 'bg-components-segmented-control-item-active-bg text-text-accent-light-mode-only shadow-sm hover:bg-components-segmented-control-item-active-bg hover:text-text-accent-light-mode-only',
|
||||
)}
|
||||
onClick={() => handleThemeChange('dark')}
|
||||
>
|
||||
<div className='p-0.5'>
|
||||
<RiMoonLine className='h-4 w-4' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -14,6 +14,7 @@ import {
|
|||
RiMap2Line,
|
||||
RiSettings3Line,
|
||||
RiStarLine,
|
||||
RiTShirt2Line,
|
||||
} from '@remixicon/react'
|
||||
import Link from 'next/link'
|
||||
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
|
||||
|
@ -25,6 +26,7 @@ import Compliance from './compliance'
|
|||
import PremiumBadge from '@/app/components/base/premium-badge'
|
||||
import I18n from '@/context/i18n'
|
||||
import Avatar from '@/app/components/base/avatar'
|
||||
import ThemeSwitcher from '@/app/components/base/theme-switcher'
|
||||
import { logout } from '@/service/common'
|
||||
import AppContext, { useAppContext } from '@/context/app-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
|
@ -83,8 +85,8 @@ export default function AppSelector() {
|
|||
<MenuItems
|
||||
className="
|
||||
absolute right-0 mt-1.5 w-60 max-w-80
|
||||
origin-top-right divide-y divide-divider-subtle rounded-xl bg-components-panel-bg-blur
|
||||
shadow-lg focus:outline-none
|
||||
origin-top-right divide-y divide-divider-subtle rounded-xl bg-components-panel-bg-blur shadow-lg
|
||||
backdrop-blur-sm focus:outline-none
|
||||
"
|
||||
>
|
||||
<MenuItem disabled>
|
||||
|
@ -189,6 +191,15 @@ export default function AppSelector() {
|
|||
)
|
||||
}
|
||||
</div>
|
||||
<MenuItem disabled>
|
||||
<div className='p-1'>
|
||||
<div className={cn(itemClassName, 'hover:bg-transparent')}>
|
||||
<RiTShirt2Line className='size-4 shrink-0 text-text-tertiary' />
|
||||
<div className='system-md-regular grow px-1 text-text-secondary'>{t('common.theme.theme')}</div>
|
||||
<ThemeSwitcher/>
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<div className='p-1' onClick={() => handleLogout()}>
|
||||
<div
|
||||
|
|
|
@ -12,6 +12,8 @@ import {
|
|||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import ThemeSwitcher from '@/app/components/base/theme-switcher'
|
||||
import InfoModal from './info-modal'
|
||||
import type { SiteInfo } from '@/models/share'
|
||||
import cn from '@/utils/classnames'
|
||||
|
@ -59,6 +61,13 @@ const MenuDropdown: FC<Props> = ({
|
|||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-50'>
|
||||
<div className='w-[224px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm'>
|
||||
<div className='p-1'>
|
||||
<div className={cn('system-md-regular flex cursor-pointer items-center rounded-lg py-1.5 pl-3 pr-2 text-text-secondary')}>
|
||||
<div className='grow'>{t('common.theme.theme')}</div>
|
||||
<ThemeSwitcher/>
|
||||
</div>
|
||||
</div>
|
||||
<Divider type='horizontal' className='my-0' />
|
||||
<div className='p-1'>
|
||||
{data?.privacy_policy && (
|
||||
<a href={data.privacy_policy} target='_blank' className='system-md-regular flex cursor-pointer items-center rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover'>
|
||||
|
|
|
@ -59,8 +59,7 @@ const LocaleLayout = async ({
|
|||
<TanstackQueryIniter>
|
||||
<ThemeProvider
|
||||
attribute='data-theme'
|
||||
forcedTheme='light'
|
||||
defaultTheme='light' // TODO: change to 'system' when dark mode ready
|
||||
defaultTheme='system'
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
import React from 'react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import Select from '@/app/components/base/select/locale'
|
||||
import ThemeSelector from '@/app/components/base/theme-selector'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { languages } from '@/i18n/language'
|
||||
import type { Locale } from '@/i18n'
|
||||
import I18n from '@/context/i18n'
|
||||
|
@ -10,17 +12,22 @@ import LogoSite from '@/app/components/base/logo/logo-site'
|
|||
const Header = () => {
|
||||
const { locale, setLocaleOnClient } = useContext(I18n)
|
||||
|
||||
return <div className='flex w-full items-center justify-between p-6'>
|
||||
<LogoSite />
|
||||
<Select
|
||||
value={locale}
|
||||
items={languages.filter(item => item.supported)}
|
||||
onChange={(value) => {
|
||||
setLocaleOnClient(value as Locale)
|
||||
}}
|
||||
/>
|
||||
|
||||
</div>
|
||||
return (
|
||||
<div className='flex w-full items-center justify-between p-6'>
|
||||
<LogoSite />
|
||||
<div className='flex items-center gap-1'>
|
||||
<Select
|
||||
value={locale}
|
||||
items={languages.filter(item => item.supported)}
|
||||
onChange={(value) => {
|
||||
setLocaleOnClient(value as Locale)
|
||||
}}
|
||||
/>
|
||||
<Divider type='vertical' className='mx-0 ml-2 h-4' />
|
||||
<ThemeSelector />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Header
|
||||
|
|
|
@ -1,4 +1,10 @@
|
|||
const translation = {
|
||||
theme: {
|
||||
theme: 'Theme',
|
||||
light: 'light',
|
||||
dark: 'dark',
|
||||
auto: 'system',
|
||||
},
|
||||
api: {
|
||||
success: 'Success',
|
||||
actionSuccess: 'Action succeeded',
|
||||
|
|
|
@ -1,4 +1,10 @@
|
|||
const translation = {
|
||||
theme: {
|
||||
theme: 'テーマ',
|
||||
light: '明るい',
|
||||
dark: '暗い',
|
||||
auto: 'システム',
|
||||
},
|
||||
api: {
|
||||
success: '成功',
|
||||
actionSuccess: 'アクションが成功しました',
|
||||
|
|
|
@ -1,4 +1,10 @@
|
|||
const translation = {
|
||||
theme: {
|
||||
theme: '主题',
|
||||
light: '浅色',
|
||||
dark: '深色',
|
||||
auto: '自动',
|
||||
},
|
||||
api: {
|
||||
success: '成功',
|
||||
actionSuccess: '操作成功',
|
||||
|
|
Loading…
Reference in New Issue