refactor: 整体重构 (#1381)

* feat: pnpm new

* Refactor build and release workflows, update dependencies

Switch build scripts and workflows from npm to pnpm, update build and artifact paths, and simplify release workflow by removing version detection and changelog steps. Add new dependencies (silk-wasm, express, ws, node-pty-prebuilt-multiarch), update exports in package.json files, and add vite config for napcat-framework. Also, rename manifest.json for framework package and fix static asset copying in shell build config.
This commit is contained in:
手瓜一十雪
2025-11-13 15:39:42 +08:00
committed by GitHub
parent c3d1892545
commit ed19c52f25
778 changed files with 2356 additions and 26391 deletions

View File

@@ -0,0 +1,132 @@
import { Input } from '@heroui/input';
import { useLocalStorage } from '@uidotdev/usehooks';
import { Controller, useForm } from 'react-hook-form';
import toast from 'react-hot-toast';
import { useNavigate } from 'react-router-dom';
import key from '@/const/key';
import SaveButtons from '@/components/button/save_buttons';
import WebUIManager from '@/controllers/webui_manager';
const ChangePasswordCard = () => {
const {
control,
handleSubmit: handleWebuiSubmit,
formState: { isSubmitting, errors },
reset,
watch,
} = useForm<{
oldToken: string;
newToken: string;
}>({
defaultValues: {
oldToken: '',
newToken: '',
},
});
const navigate = useNavigate();
const [, setToken] = useLocalStorage(key.token, '');
// 监听旧密码的值
const oldTokenValue = watch('oldToken');
const onSubmit = handleWebuiSubmit(async (data) => {
try {
// 使用正常密码更新流程
await WebUIManager.changePassword(data.oldToken, data.newToken);
toast.success('修改成功');
setToken('');
localStorage.removeItem(key.token);
navigate('/web_login');
} catch (error) {
const msg = (error as Error).message;
toast.error(`修改失败: ${msg}`);
}
});
return (
<>
<title> - NapCat WebUI</title>
<Controller
control={control}
name='oldToken'
rules={{
required: '旧密码不能为空',
validate: (value) => {
if (!value || value.trim().length === 0) {
return '旧密码不能为空';
}
return true;
},
}}
render={({ field }) => (
<Input
{...field}
label='旧密码'
placeholder='请输入旧密码'
type='password'
isRequired
isInvalid={!!errors.oldToken}
errorMessage={errors.oldToken?.message}
/>
)}
/>
<Controller
control={control}
name='newToken'
rules={{
required: '新密码不能为空',
minLength: {
value: 6,
message: '新密码至少需要6个字符',
},
validate: (value) => {
if (!value || value.trim().length === 0) {
return '新密码不能为空';
}
if (value.trim().length !== value.length) {
return '新密码不能包含前后空格';
}
if (value === oldTokenValue) {
return '新密码不能与旧密码相同';
}
// 检查是否包含字母
if (!/[a-zA-Z]/.test(value)) {
return '新密码必须包含字母';
}
// 检查是否包含数字
if (!/[0-9]/.test(value)) {
return '新密码必须包含数字';
}
return true;
},
}}
render={({ field }) => (
<Input
{...field}
label='新密码'
placeholder='至少6位包含字母和数字'
type='password'
isRequired
isInvalid={!!errors.newToken}
errorMessage={errors.newToken?.message}
/>
)}
/>
<SaveButtons
onSubmit={onSubmit}
reset={reset}
isSubmitting={isSubmitting}
/>
</>
);
};
export default ChangePasswordCard;

View File

@@ -0,0 +1,100 @@
import { Card, CardBody } from '@heroui/card';
import { Tab, Tabs } from '@heroui/tabs';
import clsx from 'clsx';
import { useMediaQuery } from 'react-responsive';
import { useNavigate, useSearchParams } from 'react-router-dom';
import ChangePasswordCard from './change_password';
import LoginConfigCard from './login';
import OneBotConfigCard from './onebot';
import ServerConfigCard from './server';
import ThemeConfigCard from './theme';
import WebUIConfigCard from './webui';
export interface ConfigPageProps {
children?: React.ReactNode
size?: 'sm' | 'md' | 'lg'
}
const ConfingPageItem: React.FC<ConfigPageProps> = ({
children,
size = 'md',
}) => {
return (
<Card className='bg-opacity-50 backdrop-blur-sm'>
<CardBody className='items-center py-5'>
<div
className={clsx('max-w-full flex flex-col gap-2', {
'w-72': size === 'sm',
'w-96': size === 'md',
'w-[32rem]': size === 'lg',
})}
>
{children}
</div>
</CardBody>
</Card>
);
};
export default function ConfigPage () {
const isMediumUp = useMediaQuery({ minWidth: 768 });
const navigate = useNavigate();
const search = useSearchParams({
tab: 'onebot',
})[0];
const tab = search.get('tab') ?? 'onebot';
return (
<section className='w-[1000px] max-w-full md:mx-auto gap-4 py-8 px-2 md:py-10'>
<Tabs
aria-label='config tab'
fullWidth
className='w-full'
isVertical={isMediumUp}
selectedKey={tab}
onSelectionChange={(key) => {
navigate(`/config?tab=${key}`);
}}
classNames={{
tabList: 'sticky flex top-14 bg-opacity-50 backdrop-blur-sm',
panel: 'w-full relative',
base: 'md:!w-auto flex-grow-0 flex-shrink-0 mr-0',
cursor: 'bg-opacity-60 backdrop-blur-sm',
}}
>
<Tab title='OneBot配置' key='onebot'>
<ConfingPageItem>
<OneBotConfigCard />
</ConfingPageItem>
</Tab>
<Tab title='服务器配置' key='server'>
<ConfingPageItem>
<ServerConfigCard />
</ConfingPageItem>
</Tab>
<Tab title='WebUI配置' key='webui'>
<ConfingPageItem>
<WebUIConfigCard />
</ConfingPageItem>
</Tab>
<Tab title='登录配置' key='login'>
<ConfingPageItem>
<LoginConfigCard />
</ConfingPageItem>
</Tab>
<Tab title='修改密码' key='token'>
<ConfingPageItem>
<ChangePasswordCard />
</ConfingPageItem>
</Tab>
<Tab title='主题配置' key='theme'>
<ConfingPageItem size='lg'>
<ThemeConfigCard />
</ConfingPageItem>
</Tab>
</Tabs>
</section>
);
}

View File

@@ -0,0 +1,89 @@
import { Input } from '@heroui/input';
import { useRequest } from 'ahooks';
import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import toast from 'react-hot-toast';
import SaveButtons from '@/components/button/save_buttons';
import PageLoading from '@/components/page_loading';
import QQManager from '@/controllers/qq_manager';
const LoginConfigCard = () => {
const {
data: quickLoginData,
loading: quickLoginLoading,
error: quickLoginError,
refreshAsync: refreshQuickLogin,
} = useRequest(QQManager.getQuickLoginQQ);
const {
control,
handleSubmit: handleOnebotSubmit,
formState: { isSubmitting },
setValue: setOnebotValue,
} = useForm<{
quickLoginQQ: string;
}>({
defaultValues: {
quickLoginQQ: '',
},
});
const reset = () => {
setOnebotValue('quickLoginQQ', quickLoginData ?? '');
};
const onSubmit = handleOnebotSubmit(async (data) => {
try {
await QQManager.setQuickLoginQQ(data.quickLoginQQ);
toast.success('保存成功');
} catch (error) {
const msg = (error as Error).message;
toast.error(`保存失败: ${msg}`);
}
});
const onRefresh = async () => {
try {
await refreshQuickLogin();
toast.success('刷新成功');
} catch (error) {
const msg = (error as Error).message;
toast.error(`刷新失败: ${msg}`);
}
};
useEffect(() => {
reset();
}, [quickLoginData]);
if (quickLoginLoading) return <PageLoading loading />;
return (
<>
<title>OneBot配置 - NapCat WebUI</title>
<div className='flex-shrink-0 w-full'>QQ</div>
<Controller
control={control}
name='quickLoginQQ'
render={({ field }) => (
<Input
{...field}
label='快速登录QQ'
placeholder='请输入QQ号'
isDisabled={!!quickLoginError}
errorMessage={quickLoginError ? '获取快速登录QQ失败' : undefined}
/>
)}
/>
<SaveButtons
onSubmit={onSubmit}
reset={reset}
isSubmitting={isSubmitting || quickLoginLoading}
refresh={onRefresh}
/>
</>
);
};
export default LoginConfigCard;

View File

@@ -0,0 +1,112 @@
import { Input } from '@heroui/input';
import { useEffect, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import toast from 'react-hot-toast';
import SaveButtons from '@/components/button/save_buttons';
import PageLoading from '@/components/page_loading';
import SwitchCard from '@/components/switch_card';
import useConfig from '@/hooks/use-config';
const OneBotConfigCard = () => {
const { config, saveConfigWithoutNetwork, refreshConfig } = useConfig();
const [loading, setLoading] = useState(false);
const {
control,
handleSubmit: handleOnebotSubmit,
formState: { isSubmitting },
setValue: setOnebotValue,
} = useForm<IConfig['onebot']>({
defaultValues: {
musicSignUrl: '',
enableLocalFile2Url: false,
parseMultMsg: false,
},
});
const reset = () => {
setOnebotValue('musicSignUrl', config.musicSignUrl);
setOnebotValue('enableLocalFile2Url', config.enableLocalFile2Url);
setOnebotValue('parseMultMsg', config.parseMultMsg);
};
const onSubmit = handleOnebotSubmit(async (data) => {
try {
await saveConfigWithoutNetwork(data);
toast.success('保存成功');
} catch (error) {
const msg = (error as Error).message;
toast.error(`保存失败: ${msg}`);
}
});
const onRefresh = async (shotTip = true) => {
try {
setLoading(true);
await refreshConfig();
if (shotTip) toast.success('刷新成功');
} catch (error) {
const msg = (error as Error).message;
toast.error(`刷新失败: ${msg}`);
} finally {
setLoading(false);
}
};
useEffect(() => {
reset();
}, [config]);
useEffect(() => {
onRefresh(false);
}, []);
if (loading) return <PageLoading loading />;
return (
<>
<title>OneBot配置 - NapCat WebUI</title>
<Controller
control={control}
name='musicSignUrl'
render={({ field }) => (
<Input
{...field}
label='音乐签名地址'
placeholder='请输入音乐签名地址'
/>
)}
/>
<Controller
control={control}
name='enableLocalFile2Url'
render={({ field }) => (
<SwitchCard
{...field}
description='启用本地文件到URL'
label='启用本地文件到URL'
/>
)}
/>
<Controller
control={control}
name='parseMultMsg'
render={({ field }) => (
<SwitchCard
{...field}
description='启用上报解析合并消息'
label='启用上报解析合并消息'
/>
)}
/>
<SaveButtons
onSubmit={onSubmit}
reset={reset}
isSubmitting={isSubmitting}
refresh={onRefresh}
/>
</>
);
};
export default OneBotConfigCard;

View File

@@ -0,0 +1,185 @@
import { Input } from '@heroui/input';
import { Switch } from '@heroui/switch';
import { useRequest } from 'ahooks';
import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import toast from 'react-hot-toast';
import SaveButtons from '@/components/button/save_buttons';
import PageLoading from '@/components/page_loading';
import WebUIManager from '@/controllers/webui_manager';
const ServerConfigCard = () => {
const {
data: configData,
loading: configLoading,
error: configError,
refreshAsync: refreshConfig,
} = useRequest(WebUIManager.getWebUIConfig);
const {
control,
handleSubmit: handleConfigSubmit,
formState: { isSubmitting },
setValue: setConfigValue,
} = useForm<{
host: string;
port: number;
loginRate: number;
disableWebUI: boolean;
disableNonLANAccess: boolean;
}>({
defaultValues: {
host: '0.0.0.0',
port: 6099,
loginRate: 10,
disableWebUI: false,
disableNonLANAccess: false,
},
});
const reset = () => {
if (configData) {
setConfigValue('host', configData.host);
setConfigValue('port', configData.port);
setConfigValue('loginRate', configData.loginRate);
setConfigValue('disableWebUI', configData.disableWebUI);
setConfigValue('disableNonLANAccess', configData.disableNonLANAccess);
}
};
const onSubmit = handleConfigSubmit(async (data) => {
try {
await WebUIManager.updateWebUIConfig(data);
toast.success('保存成功');
} catch (error) {
const msg = (error as Error).message;
toast.error(`保存失败: ${msg}`);
}
});
const onRefresh = async () => {
try {
await refreshConfig();
toast.success('刷新成功');
} catch (error) {
const msg = (error as Error).message;
toast.error(`刷新失败: ${msg}`);
}
};
useEffect(() => {
reset();
}, [configData]);
if (configLoading) return <PageLoading loading />;
return (
<>
<title> - NapCat WebUI</title>
<div className='flex flex-col gap-4'>
<div className='flex flex-col gap-2'>
<div className='flex-shrink-0 w-full'></div>
<Controller
control={control}
name='host'
render={({ field }) => (
<Input
{...field}
label='监听地址'
placeholder='请输入监听地址'
description='服务器监听的IP地址0.0.0.0表示监听所有网卡'
isDisabled={!!configError}
errorMessage={configError ? '获取配置失败' : undefined}
/>
)}
/>
<Controller
control={control}
name='port'
render={({ field }) => (
<Input
{...field}
type='number'
value={field.value?.toString() || ''}
label='监听端口'
placeholder='请输入监听端口'
description='服务器监听的端口号范围1-65535'
isDisabled={!!configError}
errorMessage={configError ? '获取配置失败' : undefined}
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
/>
)}
/>
<Controller
control={control}
name='loginRate'
render={({ field }) => (
<Input
{...field}
type='number'
value={field.value?.toString() || ''}
label='登录速率限制'
placeholder='请输入登录速率限制'
description='每小时允许的登录尝试次数'
isDisabled={!!configError}
errorMessage={configError ? '获取配置失败' : undefined}
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
/>
)}
/>
</div>
<div className='flex flex-col gap-2'>
<div className='flex-shrink-0 w-full'></div>
<Controller
control={control}
name='disableWebUI'
render={({ field }) => (
<Switch
isSelected={field.value}
onValueChange={(value) => field.onChange(value)}
isDisabled={!!configError}
>
<div className='flex flex-col'>
<span>WebUI</span>
<span className='text-sm text-default-400'>
WebUI服务
</span>
</div>
</Switch>
)}
/>
<Controller
control={control}
name='disableNonLANAccess'
render={({ field }) => (
<Switch
isSelected={field.value}
onValueChange={(value) => field.onChange(value)}
isDisabled={!!configError}
>
<div className='flex flex-col'>
<span>访</span>
<span className='text-sm text-default-400'>
访WebUI
</span>
</div>
</Switch>
)}
/>
</div>
</div>
<SaveButtons
onSubmit={onSubmit}
reset={reset}
isSubmitting={isSubmitting || configLoading}
refresh={onRefresh}
/>
</>
);
};
export default ServerConfigCard;

View File

@@ -0,0 +1,282 @@
import { Accordion, AccordionItem } from '@heroui/accordion';
import { Card, CardBody, CardHeader } from '@heroui/card';
import { useRequest } from 'ahooks';
import clsx from 'clsx';
import { useEffect, useRef } from 'react';
import { Controller, useForm, useWatch } from 'react-hook-form';
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 ColorPicker from '@/components/ColorPicker';
import SaveButtons from '@/components/button/save_buttons';
import PageLoading from '@/components/page_loading';
import { colorKeys, generateTheme, loadTheme } from '@/utils/theme';
import WebUIManager from '@/controllers/webui_manager';
export type PreviewThemeCardProps = {
theme: ThemeInfo;
onPreview: () => void;
};
const values = [
'',
'-50',
'-100',
'-200',
'-300',
'-400',
'-500',
'-600',
'-700',
'-800',
'-900',
];
const colors = [
'primary',
'secondary',
'success',
'danger',
'warning',
'default',
];
function PreviewThemeCard ({ theme, onPreview }: PreviewThemeCardProps) {
const style = document.createElement('style');
style.innerHTML = generateTheme(theme.theme, theme.name);
const cardRef = useRef<HTMLDivElement>(null);
useEffect(() => {
document.head.appendChild(style);
return () => {
document.head.removeChild(style);
};
}, []);
return (
<Card
ref={cardRef}
shadow='sm'
radius='sm'
isPressable
onPress={onPreview}
className={clsx('text-primary bg-primary-50', theme.name)}
>
<CardHeader className='pb-0 flex flex-col items-start gap-1'>
<div className='px-1 rounded-md bg-primary text-primary-foreground'>
{theme.name}
</div>
<div className='text-xs flex items-center gap-1 text-primary-300'>
<FaUserAstronaut />
{theme.author ?? '未知'}
</div>
<div className='text-xs text-primary-200'>{theme.description}</div>
</CardHeader>
<CardBody>
<div className='flex flex-col gap-1'>
{colors.map((color) => (
<div className='flex gap-1 items-center flex-wrap' key={color}>
<div className='text-xs w-4 text-right'>
{color[0].toUpperCase()}
</div>
{values.map((value) => (
<div
key={value}
className={clsx(
'w-2 h-2 rounded-full shadow-small',
`bg-${color}${value}`
)}
/>
))}
</div>
))}
</div>
</CardBody>
</Card>
);
}
const ThemeConfigCard = () => {
const { data, loading, error, refreshAsync } = useRequest(
WebUIManager.getThemeConfig
);
const {
control,
handleSubmit: handleOnebotSubmit,
formState: { isSubmitting },
setValue: setOnebotValue,
} = useForm<{
theme: ThemeConfig;
}>({
defaultValues: {
theme: {
dark: {},
light: {},
},
},
});
// 使用 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 = () => {
if (data) setOnebotValue('theme', data);
};
const onSubmit = handleOnebotSubmit(async (data) => {
try {
await WebUIManager.setThemeConfig(data.theme);
toast.success('保存成功');
loadTheme();
} catch (error) {
const msg = (error as Error).message;
toast.error(`保存失败: ${msg}`);
}
});
const onRefresh = async () => {
try {
await refreshAsync();
toast.success('刷新成功');
} catch (error) {
const msg = (error as Error).message;
toast.error(`刷新失败: ${msg}`);
}
};
useEffect(() => {
reset();
}, [data]);
useEffect(() => {
if (theme && styleTagRef.current) {
const css = generateTheme(theme);
styleTagRef.current.innerHTML = css;
}
}, [theme]);
if (loading) return <PageLoading loading />;
if (error) {
return (
<div className='py-24 text-danger-500 text-center'>{error.message}</div>
);
}
return (
<>
<title> - NapCat WebUI</title>
<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
key='select'
aria-label='Pick Color'
title='选择主题'
subtitle='可以切换夜间/白昼模式查看对应颜色'
className='shadow-small'
startContent={<IoIosColorPalette />}
>
<div className='flex flex-wrap gap-2'>
{themes.map((theme) => (
<PreviewThemeCard
key={theme.name}
theme={theme}
onPreview={() => {
setOnebotValue('theme', theme.theme);
}}
/>
))}
</div>
</AccordionItem>
<AccordionItem
key='pick'
aria-label='Pick Color'
title='自定义配色'
className='shadow-small'
startContent={<FaPaintbrush />}
>
<div className='space-y-2'>
{(['dark', 'light'] as const).map((mode) => (
<div
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) => (
<div
key={key}
className='grid grid-cols-2 items-center mb-2 gap-2'
>
<label className='text-right'>{key}</label>
<Controller
control={control}
name={`theme.${mode}.${key}`}
render={({ field: { value, onChange } }) => {
const hslArray = value?.split(' ') ?? [0, 0, 0];
const color = `hsl(${hslArray[0]}, ${hslArray[1]}, ${hslArray[2]})`;
return (
<ColorPicker
color={color}
onChange={(result) => {
onChange(
`${result.hsl.h} ${result.hsl.s * 100}% ${result.hsl.l * 100}%`
);
}}
/>
);
}}
/>
</div>
))}
</div>
))}
</div>
</AccordionItem>
</Accordion>
</>
);
};
export default ThemeConfigCard;

