refactor(settings): settings card barrel + data/integration/websearch/channels/model

Signed-off-by: Siin Xu <31815270+SiinXu@users.noreply.github.com>
This commit is contained in:
Siin Xu
2026-07-02 20:39:33 -07:00
parent 1955c06bf6
commit b9e4f9de22
22 changed files with 1712 additions and 1471 deletions

View File

@@ -95,10 +95,10 @@ function formatTime(ts: number): string {
}
const LOG_LEVEL_COLORS: Record<string, string> = {
error: '#ff4d4f',
warn: '#faad14',
info: '#1677ff',
debug: '#8c8c8c'
error: 'var(--color-error-base)',
warn: 'var(--color-warning-base)',
info: 'var(--color-info-base)',
debug: 'var(--color-foreground-muted)'
}
const NO_AGENT_VALUE = '__none'
@@ -157,7 +157,7 @@ const ChannelLogModal: FC<{
{logs.length > 0 && <CopyButton textToCopy={logsText} size={14} />}
</DialogTitle>
</DialogHeader>
<div className="max-h-100 overflow-y-auto rounded-md bg-background-subtle p-2 font-mono text-[11px] leading-[1.6]">
<div className="text-(length:--font-size-body-xs) max-h-100 overflow-y-auto rounded-md bg-background-subtle p-2 font-mono leading-[1.6]">
{logs.length === 0 && (
<div className="py-8 text-center text-muted-foreground text-xs">
{t('agent.cherryClaw.channels.noLogs')}
@@ -166,7 +166,8 @@ const ChannelLogModal: FC<{
{logs.map((entry, i) => (
<div key={i} className="flex gap-2 whitespace-pre-wrap py-px">
<span className="shrink-0 text-muted-foreground">{formatTime(entry.timestamp)}</span>
<span style={{ color: LOG_LEVEL_COLORS[entry.level] ?? '#8c8c8c', fontWeight: 500 }}>
<span
style={{ color: LOG_LEVEL_COLORS[entry.level] ?? 'var(--color-foreground-muted)', fontWeight: 500 }}>
[{entry.level.toUpperCase()}]
</span>
<span className="break-all">{entry.message}</span>
@@ -349,17 +350,17 @@ const ChannelInstanceRow: FC<{
let statusTag: React.ReactNode = null
if (channel.isActive) {
if (isConnected) {
statusColor = 'bg-green-500'
statusColor = 'bg-success'
statusTag = (
<Badge className="border-success/30 bg-success/10 px-1.5 py-0 text-[10px] text-success leading-3.5">
<Badge className="text-(length:--font-size-body-2xs) border-success/30 bg-success/10 px-1.5 py-0 text-success leading-3.5">
{t('agent.cherryClaw.channels.connected')}
</Badge>
)
} else if (hasError) {
statusColor = 'bg-red-500'
statusColor = 'bg-destructive'
statusTag = (
<Tooltip title={hasError}>
<Badge className="border-destructive/30 bg-destructive/10 px-1.5 py-0 text-[10px] text-destructive leading-3.5">
<Badge className="text-(length:--font-size-body-2xs) border-destructive/30 bg-destructive/10 px-1.5 py-0 text-destructive leading-3.5">
{t('agent.cherryClaw.channels.error')}
</Badge>
</Tooltip>
@@ -376,7 +377,7 @@ const ChannelInstanceRow: FC<{
{statusTag}
</div>
<div className="truncate text-foreground-400 text-xs">
{agentName && <span className="mr-2 text-blue-400">{agentName}</span>}
{agentName && <span className="mr-2 text-info">{agentName}</span>}
{summary}
</div>
</div>

View File

@@ -169,13 +169,11 @@ const ChannelFieldsForm: FC<ChannelFieldsFormProps> = ({
/>
<span className="mt-1 block text-gray-400 text-xs">{chatIdsConfig.hint}</span>
{!chatIds.trim() && idsKey === 'allowed_chat_ids' && (
<span className="mt-1 block text-orange-400 text-xs">
<span className="mt-1 block text-warning text-xs">
{t('agent.cherryClaw.channels.chatIdsAutoTrackHint')}
</span>
)}
{chatIdsConfig.extraHint && (
<span className="mt-1 block text-blue-400 text-xs">{chatIdsConfig.extraHint}</span>
)}
{chatIdsConfig.extraHint && <span className="mt-1 block text-info text-xs">{chatIdsConfig.extraHint}</span>}
</div>
</div>
<ChannelPermissionMode channel={channel} onConfigChange={onConfigChange} />
@@ -262,23 +260,23 @@ export const FeishuForm: FC<ChannelFormProps> = ({ channel, onConfigChange }) =>
{!hasCredentials && (
<div className="flex items-center gap-2">
{status === 'pending' && (
<span className="text-blue-400 text-xs">{t('agent.cherryClaw.channels.feishu.qrHint')}</span>
<span className="text-info text-xs">{t('agent.cherryClaw.channels.feishu.qrHint')}</span>
)}
{status === 'expired' && (
<>
<span className="inline-block h-2 w-2 rounded-full bg-red-500" />
<span className="text-red-500 text-xs">{t('agent.cherryClaw.channels.feishu.qrExpired')}</span>
<span className="inline-block h-2 w-2 rounded-full bg-destructive" />
<span className="text-destructive text-xs">{t('agent.cherryClaw.channels.feishu.qrExpired')}</span>
</>
)}
{status === 'idle' && (
<span className="text-blue-400 text-xs">{t('agent.cherryClaw.channels.feishu.loginHint')}</span>
<span className="text-info text-xs">{t('agent.cherryClaw.channels.feishu.loginHint')}</span>
)}
</div>
)}
{hasCredentials && (
<div className="flex items-center gap-2">
<span className="inline-block h-2 w-2 rounded-full bg-green-500" />
<span className="text-green-600 text-xs">{t('agent.cherryClaw.channels.feishu.connected')}</span>
<span className="inline-block h-2 w-2 rounded-full bg-success" />
<span className="text-success text-xs">{t('agent.cherryClaw.channels.feishu.connected')}</span>
</div>
)}
<ChannelFieldsForm
@@ -440,18 +438,18 @@ export const WeChatForm: FC<ChannelFormProps & { onRemove?: () => void }> = ({ c
<div className="flex items-center gap-2">
{status === 'confirmed' && (
<>
<span className="inline-block h-2 w-2 rounded-full bg-green-500" />
<span className="text-green-600 text-xs">{t('agent.cherryClaw.channels.wechat.connected')}</span>
<span className="inline-block h-2 w-2 rounded-full bg-success" />
<span className="text-success text-xs">{t('agent.cherryClaw.channels.wechat.connected')}</span>
</>
)}
{status === 'disconnected' && (
<>
<span className="inline-block h-2 w-2 rounded-full bg-red-500" />
<span className="text-red-500 text-xs">{t('agent.cherryClaw.channels.wechat.disconnected')}</span>
<span className="inline-block h-2 w-2 rounded-full bg-destructive" />
<span className="text-destructive text-xs">{t('agent.cherryClaw.channels.wechat.disconnected')}</span>
</>
)}
{(status === 'idle' || status === 'pending') && (
<span className="text-blue-400 text-xs">{t('agent.cherryClaw.channels.wechat.loginHint')}</span>
<span className="text-info text-xs">{t('agent.cherryClaw.channels.wechat.loginHint')}</span>
)}
</div>
{loginUserId && status === 'confirmed' && (

View File

@@ -13,7 +13,7 @@ import type React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..'
import { SettingCard, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
/**
* @deprecated v1 leftover. v2's preboot relocation copies the entire Electron
@@ -439,110 +439,116 @@ const BasicDataSettings: React.FC = () => {
<>
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.data.title')}</SettingTitle>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.general.backup.title')}</SettingRowTitle>
<RowFlex className="justify-between gap-1.25">
<Button onClick={() => BackupPopup.show()} variant="outline">
<SaveIcon size={14} />
{t('settings.general.backup.button')}
</Button>
<Button onClick={RestorePopup.show} variant="outline">
<FolderOpen size={14} />
{t('settings.general.restore.button')}
</Button>
</RowFlex>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.backup.skip_file_data_title')}</SettingRowTitle>
<Switch checked={skipBackupFile} onCheckedChange={onSkipBackupFilesChange} />
</SettingRow>
<SettingRow>
<SettingHelpText>{t('settings.data.backup.skip_file_data_help')}</SettingHelpText>
</SettingRow>
<SettingCard>
<SettingRow>
<SettingRowTitle>{t('settings.general.backup.title')}</SettingRowTitle>
<RowFlex className="min-w-0 flex-wrap justify-end gap-1.25">
<Button onClick={() => BackupPopup.show()} variant="outline">
<SaveIcon size={14} strokeWidth={1.6} />
{t('settings.general.backup.button')}
</Button>
<Button onClick={RestorePopup.show} variant="outline">
<FolderOpen size={14} />
{t('settings.general.restore.button')}
</Button>
</RowFlex>
</SettingRow>
<SettingRow>
<SettingRowTitle tip={t('settings.data.backup.skip_file_data_help')}>
{t('settings.data.backup.skip_file_data_title')}
</SettingRowTitle>
<Switch checked={skipBackupFile} onCheckedChange={onSkipBackupFilesChange} />
</SettingRow>
</SettingCard>
</SettingGroup>
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.data.export_to_phone.title')}</SettingTitle>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.export_to_phone.lan.title')}</SettingRowTitle>
<RowFlex className="justify-between gap-1.25">
<Button onClick={LanTransferPopup.show} variant="outline">
<Wifi size={14} />
{t('settings.data.export_to_phone.lan.button')}
</Button>
</RowFlex>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.export_to_phone.file.title')}</SettingRowTitle>
<RowFlex className="justify-between gap-1.25">
<Button onClick={() => BackupPopup.show('lan-transfer')} variant="outline">
<FolderInput size={14} />
{t('settings.data.export_to_phone.file.button')}
</Button>
</RowFlex>
</SettingRow>
<SettingCard>
<SettingRow>
<SettingRowTitle>{t('settings.data.export_to_phone.lan.title')}</SettingRowTitle>
<RowFlex className="min-w-0 flex-wrap justify-end gap-1.25">
<Button onClick={LanTransferPopup.show} variant="outline">
<Wifi size={14} strokeWidth={1.6} />
{t('settings.data.export_to_phone.lan.button')}
</Button>
</RowFlex>
</SettingRow>
<SettingRow>
<SettingRowTitle>{t('settings.data.export_to_phone.file.title')}</SettingRowTitle>
<RowFlex className="min-w-0 flex-wrap justify-end gap-1.25">
<Button
onClick={() => BackupPopup.show('lan-transfer')}
variant="outline"
className="[&_svg]:[stroke-width:var(--icon-stroke)]">
<FolderInput size={14} />
{t('settings.data.export_to_phone.file.button')}
</Button>
</RowFlex>
</SettingRow>
</SettingCard>
</SettingGroup>
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.data.data.title')}</SettingTitle>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.app_data.label')}</SettingRowTitle>
<PathRow>
<PathText
style={{ color: 'var(--color-foreground-muted)' }}
onClick={() => handleOpenPath(appInfo?.appDataPath)}>
{appInfo?.appDataPath}
</PathText>
<Tooltip title={t('settings.data.app_data.select')}>
<FolderOutput onClick={handleSelectAppDataPath} style={{ cursor: 'pointer' }} size={16} />
</Tooltip>
<RowFlex className="ml-2 gap-1.25">
<Button onClick={() => handleOpenPath(appInfo?.appDataPath)} variant="outline">
{t('settings.data.app_data.open')}
<SettingCard>
<SettingRow>
<SettingRowTitle className="flex-none">{t('settings.data.app_data.label')}</SettingRowTitle>
<PathRow>
<PathText
style={{ color: 'var(--color-foreground-muted)' }}
onClick={() => handleOpenPath(appInfo?.appDataPath)}>
{appInfo?.appDataPath}
</PathText>
<Tooltip title={t('settings.data.app_data.select')}>
<FolderOutput
onClick={handleSelectAppDataPath}
style={{ cursor: 'pointer' }}
size={16}
strokeWidth={1.6}
className="text-foreground-muted"
/>
</Tooltip>
<RowFlex className="ml-2 gap-1.25">
<Button onClick={() => handleOpenPath(appInfo?.appDataPath)} variant="outline">
{t('settings.data.app_data.open')}
</Button>
</RowFlex>
</PathRow>
</SettingRow>
<SettingRow>
<SettingRowTitle className="flex-none">{t('settings.data.app_logs.label')}</SettingRowTitle>
<PathRow>
<PathText
style={{ color: 'var(--color-foreground-muted)' }}
onClick={() => handleOpenPath(appInfo?.logsPath)}>
{appInfo?.logsPath}
</PathText>
<RowFlex className="ml-2 gap-1.25">
<Button onClick={() => handleOpenPath(appInfo?.logsPath)} variant="outline">
{t('settings.data.app_logs.button')}
</Button>
</RowFlex>
</PathRow>
</SettingRow>
<SettingRow>
<SettingRowTitle>
{t('settings.data.clear_cache.title')}
{cacheSize && <CacheText>({cacheSize}MB)</CacheText>}
</SettingRowTitle>
<RowFlex className="gap-1.25">
<Button onClick={handleClearCache} variant="outline">
{t('settings.data.clear_cache.button')}
</Button>
</RowFlex>
</PathRow>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.app_logs.label')}</SettingRowTitle>
<PathRow>
<PathText
style={{ color: 'var(--color-foreground-muted)' }}
onClick={() => handleOpenPath(appInfo?.logsPath)}>
{appInfo?.logsPath}
</PathText>
<RowFlex className="ml-2 gap-1.25">
<Button onClick={() => handleOpenPath(appInfo?.logsPath)} variant="outline">
{t('settings.data.app_logs.button')}
</SettingRow>
<SettingRow>
<SettingRowTitle>{t('settings.general.reset.title')}</SettingRowTitle>
<RowFlex className="gap-1.25">
<Button onClick={reset} variant="destructive">
{t('settings.general.reset.title')}
</Button>
</RowFlex>
</PathRow>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>
{t('settings.data.clear_cache.title')}
{cacheSize && <CacheText>({cacheSize}MB)</CacheText>}
</SettingRowTitle>
<RowFlex className="gap-1.25">
<Button onClick={handleClearCache} variant="outline">
{t('settings.data.clear_cache.button')}
</Button>
</RowFlex>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.general.reset.title')}</SettingRowTitle>
<RowFlex className="gap-1.25">
<Button onClick={reset} variant="destructive">
{t('settings.general.reset.title')}
</Button>
</RowFlex>
</SettingRow>
</SettingRow>
</SettingCard>
</SettingGroup>
</>
)
@@ -604,7 +610,7 @@ const MigrationPathRow = ({ className, ...props }: React.ComponentPropsWithoutRe
)
const MigrationPathLabel = ({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) => (
<div className={cn('font-semibold text-[15px] text-foreground', className)} {...props} />
<div className={cn('text-(length:--font-size-body-md) font-semibold text-foreground', className)} {...props} />
)
const MigrationPathValue = ({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) => (

View File

@@ -4,7 +4,7 @@ import { useTheme } from '@renderer/hooks/useTheme'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
import { SettingCard, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
const ExportMenuOptions: FC = () => {
const { t } = useTranslation()
const { theme } = useTheme()
@@ -31,87 +31,75 @@ const ExportMenuOptions: FC = () => {
return (
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.data.export_menu.title')}</SettingTitle>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.export_menu.image')}</SettingRowTitle>
<Switch checked={exportMenuOptions.image} onCheckedChange={(checked) => handleToggleOption('image', checked)} />
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.export_menu.markdown')}</SettingRowTitle>
<Switch
checked={exportMenuOptions.markdown}
onCheckedChange={(checked) => handleToggleOption('markdown', checked)}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.export_menu.markdown_reason')}</SettingRowTitle>
<Switch
checked={exportMenuOptions.markdown_reason}
onCheckedChange={(checked) => handleToggleOption('markdown_reason', checked)}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.export_menu.notion')}</SettingRowTitle>
<Switch
checked={exportMenuOptions.notion}
onCheckedChange={(checked) => handleToggleOption('notion', checked)}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.export_menu.yuque')}</SettingRowTitle>
<Switch checked={exportMenuOptions.yuque} onCheckedChange={(checked) => handleToggleOption('yuque', checked)} />
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.export_menu.joplin')}</SettingRowTitle>
<Switch
checked={exportMenuOptions.joplin}
onCheckedChange={(checked) => handleToggleOption('joplin', checked)}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.export_menu.obsidian')}</SettingRowTitle>
<Switch
checked={exportMenuOptions.obsidian}
onCheckedChange={(checked) => handleToggleOption('obsidian', checked)}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.export_menu.siyuan')}</SettingRowTitle>
<Switch
checked={exportMenuOptions.siyuan}
onCheckedChange={(checked) => handleToggleOption('siyuan', checked)}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.export_menu.docx')}</SettingRowTitle>
<Switch checked={exportMenuOptions.docx} onCheckedChange={(checked) => handleToggleOption('docx', checked)} />
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.export_menu.plain_text')}</SettingRowTitle>
<Switch
checked={exportMenuOptions.plain_text}
onCheckedChange={(checked) => handleToggleOption('plain_text', checked)}
/>
</SettingRow>
<SettingCard>
<SettingRow>
<SettingRowTitle>{t('settings.data.export_menu.image')}</SettingRowTitle>
<Switch
checked={exportMenuOptions.image}
onCheckedChange={(checked) => handleToggleOption('image', checked)}
/>
</SettingRow>
<SettingRow>
<SettingRowTitle>{t('settings.data.export_menu.markdown')}</SettingRowTitle>
<Switch
checked={exportMenuOptions.markdown}
onCheckedChange={(checked) => handleToggleOption('markdown', checked)}
/>
</SettingRow>
<SettingRow>
<SettingRowTitle>{t('settings.data.export_menu.markdown_reason')}</SettingRowTitle>
<Switch
checked={exportMenuOptions.markdown_reason}
onCheckedChange={(checked) => handleToggleOption('markdown_reason', checked)}
/>
</SettingRow>
<SettingRow>
<SettingRowTitle>{t('settings.data.export_menu.notion')}</SettingRowTitle>
<Switch
checked={exportMenuOptions.notion}
onCheckedChange={(checked) => handleToggleOption('notion', checked)}
/>
</SettingRow>
<SettingRow>
<SettingRowTitle>{t('settings.data.export_menu.yuque')}</SettingRowTitle>
<Switch
checked={exportMenuOptions.yuque}
onCheckedChange={(checked) => handleToggleOption('yuque', checked)}
/>
</SettingRow>
<SettingRow>
<SettingRowTitle>{t('settings.data.export_menu.joplin')}</SettingRowTitle>
<Switch
checked={exportMenuOptions.joplin}
onCheckedChange={(checked) => handleToggleOption('joplin', checked)}
/>
</SettingRow>
<SettingRow>
<SettingRowTitle>{t('settings.data.export_menu.obsidian')}</SettingRowTitle>
<Switch
checked={exportMenuOptions.obsidian}
onCheckedChange={(checked) => handleToggleOption('obsidian', checked)}
/>
</SettingRow>
<SettingRow>
<SettingRowTitle>{t('settings.data.export_menu.siyuan')}</SettingRowTitle>
<Switch
checked={exportMenuOptions.siyuan}
onCheckedChange={(checked) => handleToggleOption('siyuan', checked)}
/>
</SettingRow>
<SettingRow>
<SettingRowTitle>{t('settings.data.export_menu.docx')}</SettingRowTitle>
<Switch checked={exportMenuOptions.docx} onCheckedChange={(checked) => handleToggleOption('docx', checked)} />
</SettingRow>
<SettingRow>
<SettingRowTitle>{t('settings.data.export_menu.plain_text')}</SettingRowTitle>
<Switch
checked={exportMenuOptions.plain_text}
onCheckedChange={(checked) => handleToggleOption('plain_text', checked)}
/>
</SettingRow>
</SettingCard>
</SettingGroup>
)
}

View File

@@ -4,25 +4,24 @@ import { useTheme } from '@renderer/hooks/useTheme'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
import { SettingCard, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
const ImportMenuOptions: FC = () => {
const { t } = useTranslation()
const { theme } = useTheme()
return (
<SettingGroup theme={theme}>
<SettingRow>
<SettingTitle>{t('settings.data.import_settings.title')}</SettingTitle>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.import_settings.chatgpt')}</SettingRowTitle>
<RowFlex className="justify-between gap-1.25">
<Button onClick={ImportPopup.show} variant="outline">
{t('settings.data.import_settings.button')}
</Button>
</RowFlex>
</SettingRow>
<SettingTitle>{t('settings.data.import_settings.title')}</SettingTitle>
<SettingCard>
<SettingRow>
<SettingRowTitle>{t('settings.data.import_settings.chatgpt')}</SettingRowTitle>
<RowFlex className="min-w-0 flex-wrap justify-end gap-1.25">
<Button onClick={ImportPopup.show} variant="outline">
{t('settings.data.import_settings.button')}
</Button>
</RowFlex>
</SettingRow>
</SettingCard>
</SettingGroup>
)
}

View File

@@ -12,7 +12,7 @@ import { FolderOpen, RefreshCw, Save, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..'
import { SettingCard, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
const logger = loggerService.withContext('LocalBackupSettings')
@@ -181,115 +181,110 @@ const LocalBackupSettings: React.FC = () => {
return (
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.data.local.title')}</SettingTitle>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.local.directory.label')}</SettingRowTitle>
<RowFlex className="gap-1.25">
<Input
value={localBackupDir}
onChange={(e) => setLocalBackupDir(e.target.value)}
onBlur={(e) => handleLocalBackupDirChange(e.target.value)}
placeholder={t('settings.data.local.directory.placeholder')}
style={{ minWidth: 200, maxWidth: 400, flex: 1 }}
<SettingCard>
<SettingRow>
<SettingRowTitle>{t('settings.data.local.directory.label')}</SettingRowTitle>
<RowFlex className="min-w-0 flex-1 flex-wrap items-center justify-end gap-1.25">
<Input
value={localBackupDir}
onChange={(e) => setLocalBackupDir(e.target.value)}
onBlur={(e) => handleLocalBackupDirChange(e.target.value)}
placeholder={t('settings.data.local.directory.placeholder')}
className="min-w-0 max-w-[400px] flex-1"
/>
<Button onClick={handleBrowseDirectory} variant="outline">
<FolderOpen size={14} />
{t('common.browse')}
</Button>
<Button onClick={handleClearDirectory} disabled={!localBackupDir} variant="destructive">
<Trash2 size={14} />
{t('common.clear')}
</Button>
</RowFlex>
</SettingRow>
<SettingRow>
<SettingRowTitle>{t('settings.general.backup.title')}</SettingRowTitle>
<RowFlex className="min-w-0 flex-wrap justify-end gap-1.25">
<Button onClick={showBackupModal} disabled={!localBackupDir || backuping} variant="outline">
<Save size={14} />
{t('settings.data.local.backup.button')}
</Button>
<Button onClick={showBackupManager} disabled={!localBackupDir} variant="outline">
<FolderOpen size={14} />
{t('settings.data.local.restore.button')}
</Button>
</RowFlex>
</SettingRow>
<SettingRow>
<SettingRowTitle>{t('settings.data.local.autoSync.label')}</SettingRowTitle>
<Selector
size={14}
value={localBackupSyncInterval}
onChange={onSyncIntervalChange}
disabled={!localBackupDir}
options={[
{ label: t('settings.data.local.autoSync.off'), value: 0 },
{ label: t('settings.data.local.minute_interval', { count: 1 }), value: 1 },
{ label: t('settings.data.local.minute_interval', { count: 5 }), value: 5 },
{ label: t('settings.data.local.minute_interval', { count: 15 }), value: 15 },
{ label: t('settings.data.local.minute_interval', { count: 30 }), value: 30 },
{ label: t('settings.data.local.hour_interval', { count: 1 }), value: 60 },
{ label: t('settings.data.local.hour_interval', { count: 2 }), value: 120 },
{ label: t('settings.data.local.hour_interval', { count: 6 }), value: 360 },
{ label: t('settings.data.local.hour_interval', { count: 12 }), value: 720 },
{ label: t('settings.data.local.hour_interval', { count: 24 }), value: 1440 }
]}
/>
<Button onClick={handleBrowseDirectory} variant="outline">
<FolderOpen size={14} />
{t('common.browse')}
</Button>
<Button onClick={handleClearDirectory} disabled={!localBackupDir} variant="destructive">
<Trash2 size={14} />
{t('common.clear')}
</Button>
</RowFlex>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.general.backup.title')}</SettingRowTitle>
<RowFlex className="justify-between gap-1.25">
<Button onClick={showBackupModal} disabled={!localBackupDir || backuping} variant="outline">
<Save size={14} />
{t('settings.data.local.backup.button')}
</Button>
<Button onClick={showBackupManager} disabled={!localBackupDir} variant="outline">
<FolderOpen size={14} />
{t('settings.data.local.restore.button')}
</Button>
</RowFlex>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.local.autoSync.label')}</SettingRowTitle>
<Selector
size={14}
value={localBackupSyncInterval}
onChange={onSyncIntervalChange}
disabled={!localBackupDir}
options={[
{ label: t('settings.data.local.autoSync.off'), value: 0 },
{ label: t('settings.data.local.minute_interval', { count: 1 }), value: 1 },
{ label: t('settings.data.local.minute_interval', { count: 5 }), value: 5 },
{ label: t('settings.data.local.minute_interval', { count: 15 }), value: 15 },
{ label: t('settings.data.local.minute_interval', { count: 30 }), value: 30 },
{ label: t('settings.data.local.hour_interval', { count: 1 }), value: 60 },
{ label: t('settings.data.local.hour_interval', { count: 2 }), value: 120 },
{ label: t('settings.data.local.hour_interval', { count: 6 }), value: 360 },
{ label: t('settings.data.local.hour_interval', { count: 12 }), value: 720 },
{ label: t('settings.data.local.hour_interval', { count: 24 }), value: 1440 }
]}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.local.maxBackups.label')}</SettingRowTitle>
<Selector
size={14}
value={localBackupMaxBackups}
onChange={onMaxBackupsChange}
disabled={!localBackupDir}
options={[
{ label: t('settings.data.local.maxBackups.unlimited'), value: 0 },
{ label: '1', value: 1 },
{ label: '3', value: 3 },
{ label: '5', value: 5 },
{ label: '10', value: 10 },
{ label: '20', value: 20 },
{ label: '50', value: 50 }
]}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.backup.skip_file_data_title')}</SettingRowTitle>
<Switch checked={localBackupSkipBackupFile} onCheckedChange={onSkipBackupFilesChange} />
</SettingRow>
<SettingRow>
<SettingHelpText>{t('settings.data.backup.skip_file_data_help')}</SettingHelpText>
</SettingRow>
{localBackupSync && localBackupSyncInterval > 0 && (
</SettingRow>
<SettingRow>
<SettingRowTitle>{t('settings.data.local.maxBackups.label')}</SettingRowTitle>
<Selector
size={14}
value={localBackupMaxBackups}
onChange={onMaxBackupsChange}
disabled={!localBackupDir}
options={[
{ label: t('settings.data.local.maxBackups.unlimited'), value: 0 },
{ label: '1', value: 1 },
{ label: '3', value: 3 },
{ label: '5', value: 5 },
{ label: '10', value: 10 },
{ label: '20', value: 20 },
{ label: '50', value: 50 }
]}
/>
</SettingRow>
<SettingRow>
<SettingRowTitle tip={t('settings.data.backup.skip_file_data_help')}>
{t('settings.data.backup.skip_file_data_title')}
</SettingRowTitle>
<Switch checked={localBackupSkipBackupFile} onCheckedChange={onSkipBackupFilesChange} />
</SettingRow>
{localBackupSync && localBackupSyncInterval > 0 && (
<>
<SettingRow>
<SettingRowTitle>{t('settings.data.local.syncStatus')}</SettingRowTitle>
{renderSyncStatus()}
</SettingRow>
</>
)}
<>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.local.syncStatus')}</SettingRowTitle>
{renderSyncStatus()}
</SettingRow>
</>
)}
<>
<LocalBackupModal
isModalVisible={isModalVisible}
handleBackup={handleBackup}
handleCancel={handleCancel}
backuping={backuping}
customFileName={customFileName}
setCustomFileName={setCustomFileName}
/>
<LocalBackupModal
isModalVisible={isModalVisible}
handleBackup={handleBackup}
handleCancel={handleCancel}
backuping={backuping}
customFileName={customFileName}
setCustomFileName={setCustomFileName}
/>
<LocalBackupManager
visible={backupManagerVisible}
onClose={closeBackupManager}
localBackupDir={resolvedLocalBackupDir}
/>
</>
<LocalBackupManager
visible={backupManagerVisible}
onClose={closeBackupManager}
localBackupDir={resolvedLocalBackupDir}
/>
</>
</SettingCard>
</SettingGroup>
)
}

View File

@@ -13,7 +13,7 @@ import { FolderOpen, Trash2 } from 'lucide-react'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..'
import { SettingCard, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
const MarkdownExportSettings: FC = () => {
const { t } = useTranslation()
@@ -75,85 +75,73 @@ const MarkdownExportSettings: FC = () => {
return (
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.data.markdown_export.title')}</SettingTitle>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.markdown_export.path')}</SettingRowTitle>
<RowFlex className="w-78.75 items-center gap-1.25">
<InputGroup className="h-8 w-62.5">
<InputGroupInput
type="text"
value={markdownExportPath || ''}
readOnly
placeholder={t('settings.data.markdown_export.path_placeholder')}
/>
{markdownExportPath && (
<InputGroupAddon align="inline-end">
<InputGroupButton
onClick={handleClearPath}
size="icon-sm"
className="text-destructive hover:text-destructive">
<Trash2 size={14} />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
<Button onClick={handleSelectFolder} variant="outline" className="h-8">
<FolderOpen size={14} />
{t('settings.data.markdown_export.select')}
</Button>
</RowFlex>
</SettingRow>
<SettingRow>
<SettingHelpText>{t('settings.data.markdown_export.help')}</SettingHelpText>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.markdown_export.force_dollar_math.title')}</SettingRowTitle>
<Switch checked={forceDollarMathInMarkdown} onCheckedChange={handleToggleForceDollarMath} />
</SettingRow>
<SettingRow>
<SettingHelpText>{t('settings.data.markdown_export.force_dollar_math.help')}</SettingHelpText>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.message_title.use_topic_naming.title')}</SettingRowTitle>
<Switch checked={useTopicNamingForMessageTitle} onCheckedChange={handleToggleTopicNaming} />
</SettingRow>
<SettingRow>
<SettingHelpText>{t('settings.data.message_title.use_topic_naming.help')}</SettingHelpText>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.markdown_export.show_model_name.title')}</SettingRowTitle>
<Switch checked={showModelNameInExport} onCheckedChange={handleToggleShowModelName} />
</SettingRow>
<SettingRow>
<SettingHelpText>{t('settings.data.markdown_export.show_model_name.help')}</SettingHelpText>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.markdown_export.show_model_provider.title')}</SettingRowTitle>
<Switch checked={showModelProviderInMarkdown} onCheckedChange={handleToggleShowModelProvider} />
</SettingRow>
<SettingRow>
<SettingHelpText>{t('settings.data.markdown_export.show_model_provider.help')}</SettingHelpText>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.markdown_export.exclude_citations.title')}</SettingRowTitle>
<Switch checked={excludeCitationsInExport} onCheckedChange={handleToggleExcludeCitations} />
</SettingRow>
<SettingRow>
<SettingHelpText>{t('settings.data.markdown_export.exclude_citations.help')}</SettingHelpText>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.markdown_export.standardize_citations.title')}</SettingRowTitle>
<Switch checked={standardizeCitationsInExport} onCheckedChange={handleToggleStandardizeCitations} />
</SettingRow>
<SettingRow>
<SettingHelpText>{t('settings.data.markdown_export.standardize_citations.help')}</SettingHelpText>
</SettingRow>
<SettingCard>
<SettingRow>
<SettingRowTitle tip={t('settings.data.markdown_export.help')}>
{t('settings.data.markdown_export.path')}
</SettingRowTitle>
<RowFlex className="min-w-0 max-w-78.75 flex-1 items-center justify-end gap-1.25">
<InputGroup className="h-8 min-w-0 flex-1">
<InputGroupInput
type="text"
value={markdownExportPath || ''}
readOnly
placeholder={t('settings.data.markdown_export.path_placeholder')}
/>
{markdownExportPath && (
<InputGroupAddon align="inline-end">
<InputGroupButton
onClick={handleClearPath}
size="icon-sm"
className="text-destructive hover:text-destructive">
<Trash2 size={14} />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
<Button onClick={handleSelectFolder} variant="outline" className="h-8">
<FolderOpen size={14} />
{t('settings.data.markdown_export.select')}
</Button>
</RowFlex>
</SettingRow>
<SettingRow>
<SettingRowTitle tip={t('settings.data.markdown_export.force_dollar_math.help')}>
{t('settings.data.markdown_export.force_dollar_math.title')}
</SettingRowTitle>
<Switch checked={forceDollarMathInMarkdown} onCheckedChange={handleToggleForceDollarMath} />
</SettingRow>
<SettingRow>
<SettingRowTitle tip={t('settings.data.message_title.use_topic_naming.help')}>
{t('settings.data.message_title.use_topic_naming.title')}
</SettingRowTitle>
<Switch checked={useTopicNamingForMessageTitle} onCheckedChange={handleToggleTopicNaming} />
</SettingRow>
<SettingRow>
<SettingRowTitle tip={t('settings.data.markdown_export.show_model_name.help')}>
{t('settings.data.markdown_export.show_model_name.title')}
</SettingRowTitle>
<Switch checked={showModelNameInExport} onCheckedChange={handleToggleShowModelName} />
</SettingRow>
<SettingRow>
<SettingRowTitle tip={t('settings.data.markdown_export.show_model_provider.help')}>
{t('settings.data.markdown_export.show_model_provider.title')}
</SettingRowTitle>
<Switch checked={showModelProviderInMarkdown} onCheckedChange={handleToggleShowModelProvider} />
</SettingRow>
<SettingRow>
<SettingRowTitle tip={t('settings.data.markdown_export.exclude_citations.help')}>
{t('settings.data.markdown_export.exclude_citations.title')}
</SettingRowTitle>
<Switch checked={excludeCitationsInExport} onCheckedChange={handleToggleExcludeCitations} />
</SettingRow>
<SettingRow>
<SettingRowTitle tip={t('settings.data.markdown_export.standardize_citations.help')}>
{t('settings.data.markdown_export.standardize_citations.title')}
</SettingRowTitle>
<Switch checked={standardizeCitationsInExport} onCheckedChange={handleToggleStandardizeCitations} />
</SettingRow>
</SettingCard>
</SettingGroup>
)
}

View File

@@ -24,7 +24,7 @@ import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { type FileStat } from 'webdav'
import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..'
import { SettingCard, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
const NutstoreSettings: FC = () => {
const { theme } = useTheme()
@@ -206,162 +206,155 @@ const NutstoreSettings: FC = () => {
return (
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.data.nutstore.title')}</SettingTitle>
<SettingDivider />
<SettingRow>
<SettingRowTitle>
{isLogin ? t('settings.data.nutstore.isLogin') : t('settings.data.nutstore.notLogin')}
</SettingRowTitle>
{isLogin ? (
<RowFlex className="items-center justify-between gap-1.25">
<Button
variant={nsConnected ? 'ghost' : 'outline'}
onClick={handleCheckConnection}
disabled={checkConnectionLoading}>
{checkConnectionLoading ? (
<Loader2 className="animate-spin" size={14} />
) : nsConnected ? (
<Check size={14} />
) : (
t('settings.data.nutstore.checkConnection.name')
)}
</Button>
<Button variant="destructive" onClick={handleLayout}>
{t('settings.data.nutstore.logout.button')}
</Button>
</RowFlex>
) : (
<Button onClick={handleClickNutstoreSSO} variant="outline">
{t('settings.data.nutstore.login.button')}
</Button>
)}
</SettingRow>
<SettingDivider />
{isLogin && (
<>
<SettingRow>
<SettingRowTitle>{t('settings.data.nutstore.username')}</SettingRowTitle>
<span className="text-foreground-muted">{nutstoreUsername}</span>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.nutstore.path.label')}</SettingRowTitle>
<RowFlex className="justify-between gap-1">
<Input
placeholder={t('settings.data.nutstore.path.placeholder')}
style={{ width: 250 }}
value={nutstorePath}
onChange={(e) => {
void setNutstorePath(e.target.value)
}}
/>
<Button variant="outline" onClick={handleClickPathChange} size="icon">
<FolderOpen size={14} />
<SettingCard>
<SettingRow>
<SettingRowTitle>
{isLogin ? t('settings.data.nutstore.isLogin') : t('settings.data.nutstore.notLogin')}
</SettingRowTitle>
{isLogin ? (
<RowFlex className="items-center justify-between gap-1.25">
<Button
variant={nsConnected ? 'ghost' : 'outline'}
onClick={handleCheckConnection}
disabled={checkConnectionLoading}>
{checkConnectionLoading ? (
<Loader2 className="animate-spin" size={14} />
) : nsConnected ? (
<Check size={14} />
) : (
t('settings.data.nutstore.checkConnection.name')
)}
</Button>
<Button variant="destructive" onClick={handleLayout}>
{t('settings.data.nutstore.logout.button')}
</Button>
</RowFlex>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.general.backup.title')}</SettingRowTitle>
<RowFlex className="justify-between gap-1.25">
<Button onClick={showBackupModal} disabled={backuping} variant="outline">
{t('settings.data.nutstore.backup.button')}
</Button>
<Button onClick={showBackupManager} disabled={!nutstoreToken} variant="outline">
{t('settings.data.nutstore.restore.button')}
</Button>
</RowFlex>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.webdav.autoSync.label')}</SettingRowTitle>
<Selector
size={14}
value={nutstoreSyncInterval}
onChange={onSyncIntervalChange}
options={[
{ label: t('settings.data.webdav.autoSync.off'), value: 0 },
{ label: t('settings.data.webdav.minute_interval', { count: 1 }), value: 1 },
{ label: t('settings.data.webdav.minute_interval', { count: 5 }), value: 5 },
{ label: t('settings.data.webdav.minute_interval', { count: 15 }), value: 15 },
{ label: t('settings.data.webdav.minute_interval', { count: 30 }), value: 30 },
{ label: t('settings.data.webdav.hour_interval', { count: 1 }), value: 60 },
{ label: t('settings.data.webdav.hour_interval', { count: 2 }), value: 120 },
{ label: t('settings.data.webdav.hour_interval', { count: 6 }), value: 360 },
{ label: t('settings.data.webdav.hour_interval', { count: 12 }), value: 720 },
{ label: t('settings.data.webdav.hour_interval', { count: 24 }), value: 1440 }
]}
/>
</SettingRow>
{nutstoreAutoSync && nutstoreSyncInterval > 0 && (
<>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.webdav.syncStatus')}</SettingRowTitle>
{renderSyncStatus()}
</SettingRow>
</>
) : (
<Button onClick={handleClickNutstoreSSO} variant="outline">
{t('settings.data.nutstore.login.button')}
</Button>
)}
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.webdav.maxBackups')}</SettingRowTitle>
<Selector
size={14}
value={nutstoreMaxBackups}
onChange={onMaxBackupsChange}
disabled={!nutstoreToken}
options={[
{ label: t('settings.data.local.maxBackups.unlimited'), value: 0 },
{ label: '1', value: 1 },
{ label: '3', value: 3 },
{ label: '5', value: 5 },
{ label: '10', value: 10 },
{ label: '20', value: 20 },
{ label: '50', value: 50 }
]}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.backup.skip_file_data_title')}</SettingRowTitle>
<Switch checked={nutstoreSkipBackupFile} onCheckedChange={onSkipBackupFilesChange} />
</SettingRow>
<SettingRow>
<SettingHelpText>{t('settings.data.backup.skip_file_data_help')}</SettingHelpText>
</SettingRow>
</>
)}
<>
<WebdavBackupModal
isModalVisible={isModalVisible}
handleBackup={handleBackup}
handleCancel={handleCancel}
backuping={backuping}
customFileName={customFileName}
setCustomFileName={setCustomFileName}
customLabels={{
modalTitle: t('settings.data.nutstore.backup.modal.title'),
filenamePlaceholder: t('settings.data.nutstore.backup.modal.filename.placeholder')
}}
/>
</SettingRow>
{isLogin && (
<>
<SettingRow>
<SettingRowTitle>{t('settings.data.nutstore.username')}</SettingRowTitle>
<span className="text-foreground-muted">{nutstoreUsername}</span>
</SettingRow>
<WebdavBackupManager
visible={backupManagerVisible}
onClose={closeBackupManager}
webdavConfig={{
webdavHost: NUTSTORE_HOST,
webdavUser: nutstoreUsername,
webdavPass: nutstorePass,
webdavPath: nutstorePath
}}
restoreMethod={restoreFromNutstore}
customLabels={{
restoreConfirmTitle: t('settings.data.nutstore.restore.confirm.title'),
restoreConfirmContent: t('settings.data.nutstore.restore.confirm.content'),
invalidConfigMessage: t('message.error.invalid.nutstore')
}}
/>
</>
<SettingRow>
<SettingRowTitle>{t('settings.data.nutstore.path.label')}</SettingRowTitle>
<RowFlex className="justify-between gap-1">
<Input
placeholder={t('settings.data.nutstore.path.placeholder')}
className="min-w-0 max-w-[250px] flex-1"
value={nutstorePath}
onChange={(e) => {
void setNutstorePath(e.target.value)
}}
/>
<Button variant="outline" onClick={handleClickPathChange} size="icon">
<FolderOpen size={14} />
</Button>
</RowFlex>
</SettingRow>
<SettingRow>
<SettingRowTitle>{t('settings.general.backup.title')}</SettingRowTitle>
<RowFlex className="min-w-0 flex-wrap justify-end gap-1.25">
<Button onClick={showBackupModal} disabled={backuping} variant="outline">
{t('settings.data.nutstore.backup.button')}
</Button>
<Button onClick={showBackupManager} disabled={!nutstoreToken} variant="outline">
{t('settings.data.nutstore.restore.button')}
</Button>
</RowFlex>
</SettingRow>
<SettingRow>
<SettingRowTitle>{t('settings.data.webdav.autoSync.label')}</SettingRowTitle>
<Selector
size={14}
value={nutstoreSyncInterval}
onChange={onSyncIntervalChange}
options={[
{ label: t('settings.data.webdav.autoSync.off'), value: 0 },
{ label: t('settings.data.webdav.minute_interval', { count: 1 }), value: 1 },
{ label: t('settings.data.webdav.minute_interval', { count: 5 }), value: 5 },
{ label: t('settings.data.webdav.minute_interval', { count: 15 }), value: 15 },
{ label: t('settings.data.webdav.minute_interval', { count: 30 }), value: 30 },
{ label: t('settings.data.webdav.hour_interval', { count: 1 }), value: 60 },
{ label: t('settings.data.webdav.hour_interval', { count: 2 }), value: 120 },
{ label: t('settings.data.webdav.hour_interval', { count: 6 }), value: 360 },
{ label: t('settings.data.webdav.hour_interval', { count: 12 }), value: 720 },
{ label: t('settings.data.webdav.hour_interval', { count: 24 }), value: 1440 }
]}
/>
</SettingRow>
{nutstoreAutoSync && nutstoreSyncInterval > 0 && (
<>
<SettingRow>
<SettingRowTitle>{t('settings.data.webdav.syncStatus')}</SettingRowTitle>
{renderSyncStatus()}
</SettingRow>
</>
)}
<SettingRow>
<SettingRowTitle>{t('settings.data.webdav.maxBackups')}</SettingRowTitle>
<Selector
size={14}
value={nutstoreMaxBackups}
onChange={onMaxBackupsChange}
disabled={!nutstoreToken}
options={[
{ label: t('settings.data.local.maxBackups.unlimited'), value: 0 },
{ label: '1', value: 1 },
{ label: '3', value: 3 },
{ label: '5', value: 5 },
{ label: '10', value: 10 },
{ label: '20', value: 20 },
{ label: '50', value: 50 }
]}
/>
</SettingRow>
<SettingRow>
<SettingRowTitle tip={t('settings.data.backup.skip_file_data_help')}>
{t('settings.data.backup.skip_file_data_title')}
</SettingRowTitle>
<Switch checked={nutstoreSkipBackupFile} onCheckedChange={onSkipBackupFilesChange} />
</SettingRow>
</>
)}
<>
<WebdavBackupModal
isModalVisible={isModalVisible}
handleBackup={handleBackup}
handleCancel={handleCancel}
backuping={backuping}
customFileName={customFileName}
setCustomFileName={setCustomFileName}
customLabels={{
modalTitle: t('settings.data.nutstore.backup.modal.title'),
filenamePlaceholder: t('settings.data.nutstore.backup.modal.filename.placeholder')
}}
/>
<WebdavBackupManager
visible={backupManagerVisible}
onClose={closeBackupManager}
webdavConfig={{
webdavHost: NUTSTORE_HOST,
webdavUser: nutstoreUsername,
webdavPass: nutstorePass,
webdavPath: nutstorePath
}}
restoreMethod={restoreFromNutstore}
customLabels={{
restoreConfirmTitle: t('settings.data.nutstore.restore.confirm.title'),
restoreConfirmContent: t('settings.data.nutstore.restore.confirm.content'),
invalidConfigMessage: t('message.error.invalid.nutstore')
}}
/>
</>
</SettingCard>
</SettingGroup>
)
}

View File

@@ -13,7 +13,7 @@ import type { FC } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..'
import { SettingCard, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..'
const S3Settings: FC = () => {
const [, setS3AutoSync] = usePreference('data.backup.s3.auto_sync')
@@ -114,175 +114,165 @@ const S3Settings: FC = () => {
/>
</SettingTitle>
<SettingHelpText>{t('settings.data.s3.title.help')}</SettingHelpText>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.s3.endpoint.label')}</SettingRowTitle>
<Input
placeholder={t('settings.data.s3.endpoint.placeholder')}
value={s3Endpoint}
onChange={(e) => setS3Endpoint(e.target.value)}
style={{ width: 250 }}
type="url"
onBlur={(e) => setS3Endpoint(e.target.value)}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.s3.region.label')}</SettingRowTitle>
<Input
placeholder={t('settings.data.s3.region.placeholder')}
value={s3Region}
onChange={(e) => setS3Region(e.target.value)}
style={{ width: 250 }}
onBlur={(e) => setS3Region(e.target.value)}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.s3.bucket.label')}</SettingRowTitle>
<Input
placeholder={t('settings.data.s3.bucket.placeholder')}
value={s3Bucket}
onChange={(e) => setS3Bucket(e.target.value)}
style={{ width: 250 }}
onBlur={(e) => setS3Bucket(e.target.value)}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.s3.accessKeyId.label')}</SettingRowTitle>
<Input
placeholder={t('settings.data.s3.accessKeyId.placeholder')}
value={s3AccessKeyId}
onChange={(e) => setS3AccessKeyId(e.target.value)}
style={{ width: 250 }}
onBlur={(e) => setS3AccessKeyId(e.target.value)}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.s3.secretAccessKey.label')}</SettingRowTitle>
<Input
type="password"
placeholder={t('settings.data.s3.secretAccessKey.placeholder')}
value={s3SecretAccessKey}
onChange={(e) => setS3SecretAccessKey(e.target.value)}
style={{ width: 250 }}
onBlur={(e) => setS3SecretAccessKey(e.target.value)}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.s3.root.label')}</SettingRowTitle>
<Input
placeholder={t('settings.data.s3.root.placeholder')}
value={s3Root}
onChange={(e) => setS3Root(e.target.value)}
style={{ width: 250 }}
onBlur={(e) => setS3Root(e.target.value)}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.s3.backup.operation')}</SettingRowTitle>
<RowFlex className="justify-between gap-1.25">
<Button
onClick={showBackupModal}
variant="outline"
disabled={backuping || !s3Endpoint || !s3Region || !s3Bucket || !s3AccessKeyId || !s3SecretAccessKey}>
<Save size={14} />
{t('settings.data.s3.backup.button')}
</Button>
<Button
onClick={showBackupManager}
variant="outline"
disabled={!s3Endpoint || !s3Region || !s3Bucket || !s3AccessKeyId || !s3SecretAccessKey}>
<FolderOpen size={14} />
{t('settings.data.s3.backup.manager.button')}
</Button>
</RowFlex>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.s3.autoSync.label')}</SettingRowTitle>
<Selector
size={14}
value={s3SyncInterval}
onChange={onSyncIntervalChange}
disabled={!s3Endpoint || !s3AccessKeyId || !s3SecretAccessKey}
options={[
{ label: t('settings.data.s3.autoSync.off'), value: 0 },
{ label: t('settings.data.s3.autoSync.minute', { count: 1 }), value: 1 },
{ label: t('settings.data.s3.autoSync.minute', { count: 5 }), value: 5 },
{ label: t('settings.data.s3.autoSync.minute', { count: 15 }), value: 15 },
{ label: t('settings.data.s3.autoSync.minute', { count: 30 }), value: 30 },
{ label: t('settings.data.s3.autoSync.hour', { count: 1 }), value: 60 },
{ label: t('settings.data.s3.autoSync.hour', { count: 2 }), value: 120 },
{ label: t('settings.data.s3.autoSync.hour', { count: 6 }), value: 360 },
{ label: t('settings.data.s3.autoSync.hour', { count: 12 }), value: 720 },
{ label: t('settings.data.s3.autoSync.hour', { count: 24 }), value: 1440 }
]}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.s3.maxBackups.label')}</SettingRowTitle>
<Selector
size={14}
value={s3MaxBackups}
onChange={onMaxBackupsChange}
disabled={!s3Endpoint || !s3AccessKeyId || !s3SecretAccessKey}
options={[
{ label: t('settings.data.s3.maxBackups.unlimited'), value: 0 },
{ label: '1', value: 1 },
{ label: '3', value: 3 },
{ label: '5', value: 5 },
{ label: '10', value: 10 },
{ label: '20', value: 20 },
{ label: '50', value: 50 }
]}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.s3.skipBackupFile.label')}</SettingRowTitle>
<Switch checked={s3SkipBackupFile} onCheckedChange={onSkipBackupFilesChange} />
</SettingRow>
<SettingRow>
<SettingHelpText>{t('settings.data.s3.skipBackupFile.help')}</SettingHelpText>
</SettingRow>
{s3SyncInterval > 0 && (
<SettingCard>
<SettingRow>
<SettingRowTitle>{t('settings.data.s3.endpoint.label')}</SettingRowTitle>
<Input
placeholder={t('settings.data.s3.endpoint.placeholder')}
value={s3Endpoint}
onChange={(e) => setS3Endpoint(e.target.value)}
className="min-w-0 max-w-[250px] flex-1"
type="url"
onBlur={(e) => setS3Endpoint(e.target.value)}
/>
</SettingRow>
<SettingRow>
<SettingRowTitle>{t('settings.data.s3.region.label')}</SettingRowTitle>
<Input
placeholder={t('settings.data.s3.region.placeholder')}
value={s3Region}
onChange={(e) => setS3Region(e.target.value)}
className="min-w-0 max-w-[250px] flex-1"
onBlur={(e) => setS3Region(e.target.value)}
/>
</SettingRow>
<SettingRow>
<SettingRowTitle>{t('settings.data.s3.bucket.label')}</SettingRowTitle>
<Input
placeholder={t('settings.data.s3.bucket.placeholder')}
value={s3Bucket}
onChange={(e) => setS3Bucket(e.target.value)}
className="min-w-0 max-w-[250px] flex-1"
onBlur={(e) => setS3Bucket(e.target.value)}
/>
</SettingRow>
<SettingRow>
<SettingRowTitle>{t('settings.data.s3.accessKeyId.label')}</SettingRowTitle>
<Input
placeholder={t('settings.data.s3.accessKeyId.placeholder')}
value={s3AccessKeyId}
onChange={(e) => setS3AccessKeyId(e.target.value)}
className="min-w-0 max-w-[250px] flex-1"
onBlur={(e) => setS3AccessKeyId(e.target.value)}
/>
</SettingRow>
<SettingRow>
<SettingRowTitle>{t('settings.data.s3.secretAccessKey.label')}</SettingRowTitle>
<Input
type="password"
placeholder={t('settings.data.s3.secretAccessKey.placeholder')}
value={s3SecretAccessKey}
onChange={(e) => setS3SecretAccessKey(e.target.value)}
className="min-w-0 max-w-[250px] flex-1"
onBlur={(e) => setS3SecretAccessKey(e.target.value)}
/>
</SettingRow>
<SettingRow>
<SettingRowTitle>{t('settings.data.s3.root.label')}</SettingRowTitle>
<Input
placeholder={t('settings.data.s3.root.placeholder')}
value={s3Root}
onChange={(e) => setS3Root(e.target.value)}
className="min-w-0 max-w-[250px] flex-1"
onBlur={(e) => setS3Root(e.target.value)}
/>
</SettingRow>
<SettingRow>
<SettingRowTitle>{t('settings.data.s3.backup.operation')}</SettingRowTitle>
<RowFlex className="min-w-0 flex-wrap justify-end gap-1.25">
<Button
onClick={showBackupModal}
variant="outline"
disabled={backuping || !s3Endpoint || !s3Region || !s3Bucket || !s3AccessKeyId || !s3SecretAccessKey}>
<Save size={14} />
{t('settings.data.s3.backup.button')}
</Button>
<Button
onClick={showBackupManager}
variant="outline"
disabled={!s3Endpoint || !s3Region || !s3Bucket || !s3AccessKeyId || !s3SecretAccessKey}>
<FolderOpen size={14} />
{t('settings.data.s3.backup.manager.button')}
</Button>
</RowFlex>
</SettingRow>
<SettingRow>
<SettingRowTitle>{t('settings.data.s3.autoSync.label')}</SettingRowTitle>
<Selector
size={14}
value={s3SyncInterval}
onChange={onSyncIntervalChange}
disabled={!s3Endpoint || !s3AccessKeyId || !s3SecretAccessKey}
options={[
{ label: t('settings.data.s3.autoSync.off'), value: 0 },
{ label: t('settings.data.s3.autoSync.minute', { count: 1 }), value: 1 },
{ label: t('settings.data.s3.autoSync.minute', { count: 5 }), value: 5 },
{ label: t('settings.data.s3.autoSync.minute', { count: 15 }), value: 15 },
{ label: t('settings.data.s3.autoSync.minute', { count: 30 }), value: 30 },
{ label: t('settings.data.s3.autoSync.hour', { count: 1 }), value: 60 },
{ label: t('settings.data.s3.autoSync.hour', { count: 2 }), value: 120 },
{ label: t('settings.data.s3.autoSync.hour', { count: 6 }), value: 360 },
{ label: t('settings.data.s3.autoSync.hour', { count: 12 }), value: 720 },
{ label: t('settings.data.s3.autoSync.hour', { count: 24 }), value: 1440 }
]}
/>
</SettingRow>
<SettingRow>
<SettingRowTitle>{t('settings.data.s3.maxBackups.label')}</SettingRowTitle>
<Selector
size={14}
value={s3MaxBackups}
onChange={onMaxBackupsChange}
disabled={!s3Endpoint || !s3AccessKeyId || !s3SecretAccessKey}
options={[
{ label: t('settings.data.s3.maxBackups.unlimited'), value: 0 },
{ label: '1', value: 1 },
{ label: '3', value: 3 },
{ label: '5', value: 5 },
{ label: '10', value: 10 },
{ label: '20', value: 20 },
{ label: '50', value: 50 }
]}
/>
</SettingRow>
<SettingRow>
<SettingRowTitle tip={t('settings.data.s3.skipBackupFile.help')}>
{t('settings.data.s3.skipBackupFile.label')}
</SettingRowTitle>
<Switch checked={s3SkipBackupFile} onCheckedChange={onSkipBackupFilesChange} />
</SettingRow>
{s3SyncInterval > 0 && (
<>
<SettingRow>
<SettingRowTitle>{t('settings.data.s3.syncStatus.label')}</SettingRowTitle>
{renderSyncStatus()}
</SettingRow>
</>
)}
<>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.s3.syncStatus.label')}</SettingRowTitle>
{renderSyncStatus()}
</SettingRow>
</>
)}
<>
<S3BackupModal
isModalVisible={isModalVisible}
handleBackup={handleBackup}
handleCancel={handleCancel}
backuping={backuping}
customFileName={customFileName}
setCustomFileName={setCustomFileName}
/>
<S3BackupModal
isModalVisible={isModalVisible}
handleBackup={handleBackup}
handleCancel={handleCancel}
backuping={backuping}
customFileName={customFileName}
setCustomFileName={setCustomFileName}
/>
<S3BackupManager
visible={backupManagerVisible}
onClose={closeBackupManager}
s3Config={{
endpoint: s3Endpoint,
region: s3Region,
bucket: s3Bucket,
accessKeyId: s3AccessKeyId,
secretAccessKey: s3SecretAccessKey,
root: s3Root
}}
/>
</>
<S3BackupManager
visible={backupManagerVisible}
onClose={closeBackupManager}
s3Config={{
endpoint: s3Endpoint,
region: s3Region,
bucket: s3Bucket,
accessKeyId: s3AccessKeyId,
secretAccessKey: s3SecretAccessKey,
root: s3Root
}}
/>
</>
</SettingCard>
</SettingGroup>
)
}

View File

@@ -11,7 +11,7 @@ import type { FC } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..'
import { SettingCard, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
const WebDavSettings: FC = () => {
const [, setWebdavAutoSync] = usePreference('data.backup.webdav.auto_sync')
@@ -96,154 +96,144 @@ const WebDavSettings: FC = () => {
return (
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.data.webdav.title')}</SettingTitle>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.webdav.host.label')}</SettingRowTitle>
<Input
placeholder={t('settings.data.webdav.host.placeholder')}
value={webdavHost}
onChange={(e) => setWebdavHost(e.target.value)}
style={{ width: 250 }}
type="url"
onBlur={() => setWebdavHost(webdavHost || '')}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.webdav.user')}</SettingRowTitle>
<Input
placeholder={t('settings.data.webdav.user')}
value={webdavUser}
onChange={(e) => setWebdavUser(e.target.value)}
style={{ width: 250 }}
onBlur={() => setWebdavUser(webdavUser || '')}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.webdav.password')}</SettingRowTitle>
<Input
type="password"
placeholder={t('settings.data.webdav.password')}
value={webdavPass}
onChange={(e) => setWebdavPass(e.target.value)}
style={{ width: 250 }}
onBlur={() => setWebdavPass(webdavPass || '')}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.webdav.path.label')}</SettingRowTitle>
<Input
placeholder={t('settings.data.webdav.path.placeholder')}
value={webdavPath}
onChange={(e) => setWebdavPath(e.target.value)}
style={{ width: 250 }}
onBlur={() => setWebdavPath(webdavPath || '')}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.general.backup.title')}</SettingRowTitle>
<RowFlex className="justify-between gap-1.25">
<Button onClick={showBackupModal} disabled={backuping} variant="outline">
<Save size={14} />
{t('settings.data.webdav.backup.button')}
</Button>
<Button onClick={showBackupManager} disabled={!webdavHost} variant="outline">
<FolderOpen size={14} />
{t('settings.data.webdav.restore.button')}
</Button>
</RowFlex>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.webdav.autoSync.label')}</SettingRowTitle>
<Selector
size={14}
value={webdavSyncInterval}
onChange={onSyncIntervalChange}
disabled={!webdavHost}
options={[
{ label: t('settings.data.webdav.autoSync.off'), value: 0 },
{ label: t('settings.data.webdav.minute_interval', { count: 1 }), value: 1 },
{ label: t('settings.data.webdav.minute_interval', { count: 5 }), value: 5 },
{ label: t('settings.data.webdav.minute_interval', { count: 15 }), value: 15 },
{ label: t('settings.data.webdav.minute_interval', { count: 30 }), value: 30 },
{ label: t('settings.data.webdav.hour_interval', { count: 1 }), value: 60 },
{ label: t('settings.data.webdav.hour_interval', { count: 2 }), value: 120 },
{ label: t('settings.data.webdav.hour_interval', { count: 6 }), value: 360 },
{ label: t('settings.data.webdav.hour_interval', { count: 12 }), value: 720 },
{ label: t('settings.data.webdav.hour_interval', { count: 24 }), value: 1440 }
]}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.webdav.maxBackups')}</SettingRowTitle>
<Selector
size={14}
value={webdavMaxBackups}
onChange={onMaxBackupsChange}
disabled={!webdavHost}
options={[
{ label: t('settings.data.local.maxBackups.unlimited'), value: 0 },
{ label: '1', value: 1 },
{ label: '3', value: 3 },
{ label: '5', value: 5 },
{ label: '10', value: 10 },
{ label: '20', value: 20 },
{ label: '50', value: 50 }
]}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.backup.skip_file_data_title')}</SettingRowTitle>
<Switch checked={webdavSkipBackupFile} onCheckedChange={onSkipBackupFilesChange} />
</SettingRow>
<SettingRow>
<SettingHelpText>{t('settings.data.backup.skip_file_data_help')}</SettingHelpText>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.webdav.disableStream.title')}</SettingRowTitle>
<Switch checked={webdavDisableStream} onCheckedChange={onDisableStreamChange} />
</SettingRow>
<SettingRow>
<SettingHelpText>{t('settings.data.webdav.disableStream.help')}</SettingHelpText>
</SettingRow>
{webdavSync && webdavSyncInterval > 0 && (
<SettingCard>
<SettingRow>
<SettingRowTitle>{t('settings.data.webdav.host.label')}</SettingRowTitle>
<Input
placeholder={t('settings.data.webdav.host.placeholder')}
value={webdavHost}
onChange={(e) => setWebdavHost(e.target.value)}
className="min-w-0 max-w-[250px] flex-1"
type="url"
onBlur={() => setWebdavHost(webdavHost || '')}
/>
</SettingRow>
<SettingRow>
<SettingRowTitle>{t('settings.data.webdav.user')}</SettingRowTitle>
<Input
placeholder={t('settings.data.webdav.user')}
value={webdavUser}
onChange={(e) => setWebdavUser(e.target.value)}
className="min-w-0 max-w-[250px] flex-1"
onBlur={() => setWebdavUser(webdavUser || '')}
/>
</SettingRow>
<SettingRow>
<SettingRowTitle>{t('settings.data.webdav.password')}</SettingRowTitle>
<Input
type="password"
placeholder={t('settings.data.webdav.password')}
value={webdavPass}
onChange={(e) => setWebdavPass(e.target.value)}
className="min-w-0 max-w-[250px] flex-1"
onBlur={() => setWebdavPass(webdavPass || '')}
/>
</SettingRow>
<SettingRow>
<SettingRowTitle>{t('settings.data.webdav.path.label')}</SettingRowTitle>
<Input
placeholder={t('settings.data.webdav.path.placeholder')}
value={webdavPath}
onChange={(e) => setWebdavPath(e.target.value)}
className="min-w-0 max-w-[250px] flex-1"
onBlur={() => setWebdavPath(webdavPath || '')}
/>
</SettingRow>
<SettingRow>
<SettingRowTitle>{t('settings.general.backup.title')}</SettingRowTitle>
<RowFlex className="min-w-0 flex-wrap justify-end gap-1.25">
<Button onClick={showBackupModal} disabled={backuping} variant="outline">
<Save size={14} />
{t('settings.data.webdav.backup.button')}
</Button>
<Button onClick={showBackupManager} disabled={!webdavHost} variant="outline">
<FolderOpen size={14} />
{t('settings.data.webdav.restore.button')}
</Button>
</RowFlex>
</SettingRow>
<SettingRow>
<SettingRowTitle>{t('settings.data.webdav.autoSync.label')}</SettingRowTitle>
<Selector
size={14}
value={webdavSyncInterval}
onChange={onSyncIntervalChange}
disabled={!webdavHost}
options={[
{ label: t('settings.data.webdav.autoSync.off'), value: 0 },
{ label: t('settings.data.webdav.minute_interval', { count: 1 }), value: 1 },
{ label: t('settings.data.webdav.minute_interval', { count: 5 }), value: 5 },
{ label: t('settings.data.webdav.minute_interval', { count: 15 }), value: 15 },
{ label: t('settings.data.webdav.minute_interval', { count: 30 }), value: 30 },
{ label: t('settings.data.webdav.hour_interval', { count: 1 }), value: 60 },
{ label: t('settings.data.webdav.hour_interval', { count: 2 }), value: 120 },
{ label: t('settings.data.webdav.hour_interval', { count: 6 }), value: 360 },
{ label: t('settings.data.webdav.hour_interval', { count: 12 }), value: 720 },
{ label: t('settings.data.webdav.hour_interval', { count: 24 }), value: 1440 }
]}
/>
</SettingRow>
<SettingRow>
<SettingRowTitle>{t('settings.data.webdav.maxBackups')}</SettingRowTitle>
<Selector
size={14}
value={webdavMaxBackups}
onChange={onMaxBackupsChange}
disabled={!webdavHost}
options={[
{ label: t('settings.data.local.maxBackups.unlimited'), value: 0 },
{ label: '1', value: 1 },
{ label: '3', value: 3 },
{ label: '5', value: 5 },
{ label: '10', value: 10 },
{ label: '20', value: 20 },
{ label: '50', value: 50 }
]}
/>
</SettingRow>
<SettingRow>
<SettingRowTitle tip={t('settings.data.backup.skip_file_data_help')}>
{t('settings.data.backup.skip_file_data_title')}
</SettingRowTitle>
<Switch checked={webdavSkipBackupFile} onCheckedChange={onSkipBackupFilesChange} />
</SettingRow>
<SettingRow>
<SettingRowTitle tip={t('settings.data.webdav.disableStream.help')}>
{t('settings.data.webdav.disableStream.title')}
</SettingRowTitle>
<Switch checked={webdavDisableStream} onCheckedChange={onDisableStreamChange} />
</SettingRow>
{webdavSync && webdavSyncInterval > 0 && (
<>
<SettingRow>
<SettingRowTitle>{t('settings.data.webdav.syncStatus')}</SettingRowTitle>
{renderSyncStatus()}
</SettingRow>
</>
)}
<>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.webdav.syncStatus')}</SettingRowTitle>
{renderSyncStatus()}
</SettingRow>
</>
)}
<>
<WebdavBackupModal
isModalVisible={isModalVisible}
handleBackup={handleBackup}
handleCancel={handleCancel}
backuping={backuping}
customFileName={customFileName}
setCustomFileName={setCustomFileName}
/>
<WebdavBackupModal
isModalVisible={isModalVisible}
handleBackup={handleBackup}
handleCancel={handleCancel}
backuping={backuping}
customFileName={customFileName}
setCustomFileName={setCustomFileName}
/>
<WebdavBackupManager
visible={backupManagerVisible}
onClose={closeBackupManager}
webdavConfig={{
webdavHost,
webdavUser,
webdavPass,
webdavPath,
webdavDisableStream
}}
/>
</>
<WebdavBackupManager
visible={backupManagerVisible}
onClose={closeBackupManager}
webdavConfig={{
webdavHost,
webdavUser,
webdavPass,
webdavPath,
webdavDisableStream
}}
/>
</>
</SettingCard>
</SettingGroup>
)
}

View File

@@ -6,7 +6,7 @@ import { formatErrorMessage } from '@renderer/utils/error'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..'
import { SettingCard, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
const logger = loggerService.withContext('JoplinSettings')
@@ -72,55 +72,53 @@ const JoplinSettings: FC = () => {
return (
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.data.joplin.title')}</SettingTitle>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.joplin.url')}</SettingRowTitle>
<RowFlex className="w-78.75 min-w-0 max-w-full items-center gap-1.25">
<Input
type="text"
value={joplinUrl || ''}
onChange={handleJoplinUrlChange}
onBlur={handleJoplinUrlBlur}
className="w-78.75 max-w-full"
placeholder={t('settings.data.joplin.url_placeholder')}
/>
</RowFlex>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle style={{ display: 'flex', alignItems: 'center' }}>
<span>{t('settings.data.joplin.token')}</span>
<InfoTooltip
content={t('settings.data.joplin.help')}
placement="left"
iconProps={{ className: 'text-text-2 cursor-pointer ml-1' }}
onClick={handleJoplinHelpClick}
/>
</SettingRowTitle>
<RowFlex className="w-78.75 min-w-0 max-w-full items-center gap-1.25">
<RowFlex className="w-full min-w-0 items-center gap-1.25">
<SettingCard>
<SettingRow>
<SettingRowTitle>{t('settings.data.joplin.url')}</SettingRowTitle>
<RowFlex className="w-78.75 min-w-0 max-w-full items-center gap-1.25">
<Input
type="password"
value={joplinToken || ''}
onChange={handleJoplinTokenChange}
onBlur={handleJoplinTokenChange}
placeholder={t('settings.data.joplin.token_placeholder')}
style={{ width: '100%' }}
type="text"
value={joplinUrl || ''}
onChange={handleJoplinUrlChange}
onBlur={handleJoplinUrlBlur}
className="w-78.75 max-w-full"
placeholder={t('settings.data.joplin.url_placeholder')}
/>
<Button onClick={handleJoplinConnectionCheck} variant="outline" className="h-9 shrink-0">
{t('settings.data.joplin.check.button')}
</Button>
</RowFlex>
</RowFlex>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.joplin.export_reasoning.title')}</SettingRowTitle>
<Switch checked={joplinExportReasoning} onCheckedChange={handleToggleJoplinExportReasoning} />
</SettingRow>
<SettingRow>
<SettingHelpText>{t('settings.data.joplin.export_reasoning.help')}</SettingHelpText>
</SettingRow>
</SettingRow>
<SettingRow>
<SettingRowTitle style={{ display: 'flex', alignItems: 'center' }}>
<span>{t('settings.data.joplin.token')}</span>
<InfoTooltip
content={t('settings.data.joplin.help')}
placement="left"
iconProps={{ className: 'text-text-2 cursor-pointer ml-1' }}
onClick={handleJoplinHelpClick}
/>
</SettingRowTitle>
<RowFlex className="w-78.75 min-w-0 max-w-full items-center gap-1.25">
<RowFlex className="w-full min-w-0 items-center gap-1.25">
<Input
type="password"
value={joplinToken || ''}
onChange={handleJoplinTokenChange}
onBlur={handleJoplinTokenChange}
placeholder={t('settings.data.joplin.token_placeholder')}
style={{ width: '100%' }}
/>
<Button onClick={handleJoplinConnectionCheck} variant="outline" className="h-8 shrink-0 rounded-lg">
{t('settings.data.joplin.check.button')}
</Button>
</RowFlex>
</RowFlex>
</SettingRow>
<SettingRow>
<SettingRowTitle tip={t('settings.data.joplin.export_reasoning.help')}>
{t('settings.data.joplin.export_reasoning.title')}
</SettingRowTitle>
<Switch checked={joplinExportReasoning} onCheckedChange={handleToggleJoplinExportReasoning} />
</SettingRow>
</SettingCard>
</SettingGroup>
)
}

View File

@@ -7,7 +7,7 @@ import { formatErrorMessage } from '@renderer/utils/error'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..'
import { SettingCard, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
const logger = loggerService.withContext('NotionSettings')
@@ -78,59 +78,56 @@ const NotionSettings: FC = () => {
onClick={handleNotionTitleClick}
/>
</SettingTitle>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.notion.database_id')}</SettingRowTitle>
<RowFlex className="w-78.75 min-w-0 max-w-full items-center gap-1.25">
<Input
type="text"
value={notionDatabaseID || ''}
onChange={handleNotionDatabaseIdChange}
onBlur={handleNotionDatabaseIdChange}
placeholder={t('settings.data.notion.database_id_placeholder')}
/>
</RowFlex>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.notion.page_name_key')}</SettingRowTitle>
<RowFlex className="w-78.75 min-w-0 max-w-full items-center gap-1.25">
<Input
type="text"
value={notionPageNameKey || ''}
onChange={handleNotionPageNameKeyChange}
onBlur={handleNotionPageNameKeyChange}
placeholder={t('settings.data.notion.page_name_key_placeholder')}
/>
</RowFlex>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.notion.api_key')}</SettingRowTitle>
<RowFlex className="w-78.75 min-w-0 max-w-full items-center gap-1.25">
<RowFlex className="w-full min-w-0 items-center gap-1.25">
<SettingCard>
<SettingRow>
<SettingRowTitle>{t('settings.data.notion.database_id')}</SettingRowTitle>
<RowFlex className="w-78.75 min-w-0 max-w-full items-center gap-1.25">
<Input
type="password"
value={notionApiKey || ''}
onChange={handleNotionTokenChange}
onBlur={handleNotionTokenChange}
placeholder={t('settings.data.notion.api_key_placeholder')}
style={{ width: '100%' }}
type="text"
value={notionDatabaseID || ''}
onChange={handleNotionDatabaseIdChange}
onBlur={handleNotionDatabaseIdChange}
placeholder={t('settings.data.notion.database_id_placeholder')}
/>
<Button onClick={handleNotionConnectionCheck} variant="outline" className="h-9 shrink-0">
{t('settings.data.notion.check.button')}
</Button>
</RowFlex>
</RowFlex>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.notion.export_reasoning.title')}</SettingRowTitle>
<Switch checked={notionExportReasoning} onCheckedChange={handleNotionExportReasoningChange} />
</SettingRow>
<SettingRow>
<SettingHelpText>{t('settings.data.notion.export_reasoning.help')}</SettingHelpText>
</SettingRow>
</SettingRow>
<SettingRow>
<SettingRowTitle>{t('settings.data.notion.page_name_key')}</SettingRowTitle>
<RowFlex className="w-78.75 min-w-0 max-w-full items-center gap-1.25">
<Input
type="text"
value={notionPageNameKey || ''}
onChange={handleNotionPageNameKeyChange}
onBlur={handleNotionPageNameKeyChange}
placeholder={t('settings.data.notion.page_name_key_placeholder')}
/>
</RowFlex>
</SettingRow>
<SettingRow>
<SettingRowTitle>{t('settings.data.notion.api_key')}</SettingRowTitle>
<RowFlex className="w-78.75 min-w-0 max-w-full items-center gap-1.25">
<RowFlex className="w-full min-w-0 items-center gap-1.25">
<Input
type="password"
value={notionApiKey || ''}
onChange={handleNotionTokenChange}
onBlur={handleNotionTokenChange}
placeholder={t('settings.data.notion.api_key_placeholder')}
style={{ width: '100%' }}
/>
<Button onClick={handleNotionConnectionCheck} variant="outline" className="h-8 shrink-0 rounded-lg">
{t('settings.data.notion.check.button')}
</Button>
</RowFlex>
</RowFlex>
</SettingRow>
<SettingRow>
<SettingRowTitle tip={t('settings.data.notion.export_reasoning.help')}>
{t('settings.data.notion.export_reasoning.title')}
</SettingRowTitle>
<Switch checked={notionExportReasoning} onCheckedChange={handleNotionExportReasoningChange} />
</SettingRow>
</SettingCard>
</SettingGroup>
)
}

View File

@@ -14,7 +14,7 @@ import type { FC } from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
import { SettingCard, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
const logger = loggerService.withContext('ObsidianSettings')
@@ -63,34 +63,35 @@ const ObsidianSettings: FC = () => {
return (
<SettingGroup>
<SettingTitle>{t('settings.data.obsidian.title')}</SettingTitle>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.obsidian.default_vault')}</SettingRowTitle>
<RowFlex className="gap-1.25">
{loading ? (
<Spinner text={t('common.loading')} />
) : vaults.length > 0 ? (
<Select value={defaultObsidianVault || undefined} onValueChange={handleChange}>
<SelectTrigger className="w-75 max-w-full">
<SelectValue placeholder={t('settings.data.obsidian.default_vault_placeholder')} />
</SelectTrigger>
<SelectContent>
{vaults.map((vault) => (
<SelectItem key={vault.name} value={vault.name}>
{vault.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<EmptyState
compact
preset="no-resource"
description={error || t('settings.data.obsidian.default_vault_no_vaults')}
/>
)}
</RowFlex>
</SettingRow>
<SettingCard>
<SettingRow>
<SettingRowTitle>{t('settings.data.obsidian.default_vault')}</SettingRowTitle>
<RowFlex className="min-w-0 max-w-75 flex-1 justify-end gap-1.25">
{loading ? (
<Spinner text={t('common.loading')} />
) : vaults.length > 0 ? (
<Select value={defaultObsidianVault || undefined} onValueChange={handleChange}>
<SelectTrigger className="w-full">
<SelectValue placeholder={t('settings.data.obsidian.default_vault_placeholder')} />
</SelectTrigger>
<SelectContent>
{vaults.map((vault) => (
<SelectItem key={vault.name} value={vault.name}>
{vault.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<EmptyState
compact
preset="no-resource"
description={error || t('settings.data.obsidian.default_vault_no_vaults')}
/>
)}
</RowFlex>
</SettingRow>
</SettingCard>
</SettingGroup>
)
}

View File

@@ -5,7 +5,7 @@ import { useTheme } from '@renderer/hooks/useTheme'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
import { SettingCard, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
const logger = loggerService.withContext('SiyuanSettings')
@@ -74,69 +74,67 @@ const SiyuanSettings: FC = () => {
return (
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.data.siyuan.title')}</SettingTitle>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.siyuan.api_url')}</SettingRowTitle>
<RowFlex className="w-78.75 min-w-0 max-w-full items-center gap-1.25">
<Input
type="text"
value={siyuanApiUrl || ''}
onChange={handleApiUrlChange}
placeholder={t('settings.data.siyuan.api_url_placeholder')}
/>
</RowFlex>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle style={{ display: 'flex', alignItems: 'center' }}>
<span>{t('settings.data.siyuan.token.label')}</span>
<InfoTooltip
content={t('settings.data.siyuan.token.help')}
placement="left"
iconProps={{ className: 'text-text-2 cursor-pointer ml-1' }}
onClick={handleSiyuanHelpClick}
/>
</SettingRowTitle>
<RowFlex className="w-78.75 min-w-0 max-w-full items-center gap-1.25">
<RowFlex className="w-full min-w-0 items-center gap-1.25">
<SettingCard>
<SettingRow>
<SettingRowTitle>{t('settings.data.siyuan.api_url')}</SettingRowTitle>
<RowFlex className="w-78.75 min-w-0 max-w-full items-center gap-1.25">
<Input
type="password"
value={siyuanToken || ''}
onChange={handleTokenChange}
onBlur={handleTokenChange}
placeholder={t('settings.data.siyuan.token_placeholder')}
style={{ width: '100%' }}
type="text"
value={siyuanApiUrl || ''}
onChange={handleApiUrlChange}
placeholder={t('settings.data.siyuan.api_url_placeholder')}
/>
<Button onClick={handleCheckConnection} variant="outline" className="h-9 shrink-0">
{t('settings.data.siyuan.check.button')}
</Button>
</RowFlex>
</RowFlex>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.siyuan.box_id')}</SettingRowTitle>
<RowFlex className="w-78.75 min-w-0 max-w-full items-center gap-1.25">
<Input
type="text"
value={siyuanBoxId || ''}
onChange={handleBoxIdChange}
placeholder={t('settings.data.siyuan.box_id_placeholder')}
/>
</RowFlex>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.siyuan.root_path')}</SettingRowTitle>
<RowFlex className="w-78.75 min-w-0 max-w-full items-center gap-1.25">
<Input
type="text"
value={siyuanRootPath || ''}
onChange={handleRootPathChange}
placeholder={t('settings.data.siyuan.root_path_placeholder')}
/>
</RowFlex>
</SettingRow>
</SettingRow>
<SettingRow>
<SettingRowTitle style={{ display: 'flex', alignItems: 'center' }}>
<span>{t('settings.data.siyuan.token.label')}</span>
<InfoTooltip
content={t('settings.data.siyuan.token.help')}
placement="left"
iconProps={{ className: 'text-text-2 cursor-pointer ml-1' }}
onClick={handleSiyuanHelpClick}
/>
</SettingRowTitle>
<RowFlex className="w-78.75 min-w-0 max-w-full items-center gap-1.25">
<RowFlex className="w-full min-w-0 items-center gap-1.25">
<Input
type="password"
value={siyuanToken || ''}
onChange={handleTokenChange}
onBlur={handleTokenChange}
placeholder={t('settings.data.siyuan.token_placeholder')}
style={{ width: '100%' }}
/>
<Button onClick={handleCheckConnection} variant="outline" className="h-8 shrink-0 rounded-lg">
{t('settings.data.siyuan.check.button')}
</Button>
</RowFlex>
</RowFlex>
</SettingRow>
<SettingRow>
<SettingRowTitle>{t('settings.data.siyuan.box_id')}</SettingRowTitle>
<RowFlex className="w-78.75 min-w-0 max-w-full items-center gap-1.25">
<Input
type="text"
value={siyuanBoxId || ''}
onChange={handleBoxIdChange}
placeholder={t('settings.data.siyuan.box_id_placeholder')}
/>
</RowFlex>
</SettingRow>
<SettingRow>
<SettingRowTitle>{t('settings.data.siyuan.root_path')}</SettingRowTitle>
<RowFlex className="w-78.75 min-w-0 max-w-full items-center gap-1.25">
<Input
type="text"
value={siyuanRootPath || ''}
onChange={handleRootPathChange}
placeholder={t('settings.data.siyuan.root_path_placeholder')}
/>
</RowFlex>
</SettingRow>
</SettingCard>
</SettingGroup>
)
}

View File

@@ -6,7 +6,7 @@ import { formatErrorMessage } from '@renderer/utils/error'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
import { SettingCard, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
const logger = loggerService.withContext('YuqueSettings')
@@ -84,47 +84,47 @@ const YuqueSettings: FC = () => {
return (
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.data.yuque.title')}</SettingTitle>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.yuque.repo_url')}</SettingRowTitle>
<RowFlex className="w-78.75 min-w-0 max-w-full items-center gap-1.25">
<Input
type="text"
value={yuqueUrl || ''}
onChange={handleYuqueRepoUrlChange}
placeholder={t('settings.data.yuque.repo_url_placeholder')}
/>
</RowFlex>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>
{t('settings.data.yuque.token')}
<InfoTooltip
content={t('settings.data.yuque.help')}
placement="left"
iconProps={{
className: 'text-text-2 cursor-pointer ml-1'
}}
onClick={handleYuqueHelpClick}
/>
</SettingRowTitle>
<RowFlex className="w-78.75 min-w-0 max-w-full items-center gap-1.25">
<RowFlex className="w-full min-w-0 items-center gap-1.25">
<SettingCard>
<SettingRow>
<SettingRowTitle>{t('settings.data.yuque.repo_url')}</SettingRowTitle>
<RowFlex className="w-78.75 min-w-0 max-w-full items-center gap-1.25">
<Input
type="password"
value={yuqueToken || ''}
onChange={handleYuqueTokenChange}
onBlur={handleYuqueTokenChange}
placeholder={t('settings.data.yuque.token_placeholder')}
style={{ width: '100%' }}
type="text"
value={yuqueUrl || ''}
onChange={handleYuqueRepoUrlChange}
placeholder={t('settings.data.yuque.repo_url_placeholder')}
/>
<Button onClick={handleYuqueConnectionCheck} variant="outline" className="h-9 shrink-0">
{t('settings.data.yuque.check.button')}
</Button>
</RowFlex>
</RowFlex>
</SettingRow>
</SettingRow>
<SettingRow>
<SettingRowTitle>
{t('settings.data.yuque.token')}
<InfoTooltip
content={t('settings.data.yuque.help')}
placement="left"
iconProps={{
className: 'text-text-2 cursor-pointer ml-1'
}}
onClick={handleYuqueHelpClick}
/>
</SettingRowTitle>
<RowFlex className="w-78.75 min-w-0 max-w-full items-center gap-1.25">
<RowFlex className="w-full min-w-0 items-center gap-1.25">
<Input
type="password"
value={yuqueToken || ''}
onChange={handleYuqueTokenChange}
onBlur={handleYuqueTokenChange}
placeholder={t('settings.data.yuque.token_placeholder')}
style={{ width: '100%' }}
/>
<Button onClick={handleYuqueConnectionCheck} variant="outline" className="h-8 shrink-0 rounded-lg">
{t('settings.data.yuque.check.button')}
</Button>
</RowFlex>
</RowFlex>
</SettingRow>
</SettingCard>
</SettingGroup>
)
}

View File

@@ -1,4 +1,4 @@
import { Avatar, AvatarFallback, Button, InfoTooltip, PageSidePanel, Tooltip } from '@cherrystudio/ui'
import { Avatar, AvatarFallback, Button, PageSidePanel, Tooltip } from '@cherrystudio/ui'
import { resolveIcon } from '@cherrystudio/ui/icons'
import { usePreference } from '@data/hooks/usePreference'
import { loggerService } from '@logger'
@@ -13,20 +13,18 @@ import { TRANSLATE_PROMPT } from '@shared/ai/prompts'
import { type Model, parseUniqueModelId } from '@shared/data/types/model'
import type { Provider } from '@shared/data/types/provider'
import { isNonChatModel } from '@shared/utils/model'
import { ChevronDown, Languages, MessageSquareMore, Rocket, RotateCcw, Settings2 } from 'lucide-react'
import { ChevronDown, Languages, MessageSquareMore, Package, Rocket, RotateCcw, Settings2 } from 'lucide-react'
import type { FC, ReactNode } from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
SettingContainer,
SettingDescription,
SettingDivider,
SettingGroup,
SettingRow,
SettingRowTitle,
SettingsContentColumn,
SettingTitle
SettingsPageHeader
} from '..'
import { TopicNamingSettings } from './QuickModelPopup'
@@ -41,21 +39,21 @@ interface ModelSettingsProps {
interface ModelSettingRowProps {
icon: ReactNode
title: ReactNode
description?: ReactNode
tip?: ReactNode
compact?: boolean
children: ReactNode
}
const ModelSettingRow: FC<ModelSettingRowProps> = ({ icon, title, description, compact, children }) => (
<SettingRow className={cn(compact ? 'flex-col items-stretch gap-3 py-1' : 'items-start gap-6 py-1.5')}>
<div className="min-w-0 flex-1">
<SettingRowTitle className="gap-2 font-semibold">
{icon}
{title}
</SettingRowTitle>
{description && <SettingDescription className="mt-1.5 leading-5">{description}</SettingDescription>}
</div>
<div className={compact ? 'flex w-full items-center gap-2' : 'flex w-[340px] shrink-0 items-center gap-2'}>
const ModelSettingRow: FC<ModelSettingRowProps> = ({ icon, title, tip, compact, children }) => (
<SettingRow className={cn('rounded-xl border border-border/60 px-4 py-3', compact && 'flex-col items-stretch gap-3')}>
<SettingRowTitle
tip={tip}
className="text-(length:--font-size-body-sm) min-w-0 flex-1 gap-2 leading-(--line-height-body-sm) [&_svg]:shrink-0">
{icon}
<span className="min-w-0 truncate">{title}</span>
</SettingRowTitle>
<div
className={compact ? 'flex w-full items-center gap-2' : 'flex min-w-0 max-w-[340px] flex-1 items-center gap-2'}>
{children}
</div>
</SettingRow>
@@ -93,9 +91,12 @@ const renderModelSelectorTrigger = ({ model, providers, placeholder, compact }:
return (
<Button
type="button"
variant="outline"
variant="ghost"
size={compact ? 'lg' : 'default'}
className={cn('min-w-0 flex-1 justify-between px-2.5 text-left font-normal', compact ? 'h-9' : 'h-7.5')}>
className={cn(
'min-w-0 flex-1 justify-between rounded-lg bg-muted/50 px-2.5 text-left font-normal hover:bg-muted data-[state=open]:bg-muted',
compact ? 'h-8' : 'h-7'
)}>
<span className="flex min-w-0 flex-1 items-center gap-2">
{model && icon ? (
<icon.Avatar size={20} />
@@ -107,7 +108,7 @@ const renderModelSelectorTrigger = ({ model, providers, placeholder, compact }:
<span className="min-w-0 flex-1 truncate">{model?.name ?? placeholder}</span>
{providerName && <span className="max-w-[32%] truncate text-muted-foreground text-xs">{providerName}</span>}
</span>
<ChevronDown size={14} className="shrink-0 text-muted-foreground" />
<ChevronDown size={14} className="lucide-custom shrink-0 text-muted-foreground/40" />
</Button>
)
}
@@ -190,89 +191,110 @@ const ModelSettings: FC<ModelSettingsProps> = ({
<ContainerComponent theme={theme} {...containerProps}>
<SettingGroup theme={theme} style={groupStyle}>
{!compact && (
<>
<SettingTitle>{t('settings.model')}</SettingTitle>
<SettingDivider />
</>
<SettingsPageHeader
icon={<Package />}
title={t('settings.model')}
description={t('settings.models.page_description')}
/>
)}
<ModelSettingRow
compact={compact}
icon={<MessageSquareMore size={16} className="lucide-custom shrink-0 text-foreground" />}
title={t('settings.models.default_assistant_model')}
description={showDescription ? t('settings.models.default_assistant_model_description') : undefined}>
<DefaultModelSelector
model={defaultModel}
providers={providers}
filter={modelFilter}
<div className="mt-4 space-y-4">
<ModelSettingRow
compact={compact}
onSelect={onSelectDefault}
placeholder={t('settings.models.empty')}
/>
</ModelSettingRow>
<SettingDivider />
<ModelSettingRow
compact={compact}
icon={<Rocket size={16} className="lucide-custom shrink-0 text-foreground" />}
title={
<>
{t('settings.models.quick_model.label')}
<InfoTooltip content={t('settings.models.quick_model.tooltip')} />
</>
}
description={showDescription ? t('settings.models.quick_model.description') : undefined}>
<DefaultModelSelector
model={quickModel}
providers={providers}
filter={modelFilter}
icon={
<MessageSquareMore
size={16}
className="lucide-custom shrink-0 text-foreground [stroke-width:var(--icon-stroke)]"
/>
}
title={t('settings.models.default_assistant_model')}
tip={showDescription ? t('settings.models.default_assistant_model_description') : undefined}>
<DefaultModelSelector
model={defaultModel}
providers={providers}
filter={modelFilter}
compact={compact}
onSelect={onSelectDefault}
placeholder={t('settings.models.empty')}
/>
</ModelSettingRow>
<ModelSettingRow
compact={compact}
onSelect={onSelectQuick}
placeholder={t('settings.models.empty')}
/>
{showSettingsButton && (
<Button
aria-label={t('settings.models.quick_model.setting_title')}
className="shrink-0"
onClick={() => setActivePanel('quick-model')}
size="icon-sm"
variant="outline">
<Settings2 size={16} />
</Button>
)}
</ModelSettingRow>
<SettingDivider />
<ModelSettingRow
compact={compact}
icon={<Languages size={16} className="lucide-custom shrink-0 text-foreground" />}
title={t('settings.models.translate_model')}
description={showDescription ? t('settings.models.translate_model_description') : undefined}>
<DefaultModelSelector
model={translateModel}
providers={providers}
filter={modelFilter}
compact={compact}
onSelect={onSelectTranslate}
placeholder={t('settings.models.empty')}
/>
{showSettingsButton && (
<>
icon={
<Rocket
size={16}
className="lucide-custom shrink-0 text-foreground [stroke-width:var(--icon-stroke)]"
/>
}
title={t('settings.models.quick_model.label')}
tip={
showDescription ? (
<>
{t('settings.models.quick_model.description')}
<br />
{t('settings.models.quick_model.tooltip')}
</>
) : (
t('settings.models.quick_model.tooltip')
)
}>
<DefaultModelSelector
model={quickModel}
providers={providers}
filter={modelFilter}
compact={compact}
onSelect={onSelectQuick}
placeholder={t('settings.models.empty')}
/>
{showSettingsButton && (
<Button
aria-label={t('settings.translate.title')}
className="shrink-0"
onClick={() => setActivePanel('translate')}
aria-label={t('settings.models.quick_model.setting_title')}
className="shrink-0 rounded-lg border-[color:var(--color-border-fg-muted)] hover:bg-(--color-surface-fg-subtle-solid) [&_svg]:size-3.5! [&_svg]:[stroke-width:var(--icon-stroke)]"
onClick={() => setActivePanel('quick-model')}
size="icon-sm"
variant="outline">
<Settings2 size={16} />
<Settings2 />
</Button>
{translateModelPrompt !== TRANSLATE_PROMPT && (
<Tooltip content={t('common.reset')}>
<Button className="shrink-0" onClick={onResetTranslatePrompt} size="icon-sm" variant="outline">
<RotateCcw size={16} />
</Button>
</Tooltip>
)}
</>
)}
</ModelSettingRow>
)}
</ModelSettingRow>
<ModelSettingRow
compact={compact}
icon={
<Languages
size={16}
className="lucide-custom shrink-0 text-foreground [stroke-width:var(--icon-stroke)]"
/>
}
title={t('settings.models.translate_model')}
tip={showDescription ? t('settings.models.translate_model_description') : undefined}>
<DefaultModelSelector
model={translateModel}
providers={providers}
filter={modelFilter}
compact={compact}
onSelect={onSelectTranslate}
placeholder={t('settings.models.empty')}
/>
{showSettingsButton && (
<>
<Button
aria-label={t('settings.translate.title')}
className="shrink-0 rounded-lg border-[color:var(--color-border-fg-muted)] hover:bg-(--color-surface-fg-subtle-solid) [&_svg]:size-3.5! [&_svg]:[stroke-width:var(--icon-stroke)]"
onClick={() => setActivePanel('translate')}
size="icon-sm"
variant="outline">
<Settings2 />
</Button>
{translateModelPrompt !== TRANSLATE_PROMPT && (
<Tooltip content={t('common.reset')}>
<Button className="shrink-0" onClick={onResetTranslatePrompt} size="icon-sm" variant="outline">
<RotateCcw size={16} />
</Button>
</Tooltip>
)}
</>
)}
</ModelSettingRow>
</div>
</SettingGroup>
</ContainerComponent>
{showSettingsButton && (

View File

@@ -104,10 +104,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
return (
<Dialog open={open} onOpenChange={(next) => !next && closePopup()}>
<DialogContent
closeOnOverlayClick={false}
className="p-6"
onPointerDownOutside={(event) => event.preventDefault()}>
<DialogContent className="p-6" onPointerDownOutside={(event) => event.preventDefault()}>
<DialogHeader>
<DialogTitle>{t('settings.models.quick_model.setting_title')}</DialogTitle>
</DialogHeader>

View File

@@ -17,7 +17,7 @@ import type { FC } from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '../..'
import { SettingCard, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '../..'
import { useWebSearchPersist } from '../hooks/useWebSearchPersist'
import { useWebSearchProviderLists } from '../hooks/useWebSearchProviderLists'
import CompressionSettings from './CompressionSettings'
@@ -25,7 +25,7 @@ import { WebSearchProviderOption } from './WebSearchProviderOption'
const settingRowClassName = 'items-center justify-between gap-6 py-1'
const settingLabelClassName = 'min-w-0 flex-1'
const selectTriggerClassName = 'h-8 w-56 text-sm'
const selectTriggerClassName = 'h-8 min-w-0 max-w-56 flex-1 text-sm'
const DEFAULT_MAX_RESULTS = 5
const BasicSettings: FC = () => {
@@ -96,96 +96,98 @@ const BasicSettings: FC = () => {
<>
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.tool.websearch.search_provider')}</SettingTitle>
<SettingDivider />
<SettingRow className={settingRowClassName}>
<SettingRowTitle className={settingLabelClassName}>
{t('settings.tool.websearch.default_provider')}
</SettingRowTitle>
<Select
value={defaultProvider?.id}
onValueChange={(providerId) =>
updateSelectedWebSearchProvider(providerId, setDefaultSearchKeywordsProvider)
}>
<SelectTrigger size="sm" className={selectTriggerClassName}>
<SelectValue placeholder={t('settings.tool.websearch.search_provider_placeholder')} />
</SelectTrigger>
<SelectContent>
{keywordProviders.map((provider) => (
<SelectItem key={provider.id} value={provider.id}>
<WebSearchProviderOption provider={provider} />
</SelectItem>
))}
</SelectContent>
</Select>
</SettingRow>
<SettingRow className={settingRowClassName}>
<SettingRowTitle className={settingLabelClassName}>
{t('settings.tool.websearch.fetch_urls_provider')}
</SettingRowTitle>
<Select
value={defaultFetchUrlsProvider?.id}
onValueChange={(providerId) => updateSelectedWebSearchProvider(providerId, setDefaultFetchUrlsProvider)}>
<SelectTrigger size="sm" className={selectTriggerClassName}>
<SelectValue placeholder={t('settings.tool.websearch.search_provider_placeholder')} />
</SelectTrigger>
<SelectContent>
{fetchUrlsProviders.map((provider) => (
<SelectItem key={provider.id} value={provider.id}>
<WebSearchProviderOption provider={provider} />
</SelectItem>
))}
</SelectContent>
</Select>
</SettingRow>
<SettingCard>
<SettingRow className={settingRowClassName}>
<SettingRowTitle className={settingLabelClassName}>
{t('settings.tool.websearch.default_provider')}
</SettingRowTitle>
<Select
value={defaultProvider?.id}
onValueChange={(providerId) =>
updateSelectedWebSearchProvider(providerId, setDefaultSearchKeywordsProvider)
}>
<SelectTrigger size="sm" className={selectTriggerClassName}>
<SelectValue placeholder={t('settings.tool.websearch.search_provider_placeholder')} />
</SelectTrigger>
<SelectContent>
{keywordProviders.map((provider) => (
<SelectItem key={provider.id} value={provider.id}>
<WebSearchProviderOption provider={provider} />
</SelectItem>
))}
</SelectContent>
</Select>
</SettingRow>
<SettingRow className={settingRowClassName}>
<SettingRowTitle className={settingLabelClassName}>
{t('settings.tool.websearch.fetch_urls_provider')}
</SettingRowTitle>
<Select
value={defaultFetchUrlsProvider?.id}
onValueChange={(providerId) => updateSelectedWebSearchProvider(providerId, setDefaultFetchUrlsProvider)}>
<SelectTrigger size="sm" className={selectTriggerClassName}>
<SelectValue placeholder={t('settings.tool.websearch.search_provider_placeholder')} />
</SelectTrigger>
<SelectContent>
{fetchUrlsProviders.map((provider) => (
<SelectItem key={provider.id} value={provider.id}>
<WebSearchProviderOption provider={provider} />
</SelectItem>
))}
</SelectContent>
</Select>
</SettingRow>
</SettingCard>
</SettingGroup>
<SettingGroup theme={theme} style={{ paddingBottom: 8 }}>
<SettingTitle>{t('settings.general.label')}</SettingTitle>
<SettingDivider />
<SettingRow className={settingRowClassName}>
<SettingRowTitle className={settingLabelClassName}>
{t('settings.tool.websearch.search_max_result.label')}
{maxResults > 20 && compressionConfig?.method === 'none' && (
<InfoTooltip
content={t('settings.tool.websearch.search_max_result.tooltip')}
iconProps={{ size: 16, color: 'var(--color-icon)', className: 'ml-1 cursor-pointer' }}
<SettingCard>
<SettingRow className={settingRowClassName}>
<SettingRowTitle className={settingLabelClassName}>
{t('settings.tool.websearch.search_max_result.label')}
{maxResults > 20 && compressionConfig?.method === 'none' && (
<InfoTooltip
content={t('settings.tool.websearch.search_max_result.tooltip')}
iconProps={{ size: 16, color: 'var(--color-icon)', className: 'ml-1 cursor-pointer' }}
/>
)}
</SettingRowTitle>
<div className="flex min-w-0 max-w-56 flex-1 items-center justify-end gap-2">
{!isMaxResultsDefault && (
<Tooltip content={t('common.reset')}>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-icon hover:text-foreground"
aria-label={t('common.reset')}
onMouseDown={(e) => e.preventDefault()}
onClick={resetMaxResults}>
<ResetIcon size={14} />
</Button>
</Tooltip>
)}
<Input
aria-label={t('settings.tool.websearch.search_max_result.label')}
type="number"
min={1}
max={100}
step={1}
value={draftMaxResultsInput}
className="h-8 w-20 text-center text-sm"
onChange={(e) => setDraftMaxResultsInput(e.target.value)}
onBlur={commitMaxResultsDraft}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur()
}
}}
/>
)}
</SettingRowTitle>
<div className="flex w-56 shrink-0 items-center justify-end gap-2">
{!isMaxResultsDefault && (
<Tooltip content={t('common.reset')}>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-icon hover:text-foreground"
aria-label={t('common.reset')}
onMouseDown={(e) => e.preventDefault()}
onClick={resetMaxResults}>
<ResetIcon size={14} />
</Button>
</Tooltip>
)}
<Input
aria-label={t('settings.tool.websearch.search_max_result.label')}
type="number"
min={1}
max={100}
step={1}
value={draftMaxResultsInput}
className="h-8 w-20 text-center text-sm"
onChange={(e) => setDraftMaxResultsInput(e.target.value)}
onBlur={commitMaxResultsDraft}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur()
}
}}
/>
</div>
</SettingRow>
<CompressionSettings />
</div>
</SettingRow>
<CompressionSettings />
</SettingCard>
</SettingGroup>
</>
)

View File

@@ -1,4 +1,4 @@
import { Alert, Button, Textarea } from '@cherrystudio/ui'
import { Alert, Button, InfoTooltip, Textarea } from '@cherrystudio/ui'
import { useTheme } from '@renderer/hooks/useTheme'
import { useWebSearchSettings } from '@renderer/hooks/useWebSearch'
import { Info } from 'lucide-react'
@@ -6,7 +6,7 @@ import type { FC } from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingDivider, SettingGroup, SettingTitle } from '../..'
import { SettingGroup, SettingTitle } from '../..'
import { useWebSearchPersist } from '../hooks/useWebSearchPersist'
import { parseWebSearchBlacklistInput } from '../utils/webSearchBlacklist'
@@ -50,44 +50,51 @@ const BlacklistSettings: FC = () => {
return (
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.tool.websearch.blacklist')}</SettingTitle>
<SettingDivider />
<div className="space-y-2 py-2.5">
<div className="flex items-center gap-2 text-foreground-muted text-sm leading-5">
<span>{t('settings.tool.websearch.blacklist_description')}</span>
<span className="rounded-md bg-muted px-1.5 py-px font-medium text-foreground-muted text-xs leading-tight">
{excludeDomains.length}
</span>
</div>
<div className="relative">
<Textarea.Input
value={blacklistInput}
onChange={(e) => setBlacklistInput(e.target.value)}
placeholder={t('settings.tool.websearch.blacklist_tooltip')}
className="max-h-40 min-h-28 rounded-lg pr-20 text-sm leading-5 shadow-none"
rows={4}
<SettingTitle>
<span className="flex min-w-0 items-center gap-1.5">
{t('settings.tool.websearch.blacklist')}
<InfoTooltip
content={t('settings.tool.websearch.blacklist_description')}
placement="right"
iconProps={{ size: 13, className: 'shrink-0 cursor-pointer text-foreground-muted' }}
/>
{blacklistDirty && (
<Button
type="button"
size="sm"
variant="outline"
className="absolute right-2 bottom-2 h-7 px-2.5"
onClick={() => void updateManualBlacklist(blacklistInput)}>
{t('common.save')}
</Button>
)}
</span>
<span className="shrink-0 rounded-md bg-muted px-1.5 py-px font-medium text-foreground-muted text-xs leading-tight">
{excludeDomains.length}
</span>
</SettingTitle>
<div className="mt-3">
<div className="space-y-2">
<div className="relative">
<Textarea.Input
value={blacklistInput}
onChange={(e) => setBlacklistInput(e.target.value)}
placeholder={t('settings.tool.websearch.blacklist_tooltip')}
className="max-h-40 min-h-28 rounded-lg pr-20 text-sm leading-5 shadow-none"
rows={4}
/>
{blacklistDirty && (
<Button
type="button"
size="sm"
variant="outline"
className="absolute right-2 bottom-2 h-7 px-2.5"
onClick={() => void updateManualBlacklist(blacklistInput)}>
{t('common.save')}
</Button>
)}
</div>
</div>
{invalidEntries.length > 0 && (
<Alert
className="mt-1"
message={t('settings.tool.websearch.blacklist_invalid_entries', {
entries: invalidEntries.join(', ')
})}
type="error"
/>
)}
</div>
{invalidEntries.length > 0 && (
<Alert
className="mt-1"
message={t('settings.tool.websearch.blacklist_invalid_entries', {
entries: invalidEntries.join(', ')
})}
type="error"
/>
)}
</SettingGroup>
)
}

View File

@@ -1,4 +1,4 @@
import { Button, ButtonGroup, Flex, InfoTooltip, Input, Label, Tooltip } from '@cherrystudio/ui'
import { Button, Flex, InfoTooltip, Input, Label, Tooltip } from '@cherrystudio/ui'
import { useTheme } from '@renderer/hooks/useTheme'
import type { WebSearchBasicAuthPatch } from '@renderer/hooks/useWebSearch'
import { formatApiKeys, splitApiKeyString, withoutTrailingSlash } from '@renderer/utils/api'
@@ -23,7 +23,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
SettingDivider,
SettingCard,
SettingGroup,
SettingHelpLink,
SettingHelpText,
@@ -296,7 +296,9 @@ export const WebSearchProviderSetting: FC<Props> = ({
<div className="min-w-0">
<SettingTitle>
<Flex className="min-w-0 items-center gap-2.5">
<span className="truncate font-semibold text-[17px] text-foreground">{provider.name}</span>
<span className="text-(length:--font-size-body-md) truncate font-semibold text-foreground">
{provider.name}
</span>
{officialWebsite && (
<SettingTitleExternalLink href={officialWebsite}>
<ExternalLink size={13} />
@@ -320,135 +322,146 @@ export const WebSearchProviderSetting: FC<Props> = ({
{isDefault ? t('settings.tool.websearch.is_default') : t('settings.tool.websearch.set_as_default')}
</Button>
</div>
<SettingDivider style={{ width: '100%', margin: '8px 0 12px' }} />
<div className={providerFormClassName}>
{usesLlmProviderApiKey && (
<div className={providerFieldClassName}>
<SettingSubtitle>{t('settings.provider.api_key.label')}</SettingSubtitle>
<Button variant="outline" size="sm" className="w-fit" onClick={openLlmProviderSettings}>
<ExternalLink size={14} />
{t('navigate.provider_settings')}
</Button>
</div>
)}
{showApiKeySettings && !usesLlmProviderApiKey && (
<div className={providerFieldClassName}>
<div className={providerFieldHeaderClassName}>
<SettingSubtitle>{t('settings.provider.api_key.label')}</SettingSubtitle>
<Tooltip content={t('settings.provider.api.key.list.open')} delay={500}>
<Button
variant="ghost"
size="icon-sm"
className="text-icon hover:text-foreground"
aria-label={t('settings.provider.api.key.list.open')}
onClick={openApiKeyList}>
<List size={14} />
{(usesLlmProviderApiKey || showApiKeySettings || showApiHostSetting || supportsBasicAuth) && (
<SettingCard>
<div className={providerFormClassName}>
{usesLlmProviderApiKey && (
<div className={providerFieldClassName}>
<SettingSubtitle>{t('settings.provider.api_key.label')}</SettingSubtitle>
<Button variant="outline" size="sm" className="w-fit" onClick={openLlmProviderSettings}>
<ExternalLink size={14} />
{t('navigate.provider_settings')}
</Button>
</Tooltip>
</div>
<ButtonGroup className="w-full">
<Input
type="password"
value={apiKeysInput}
placeholder={t('settings.provider.api_key.label')}
onChange={(e) => setApiKeysInput(e.target.value)}
onBlur={() => void persist(commitApiKeysDraft, 'Failed to save web search API keys')}
spellCheck={false}
autoFocus={provider.apiKeys.length === 0}
className="min-w-0 flex-1"
/>
<Button
variant="outline"
className="h-9 shrink-0 px-3 shadow-none"
disabled={providerCheck.checking}
onClick={() => void checkProvider()}>
{t('settings.tool.websearch.check')}
</Button>
</ButtonGroup>
{apiKeyWebsite && (
<SettingHelpTextRow className={providerHelpRowClassName}>
<SettingHelpLink target="_blank" href={apiKeyWebsite}>
{t('settings.provider.get_api_key')}
</SettingHelpLink>
</SettingHelpTextRow>
</div>
)}
{showApiKeySettings && !usesLlmProviderApiKey && (
<div className={providerFieldClassName}>
<div className={providerFieldHeaderClassName}>
<SettingSubtitle>{t('settings.provider.api_key.label')}</SettingSubtitle>
<Tooltip content={t('settings.provider.api.key.list.open')} delay={500}>
<Button
variant="ghost"
size="icon-sm"
className="text-foreground/80 hover:text-foreground [&_svg]:[stroke-width:1.6]"
aria-label={t('settings.provider.api.key.list.open')}
onClick={openApiKeyList}>
<List size={14} />
</Button>
</Tooltip>
</div>
<div className="flex w-full min-w-0 items-center gap-1.25">
<Input
type="password"
value={apiKeysInput}
placeholder={t('settings.provider.api_key.label')}
onChange={(e) => setApiKeysInput(e.target.value)}
onBlur={() => void persist(commitApiKeysDraft, 'Failed to save web search API keys')}
spellCheck={false}
autoFocus={provider.apiKeys.length === 0}
className="min-w-0 flex-1"
/>
<Button
variant="outline"
className="h-8 shrink-0 rounded-lg"
disabled={providerCheck.checking}
onClick={() => void checkProvider()}>
{t('settings.tool.websearch.check')}
</Button>
</div>
{apiKeyWebsite && (
<SettingHelpTextRow className={providerHelpRowClassName}>
<SettingHelpLink target="_blank" href={apiKeyWebsite}>
{t('settings.provider.get_api_key')}
</SettingHelpLink>
</SettingHelpTextRow>
)}
</div>
)}
{showApiHostSetting && (
<div className={providerFieldClassName}>
<SettingSubtitle>{t('settings.provider.api_host')}</SettingSubtitle>
<div className="flex w-full min-w-0 items-center gap-1.25">
<Input
value={apiHostInput}
placeholder={t('settings.provider.api_host')}
onChange={(e) => setApiHostInput(e.target.value)}
onBlur={() => void persist(commitApiHostDraft, 'Failed to save web search API host')}
className="min-w-0 flex-1"
/>
{showApiHostCheckButton && (
<Button
variant="outline"
className="h-8 shrink-0 rounded-lg"
disabled={providerCheck.checking}
onClick={() => void checkProvider()}>
{t('settings.tool.websearch.check')}
</Button>
)}
</div>
</div>
)}
{supportsBasicAuth && (
<>
<SettingSubtitle
style={{
marginTop: 5,
marginBottom: 10,
display: 'flex',
flexDirection: 'row',
alignItems: 'center'
}}>
{t('settings.provider.basic_auth.label')}
<InfoTooltip
placement="right"
content={t('settings.provider.basic_auth.tip')}
iconProps={{
size: 16,
color: 'var(--color-icon)',
className: 'ml-1 cursor-pointer'
}}
/>
</SettingSubtitle>
<div className="flex w-full flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="websearch-basic-auth-username">
{t('settings.provider.basic_auth.user_name.label')}
</Label>
<Input
id="websearch-basic-auth-username"
value={basicAuthUsernameInput}
placeholder={t('settings.provider.basic_auth.user_name.tip')}
onChange={(e) => setBasicAuthUsernameDraft(e.target.value)}
onBlur={() =>
void persist(commitBasicAuthDraft, 'Failed to save web search basic auth username')
}
/>
</div>
{basicAuthUsernameInput && (
<div className="flex flex-col gap-2">
<Label htmlFor="websearch-basic-auth-password">
{t('settings.provider.basic_auth.password.label')}
</Label>
<Input
id="websearch-basic-auth-password"
type="password"
value={basicAuthPasswordInput}
placeholder={t('settings.provider.basic_auth.password.tip')}
onChange={(e) => setBasicAuthPasswordInput(e.target.value)}
onBlur={() =>
void persist(commitBasicAuthDraft, 'Failed to save web search basic auth password')
}
/>
</div>
)}
</div>
</>
)}
</div>
)}
{showApiHostSetting && (
<div className={providerFieldClassName}>
<SettingSubtitle>{t('settings.provider.api_host')}</SettingSubtitle>
<ButtonGroup className="w-full">
<Input
value={apiHostInput}
placeholder={t('settings.provider.api_host')}
onChange={(e) => setApiHostInput(e.target.value)}
onBlur={() => void persist(commitApiHostDraft, 'Failed to save web search API host')}
className="min-w-0 flex-1"
/>
{showApiHostCheckButton && (
<Button
variant="outline"
className="h-9 shrink-0 px-3 shadow-none"
disabled={providerCheck.checking}
onClick={() => void checkProvider()}>
{t('settings.tool.websearch.check')}
</Button>
)}
</ButtonGroup>
</div>
)}
{supportsBasicAuth && (
<>
<SettingDivider style={{ marginTop: 0, marginBottom: 0 }} />
<SettingSubtitle
style={{ marginTop: 5, marginBottom: 10, display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
{t('settings.provider.basic_auth.label')}
<InfoTooltip
placement="right"
content={t('settings.provider.basic_auth.tip')}
iconProps={{
size: 16,
color: 'var(--color-icon)',
className: 'ml-1 cursor-pointer'
}}
/>
</SettingSubtitle>
<div className="flex w-full flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="websearch-basic-auth-username">
{t('settings.provider.basic_auth.user_name.label')}
</Label>
<Input
id="websearch-basic-auth-username"
value={basicAuthUsernameInput}
placeholder={t('settings.provider.basic_auth.user_name.tip')}
onChange={(e) => setBasicAuthUsernameDraft(e.target.value)}
onBlur={() => void persist(commitBasicAuthDraft, 'Failed to save web search basic auth username')}
/>
</div>
{basicAuthUsernameInput && (
<div className="flex flex-col gap-2">
<Label htmlFor="websearch-basic-auth-password">
{t('settings.provider.basic_auth.password.label')}
</Label>
<Input
id="websearch-basic-auth-password"
type="password"
value={basicAuthPasswordInput}
placeholder={t('settings.provider.basic_auth.password.tip')}
onChange={(e) => setBasicAuthPasswordInput(e.target.value)}
onBlur={() => void persist(commitBasicAuthDraft, 'Failed to save web search basic auth password')}
/>
</div>
)}
</div>
</>
)}
</div>
</SettingCard>
)}
</SettingGroup>
</SettingsContentColumn>
)

View File

@@ -92,7 +92,7 @@ const WebSearchSettings: FC = () => {
labelClassName={settingsSubmenuItemLabelClassName}
suffix={
isDefault ? (
<Badge className="mr-0 ml-auto rounded-full border border-green-500/30 bg-green-500/10 px-2.5 py-0.5 font-medium text-green-600 text-xs dark:text-green-400">
<Badge className="mr-0 ml-auto rounded-full border border-success/30 bg-success/10 px-2.5 py-0.5 font-medium text-success text-xs">
{t('common.default')}
</Badge>
) : undefined

View File

@@ -1,41 +1,299 @@
export {
SettingContainer,
SettingDescription,
SettingDivider,
SettingGroup,
SettingHelpLink,
SettingHelpText,
SettingHelpTextRow,
SettingRow,
SettingRowTitle,
SettingsContentBody,
SettingsContentColumn,
SettingSubtitle,
SettingTitle,
SettingTitleExternalLink
} from '@renderer/components/SettingsPrimitives'
import { InfoTooltip, NormalTooltip } from '@cherrystudio/ui'
import { cn } from '@renderer/utils/style'
import type { ThemeMode } from '@shared/data/preference/preferenceTypes'
import React from 'react'
// Flatten a label node to plain text so an overflow tooltip can show the full string
// even when the label mixes text with an inline icon (e.g. a trailing InfoTooltip).
const getNodeText = (node: React.ReactNode): string => {
if (node === null || node === undefined || typeof node === 'boolean') return ''
if (typeof node === 'string' || typeof node === 'number') return String(node)
if (Array.isArray(node)) return node.map(getNodeText).join('')
if (React.isValidElement(node)) return getNodeText((node.props as { children?: React.ReactNode }).children)
return ''
}
// Horizontal divider between setting rows — re-exported from the shared Divider primitive.
export { Divider as SettingDivider } from '@cherrystudio/ui'
// Legacy scrollable settings shell with uniform padding — kept for pages that don't use SettingsContentColumn.
export const SettingContainer = ({
className,
theme,
...props
}: React.ComponentPropsWithoutRef<'div'> & { theme?: ThemeMode }) => (
<div
data-theme-mode={theme}
className={cn('flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto p-4 [&::-webkit-scrollbar]:hidden', className)}
{...props}
/>
)
// Canonical settings page container — mirrors the model service (Provider Settings) detail column:
// outer px-6 py-4 + inner mx-auto max-w-3xl. Use for "simple right-content" settings pages.
// Pages with their own internal split layout (Data / Integration / MCP / WebSearch / FileProcessing / Channels)
// keep SettingContainer instead. See DESIGN.md §4 "Settings Page Content Container".
export const SettingsContentColumn = ({
className,
innerClassName,
theme,
children,
...rest
}: React.ComponentPropsWithoutRef<'div'> & { theme?: ThemeMode; innerClassName?: string }) => (
<div
data-theme-mode={theme}
className={cn(
'flex min-h-0 flex-1 flex-col overflow-y-auto px-6 py-4 pt-3 [&::-webkit-scrollbar]:hidden',
className
)}
{...rest}>
<div className={cn('mx-auto w-full max-w-3xl', innerClassName)}>{children}</div>
</div>
)
// Body variant for pages that handle their own Scrollbar (e.g. CommonSettings, ShortcutSettings).
// Renders the same two-layer structure (outer px-6 py-4, inner mx-auto max-w-3xl) without owning the scroll.
export const SettingsContentBody = ({
className,
innerClassName,
children,
...rest
}: React.ComponentPropsWithoutRef<'div'> & { innerClassName?: string }) => (
<div className={cn('flex min-h-full w-full flex-col px-6 py-4 pt-3', className)} {...rest}>
<div className={cn('mx-auto w-full max-w-3xl', innerClassName)}>{children}</div>
</div>
)
// Group / section title within a page (14px medium — bold-looking but lighter than semibold; a real CJK weight).
// Sits above each SettingCard or SettingGroup body.
export const SettingTitle = ({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) => (
<div className={cn('flex select-none items-center justify-between font-medium text-sm', className)} {...props} />
)
// Canonical 2-column page header: leading icon + 16px semibold title + optional description / action.
// Renders an <h1> for accessibility; pages should use this at the top, then SettingTitle for group titles below.
export const SettingsPageHeader = ({
icon,
title,
description,
action,
className,
...rest
}: Omit<React.ComponentPropsWithoutRef<'div'>, 'title'> & {
icon?: React.ReactNode
title: React.ReactNode
description?: React.ReactNode
action?: React.ReactNode
}) => (
<div className={cn('flex items-start justify-between gap-3', className)} {...rest}>
<div className="min-w-0">
<div className="flex items-center gap-2 text-foreground">
{icon ? <span className="inline-flex shrink-0 [&_svg]:size-5 [&_svg]:text-foreground">{icon}</span> : null}
<h1 className="m-0 select-none font-[550] text-lg leading-6">{title}</h1>
</div>
{description ? <p className="m-0 mt-1.5 text-foreground-muted text-xs">{description}</p> : null}
</div>
{action}
</div>
)
// Subtitle inside a SettingCard for nested subsections (14px medium, foreground).
export const SettingSubtitle = ({
ref,
className,
...props
}: React.HTMLAttributes<HTMLDivElement> & { ref?: React.RefObject<HTMLDivElement | null> }) => (
<div ref={ref} className={cn('select-none font-medium text-foreground text-sm', className)} {...props} />
)
// Caption-sized helper text under a SettingRow (muted, 12px).
export const SettingDescription = ({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) => (
<div className={cn('mt-2.5 text-foreground-muted text-xs', className)} {...props} />
)
// One horizontal setting row: label + control(s), gap-x-4, min-h-8.
export const SettingRow = ({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) => (
<div className={cn('flex min-h-8 flex-wrap items-center justify-between gap-x-4 gap-y-2', className)} {...props} />
)
// Row label (13px, normal weight to match span-reset text via the global `* { font-weight: normal }` base rule).
// Supports an optional `tip` tooltip rendered as a help-icon next to the text.
export const SettingRowTitle = ({
className,
tip,
children,
...props
}: React.ComponentPropsWithoutRef<'div'> & { tip?: React.ReactNode }) => {
const labelRef = React.useRef<HTMLSpanElement>(null)
const [isTruncated, setIsTruncated] = React.useState(false)
const [open, setOpen] = React.useState(false)
// Only plain-string labels get the truncate-span + overflow tooltip. Mixed children
// (leading icon + label, or a trailing InfoTooltip) render as direct flex children so the
// row's gap spacing is preserved — wrapping them in a single span would collapse the gap.
const isPlainText = typeof children === 'string' || typeof children === 'number'
const labelText = getNodeText(children)
React.useEffect(() => {
if (!isPlainText) return
const el = labelRef.current
if (!el) return
const measure = () => setIsTruncated(el.scrollWidth > el.clientWidth + 1)
measure()
const observer = new ResizeObserver(measure)
observer.observe(el)
return () => observer.disconnect()
}, [isPlainText, labelText])
const baseClassName = cn(
'text-(length:--font-size-body-xs) flex min-w-0 flex-1 items-center font-normal text-foreground leading-4.5',
className
)
const helpIcon = tip ? (
<InfoTooltip
content={tip}
iconProps={{ size: 14, className: 'ml-1.5 shrink-0 cursor-pointer text-foreground-muted' }}
/>
) : null
if (!isPlainText) {
return (
<div className={baseClassName} {...props}>
{children}
{helpIcon}
</div>
)
}
const canShowTooltip = isTruncated && labelText !== ''
return (
<div className={baseClassName} {...props}>
<NormalTooltip
content={labelText}
side="top"
align="start"
open={canShowTooltip ? open : false}
onOpenChange={setOpen}>
<span ref={labelRef} className="min-w-0 truncate">
{children}
</span>
</NormalTooltip>
{helpIcon}
</div>
)
}
// Horizontal container for inline help links + help text under a setting field.
export const SettingHelpTextRow = ({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) => (
<div className={cn('flex items-center py-1.25', className)} {...props} />
)
// Inline hint copy under a field (11px, low-emphasis foreground).
// Shows the full text in a tooltip on hover when it is actually truncated.
export const SettingHelpText = ({ className, children, ...props }: React.ComponentPropsWithoutRef<'div'>) => {
const textRef = React.useRef<HTMLDivElement>(null)
const [isTruncated, setIsTruncated] = React.useState(false)
const [open, setOpen] = React.useState(false)
const text = getNodeText(children)
React.useEffect(() => {
const el = textRef.current
if (!el) return
const measure = () => setIsTruncated(el.scrollWidth > el.clientWidth + 1)
measure()
const observer = new ResizeObserver(measure)
observer.observe(el)
return () => observer.disconnect()
}, [text])
const canShowTooltip = isTruncated && text !== ''
return (
<NormalTooltip content={text} side="top" align="start" open={canShowTooltip ? open : false} onOpenChange={setOpen}>
<div
ref={textRef}
className={cn('text-(length:--font-size-body-xs) min-w-0 truncate text-foreground/40', className)}
{...props}>
{children}
</div>
</NormalTooltip>
)
}
// Inline help link in caption tier (11px, blue info color).
export const SettingHelpLink = ({ className, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
<a
className={cn(
'!text-info text-(length:--font-size-body-xs) shrink-0 cursor-pointer whitespace-nowrap hover:underline',
className
)}
{...props}
/>
)
// External link displayed next to a SettingTitle (inline-flex, blue info color, opens in new tab by default).
export const SettingTitleExternalLink = ({
className,
target = '_blank',
rel = 'noreferrer',
...props
}: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
<a
target={target}
rel={rel}
className={cn('!text-info inline-flex items-center hover:underline', className)}
{...props}
/>
)
// Vertical group wrapper around a SettingTitle + body — adds top spacing between consecutive groups.
export const SettingGroup = ({
className,
theme,
...props
}: React.ComponentPropsWithoutRef<'div'> & { theme?: ThemeMode }) => (
<div data-theme-mode={theme} className={cn('mt-6 first:mt-0', className)} {...props} />
)
// Card shell for a group's rows — SettingTitle stays outside, rows go inside.
// Direct children get uniform row padding via the `*:` variant.
export const SettingCard = ({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) => (
<div className={cn('mt-3 rounded-xl border border-border/60 py-1.5 *:px-4 *:py-1.5', className)} {...props} />
)
// Left submenu scroll column — fixed settings width with a hairline right border.
export const settingsSubmenuScrollClassName =
'h-[calc(100vh-var(--navbar-height))] w-(--settings-width) border-border border-r-[0.5px]'
export const settingsSubmenuListClassName = 'flex flex-col gap-1 px-2.5 pb-2.5 [box-sizing:border-box]'
// Submenu list wrapper — vertical stack of MenuItems with small gaps and side padding.
export const settingsSubmenuListClassName = 'flex flex-col gap-0.5 px-2.5 pb-2.5 [box-sizing:border-box]'
// Submenu MenuItem — idle/hover/active states for a settings nav entry (selected surface + medium weight when active).
export const settingsSubmenuItemClassName =
'h-8 rounded-[10px] border-transparent px-2.5 font-normal text-foreground text-sm hover:!bg-muted data-[active=true]:!border-transparent data-[active=true]:!bg-muted data-[active=true]:!font-medium data-[active=true]:!text-foreground [&_svg]:size-4 [&_svg]:text-foreground'
'h-7.5 gap-2.5 rounded-lg border-transparent px-2.5 font-normal text-foreground/80 text-sm hover:!bg-muted hover:text-foreground data-[active=true]:!border-transparent data-[active=true]:!bg-selected data-[active=true]:!shadow-(--shadow-selected-outline) data-[active=true]:!font-medium data-[active=true]:!text-foreground [&_svg]:size-4 [&_svg]:text-current [&_svg]:[stroke-width:1.6]'
// Submenu MenuItem label — bumps to medium weight when the item is active.
export const settingsSubmenuItemLabelClassName = 'group-data-[active=true]:font-medium'
// Submenu section heading between groups of nav entries (muted, 12px).
export const settingsSubmenuSectionTitleClassName =
'px-2.5 pt-1.5 pb-1 font-normal text-foreground-muted text-xs first:pt-0'
// Submenu group divider — transparent spacer between nav sections.
export const settingsSubmenuDividerClassName = 'my-1 bg-transparent'
// Right content column scroll container — fills remaining width, hides horizontal overflow.
export const settingsContentScrollClassName = 'flex-1 min-h-0 min-w-0 overflow-x-hidden'
// Right content body — same two-layer padding as SettingsContentBody, applied via className.
export const settingsContentBodyClassName = 'flex min-h-full w-full flex-col px-6 py-4'
// Spacing wrapper below a 3-column page's content header.
export const settingsContentHeaderClassName = 'mb-5'
export const settingsContentHeaderTitleClassName = 'font-semibold text-foreground text-[15px]'
// 3-column page content-header title (15px, weight 550).
export const settingsContentHeaderTitleClassName = 'font-[550] text-foreground text-(length:--font-size-body-md)'
// 3-column page content-header description (muted, 14px).
export const settingsContentHeaderDescriptionClassName = 'mt-1 text-foreground-muted text-sm'