feat: 预定义主题

This commit is contained in:
bietiaop
2025-02-09 11:58:46 +08:00
parent 2c7b0625e8
commit 9f78e1ce1e
6 changed files with 311 additions and 170 deletions

View File

@@ -1,4 +1,5 @@
import { Button } from '@heroui/button' import { Button } from '@heroui/button'
import clsx from 'clsx'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { IoMdRefresh } from 'react-icons/io' import { IoMdRefresh } from 'react-icons/io'
@@ -7,15 +8,22 @@ export interface SaveButtonsProps {
reset: () => void reset: () => void
refresh?: () => void refresh?: () => void
isSubmitting: boolean isSubmitting: boolean
className?: string
} }
const SaveButtons: React.FC<SaveButtonsProps> = ({ const SaveButtons: React.FC<SaveButtonsProps> = ({
onSubmit, onSubmit,
reset, reset,
isSubmitting, isSubmitting,
refresh refresh,
className
}) => ( }) => (
<div className="max-w-full mx-3 w-96 flex flex-col justify-center gap-3"> <div
className={clsx(
'max-w-full mx-3 w-96 flex flex-col justify-center gap-3',
className
)}
>
<div className="flex items-center justify-center gap-2 mt-5"> <div className="flex items-center justify-center gap-2 mt-5">
<Button <Button
color="default" color="default"

View File

@@ -252,7 +252,5 @@ export default {
theme, theme,
author: 'NapCat', author: 'NapCat',
name: 'nc_pink', name: 'nc_pink',
bgColor: 'hsl(339.2,90.36%,51.18%)',
textColor: 'hsl(0,0%,100%)',
description: 'NapCat Pink Theme' description: 'NapCat Pink Theme'
} satisfies ThemeInfo } satisfies ThemeInfo

View File

@@ -1,8 +1,14 @@
import { Accordion, AccordionItem } from '@heroui/accordion' import { Accordion, AccordionItem } from '@heroui/accordion'
import { Card, CardBody, CardHeader } from '@heroui/card'
import { useRequest } from 'ahooks' import { useRequest } from 'ahooks'
import { useEffect } from 'react' import clsx from 'clsx'
import { Controller, useForm } from 'react-hook-form' import { useEffect, useRef } from 'react'
import { Controller, useForm, useWatch } from 'react-hook-form'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { FaUserAstronaut } from 'react-icons/fa'
import { FaPaintbrush } from 'react-icons/fa6'
import { IoIosColorPalette } from 'react-icons/io'
import { MdDarkMode, MdLightMode } from 'react-icons/md'
import themes from '@/const/themes' import themes from '@/const/themes'
@@ -10,120 +16,82 @@ import ColorPicker from '@/components/ColorPicker'
import SaveButtons from '@/components/button/save_buttons' import SaveButtons from '@/components/button/save_buttons'
import PageLoading from '@/components/page_loading' import PageLoading from '@/components/page_loading'
import { loadTheme } from '@/utils/theme' import { colorKeys, generateTheme, loadTheme } from '@/utils/theme'
import WebUIManager from '@/controllers/webui_manager' import WebUIManager from '@/controllers/webui_manager'
// 将颜色 key 补全为 ThemeConfigItem 中定义的所有颜色相关属性 export type PreviewThemeCardProps = {
const colorKeys = [ theme: ThemeInfo
'--heroui-background', onPreview: () => void
}
'--heroui-foreground-50', const values = [
'--heroui-foreground-100', '',
'--heroui-foreground-200', '-50',
'--heroui-foreground-300', '-100',
'--heroui-foreground-400', '-200',
'--heroui-foreground-500', '-300',
'--heroui-foreground-600', '-400',
'--heroui-foreground-700', '-500',
'--heroui-foreground-800', '-600',
'--heroui-foreground-900', '-700',
'--heroui-foreground', '-800',
'-900'
]
const colors = ['primary', 'secondary', 'success', 'danger', 'warning']
'--heroui-content1', function PreviewThemeCard({ theme, onPreview }: PreviewThemeCardProps) {
'--heroui-content1-foreground', const style = document.createElement('style')
'--heroui-content2', style.innerHTML = generateTheme(theme.theme, theme.name)
'--heroui-content2-foreground', const cardRef = useRef<HTMLDivElement>(null)
'--heroui-content3', useEffect(() => {
'--heroui-content3-foreground', document.head.appendChild(style)
'--heroui-content4', return () => {
'--heroui-content4-foreground', document.head.removeChild(style)
}
'--heroui-default-50', }, [])
'--heroui-default-100', return (
'--heroui-default-200', <Card
'--heroui-default-300', ref={cardRef}
'--heroui-default-400', shadow="sm"
'--heroui-default-500', radius="sm"
'--heroui-default-600', isPressable
'--heroui-default-700', onPress={onPreview}
'--heroui-default-800', className={clsx('text-primary bg-primary-50', theme.name)}
'--heroui-default-900', >
'--heroui-default-foreground', <CardHeader className="pb-0 flex flex-col items-start gap-1">
'--heroui-default', <div className="px-1 rounded-md bg-primary text-primary-foreground">
{theme.name}
'--heroui-danger-50', </div>
'--heroui-danger-100', <div className="text-xs flex items-center gap-1 text-primary-300">
'--heroui-danger-200', <FaUserAstronaut />
'--heroui-danger-300', {theme.author ?? '未知'}
'--heroui-danger-400', </div>
'--heroui-danger-500', <div className="text-xs text-primary-200">{theme.description}</div>
'--heroui-danger-600', </CardHeader>
'--heroui-danger-700', <CardBody>
'--heroui-danger-800', <div className="flex flex-col gap-1">
'--heroui-danger-900', {colors.map((color) => (
'--heroui-danger-foreground', <div className="flex gap-1 items-center flex-wrap" key={color}>
'--heroui-danger', <div className="text-xs w-4 text-right">
{color[0].toUpperCase()}
'--heroui-primary-50', </div>
'--heroui-primary-100', {values.map((value) => (
'--heroui-primary-200', <div
'--heroui-primary-300', key={value}
'--heroui-primary-400', className={clsx(
'--heroui-primary-500', 'w-2 h-2 rounded-full shadow-small',
'--heroui-primary-600', `bg-${color}${value}`
'--heroui-primary-700', )}
'--heroui-primary-800', ></div>
'--heroui-primary-900', ))}
'--heroui-primary-foreground', </div>
'--heroui-primary', ))}
</div>
'--heroui-secondary-50', </CardBody>
'--heroui-secondary-100', </Card>
'--heroui-secondary-200', )
'--heroui-secondary-300', }
'--heroui-secondary-400',
'--heroui-secondary-500',
'--heroui-secondary-600',
'--heroui-secondary-700',
'--heroui-secondary-800',
'--heroui-secondary-900',
'--heroui-secondary-foreground',
'--heroui-secondary',
'--heroui-success-50',
'--heroui-success-100',
'--heroui-success-200',
'--heroui-success-300',
'--heroui-success-400',
'--heroui-success-500',
'--heroui-success-600',
'--heroui-success-700',
'--heroui-success-800',
'--heroui-success-900',
'--heroui-success-foreground',
'--heroui-success',
'--heroui-warning-50',
'--heroui-warning-100',
'--heroui-warning-200',
'--heroui-warning-300',
'--heroui-warning-400',
'--heroui-warning-500',
'--heroui-warning-600',
'--heroui-warning-700',
'--heroui-warning-800',
'--heroui-warning-900',
'--heroui-warning-foreground',
'--heroui-warning',
'--heroui-focus',
'--heroui-overlay',
'--heroui-divider',
'--heroui-code-background',
'--heroui-strong',
'--heroui-code-mdx'
] as const
const ThemeConfigCard = () => { const ThemeConfigCard = () => {
const { data, loading, error, refreshAsync } = useRequest( const { data, loading, error, refreshAsync } = useRequest(
@@ -145,6 +113,23 @@ const ThemeConfigCard = () => {
} }
}) })
// 使用 useRef 存储 style 标签引用
const styleTagRef = useRef<HTMLStyleElement | null>(null)
// 在组件挂载时创建 style 标签,并在卸载时清理
useEffect(() => {
const styleTag = document.createElement('style')
document.head.appendChild(styleTag)
styleTagRef.current = styleTag
return () => {
if (styleTagRef.current) {
document.head.removeChild(styleTagRef.current)
}
}
}, [])
const theme = useWatch({ control, name: 'theme' })
const reset = () => { const reset = () => {
if (data) setOnebotValue('theme', data) if (data) setOnebotValue('theme', data)
} }
@@ -174,6 +159,13 @@ const ThemeConfigCard = () => {
reset() reset()
}, [data]) }, [data])
useEffect(() => {
if (theme && styleTagRef.current) {
const css = generateTheme(theme)
styleTagRef.current.innerHTML = css
}
}, [theme])
if (loading) return <PageLoading loading={true} /> if (loading) return <PageLoading loading={true} />
if (error) if (error)
@@ -184,31 +176,33 @@ const ThemeConfigCard = () => {
return ( return (
<> <>
<title> - NapCat WebUI</title> <title> - NapCat WebUI</title>
<Accordion variant="splitted">
<SaveButtons
onSubmit={onSubmit}
reset={reset}
isSubmitting={isSubmitting}
refresh={onRefresh}
className="items-end w-full p-4"
/>
<div className="px-4 text-sm text-default-600"></div>
<Accordion variant="splitted" defaultExpandedKeys={['select']}>
<AccordionItem <AccordionItem
key="select" key="select"
aria-label="Pick Color" aria-label="Pick Color"
title="选择主题" title="选择主题"
subtitle="点击立即生效" subtitle="可以切换夜间/白昼模式查看对应颜色"
className="shadow-small" className="shadow-small"
startContent={<IoIosColorPalette />}
> >
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{themes.map((theme) => ( {themes.map((theme) => (
<div <PreviewThemeCard
key={theme.name} key={theme.name}
className="p-4 rounded-md cursor-pointer" theme={theme}
style={{ onPreview={() => {
backgroundColor: theme.bgColor,
color: theme.textColor
}}
onClick={() => {
setOnebotValue('theme', theme.theme) setOnebotValue('theme', theme.theme)
onSubmit()
}} }}
> />
<div>{theme.name}</div>
<div>{theme.author}</div>
</div>
))} ))}
</div> </div>
</AccordionItem> </AccordionItem>
@@ -217,12 +211,29 @@ const ThemeConfigCard = () => {
key="pick" key="pick"
aria-label="Pick Color" aria-label="Pick Color"
title="自定义配色" title="自定义配色"
subtitle="需手动点击保存"
className="shadow-small" className="shadow-small"
startContent={<FaPaintbrush />}
> >
<div className="space-y-2">
{(['dark', 'light'] as const).map((mode) => ( {(['dark', 'light'] as const).map((mode) => (
<div key={mode}> <div
<h3>{mode === 'dark' ? '暗色主题' : '亮色主题'}</h3> key={mode}
className={clsx(
'p-2 rounded-md',
mode === 'dark' ? 'text-white' : 'text-black',
mode === 'dark'
? 'bg-content1-foreground dark:bg-content1'
: 'bg-content1 dark:bg-content1-foreground'
)}
>
<h3 className="text-center p-2 rounded-md bg-content2 mb-2 text-default-800 flex items-center justify-center">
{mode === 'dark' ? (
<MdDarkMode size={24} />
) : (
<MdLightMode size={24} />
)}
{mode === 'dark' ? '夜间模式主题' : '白昼模式主题'}
</h3>
{colorKeys.map((key) => ( {colorKeys.map((key) => (
<div <div
key={key} key={key}
@@ -251,13 +262,7 @@ const ThemeConfigCard = () => {
))} ))}
</div> </div>
))} ))}
</div>
<SaveButtons
onSubmit={onSubmit}
reset={reset}
isSubmitting={isSubmitting}
refresh={onRefresh}
/>
</AccordionItem> </AccordionItem>
</Accordion> </Accordion>
</> </>

View File

@@ -1,8 +1,6 @@
interface ThemeInfo { interface ThemeInfo {
theme: ThemeConfig theme: ThemeConfig
name: string name: string
bgColor: string
textColor: string
description?: string description?: string
author?: string author?: string
} }

View File

@@ -13,3 +13,129 @@ export function loadTheme() {
console.error('Failed to load theme.css') console.error('Failed to load theme.css')
}) })
} }
export const colorKeys = [
'--heroui-background',
'--heroui-foreground-50',
'--heroui-foreground-100',
'--heroui-foreground-200',
'--heroui-foreground-300',
'--heroui-foreground-400',
'--heroui-foreground-500',
'--heroui-foreground-600',
'--heroui-foreground-700',
'--heroui-foreground-800',
'--heroui-foreground-900',
'--heroui-foreground',
'--heroui-content1',
'--heroui-content1-foreground',
'--heroui-content2',
'--heroui-content2-foreground',
'--heroui-content3',
'--heroui-content3-foreground',
'--heroui-content4',
'--heroui-content4-foreground',
'--heroui-default-50',
'--heroui-default-100',
'--heroui-default-200',
'--heroui-default-300',
'--heroui-default-400',
'--heroui-default-500',
'--heroui-default-600',
'--heroui-default-700',
'--heroui-default-800',
'--heroui-default-900',
'--heroui-default-foreground',
'--heroui-default',
'--heroui-danger-50',
'--heroui-danger-100',
'--heroui-danger-200',
'--heroui-danger-300',
'--heroui-danger-400',
'--heroui-danger-500',
'--heroui-danger-600',
'--heroui-danger-700',
'--heroui-danger-800',
'--heroui-danger-900',
'--heroui-danger-foreground',
'--heroui-danger',
'--heroui-primary-50',
'--heroui-primary-100',
'--heroui-primary-200',
'--heroui-primary-300',
'--heroui-primary-400',
'--heroui-primary-500',
'--heroui-primary-600',
'--heroui-primary-700',
'--heroui-primary-800',
'--heroui-primary-900',
'--heroui-primary-foreground',
'--heroui-primary',
'--heroui-secondary-50',
'--heroui-secondary-100',
'--heroui-secondary-200',
'--heroui-secondary-300',
'--heroui-secondary-400',
'--heroui-secondary-500',
'--heroui-secondary-600',
'--heroui-secondary-700',
'--heroui-secondary-800',
'--heroui-secondary-900',
'--heroui-secondary-foreground',
'--heroui-secondary',
'--heroui-success-50',
'--heroui-success-100',
'--heroui-success-200',
'--heroui-success-300',
'--heroui-success-400',
'--heroui-success-500',
'--heroui-success-600',
'--heroui-success-700',
'--heroui-success-800',
'--heroui-success-900',
'--heroui-success-foreground',
'--heroui-success',
'--heroui-warning-50',
'--heroui-warning-100',
'--heroui-warning-200',
'--heroui-warning-300',
'--heroui-warning-400',
'--heroui-warning-500',
'--heroui-warning-600',
'--heroui-warning-700',
'--heroui-warning-800',
'--heroui-warning-900',
'--heroui-warning-foreground',
'--heroui-warning',
'--heroui-focus',
'--heroui-overlay',
'--heroui-divider',
'--heroui-code-background',
'--heroui-strong',
'--heroui-code-mdx'
] as const
export const generateTheme = (theme: ThemeConfig, validField?: string) => {
let css = `:root ${validField ? `.${validField}` : ''}, .light ${validField ? `.${validField}` : ''}, [data-theme="light"] ${validField ? `.${validField}` : ''} {`
for (const key in theme.light) {
const _key = key as keyof ThemeConfigItem
css += `${_key}: ${theme.light[_key]};`
}
css += `}`
css += `.dark ${validField ? `.${validField}` : ''}, [data-theme="dark"] ${validField ? `.${validField}` : ''} {`
for (const key in theme.dark) {
const _key = key as keyof ThemeConfigItem
css += `${_key}: ${theme.dark[_key]};`
}
css += `}`
return css
}

View File

@@ -9,6 +9,12 @@ export default {
'./src/components/**/*.{js,ts,jsx,tsx,mdx}', './src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}' './node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}'
], ],
safelist: [
{
pattern:
/bg-(primary|secondary|success|danger|warning)-(50|100|200|300|400|500|600|700|800|900)/
}
],
theme: { theme: {
extend: {} extend: {}
}, },