View File

@@ -0,0 +1,137 @@
import { Input } from '@heroui/input';
import { useLocalStorage } from '@uidotdev/usehooks';
import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import toast from 'react-hot-toast';
import key from '@/const/key';
import SaveButtons from '@/components/button/save_buttons';
import FileInput from '@/components/input/file_input';
import ImageInput from '@/components/input/image_input';
import useMusic from '@/hooks/use-music';
import { siteConfig } from '@/config/site';
import FileManager from '@/controllers/file_manager';
const WebUIConfigCard = () => {
const {
control,
handleSubmit: handleWebuiSubmit,
formState: { isSubmitting },
setValue: setWebuiValue,
} = useForm<IConfig['webui']>({
defaultValues: {
background: '',
musicListID: '',
customIcons: {},
},
});
const [b64img, setB64img] = useLocalStorage(key.backgroundImage, '');
const [customIcons, setCustomIcons] = useLocalStorage<Record<string, string>>(
key.customIcons,
{}
);
const { setListId, listId } = useMusic();
const reset = () => {
setWebuiValue('musicListID', listId);
setWebuiValue('customIcons', customIcons);
setWebuiValue('background', b64img);
};
const onSubmit = handleWebuiSubmit((data) => {
try {
setListId(data.musicListID);
setCustomIcons(data.customIcons);
setB64img(data.background);
toast.success('保存成功');
} catch (error) {
const msg = (error as Error).message;
toast.error(`保存失败: ${msg}`);
}
});
useEffect(() => {
reset();
}, [listId, customIcons, b64img]);
return (
<>
<title>WebUI配置 - NapCat WebUI</title>
<div className='flex flex-col gap-2'>
<div className='flex-shrink-0 w-full'>WebUI字体</div>
<div className='text-sm text-default-400'>
<FileInput
label='中文字体'
onChange={async (file) => {
try {
await FileManager.uploadWebUIFont(file);
toast.success('上传成功');
setTimeout(() => {
window.location.reload();
}, 1000);
} catch (error) {
toast.error('上传失败: ' + (error as Error).message);
}
}}
onDelete={async () => {
try {
await FileManager.deleteWebUIFont();
toast.success('删除成功');
setTimeout(() => {
window.location.reload();
}, 1000);
} catch (error) {
toast.error('删除失败: ' + (error as Error).message);
}
}}
/>
</div>
</div>
<div className='flex flex-col gap-2'>
<div className='flex-shrink-0 w-full'>WebUI音乐播放器</div>
<Controller
control={control}
name='musicListID'
render={({ field }) => (
<Input
{...field}
label='网易云音乐歌单ID网页内音乐播放器'
placeholder='请输入歌单ID'
/>
)}
/>
</div>
<div className='flex flex-col gap-2'>
<div className='flex-shrink-0 w-full'></div>
<Controller
control={control}
name='background'
render={({ field }) => <ImageInput {...field} />}
/>
</div>
<div className='flex flex-col gap-2'>
<div></div>
{siteConfig.navItems.map((item) => (
<Controller
key={item.label}
control={control}
name={`customIcons.${item.label}`}
render={({ field }) => <ImageInput {...field} label={item.label} />}
/>
))}
</div>
<SaveButtons
onSubmit={onSubmit}
reset={reset}
isSubmitting={isSubmitting}
/>
</>
);
};
export default WebUIConfigCard;