feat: add theme switcher

This commit is contained in:
jZonG 2025-04-15 17:38:55 +08:00
parent 46616db689
commit 91009eaef6
9 changed files with 214 additions and 15 deletions

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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

View File

@ -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'>

View File

@ -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
>

View File

@ -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

View File

@ -1,4 +1,10 @@
const translation = {
theme: {
theme: 'Theme',
light: 'light',
dark: 'dark',
auto: 'system',
},
api: {
success: 'Success',
actionSuccess: 'Action succeeded',

View File

@ -1,4 +1,10 @@
const translation = {
theme: {
theme: 'テーマ',
light: '明るい',
dark: '暗い',
auto: 'システム',
},
api: {
success: '成功',
actionSuccess: 'アクションが成功しました',

View File

@ -1,4 +1,10 @@
const translation = {
theme: {
theme: '主题',
light: '浅色',
dark: '深色',
auto: '自动',
},
api: {
success: '成功',
actionSuccess: '操作成功',