Commit b08febaa by t0ng7u

refactor: system settings UI for consistent, compact layouts

Redesign the system settings interface to align with the rest of the console experience by using fixed header actions, removing redundant subtitles, respecting global content width, and standardizing responsive form layouts.

Introduce reusable settings layout primitives for forms, switch rows, grouped controls, nested control sections, title status indicators, and page action portals. Replace duplicated card-style switch markup with explicit compact components, improve nested switch readability, and reduce visual noise across authentication, billing, content, integrations, maintenance, models, and request-limit settings.

Also complete missing i18n translations, remove obsolete subtitle translation keys, refine i18n sync reporting, fix sidebar truncation for long labels, and verify the frontend with type checking and lint diagnostics.
parent 92a09594
......@@ -29,6 +29,90 @@ const OBFUSCATED_KEYS = [
},
]
const BRAND_AND_LITERAL_KEYS = new Set([
'AI Proxy',
'AIGC2D',
'Alipay',
'Anthropic',
'API URL',
'API2GPT',
'AccessKey / SecretAccessKey',
'AZURE_OPENAI_ENDPOINT *',
'Baidu V2',
'ChatGPT',
'Claude',
'Client ID',
'Client Secret',
'Cloudflare',
'Cohere',
'DeepSeek',
'Discord',
'DoubaoVideo',
'FastGPT',
'Gemini',
'Gemini Image 4K',
'GitHub',
'Jimeng',
'JustSong',
'LingYiWanWu',
'LinuxDO',
'Midjourney',
'MidjourneyPlus',
'Midjourney-Proxy',
'MiniMax',
'Mistral',
'MokaAI',
'Moonshot',
'New API',
'New API <noreply@example.com>',
'NewAPI',
'OAuth Client Secret',
'OhMyGPT',
'Ollama',
'One API',
'OpenAI',
'OpenAIMax',
'OpenRouter',
'Pancake',
'Passkey',
'Perplexity',
'QuantumNous',
'Quota:',
'Replicate',
'SiliconFlow',
'Stripe',
'Submodel',
'SunoAPI',
'Telegram',
'Tencent',
'TTFT P50',
'TTFT P95',
'TTFT P99',
'Uptime Kuma',
'Uptime Kuma URL',
'Vertex AI',
'VolcEngine',
'Waffo Pancake Dashboard',
'Waffo Pancake MoR',
'WeChat',
'WeChat Pay',
'Webhook URL',
'Webhook URL:',
'Well-Known URL',
'Worker URL',
'Xinference',
'Xunfei',
'Zhipu V4',
'"default": "us-central1", "claude-3-5-sonnet-20240620": "europe-west1"',
'edit_this',
'footer.columns.related.links.midjourney',
'footer.columns.related.links.newApiKeyTool',
'my-status',
'new-api-key-tool',
'price_xxx',
'whsec_xxx',
])
function isPlainObject(v) {
return typeof v === 'object' && v !== null && !Array.isArray(v)
}
......@@ -97,6 +181,24 @@ function isLikelyUntranslated({ locale, baseValue, value }) {
// Skip short tokens / acronyms / ids
const s = baseValue.trim()
if (BRAND_AND_LITERAL_KEYS.has(s)) return false
if (
/^https?:\/\//.test(s) ||
/^\/[\w/-]+/.test(s) ||
/^[\w.-]+@[\w.-]+$/.test(s) ||
/^smtp\./i.test(s) ||
/^socks5:/i.test(s) ||
/^org-/.test(s) ||
/^gpt-/i.test(s) ||
/^checkout\./.test(s) ||
/^footer\./.test(s) ||
/^[A-Z0-9_ *./:-]+$/.test(s) ||
s.startsWith('{') ||
s.startsWith('[') ||
s.includes('
')
) {
return false
}
if (s.length < 6) return false
if (!/[A-Za-z]{3,}/.test(s)) return false
......@@ -187,6 +289,8 @@ async function main() {
if (Object.keys(extras).length > 0) {
await fs.writeFile(path.join(extrasDir, `${locale}.extras.json`), stableStringify(extras), 'utf8')
} else {
await fs.rm(path.join(extrasDir, `${locale}.extras.json`), { force: true })
}
if (Object.keys(untranslated).length > 0) {
await fs.writeFile(
......@@ -194,6 +298,8 @@ async function main() {
stableStringify(untranslated),
'utf8',
)
} else {
await fs.rm(path.join(reportsDir, `${locale}.untranslated.json`), { force: true })
}
// Rewrite locale file in base order (even for en to normalize formatting)
......
......@@ -231,9 +231,9 @@ export function ChatPresetsItem({ item }: { item: NavChatPresets }) {
<DropdownMenuTrigger
render={<SidebarMenuButton tooltip={item.title} />}
>
{item.icon && <item.icon className='h-4 w-4' />}
<span>{item.title}</span>
<ChevronRight className='ms-auto h-4 w-4 opacity-70' />
{item.icon && <item.icon className='h-4 w-4 shrink-0' />}
<span className='min-w-0 flex-1 truncate'>{item.title}</span>
<ChevronRight className='ms-auto h-4 w-4 shrink-0 opacity-70' />
</DropdownMenuTrigger>
<DropdownMenuContent align='start'>
{visiblePresets.map((preset) => (
......@@ -261,9 +261,9 @@ export function ChatPresetsItem({ item }: { item: NavChatPresets }) {
className='group/collapsible-trigger'
render={<SidebarMenuButton />}
>
{item.icon && <item.icon />}
<span>{item.title}</span>
<ChevronRight className='ms-auto transition-transform duration-200 group-data-[panel-open]/collapsible-trigger:rotate-90' />
{item.icon && <item.icon className='shrink-0' />}
<span className='min-w-0 flex-1 truncate'>{item.title}</span>
<ChevronRight className='ms-auto size-4 shrink-0 transition-transform duration-200 group-data-[panel-open]/collapsible-trigger:rotate-90' />
</CollapsibleTrigger>
<CollapsibleContent className='CollapsibleContent'>
<SidebarMenuSub>
......
......@@ -112,7 +112,7 @@ export function NavGroup({ title, items }: NavGroupProps) {
* Navigation badge component
*/
function NavBadge({ children }: { children: ReactNode }) {
return <Badge className='px-1 py-0 text-xs'>{children}</Badge>
return <Badge className='shrink-0 px-1 py-0 text-xs'>{children}</Badge>
}
/**
......@@ -127,8 +127,8 @@ function SidebarMenuLink({ item, href }: { item: NavLink; href: string }) {
tooltip={item.title}
render={<Link to={item.url} onClick={() => setOpenMobile(false)} />}
>
{item.icon && <item.icon />}
<span>{item.title}</span>
{item.icon && <item.icon className='shrink-0' />}
<span className='min-w-0 flex-1 truncate'>{item.title}</span>
{item.badge && <NavBadge>{item.badge}</NavBadge>}
</SidebarMenuButton>
</SidebarMenuItem>
......@@ -170,10 +170,10 @@ function SidebarMenuCollapsible({
className='group/collapsible-trigger'
render={<SidebarMenuButton tooltip={item.title} />}
>
{item.icon && <item.icon />}
<span>{item.title}</span>
{item.icon && <item.icon className='shrink-0' />}
<span className='min-w-0 flex-1 truncate'>{item.title}</span>
{item.badge && <NavBadge>{item.badge}</NavBadge>}
<ChevronRight className='ms-auto transition-transform duration-200 group-data-[panel-open]/collapsible-trigger:rotate-90' />
<ChevronRight className='ms-auto size-4 shrink-0 transition-transform duration-200 group-data-[panel-open]/collapsible-trigger:rotate-90' />
</CollapsibleTrigger>
<CollapsibleContent className='CollapsibleContent'>
<SidebarMenuSub>
......@@ -185,8 +185,8 @@ function SidebarMenuCollapsible({
<Link to={subItem.url} onClick={() => setOpenMobile(false)} />
}
>
{subItem.icon && <subItem.icon />}
<span>{subItem.title}</span>
{subItem.icon && <subItem.icon className='shrink-0' />}
<span className='min-w-0 flex-1 truncate'>{subItem.title}</span>
{subItem.badge && <NavBadge>{subItem.badge}</NavBadge>}
</SidebarMenuSubButton>
</SidebarMenuSubItem>
......@@ -219,10 +219,10 @@ function SidebarMenuCollapsedDropdown({
/>
}
>
{item.icon && <item.icon />}
<span>{item.title}</span>
{item.icon && <item.icon className='shrink-0' />}
<span className='min-w-0 flex-1 truncate'>{item.title}</span>
{item.badge && <NavBadge>{item.badge}</NavBadge>}
<ChevronRight className='ms-auto transition-transform duration-200 group-data-[popup-open]/dropdown-trigger:rotate-90' />
<ChevronRight className='ms-auto size-4 shrink-0 transition-transform duration-200 group-data-[popup-open]/dropdown-trigger:rotate-90' />
</DropdownMenuTrigger>
<DropdownMenuContent side='right' align='start' sideOffset={4}>
<DropdownMenuGroup>
......
......@@ -33,11 +33,6 @@ function SectionPageLayoutTitle(_props: SlotProps) {
}
SectionPageLayoutTitle.displayName = 'SectionPageLayout.Title'
function SectionPageLayoutDescription(_props: SlotProps) {
return null
}
SectionPageLayoutDescription.displayName = 'SectionPageLayout.Description'
function SectionPageLayoutActions(_props: SlotProps) {
return null
}
......@@ -87,13 +82,13 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
<div className='mb-2 sm:mb-3'>{breadcrumb}</div>
)}
<div className='flex flex-wrap items-center justify-between gap-x-3 gap-y-2 sm:gap-x-4'>
<div className='min-w-0'>
<div className='min-w-0 flex-1'>
<h2 className='truncate text-base font-bold tracking-tight sm:text-lg'>
{title}
</h2>
</div>
{actions != null && (
<div className='flex shrink-0 flex-wrap items-center gap-2 sm:gap-x-4'>
<div className='flex shrink-0 flex-wrap items-center justify-end gap-2 sm:gap-x-4'>
{actions}
</div>
)}
......@@ -114,7 +109,6 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
}
SectionPageLayout.Title = SectionPageLayoutTitle
SectionPageLayout.Description = SectionPageLayoutDescription
SectionPageLayout.Actions = SectionPageLayoutActions
SectionPageLayout.Content = SectionPageLayoutContent
SectionPageLayout.Breadcrumb = SectionPageLayoutBreadcrumb
......@@ -29,9 +29,6 @@ export function Channels() {
<ChannelsProvider>
<SectionPageLayout>
<SectionPageLayout.Title>{t('Channels')}</SectionPageLayout.Title>
<SectionPageLayout.Description>
{t('Manage API channels and provider configurations')}
</SectionPageLayout.Description>
<SectionPageLayout.Actions>
<ChannelsPrimaryButtons />
</SectionPageLayout.Actions>
......
......@@ -130,21 +130,15 @@ function PerformanceOverviewFallback() {
)
}
const SECTION_META: Record<
DashboardSectionId,
{ titleKey: string; descriptionKey: string }
> = {
const SECTION_META: Record<DashboardSectionId, { titleKey: string }> = {
overview: {
titleKey: 'Overview',
descriptionKey: 'View dashboard overview and statistics',
},
models: {
titleKey: 'Model Call Analytics',
descriptionKey: 'View model call count analytics and charts',
},
users: {
titleKey: 'User Analytics',
descriptionKey: 'View user consumption statistics and charts',
},
}
......@@ -227,9 +221,6 @@ export function Dashboard() {
return (
<SectionPageLayout>
<SectionPageLayout.Title>{t(meta.titleKey)}</SectionPageLayout.Title>
<SectionPageLayout.Description>
{t(meta.descriptionKey)}
</SectionPageLayout.Description>
<SectionPageLayout.Content>
<div className='space-y-3 sm:space-y-4'>
{activeSection !== 'overview' && (
......
......@@ -26,19 +26,16 @@ const DASHBOARD_SECTIONS = [
{
id: 'overview',
titleKey: 'Overview',
descriptionKey: 'View dashboard overview and statistics',
build: () => null,
},
{
id: 'models',
titleKey: 'Model Call Analytics',
descriptionKey: 'View model call count analytics and charts',
build: () => null,
},
{
id: 'users',
titleKey: 'User Analytics',
descriptionKey: 'View user consumption statistics and charts',
adminOnly: true,
build: () => null,
},
......
......@@ -29,9 +29,6 @@ export function ApiKeys() {
<ApiKeysProvider>
<SectionPageLayout>
<SectionPageLayout.Title>{t('API Keys')}</SectionPageLayout.Title>
<SectionPageLayout.Description>
{t('Manage your API keys for accessing the service')}
</SectionPageLayout.Description>
<SectionPageLayout.Actions>
<ApiKeysPrimaryButtons />
</SectionPageLayout.Actions>
......
......@@ -42,17 +42,12 @@ import {
const route = getRouteApi('/_authenticated/models/$section')
const SECTION_META: Record<
ModelsSectionId,
{ titleKey: string; descriptionKey: string }
> = {
const SECTION_META: Record<ModelsSectionId, { titleKey: string }> = {
metadata: {
titleKey: 'Metadata',
descriptionKey: 'Manage model metadata and configuration',
},
deployments: {
titleKey: 'Deployments',
descriptionKey: 'Manage model deployments',
},
}
......@@ -126,9 +121,6 @@ function ModelsContent() {
<>
<SectionPageLayout>
<SectionPageLayout.Title>{t(meta.titleKey)}</SectionPageLayout.Title>
<SectionPageLayout.Description>
{t(meta.descriptionKey)}
</SectionPageLayout.Description>
<SectionPageLayout.Actions>
{activeSection === 'metadata' ? (
<ModelsPrimaryButtons />
......
......@@ -25,13 +25,11 @@ const MODELS_SECTIONS = [
{
id: 'metadata',
titleKey: 'Metadata',
descriptionKey: 'Manage model metadata and configuration',
build: () => null, // Content is rendered directly in the page component
},
{
id: 'deployments',
titleKey: 'Deployments',
descriptionKey: 'Manage model deployments',
build: () => null, // Content is rendered directly in the page component
},
] as const
......
......@@ -31,9 +31,6 @@ export function Redemptions() {
<SectionPageLayout.Title>
{t('Redemption Codes')}
</SectionPageLayout.Title>
<SectionPageLayout.Description>
{t('Manage redemption codes for quota top-up')}
</SectionPageLayout.Description>
<SectionPageLayout.Actions>
<RedemptionsPrimaryButtons />
</SectionPageLayout.Actions>
......
......@@ -38,9 +38,6 @@ function SubscriptionsContent() {
<SectionPageLayout.Title>
{t('Subscription Management')}
</SectionPageLayout.Title>
<SectionPageLayout.Description>
{t('Manage subscription plan creation, pricing and status')}
</SectionPageLayout.Description>
<SectionPageLayout.Actions>
<div className='flex items-center gap-2'>
<Alert variant='default' className='hidden px-3 py-2 sm:flex'>
......
......@@ -21,7 +21,6 @@ import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
......@@ -33,6 +32,12 @@ import {
} from '@/components/ui/form'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import {
SettingsForm,
SettingsSwitchContent,
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useResetForm } from '../hooks/use-reset-form'
import { useUpdateOption } from '../hooks/use-update-option'
......@@ -100,32 +105,31 @@ export function BasicAuthSection({ defaultValues }: BasicAuthSectionProps) {
}
return (
<SettingsSection
title={t('Basic Authentication')}
description={t('Configure password-based login and registration')}
>
<SettingsSection title={t('Basic Authentication')}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
isSaving={updateOption.isPending}
/>
<FormField
control={form.control}
name='PasswordLoginEnabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Password Login')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Password Login')}</FormLabel>
<FormDescription>
{t('Allow users to log in with password')}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
......@@ -133,22 +137,20 @@ export function BasicAuthSection({ defaultValues }: BasicAuthSectionProps) {
control={form.control}
name='RegisterEnabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Registration Enabled')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Registration Enabled')}</FormLabel>
<FormDescription>
{t('Allow new users to register')}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
......@@ -156,22 +158,20 @@ export function BasicAuthSection({ defaultValues }: BasicAuthSectionProps) {
control={form.control}
name='PasswordRegisterEnabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Password Registration')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Password Registration')}</FormLabel>
<FormDescription>
{t('Allow registration with password')}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
......@@ -179,22 +179,20 @@ export function BasicAuthSection({ defaultValues }: BasicAuthSectionProps) {
control={form.control}
name='EmailVerificationEnabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Email Verification')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Email Verification')}</FormLabel>
<FormDescription>
{t('Require email verification for new accounts')}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
......@@ -202,22 +200,20 @@ export function BasicAuthSection({ defaultValues }: BasicAuthSectionProps) {
control={form.control}
name='EmailDomainRestrictionEnabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Email Domain Restriction')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Email Domain Restriction')}</FormLabel>
<FormDescription>
{t('Only allow specific email domains')}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
......@@ -225,22 +221,20 @@ export function BasicAuthSection({ defaultValues }: BasicAuthSectionProps) {
control={form.control}
name='EmailAliasRestrictionEnabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Email Alias Restriction')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Email Alias Restriction')}</FormLabel>
<FormDescription>
{t('Block email aliases (e.g., user+alias@domain.com)')}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
......@@ -266,11 +260,7 @@ export function BasicAuthSection({ defaultValues }: BasicAuthSectionProps) {
</FormItem>
)}
/>
<Button type='submit' disabled={updateOption.isPending}>
{updateOption.isPending ? t('Saving...') : t('Save Changes')}
</Button>
</form>
</SettingsForm>
</Form>
</SettingsSection>
)
......
......@@ -21,7 +21,6 @@ import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
......@@ -33,6 +32,12 @@ import {
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import {
SettingsForm,
SettingsSwitchContent,
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
......@@ -75,40 +80,33 @@ export function BotProtectionSection({
}
return (
<SettingsSection
title={t('Bot Protection')}
description={t(
'Protect login and registration with Cloudflare Turnstile'
)}
>
<SettingsSection title={t('Bot Protection')}>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-6'
autoComplete='off'
>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)} autoComplete='off'>
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
isSaving={updateOption.isPending}
/>
<FormField
control={form.control}
name='TurnstileCheckEnabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Enable Turnstile')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Enable Turnstile')}</FormLabel>
<FormDescription>
{t(
'Protect login and registration with Cloudflare Turnstile'
)}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
......@@ -148,11 +146,7 @@ export function BotProtectionSection({
</FormItem>
)}
/>
<Button type='submit' disabled={updateOption.isPending}>
{updateOption.isPending ? t('Saving...') : t('Save Changes')}
</Button>
</form>
</SettingsForm>
</Form>
</SettingsSection>
)
......
......@@ -29,6 +29,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { SettingsControlGroup } from '../../../components/settings-form-layout'
import { OAUTH_PRESETS, type CustomOAuthFormValues } from '../types'
type PresetSelectorProps = {
......@@ -102,7 +103,7 @@ export function PresetSelector(props: PresetSelectorProps) {
}
return (
<div className='space-y-3 rounded-lg border border-dashed p-4'>
<SettingsControlGroup className='space-y-3 border-dashed'>
<p className='text-sm font-medium'>{t('Quick Setup from Preset')}</p>
<div className='grid grid-cols-1 gap-3 sm:grid-cols-2'>
<div className='space-y-1.5'>
......@@ -140,6 +141,6 @@ export function PresetSelector(props: PresetSelectorProps) {
/>
</div>
</div>
</div>
</SettingsControlGroup>
)
}
......@@ -51,6 +51,11 @@ import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import {
SettingsForm,
SettingsSwitchContent,
SettingsSwitchItem,
} from '../../../components/settings-form-layout'
import {
useCreateProvider,
useUpdateProvider,
} from '../hooks/use-custom-oauth-mutations'
......@@ -185,7 +190,7 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) {
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
{/* Preset Selector (only for creating) */}
{!isEditing && <PresetSelector form={form} />}
......@@ -197,22 +202,20 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) {
control={form.control}
name='enabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Enabled')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Enabled')}</FormLabel>
<FormDescription>
{t('Allow users to sign in with this provider')}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
......@@ -602,7 +605,7 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) {
: t('Create Provider')}
</Button>
</DialogFooter>
</form>
</SettingsForm>
</Form>
</DialogContent>
</Dialog>
......
......@@ -50,12 +50,7 @@ export function CustomOAuthSection() {
if (isLoading) {
return (
<SettingsSection
title={t('Custom OAuth Providers')}
description={t(
'Configure custom OAuth providers for user authentication'
)}
>
<SettingsSection title={t('Custom OAuth Providers')}>
<div className='text-muted-foreground py-8 text-center text-sm'>
{t('Loading...')}
</div>
......@@ -64,12 +59,7 @@ export function CustomOAuthSection() {
}
return (
<SettingsSection
title={t('Custom OAuth Providers')}
description={t(
'Configure custom OAuth providers for user authentication'
)}
>
<SettingsSection title={t('Custom OAuth Providers')}>
<ProviderTable
providers={providers}
onEdit={handleEdit}
......
......@@ -21,6 +21,7 @@ import type { AuthSettings } from '../types'
import {
AUTH_DEFAULT_SECTION,
getAuthSectionContent,
getAuthSectionMeta,
} from './section-registry.tsx'
const defaultAuthSettings: AuthSettings = {
......@@ -74,6 +75,7 @@ export function AuthSettings() {
defaultSettings={defaultAuthSettings}
defaultSection={AUTH_DEFAULT_SECTION}
getSectionContent={getAuthSectionContent}
getSectionMeta={getAuthSectionMeta}
/>
)
}
......@@ -21,7 +21,6 @@ import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
......@@ -42,6 +41,12 @@ import {
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import {
SettingsForm,
SettingsSwitchContent,
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useResetForm } from '../hooks/use-reset-form'
import { useUpdateOption } from '../hooks/use-update-option'
......@@ -156,34 +161,33 @@ export function PasskeySection({ defaultValues }: PasskeySectionProps) {
}
return (
<SettingsSection
title={t('Passkey Authentication')}
description={t('Configure Passkey (WebAuthn) login settings')}
>
<SettingsSection title={t('Passkey Authentication')}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
isSaving={updateOption.isPending}
/>
<FormField
control={form.control}
name='passkey.enabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Enable Passkey')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Enable Passkey')}</FormLabel>
<FormDescription>
{t(
'Allow users to register and sign in with Passkey (WebAuthn)'
)}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
......@@ -323,24 +327,22 @@ export function PasskeySection({ defaultValues }: PasskeySectionProps) {
control={form.control}
name='passkey.allow_insecure_origin'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Allow Insecure Origins')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Allow Insecure Origins')}</FormLabel>
<FormDescription>
{t(
'Permit Passkey registration on non-HTTPS origins (only recommended for development)'
)}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
......@@ -367,9 +369,7 @@ export function PasskeySection({ defaultValues }: PasskeySectionProps) {
</FormItem>
)}
/>
<Button type='submit'>{t('Save Changes')}</Button>
</form>
</SettingsForm>
</Form>
</SettingsSection>
)
......
......@@ -28,7 +28,6 @@ const AUTH_SECTIONS = [
{
id: 'basic-auth',
titleKey: 'Basic Authentication',
descriptionKey: 'Configure password-based login and registration',
build: (settings: AuthSettings) => (
<BasicAuthSection
defaultValues={{
......@@ -46,7 +45,6 @@ const AUTH_SECTIONS = [
{
id: 'oauth',
titleKey: 'OAuth Integrations',
descriptionKey: 'Configure third-party authentication providers',
build: (settings: AuthSettings) => (
<OAuthSection
defaultValues={{
......@@ -82,7 +80,6 @@ const AUTH_SECTIONS = [
{
id: 'passkey',
titleKey: 'Passkey Authentication',
descriptionKey: 'Configure Passkey (WebAuthn) login settings',
build: (settings: AuthSettings) => (
<PasskeySection
defaultValues={{
......@@ -111,7 +108,6 @@ const AUTH_SECTIONS = [
{
id: 'bot-protection',
titleKey: 'Bot Protection',
descriptionKey: 'Protect login and registration with Cloudflare Turnstile',
build: (settings: AuthSettings) => (
<BotProtectionSection
defaultValues={{
......@@ -125,7 +121,6 @@ const AUTH_SECTIONS = [
{
id: 'custom-oauth',
titleKey: 'Custom OAuth',
descriptionKey: 'Configure custom OAuth providers for user authentication',
build: () => <CustomOAuthSection />,
},
] as const
......@@ -143,3 +138,4 @@ export const AUTH_SECTION_IDS = authRegistry.sectionIds
export const AUTH_DEFAULT_SECTION = authRegistry.defaultSection
export const getAuthSectionNavItems = authRegistry.getSectionNavItems
export const getAuthSectionContent = authRegistry.getSectionContent
export const getAuthSectionMeta = authRegistry.getSectionMeta
......@@ -21,6 +21,7 @@ import type { BillingSettings } from '../types'
import {
BILLING_DEFAULT_SECTION,
getBillingSectionContent,
getBillingSectionMeta,
} from './section-registry.tsx'
const defaultBillingSettings: BillingSettings = {
......@@ -113,6 +114,7 @@ export function BillingSettings() {
defaultSettings={defaultBillingSettings}
defaultSection={BILLING_DEFAULT_SECTION}
getSectionContent={getBillingSectionContent}
getSectionMeta={getBillingSectionMeta}
/>
)
}
......@@ -54,7 +54,6 @@ const BILLING_SECTIONS = [
{
id: 'quota',
titleKey: 'Quota Settings',
descriptionKey: 'Configure user quota allocation and rewards',
build: (settings: BillingSettings) => (
<QuotaSettingsSection
defaultValues={{
......@@ -81,7 +80,6 @@ const BILLING_SECTIONS = [
{
id: 'currency',
titleKey: 'Currency & Display',
descriptionKey: 'Configure currency conversion and quota display options',
build: (settings: BillingSettings) => (
<PricingSection
defaultValues={{
......@@ -105,11 +103,9 @@ const BILLING_SECTIONS = [
{
id: 'model-pricing',
titleKey: 'Model Pricing',
descriptionKey: 'Configure model pricing ratios and tool prices',
build: (settings: BillingSettings) => (
<RatioSettingsCard
titleKey='Model Pricing'
descriptionKey='Configure model pricing ratios and tool prices'
modelDefaults={getModelDefaults(settings)}
groupDefaults={getGroupDefaults(settings)}
toolPricesDefault={settings['tool_price_setting.prices']}
......@@ -120,11 +116,9 @@ const BILLING_SECTIONS = [
{
id: 'group-pricing',
titleKey: 'Group Pricing',
descriptionKey: 'Configure group ratios and group-specific pricing rules',
build: (settings: BillingSettings) => (
<RatioSettingsCard
titleKey='Group Pricing'
descriptionKey='Configure group ratios and group-specific pricing rules'
modelDefaults={getModelDefaults(settings)}
groupDefaults={getGroupDefaults(settings)}
toolPricesDefault={settings['tool_price_setting.prices']}
......@@ -135,7 +129,6 @@ const BILLING_SECTIONS = [
{
id: 'payment',
titleKey: 'Payment Gateway',
descriptionKey: 'Configure payment gateway integrations',
build: (settings: BillingSettings) => (
<PaymentSettingsSection
defaultValues={{
......@@ -196,7 +189,6 @@ const BILLING_SECTIONS = [
{
id: 'checkin',
titleKey: 'Check-in Rewards',
descriptionKey: 'Configure daily check-in rewards for users',
build: (settings: BillingSettings) => (
<CheckinSettingsSection
defaultValues={{
......@@ -225,3 +217,4 @@ export const BILLING_SECTION_IDS = billingRegistry.sectionIds
export const BILLING_DEFAULT_SECTION = billingRegistry.defaultSection
export const getBillingSectionNavItems = billingRegistry.getSectionNavItems
export const getBillingSectionContent = billingRegistry.getSectionContent
export const getBillingSectionMeta = billingRegistry.getSectionMeta
......@@ -16,9 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Info } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { SettingsPageTitleStatusPortal } from './settings-page-context'
type FormDirtyIndicatorProps = {
isDirty: boolean
......@@ -26,7 +25,7 @@ type FormDirtyIndicatorProps = {
}
/**
* Visual indicator that the form has unsaved changes
* Compact page-title status indicator for unsaved form changes.
*
* @example
* ```tsx
......@@ -41,14 +40,11 @@ export function FormDirtyIndicator({
if (!isDirty) return null
return (
<Alert
variant='default'
className='border-orange-500/50 bg-orange-50 dark:bg-orange-950/20'
>
<Info className='h-4 w-4 text-orange-600 dark:text-orange-500' />
<AlertDescription className='text-orange-800 dark:text-orange-400'>
{message ?? t('You have unsaved changes')}
</AlertDescription>
</Alert>
<SettingsPageTitleStatusPortal>
<span className='inline-flex h-5 items-center gap-1.5 rounded-full bg-amber-500/10 px-2 text-[11px] font-medium whitespace-nowrap text-amber-700 ring-1 ring-amber-500/20 ring-inset dark:bg-amber-400/10 dark:text-amber-300 dark:ring-amber-400/20'>
<span className='size-1.5 rounded-full bg-amber-500 dark:bg-amber-300' />
{message ? t(message) : t('Unsaved changes')}
</span>
</SettingsPageTitleStatusPortal>
)
}
......@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { cn } from '@/lib/utils'
import {
AccordionItem,
AccordionTrigger,
......@@ -25,7 +26,6 @@ import {
type SettingsAccordionProps = {
value: string
title: string
description?: string
children: React.ReactNode
className?: string
}
......@@ -33,18 +33,14 @@ type SettingsAccordionProps = {
export function SettingsAccordion({
value,
title,
description,
children,
className,
}: SettingsAccordionProps) {
return (
<AccordionItem value={value} className={className}>
<AccordionItem value={value} className={cn(className)}>
<AccordionTrigger className='hover:no-underline'>
<div className='flex flex-col gap-1 text-left'>
<div className='text-base font-semibold'>{title}</div>
{description && (
<div className='text-muted-foreground text-sm'>{description}</div>
)}
</div>
</AccordionTrigger>
<AccordionContent className='pt-4'>{children}</AccordionContent>
......
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { ComponentProps, ReactNode } from 'react'
import { cn } from '@/lib/utils'
import { FormItem } from '@/components/ui/form'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
type SettingsFormGridProps = {
children: ReactNode
className?: string
}
type SettingsFormGridItemProps = SettingsFormGridProps & {
span?: 'default' | 'full'
}
type SettingsSwitchItemProps = ComponentProps<typeof FormItem>
type SettingsSwitchRowProps = ComponentProps<'div'>
type SettingsControlGroupProps = ComponentProps<'div'>
type SettingsControlChildrenProps = ComponentProps<'div'>
type SettingsSwitchFieldProps = SettingsSwitchRowProps & {
checked: boolean
onCheckedChange: (checked: boolean) => void
label: ReactNode
description?: ReactNode
disabled?: boolean
}
const settingsSwitchRowClassName =
'flex min-w-0 flex-row items-center justify-between gap-4 border-b py-2.5 last:border-b-0'
export function SettingsFormGrid(props: SettingsFormGridProps) {
return (
<div
data-settings-form-span='full'
className={cn(
'grid min-w-0 gap-x-5 gap-y-6 lg:grid-cols-2',
props.className
)}
>
{props.children}
</div>
)
}
export function SettingsFormGridItem(props: SettingsFormGridItemProps) {
return (
<div
data-settings-form-span={props.span === 'full' ? 'full' : undefined}
className={cn(
'min-w-0',
props.span === 'full' && 'lg:col-span-2',
props.className
)}
>
{props.children}
</div>
)
}
export function SettingsSwitchItem({
className,
...props
}: SettingsSwitchItemProps) {
return (
<FormItem
data-settings-form-span='full'
className={cn(settingsSwitchRowClassName, className)}
{...props}
/>
)
}
export function SettingsSwitchRow({
className,
...props
}: SettingsSwitchRowProps) {
return (
<div
data-settings-form-span='full'
className={cn(settingsSwitchRowClassName, className)}
{...props}
/>
)
}
export function SettingsSwitchField({
checked,
onCheckedChange,
label,
description,
disabled,
className,
...props
}: SettingsSwitchFieldProps) {
return (
<SettingsSwitchRow className={className} {...props}>
<SettingsSwitchContent>
<Label className='text-sm font-medium'>{label}</Label>
{description ? (
<p className='text-muted-foreground text-xs'>{description}</p>
) : null}
</SettingsSwitchContent>
<Switch
checked={checked}
onCheckedChange={onCheckedChange}
disabled={disabled}
/>
</SettingsSwitchRow>
)
}
export function SettingsSwitchContent(props: SettingsFormGridProps) {
return (
<div className={cn('min-w-0 space-y-0.5', props.className)}>
{props.children}
</div>
)
}
export function SettingsControlGroup({
className,
...props
}: SettingsControlGroupProps) {
return (
<div
data-settings-form-span='full'
className={cn(
'bg-muted/20 min-w-0 space-y-3 rounded-xl border px-3 py-2.5',
className
)}
{...props}
/>
)
}
export function SettingsControlChildren({
className,
...props
}: SettingsControlChildrenProps) {
return (
<div
className={cn('border-border/70 ml-2 min-w-0 border-l pl-3', className)}
{...props}
/>
)
}
export function SettingsForm({ className, ...props }: ComponentProps<'form'>) {
return (
<form
className={cn(
'grid min-w-0 gap-x-5 gap-y-6 lg:grid-cols-2',
'lg:[&>*:not([data-slot=form-item])]:col-span-2',
'lg:[&>[data-settings-form-span=full]]:col-span-2',
'lg:[&>[data-slot=alert]]:col-span-2',
'[&>[data-slot=form-item]]:min-w-0',
'lg:[&>[data-slot=form-item]:has(textarea)]:col-span-2',
'lg:[&>[data-slot=form-item]:has([data-slot=switch])]:col-span-2',
className
)}
{...props}
/>
)
}
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import {
createContext,
useContext,
type ComponentProps,
type ReactNode,
type RefObject,
} from 'react'
import { RotateCcw, Save } from 'lucide-react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
type SettingsPageContextValue = {
actionsContainer: HTMLDivElement | null
titleStatusContainer: HTMLSpanElement | null
suppressSectionHeader: boolean
}
const SettingsPageContext = createContext<SettingsPageContextValue>({
actionsContainer: null,
titleStatusContainer: null,
suppressSectionHeader: false,
})
type SettingsPageProviderProps = {
actionsContainer: HTMLDivElement | null
titleStatusContainer?: HTMLSpanElement | null
children: ReactNode
suppressSectionHeader?: boolean
}
export function SettingsPageProvider(props: SettingsPageProviderProps) {
return (
<SettingsPageContext.Provider
value={{
actionsContainer: props.actionsContainer,
titleStatusContainer: props.titleStatusContainer ?? null,
suppressSectionHeader: props.suppressSectionHeader ?? true,
}}
>
{props.children}
</SettingsPageContext.Provider>
)
}
export function useSuppressSettingsSectionHeader() {
return useContext(SettingsPageContext).suppressSectionHeader
}
type SettingsPageTitleStatusPortalProps = {
children: ReactNode
}
export function SettingsPageTitleStatusPortal(
props: SettingsPageTitleStatusPortalProps
) {
const { titleStatusContainer } = useContext(SettingsPageContext)
if (!titleStatusContainer) return null
return createPortal(props.children, titleStatusContainer)
}
type SettingsPageActionsPortalProps = {
children: ReactNode
}
export function SettingsPageActionsPortal(
props: SettingsPageActionsPortalProps
) {
const { actionsContainer } = useContext(SettingsPageContext)
if (!actionsContainer) return null
return createPortal(
<div className='flex flex-wrap items-center justify-end gap-2'>
{props.children}
</div>,
actionsContainer
)
}
type SettingsPageFormActionsProps = {
onSave: () => void
onReset?: () => void
isSaving?: boolean
isSaveDisabled?: boolean
isResetDisabled?: boolean
saveLabel?: string
savingLabel?: string
resetLabel?: string
resetVariant?: ComponentProps<typeof Button>['variant']
saveButtonRef?: RefObject<HTMLButtonElement | null>
}
export function SettingsPageFormActions(props: SettingsPageFormActionsProps) {
const { t } = useTranslation()
const saveLabel = props.isSaving
? (props.savingLabel ?? 'Saving...')
: (props.saveLabel ?? 'Save Changes')
return (
<SettingsPageActionsPortal>
{props.onReset && (
<Button
type='button'
size='sm'
variant={props.resetVariant ?? 'outline'}
onClick={props.onReset}
disabled={props.isResetDisabled || props.isSaving}
>
<RotateCcw data-icon='inline-start' />
<span>{t(props.resetLabel ?? 'Reset')}</span>
</Button>
)}
<Button
ref={props.saveButtonRef}
type='button'
size='sm'
onClick={props.onSave}
disabled={props.isSaving || props.isSaveDisabled}
>
<Save data-icon='inline-start' />
<span>{t(saveLabel)}</span>
</Button>
</SettingsPageActionsPortal>
)
}
......@@ -16,13 +16,18 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useMemo, useState, type ReactNode } from 'react'
import { useParams } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { SectionPageLayout } from '@/components/layout'
import { useSystemOptions, getOptionValue } from '../hooks/use-system-options'
import type { SystemOption } from '../types'
import { SettingsPageProvider } from './settings-page-context'
type SettingsPageProps<
TSettings extends Record<string, string | number | boolean | unknown[]>,
TSectionId extends string,
TExtraArgs extends unknown[] = [],
> = {
routePath: string
defaultSettings: TSettings
......@@ -30,9 +35,57 @@ type SettingsPageProps<
getSectionContent: (
sectionId: TSectionId,
settings: TSettings,
...extraArgs: unknown[]
) => React.ReactNode
extraArgs?: unknown[]
...extraArgs: TExtraArgs
) => ReactNode
getSectionMeta: (sectionId: TSectionId) => {
titleKey: string
}
extraArgs?: TExtraArgs
loadingMessage?: string
resolveSettings?: (
settings: TSettings,
raw: SystemOption[] | undefined
) => TSettings
}
type SettingsPageFrameProps = {
title: ReactNode
children: ReactNode
}
function SettingsPageFrame(props: SettingsPageFrameProps) {
const [actionsContainer, setActionsContainer] =
useState<HTMLDivElement | null>(null)
const [titleStatusContainer, setTitleStatusContainer] =
useState<HTMLSpanElement | null>(null)
return (
<SettingsPageProvider
actionsContainer={actionsContainer}
titleStatusContainer={titleStatusContainer}
>
<SectionPageLayout>
<SectionPageLayout.Title>
<span className='inline-flex max-w-full min-w-0 items-center gap-2 align-middle'>
<span className='truncate'>{props.title}</span>
<span
ref={setTitleStatusContainer}
className='inline-flex shrink-0'
/>
</span>
</SectionPageLayout.Title>
<SectionPageLayout.Actions>
<div
ref={setActionsContainer}
className='flex flex-wrap items-center justify-end gap-2'
/>
</SectionPageLayout.Actions>
<SectionPageLayout.Content>
<div className='flex w-full flex-col gap-4'>{props.children}</div>
</SectionPageLayout.Content>
</SectionPageLayout>
</SettingsPageProvider>
)
}
/**
......@@ -42,39 +95,53 @@ type SettingsPageProps<
export function SettingsPage<
TSettings extends Record<string, string | number | boolean | unknown[]>,
TSectionId extends string,
TExtraArgs extends unknown[] = [],
>({
routePath,
defaultSettings,
defaultSection,
getSectionContent,
extraArgs = [],
}: SettingsPageProps<TSettings, TSectionId>) {
getSectionMeta,
extraArgs,
loadingMessage = 'Loading settings...',
resolveSettings,
}: SettingsPageProps<TSettings, TSectionId, TExtraArgs>) {
const { t } = useTranslation()
const { data, isLoading } = useSystemOptions()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const params = useParams({ from: routePath as any })
const activeSection = (params?.section ?? defaultSection) as TSectionId
const sectionMeta = getSectionMeta(activeSection)
const settings = useMemo(() => {
const baseSettings = getOptionValue(
data?.data,
defaultSettings
) as TSettings
return resolveSettings
? resolveSettings(baseSettings, data?.data)
: baseSettings
}, [data?.data, defaultSettings, resolveSettings])
if (isLoading) {
return (
<div className='flex items-center justify-center py-12'>
<div className='text-muted-foreground'>{t('Loading settings...')}</div>
<SettingsPageFrame title={t(sectionMeta.titleKey)}>
<div className='text-muted-foreground flex min-h-40 items-center justify-center text-sm'>
{t(loadingMessage)}
</div>
</SettingsPageFrame>
)
}
const settings = getOptionValue(data?.data, defaultSettings) as TSettings
const activeSection = (params?.section ?? defaultSection) as TSectionId
const sectionContent = getSectionContent(
activeSection,
settings,
...extraArgs
...((extraArgs ?? []) as TExtraArgs)
)
return (
<div className='flex h-full w-full flex-1 flex-col'>
<div className='faded-bottom h-full w-full overflow-y-auto scroll-smooth pe-4 pb-12'>
<div className='space-y-4'>{sectionContent}</div>
</div>
</div>
<SettingsPageFrame title={t(sectionMeta.titleKey)}>
{sectionContent}
</SettingsPageFrame>
)
}
......@@ -16,10 +16,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { cn } from '@/lib/utils'
import { useSuppressSettingsSectionHeader } from './settings-page-context'
type SettingsSectionProps = {
title: string
titleProps?: React.HTMLAttributes<HTMLHeadingElement>
description?: string
children: React.ReactNode
className?: string
}
......@@ -27,32 +29,23 @@ type SettingsSectionProps = {
export function SettingsSection({
title,
titleProps,
description,
children,
className,
}: SettingsSectionProps) {
const baseClassName = 'space-y-4'
const sectionClassName = className
? `${baseClassName} ${className}`
: baseClassName
const suppressHeader = useSuppressSettingsSectionHeader()
return (
<section className={sectionClassName}>
<div className='space-y-1'>
<section className={cn('flex flex-col gap-4', className)}>
{!suppressHeader && (
<div className='flex flex-col gap-1'>
<h3
{...titleProps}
className={
titleProps?.className
? `text-base font-semibold ${titleProps.className}`
: 'text-base font-semibold'
}
className={cn('text-base font-semibold', titleProps?.className)}
>
{title}
</h3>
{description && (
<p className='text-muted-foreground text-sm'>{description}</p>
)}
</div>
)}
{children}
</section>
)
......
......@@ -62,7 +62,6 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import {
Table,
TableBody,
......@@ -74,6 +73,7 @@ import {
import { Textarea } from '@/components/ui/textarea'
import { DateTimePicker } from '@/components/datetime-picker'
import { StatusBadge } from '@/components/status-badge'
import { SettingsSwitchField } from '../components/settings-form-layout'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
......@@ -319,10 +319,7 @@ export function AnnouncementsSection({
}
return (
<SettingsSection
title={t('Announcements')}
description={t('Broadcast short system notices on the dashboard')}
>
<SettingsSection title={t('Announcements')}>
<div className='space-y-4'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='flex flex-wrap items-center gap-2'>
......@@ -350,12 +347,12 @@ export function AnnouncementsSection({
{updateOption.isPending ? t('Saving...') : t('Save Settings')}
</Button>
</div>
<div className='flex items-center gap-2'>
<span className='text-muted-foreground text-sm'>
{t('Enabled')}
</span>
<Switch checked={isEnabled} onCheckedChange={handleToggleEnabled} />
</div>
<SettingsSwitchField
checked={isEnabled}
onCheckedChange={handleToggleEnabled}
label={t('Enabled')}
className='border-b-0 py-0'
/>
</div>
<div className='rounded-md border'>
......
......@@ -62,7 +62,6 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import {
Table,
TableBody,
......@@ -72,6 +71,7 @@ import {
TableRow,
} from '@/components/ui/table'
import { StatusBadge } from '@/components/status-badge'
import { SettingsSwitchField } from '../components/settings-form-layout'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
......@@ -275,10 +275,7 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
const getColorClass = (color: string) => getBgColorClass(color)
return (
<SettingsSection
title={t('API Addresses')}
description={t('Curate quick links to your different Domains')}
>
<SettingsSection title={t('API Addresses')}>
<div className='space-y-4'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='flex flex-wrap items-center gap-2'>
......@@ -306,12 +303,12 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
{updateOption.isPending ? t('Saving...') : t('Save Settings')}
</Button>
</div>
<div className='flex items-center gap-2'>
<span className='text-muted-foreground text-sm'>
{t('Enabled')}
</span>
<Switch checked={isEnabled} onCheckedChange={handleToggleEnabled} />
</div>
<SettingsSwitchField
checked={isEnabled}
onCheckedChange={handleToggleEnabled}
label={t('Enabled')}
className='border-b-0 py-0'
/>
</div>
<div className='rounded-md border'>
......
......@@ -21,7 +21,6 @@ import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
......@@ -33,6 +32,8 @@ import {
} from '@/components/ui/form'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
import { SettingsForm } from '../components/settings-form-layout'
import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
import { ChatSettingsVisualEditor } from './chat-settings-visual-editor'
......@@ -125,13 +126,15 @@ export function ChatSettingsSection({
}
return (
<SettingsSection
title={t('Chat Presets')}
description={t('Configure predefined chat links surfaced to end users.')}
>
<SettingsSection title={t('Chat Presets')}>
<Form {...form}>
{/* eslint-disable-next-line react-hooks/refs */}
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
isSaving={updateOption.isPending}
saveLabel='Save chat settings'
/>
<Tabs
value={editMode}
onValueChange={(value) => setEditMode(value as 'visual' | 'json')}
......@@ -186,11 +189,7 @@ export function ChatSettingsSection({
/>
</TabsContent>
</Tabs>
<Button type='submit' disabled={updateOption.isPending}>
{updateOption.isPending ? t('Saving...') : t('Save chat settings')}
</Button>
</form>
</SettingsForm>
</Form>
</SettingsSection>
)
......
......@@ -21,7 +21,6 @@ import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
......@@ -41,6 +40,12 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import {
SettingsForm,
SettingsSwitchContent,
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
......@@ -89,29 +94,28 @@ export function DashboardSection({ defaultValues }: DashboardSectionProps) {
const isEnabled = form.watch('DataExportEnabled')
return (
<SettingsSection
title={t('Data Dashboard')}
description={t('Configure experimental data export for the dashboard')}
>
<SettingsSection title={t('Data Dashboard')}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
isSaving={updateOption.isPending}
/>
<FormField
control={form.control}
name='DataExportEnabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Enable Data Dashboard')}
</FormLabel>
</div>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Enable Data Dashboard')}</FormLabel>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
......@@ -183,11 +187,7 @@ export function DashboardSection({ defaultValues }: DashboardSectionProps) {
)}
/>
</div>
<Button type='submit' disabled={updateOption.isPending}>
{updateOption.isPending ? t('Saving...') : t('Save Changes')}
</Button>
</form>
</SettingsForm>
</Form>
</SettingsSection>
)
......
......@@ -21,17 +21,21 @@ import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Switch } from '@/components/ui/switch'
import {
SettingsForm,
SettingsSwitchContent,
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
......@@ -124,12 +128,14 @@ export function DrawingSettingsSection({
]
return (
<SettingsSection
title={t('Drawing')}
description={t('Fine-tune Midjourney integration and guardrails.')}
>
<SettingsSection title={t('Drawing')}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
isSaving={updateOption.isPending}
saveLabel='Save drawing settings'
/>
<div className='space-y-4'>
{switches.map((item) => (
<FormField
......@@ -137,11 +143,11 @@ export function DrawingSettingsSection({
control={form.control}
name={item.name}
render={({ field }) => (
<FormItem className='flex flex-row items-start justify-between rounded-lg border p-4'>
<div className='space-y-0.5 pe-4'>
<FormLabel className='text-base'>{item.label}</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{item.label}</FormLabel>
<FormDescription>{item.description}</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
......@@ -149,18 +155,12 @@ export function DrawingSettingsSection({
/>
</FormControl>
<FormMessage />
</FormItem>
</SettingsSwitchItem>
)}
/>
))}
</div>
<Button type='submit' disabled={updateOption.isPending}>
{updateOption.isPending
? t('Saving...')
: t('Save drawing settings')}
</Button>
</form>
</SettingsForm>
</Form>
</SettingsSection>
)
......
......@@ -53,7 +53,6 @@ import {
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import {
Table,
TableBody,
......@@ -63,6 +62,7 @@ import {
TableRow,
} from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { SettingsSwitchField } from '../components/settings-form-layout'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
......@@ -238,12 +238,7 @@ export function FAQSection({ enabled, data }: FAQSectionProps) {
}
return (
<SettingsSection
title={t('FAQ')}
description={t(
'Maintain a list of common questions for the dashboard help panel'
)}
>
<SettingsSection title={t('FAQ')}>
<div className='space-y-4'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='flex flex-wrap items-center gap-2'>
......@@ -271,12 +266,12 @@ export function FAQSection({ enabled, data }: FAQSectionProps) {
{updateOption.isPending ? t('Saving...') : t('Save Settings')}
</Button>
</div>
<div className='flex items-center gap-2'>
<span className='text-muted-foreground text-sm'>
{t('Enabled')}
</span>
<Switch checked={isEnabled} onCheckedChange={handleToggleEnabled} />
</div>
<SettingsSwitchField
checked={isEnabled}
onCheckedChange={handleToggleEnabled}
label={t('Enabled')}
className='border-b-0 py-0'
/>
</div>
<div className='rounded-md border'>
......
......@@ -16,14 +16,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useMemo } from 'react'
import { useParams } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { getOptionValue, useSystemOptions } from '../hooks/use-system-options'
import type { ContentSettings } from '../types'
import { SettingsPage } from '../components/settings-page'
import type { ContentSettings, SystemOption } from '../types'
import {
CONTENT_DEFAULT_SECTION,
getContentSectionContent,
getContentSectionMeta,
} from './section-registry.tsx'
const defaultContentSettings: ContentSettings = {
......@@ -47,84 +45,53 @@ const defaultContentSettings: ContentSettings = {
MjActionCheckSuccessEnabled: false,
}
export function ContentSettings() {
const { t } = useTranslation()
const { data, isLoading } = useSystemOptions()
const params = useParams({
from: '/_authenticated/system-settings/content/$section',
})
const settings = useMemo(() => {
const resolved = getOptionValue(data?.data, defaultContentSettings)
const optionMap = new Map(
(data?.data ?? []).map((item) => [item.key, item.value])
)
if (!optionMap.has('console_setting.announcements')) {
const legacy = optionMap.get('Announcements')
if (legacy !== undefined) {
resolved['console_setting.announcements'] = legacy
function resolveContentSettings(
settings: ContentSettings,
raw: SystemOption[] | undefined
): ContentSettings {
if (!raw || raw.length === 0) return settings
const optionMap = new Map(raw.map((item) => [item.key, item.value]))
const next = { ...settings }
const legacyMap = [
{ current: 'console_setting.announcements', legacy: 'Announcements' },
{ current: 'console_setting.api_info', legacy: 'ApiInfo' },
{ current: 'console_setting.faq', legacy: 'FAQ' },
] as const
for (const { current, legacy } of legacyMap) {
if (!optionMap.has(current)) {
const legacyValue = optionMap.get(legacy)
if (legacyValue !== undefined) {
next[current] = legacyValue
}
}
if (!optionMap.has('console_setting.api_info')) {
const legacy = optionMap.get('ApiInfo')
if (legacy !== undefined) {
resolved['console_setting.api_info'] = legacy
}
}
if (!optionMap.has('console_setting.faq')) {
const legacy = optionMap.get('FAQ')
if (legacy !== undefined) {
resolved['console_setting.faq'] = legacy
}
}
if (!optionMap.has('console_setting.uptime_kuma_groups')) {
const legacyUrl = optionMap.get('UptimeKumaUrl')
const legacySlug = optionMap.get('UptimeKumaSlug')
if (legacyUrl && legacySlug) {
resolved['console_setting.uptime_kuma_groups'] = JSON.stringify([
{
id: 1,
categoryName: 'Legacy',
url: legacyUrl,
slug: legacySlug,
},
next['console_setting.uptime_kuma_groups'] = JSON.stringify([
{ id: 1, categoryName: 'Legacy', url: legacyUrl, slug: legacySlug },
])
}
}
return resolved
}, [data?.data])
if (isLoading) {
return (
<div className='flex items-center justify-center py-12'>
<div className='text-muted-foreground'>
{t('Loading content settings...')}
</div>
</div>
)
}
const activeSection = (params?.section ?? CONTENT_DEFAULT_SECTION) as
| 'dashboard'
| 'announcements'
| 'api-info'
| 'faq'
| 'uptime-kuma'
| 'chat'
| 'drawing'
const sectionContent = getContentSectionContent(activeSection, settings)
return next
}
export function ContentSettings() {
return (
<div className='flex h-full w-full flex-1 flex-col'>
<div className='faded-bottom h-full w-full overflow-y-auto scroll-smooth pe-4 pb-12'>
<div className='space-y-4'>{sectionContent}</div>
</div>
</div>
<SettingsPage
routePath='/_authenticated/system-settings/content/$section'
defaultSettings={defaultContentSettings}
defaultSection={CONTENT_DEFAULT_SECTION}
getSectionContent={getContentSectionContent}
getSectionMeta={getContentSectionMeta}
loadingMessage='Loading content settings...'
resolveSettings={resolveContentSettings}
/>
)
}
......@@ -21,7 +21,6 @@ import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
......@@ -34,13 +33,18 @@ import {
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { SettingsAccordion } from '../components/settings-accordion'
import {
SettingsForm,
SettingsSwitchContent,
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { SettingsPageFormActions } from '../components/settings-page-context'
import { useUpdateOption } from '../hooks/use-update-option'
import { formatJsonForEditor, normalizeJsonString } from './utils'
type JsonToggleSectionProps = {
value: string
title: string
description?: string
toggleDescription?: string
optionKey: string
enabledKey: string
......@@ -63,7 +67,6 @@ type JsonToggleFormValues = {
export function JsonToggleSection({
value,
title,
description,
toggleDescription,
optionKey,
enabledKey,
......@@ -157,30 +160,33 @@ export function JsonToggleSection({
}
return (
<SettingsAccordion value={value} title={title} description={description}>
<SettingsAccordion value={value} title={title}>
<Form {...form}>
{/* eslint-disable-next-line react-hooks/refs */}
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
isSaving={updateOption.isPending}
saveLabel={submitLabel}
/>
<FormField
control={form.control}
name='enabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Module availability')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Module availability')}</FormLabel>
{toggleDescription && (
<FormDescription>{t(toggleDescription)}</FormDescription>
)}
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
......@@ -203,11 +209,7 @@ export function JsonToggleSection({
</FormItem>
)}
/>
<Button type='submit' disabled={updateOption.isPending}>
{updateOption.isPending ? t('Saving...') : t(submitLabel)}
</Button>
</form>
</SettingsForm>
</Form>
</SettingsAccordion>
)
......
......@@ -41,7 +41,6 @@ const CONTENT_SECTIONS = [
{
id: 'dashboard',
titleKey: 'Data Dashboard',
descriptionKey: 'Configure data export settings for dashboard',
build: (settings: ContentSettings) => (
<DashboardSection
defaultValues={{
......@@ -57,7 +56,6 @@ const CONTENT_SECTIONS = [
{
id: 'announcements',
titleKey: 'Announcements',
descriptionKey: 'Configure system announcements',
build: (settings: ContentSettings) => (
<AnnouncementsSection
enabled={settings['console_setting.announcements_enabled']}
......@@ -68,7 +66,6 @@ const CONTENT_SECTIONS = [
{
id: 'api-info',
titleKey: 'API Addresses',
descriptionKey: 'Configure API information display',
build: (settings: ContentSettings) => (
<ApiInfoSection
enabled={settings['console_setting.api_info_enabled']}
......@@ -79,7 +76,6 @@ const CONTENT_SECTIONS = [
{
id: 'faq',
titleKey: 'FAQ',
descriptionKey: 'Configure frequently asked questions',
build: (settings: ContentSettings) => (
<FAQSection
enabled={settings['console_setting.faq_enabled']}
......@@ -90,7 +86,6 @@ const CONTENT_SECTIONS = [
{
id: 'uptime-kuma',
titleKey: 'Uptime Kuma',
descriptionKey: 'Configure Uptime Kuma monitoring integration',
build: (settings: ContentSettings) => (
<UptimeKumaSection
enabled={settings['console_setting.uptime_kuma_enabled']}
......@@ -101,7 +96,6 @@ const CONTENT_SECTIONS = [
{
id: 'chat',
titleKey: 'Chat Presets',
descriptionKey: 'Configure chat-related settings',
build: (settings: ContentSettings) => (
<ChatSettingsSection defaultValue={settings.Chats} />
),
......@@ -109,7 +103,6 @@ const CONTENT_SECTIONS = [
{
id: 'drawing',
titleKey: 'Drawing',
descriptionKey: 'Configure drawing and Midjourney settings',
build: (settings: ContentSettings) => (
<DrawingSettingsSection
defaultValues={{
......@@ -141,3 +134,4 @@ export const CONTENT_SECTION_IDS = contentRegistry.sectionIds
export const CONTENT_DEFAULT_SECTION = contentRegistry.defaultSection
export const getContentSectionNavItems = contentRegistry.getSectionNavItems
export const getContentSectionContent = contentRegistry.getSectionContent
export const getContentSectionMeta = contentRegistry.getSectionMeta
......@@ -53,7 +53,6 @@ import {
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import {
Table,
TableBody,
......@@ -62,6 +61,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import { SettingsSwitchField } from '../components/settings-form-layout'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
......@@ -247,12 +247,7 @@ export function UptimeKumaSection({ enabled, data }: UptimeKumaSectionProps) {
}
return (
<SettingsSection
title={t('Uptime Kuma')}
description={t(
'Expose grouped Uptime Kuma status pages directly on the dashboard'
)}
>
<SettingsSection title={t('Uptime Kuma')}>
<div className='space-y-4'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='flex flex-wrap items-center gap-2'>
......@@ -280,12 +275,12 @@ export function UptimeKumaSection({ enabled, data }: UptimeKumaSectionProps) {
{updateOption.isPending ? t('Saving...') : t('Save Settings')}
</Button>
</div>
<div className='flex items-center gap-2'>
<span className='text-muted-foreground text-sm'>
{t('Enabled')}
</span>
<Switch checked={isEnabled} onCheckedChange={handleToggleEnabled} />
</div>
<SettingsSwitchField
checked={isEnabled}
onCheckedChange={handleToggleEnabled}
label={t('Enabled')}
className='border-b-0 py-0'
/>
</div>
<div className='rounded-md border'>
......
......@@ -31,7 +31,6 @@ import {
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch'
import {
Table,
TableBody,
......@@ -43,6 +42,8 @@ import {
import { Textarea } from '@/components/ui/textarea'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StatusBadge } from '@/components/status-badge'
import { SettingsSwitchField } from '../../components/settings-form-layout'
import { SettingsPageActionsPortal } from '../../components/settings-page-context'
import { SettingsSection } from '../../components/settings-section'
import { useUpdateOption } from '../../hooks/use-update-option'
import { getCacheStats, clearAllCache, clearRuleCache } from './api'
......@@ -333,12 +334,7 @@ export function ChannelAffinitySection(props: Props) {
return (
<>
<SettingsSection
title={t('Channel Affinity')}
description={t(
'Prioritize reusing the last successful channel based on keys extracted from request context (sticky routing)'
)}
>
<SettingsSection title={t('Channel Affinity')}>
<Alert>
<AlertDescription className='text-xs'>
{t(
......@@ -349,10 +345,12 @@ export function ChannelAffinitySection(props: Props) {
{/* Basic Settings */}
<div className='grid grid-cols-1 gap-4 md:grid-cols-3'>
<div className='flex items-center gap-2'>
<Switch checked={enabled} onCheckedChange={setEnabled} />
<Label>{t('Enable')}</Label>
</div>
<SettingsSwitchField
checked={enabled}
onCheckedChange={setEnabled}
label={t('Enable')}
className='border-b-0 py-0'
/>
<div className='grid gap-1.5'>
<Label>{t('Max Entries')}</Label>
<Input
......@@ -373,23 +371,18 @@ export function ChannelAffinitySection(props: Props) {
</div>
</div>
<div className='flex items-center gap-2'>
<Switch
<SettingsSwitchField
checked={switchOnSuccess}
onCheckedChange={setSwitchOnSuccess}
/>
<Label>{t('Switch affinity on success')}</Label>
<span className='text-muted-foreground text-xs'>
{t(
label={t('Switch affinity on success')}
description={t(
'If the affinity channel fails and retry succeeds on another channel, update affinity to the successful channel.'
)}
</span>
</div>
/>
<Separator />
{/* Toolbar */}
<div className='flex flex-wrap items-center gap-2'>
<SettingsPageActionsPortal>
<Button
variant={editMode === 'visual' ? 'default' : 'outline'}
size='sm'
......@@ -472,7 +465,7 @@ export function ChannelAffinitySection(props: Props) {
{cacheStats.cache_capacity}
</span>
)}
</div>
</SettingsPageActionsPortal>
{/* Rules Table or JSON Editor */}
{editMode === 'visual' ? (
......
......@@ -45,8 +45,8 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { SettingsSwitchField } from '../../components/settings-form-layout'
import { RULE_TEMPLATES } from './constants'
import type { AffinityRule, KeySource } from './types'
......@@ -264,13 +264,11 @@ export function RuleEditorDialog(props: Props) {
</div>
</div>
<div className='flex items-center gap-2'>
<Switch
<SettingsSwitchField
checked={form.watch('skip_retry_on_failure')}
onCheckedChange={(v) => form.setValue('skip_retry_on_failure', v)}
label={t('Skip retry on failure')}
/>
<Label>{t('Skip retry on failure')}</Label>
</div>
<Separator />
......@@ -415,34 +413,29 @@ export function RuleEditorDialog(props: Props) {
/>
</div>
<div className='grid grid-cols-3 gap-3'>
<div className='flex items-center gap-2'>
<Switch
<div className='grid gap-3 sm:grid-cols-3'>
<SettingsSwitchField
checked={form.watch('include_using_group')}
onCheckedChange={(v) =>
form.setValue('include_using_group', v)
}
label={t('Include Group')}
className='border-b-0 py-0'
/>
<Label className='text-xs'>{t('Include Group')}</Label>
</div>
<div className='flex items-center gap-2'>
<Switch
<SettingsSwitchField
checked={form.watch('include_model_name')}
onCheckedChange={(v) =>
form.setValue('include_model_name', v)
}
label={t('Include Model')}
className='border-b-0 py-0'
/>
<Label className='text-xs'>{t('Include Model')}</Label>
</div>
<div className='flex items-center gap-2'>
<Switch
<SettingsSwitchField
checked={form.watch('include_rule_name')}
onCheckedChange={(v) =>
form.setValue('include_rule_name', v)
}
onCheckedChange={(v) => form.setValue('include_rule_name', v)}
label={t('Include Rule Name')}
className='border-b-0 py-0'
/>
<Label className='text-xs'>{t('Include Rule Name')}</Label>
</div>
</div>
</CollapsibleContent>
</Collapsible>
......
......@@ -21,7 +21,6 @@ import { useForm, type Resolver } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
......@@ -33,6 +32,12 @@ import {
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import {
SettingsForm,
SettingsSwitchContent,
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
......@@ -105,31 +110,28 @@ export function CheckinSettingsSection({
}
return (
<SettingsSection
title={t('Check-in Settings')}
description={t('Configure daily check-in rewards for users')}
>
<SettingsSection title={t('Check-in Settings')}>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
autoComplete='off'
className='space-y-6'
>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)} autoComplete='off'>
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
isSaving={updateOption.isPending || isSubmitting}
isSaveDisabled={!isDirty}
saveLabel='Save check-in settings'
/>
<FormField
control={form.control}
name='enabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Enable check-in feature')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Enable check-in feature')}</FormLabel>
<FormDescription>
{t(
'Allow users to check in daily for random quota rewards'
)}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
......@@ -137,7 +139,7 @@ export function CheckinSettingsSection({
disabled={updateOption.isPending || isSubmitting}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
......@@ -188,16 +190,7 @@ export function CheckinSettingsSection({
/>
</div>
)}
<Button
type='submit'
disabled={!isDirty || updateOption.isPending || isSubmitting}
>
{updateOption.isPending || isSubmitting
? t('Saving...')
: t('Save check-in settings')}
</Button>
</form>
</SettingsForm>
</Form>
</SettingsSection>
)
......
......@@ -19,10 +19,8 @@ For commercial licensing, please contact support@quantumnous.com
import * as z from 'zod'
import type { Resolver } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { RotateCcw } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { DEFAULT_CURRENCY_CONFIG } from '@/stores/system-config-store'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
......@@ -44,6 +42,12 @@ import {
import { Switch } from '@/components/ui/switch'
import { FormDirtyIndicator } from '../components/form-dirty-indicator'
import { FormNavigationGuard } from '../components/form-navigation-guard'
import {
SettingsForm,
SettingsSwitchContent,
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useSettingsForm } from '../hooks/use-settings-form'
import { useUpdateOption } from '../hooks/use-update-option'
......@@ -141,12 +145,15 @@ export function PricingSection({ defaultValues }: PricingSectionProps) {
<>
<FormNavigationGuard when={isDirty} />
<SettingsSection
title={t('Pricing & Display')}
description={t('Configure pricing model and display options')}
>
<SettingsSection title={t('Pricing & Display')}>
<Form {...form}>
<form onSubmit={handleSubmit} className='space-y-6'>
<SettingsForm onSubmit={handleSubmit}>
<SettingsPageFormActions
onSave={handleSubmit}
onReset={handleReset}
isSaving={updateOption.isPending || isSubmitting}
isResetDisabled={!isDirty}
/>
<FormDirtyIndicator isDirty={isDirty} />
{showQuotaPerUnit && (
<FormField
......@@ -320,11 +327,9 @@ export function PricingSection({ defaultValues }: PricingSectionProps) {
control={form.control}
name='DisplayInCurrencyEnabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Display in Currency')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Display in Currency')}</FormLabel>
<FormDescription>
{displayType === 'TOKENS'
? t(
......@@ -332,14 +337,14 @@ export function PricingSection({ defaultValues }: PricingSectionProps) {
)
: t('Show prices in currency instead of quota.')}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
)}
......@@ -348,43 +353,23 @@ export function PricingSection({ defaultValues }: PricingSectionProps) {
control={form.control}
name='DisplayTokenStatEnabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Display Token Statistics')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Display Token Statistics')}</FormLabel>
<FormDescription>
{t('Show token usage statistics in the UI')}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
<div className='flex gap-2'>
<Button
type='submit'
disabled={updateOption.isPending || isSubmitting}
>
{updateOption.isPending ? t('Saving...') : t('Save Changes')}
</Button>
<Button
type='button'
variant='outline'
onClick={handleReset}
disabled={!isDirty || updateOption.isPending || isSubmitting}
>
<RotateCcw className='mr-2 h-4 w-4' />
{t('Reset')}
</Button>
</div>
</form>
</SettingsForm>
</Form>
</SettingsSection>
</>
......
......@@ -22,7 +22,6 @@ import type { Resolver } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
......@@ -36,6 +35,14 @@ import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { FormDirtyIndicator } from '../components/form-dirty-indicator'
import { FormNavigationGuard } from '../components/form-navigation-guard'
import {
SettingsForm,
SettingsSwitchContent,
SettingsSwitchItem,
SettingsFormGrid,
SettingsFormGridItem,
} from '../components/settings-form-layout'
import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useSettingsForm } from '../hooks/use-settings-form'
import { useUpdateOption } from '../hooks/use-update-option'
......@@ -94,10 +101,7 @@ export function QuotaSettingsSection({
})
return (
<SettingsSection
title={t('Quota Settings')}
description={t('Configure user quota allocation and rewards')}
>
<SettingsSection title={t('Quota Settings')}>
<FormNavigationGuard when={isDirty} />
{!complianceConfirmed ? (
......@@ -111,8 +115,13 @@ export function QuotaSettingsSection({
) : null}
<Form {...form}>
<form onSubmit={handleSubmit} className='space-y-6'>
<SettingsForm onSubmit={handleSubmit}>
<SettingsPageFormActions
onSave={handleSubmit}
isSaving={updateOption.isPending || isSubmitting}
/>
<FormDirtyIndicator isDirty={isDirty} />
<SettingsFormGrid>
<FormField
control={form.control}
name='QuotaForNewUser'
......@@ -209,21 +218,20 @@ export function QuotaSettingsSection({
)}
/>
<SettingsFormGridItem span='full'>
<FormField
control={form.control}
name='quota_setting.enable_free_model_pre_consume'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Pre-Consume for Free Models')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Pre-Consume for Free Models')}</FormLabel>
<FormDescription>
{t(
'When enabled, zero-cost models also pre-consume quota before final settlement.'
)}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
......@@ -231,9 +239,10 @@ export function QuotaSettingsSection({
disabled={updateOption.isPending}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
</SettingsFormGridItem>
<FormField
control={form.control}
......@@ -274,14 +283,8 @@ export function QuotaSettingsSection({
</FormItem>
)}
/>
<Button
type='submit'
disabled={updateOption.isPending || isSubmitting}
>
{updateOption.isPending ? t('Saving...') : t('Save Changes')}
</Button>
</form>
</SettingsFormGrid>
</SettingsForm>
</Form>
</SettingsSection>
)
......
......@@ -20,7 +20,6 @@ import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
......@@ -32,6 +31,12 @@ import {
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import {
SettingsForm,
SettingsSwitchContent,
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useResetForm } from '../hooks/use-reset-form'
import { useUpdateOption } from '../hooks/use-update-option'
......@@ -73,12 +78,13 @@ export function SystemBehaviorSection({
}
return (
<SettingsSection
title={t('System Behavior')}
description={t('Configure system-wide behavior and defaults')}
>
<SettingsSection title={t('System Behavior')}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
isSaving={updateOption.isPending}
/>
<FormField
control={form.control}
name='RetryTimes'
......@@ -109,22 +115,20 @@ export function SystemBehaviorSection({
control={form.control}
name='DefaultCollapseSidebar'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Default Collapse Sidebar')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Default Collapse Sidebar')}</FormLabel>
<FormDescription>
{t('Sidebar collapsed by default for new users')}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
......@@ -132,22 +136,20 @@ export function SystemBehaviorSection({
control={form.control}
name='DemoSiteEnabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Demo Site Mode')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Demo Site Mode')}</FormLabel>
<FormDescription>
{t('Enable demo mode with limited functionality')}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
......@@ -155,29 +157,23 @@ export function SystemBehaviorSection({
control={form.control}
name='SelfUseModeEnabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Self-Use Mode')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Self-Use Mode')}</FormLabel>
<FormDescription>
{t('Optimize system for self-hosted single-user usage')}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
<Button type='submit' disabled={updateOption.isPending}>
{updateOption.isPending ? t('Saving...') : t('Save Changes')}
</Button>
</form>
</SettingsForm>
</Form>
</SettingsSection>
)
......
......@@ -19,9 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import * as z from 'zod'
import type { Resolver } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { RotateCcw } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
......@@ -43,6 +41,12 @@ import {
import { Textarea } from '@/components/ui/textarea'
import { FormDirtyIndicator } from '../components/form-dirty-indicator'
import { FormNavigationGuard } from '../components/form-navigation-guard'
import {
SettingsForm,
SettingsFormGrid,
SettingsFormGridItem,
} from '../components/settings-form-layout'
import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useSettingsForm } from '../hooks/use-settings-form'
import { useUpdateOption } from '../hooks/use-update-option'
......@@ -139,13 +143,17 @@ export function SystemInfoSection({ defaultValues }: SystemInfoSectionProps) {
<>
<FormNavigationGuard when={isDirty} />
<SettingsSection
title={t('System Information')}
description={t('Configure basic system information and branding')}
>
<SettingsSection title={t('System Information')}>
<Form {...form}>
<form onSubmit={handleSubmit} className='space-y-6'>
<SettingsForm onSubmit={handleSubmit}>
<SettingsPageFormActions
onSave={handleSubmit}
onReset={handleReset}
isSaving={isSubmitting || updateOption.isPending}
isResetDisabled={!isDirty}
/>
<FormDirtyIndicator isDirty={isDirty} />
<SettingsFormGrid>
<FormField
control={form.control}
name='theme.frontend'
......@@ -154,7 +162,10 @@ export function SystemInfoSection({ defaultValues }: SystemInfoSectionProps) {
<FormLabel>{t('Frontend Theme')}</FormLabel>
<Select
items={[
{ value: 'default', label: t('Default (New Frontend)') },
{
value: 'default',
label: t('Default (New Frontend)'),
},
{
value: 'classic',
label: t('Classic (Legacy Frontend)'),
......@@ -256,6 +267,7 @@ export function SystemInfoSection({ defaultValues }: SystemInfoSectionProps) {
placeholder={t(
'© 2025 Your Company. All rights reserved.'
)}
rows={4}
{...field}
/>
</FormControl>
......@@ -292,6 +304,7 @@ export function SystemInfoSection({ defaultValues }: SystemInfoSectionProps) {
)}
/>
<SettingsFormGridItem span='full'>
<FormField
control={form.control}
name='HomePageContent'
......@@ -314,6 +327,7 @@ export function SystemInfoSection({ defaultValues }: SystemInfoSectionProps) {
</FormItem>
)}
/>
</SettingsFormGridItem>
<FormField
control={form.control}
......@@ -364,25 +378,8 @@ export function SystemInfoSection({ defaultValues }: SystemInfoSectionProps) {
</FormItem>
)}
/>
<div className='flex gap-2'>
<Button
type='submit'
disabled={isSubmitting || updateOption.isPending}
>
{updateOption.isPending ? t('Saving...') : t('Save Changes')}
</Button>
<Button
type='button'
variant='outline'
onClick={handleReset}
disabled={!isDirty || updateOption.isPending || isSubmitting}
>
<RotateCcw className='mr-2 h-4 w-4' />
{t('Reset')}
</Button>
</div>
</form>
</SettingsFormGrid>
</SettingsForm>
</Form>
</SettingsSection>
</>
......
......@@ -17,14 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Outlet } from '@tanstack/react-router'
import { Main } from '@/components/layout'
export function SystemSettings() {
return (
<Main>
<div className='min-h-0 flex-1 px-4 pt-6 pb-4'>
<Outlet />
</div>
</Main>
)
return <Outlet />
}
......@@ -20,7 +20,6 @@ import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
......@@ -32,6 +31,12 @@ import {
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import {
SettingsForm,
SettingsSwitchContent,
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useResetForm } from '../hooks/use-reset-form'
import { useUpdateOption } from '../hooks/use-update-option'
......@@ -138,16 +143,14 @@ export function EmailSettingsSection({
}
return (
<SettingsSection
title={t('SMTP Email')}
description={t('Configure outgoing email server for notifications')}
>
<SettingsSection title={t('SMTP Email')}>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-6'
autoComplete='off'
>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)} autoComplete='off'>
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
isSaving={updateOption.isPending}
saveLabel='Save SMTP settings'
/>
<FormField
control={form.control}
name='SMTPServer'
......@@ -198,22 +201,20 @@ export function EmailSettingsSection({
control={form.control}
name='SMTPSSLEnabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Enable SSL/TLS')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Enable SSL/TLS')}</FormLabel>
<FormDescription>
{t('Use secure connection when sending emails')}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
......@@ -221,22 +222,20 @@ export function EmailSettingsSection({
control={form.control}
name='SMTPForceAuthLogin'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Force AUTH LOGIN')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Force AUTH LOGIN')}</FormLabel>
<FormDescription>
{t('Force SMTP authentication using AUTH LOGIN method')}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
</div>
......@@ -307,11 +306,7 @@ export function EmailSettingsSection({
</FormItem>
)}
/>
<Button type='submit' disabled={updateOption.isPending}>
{updateOption.isPending ? t('Saving...') : t('Save SMTP settings')}
</Button>
</form>
</SettingsForm>
</Form>
</SettingsSection>
)
......
......@@ -37,6 +37,12 @@ import {
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { testDeploymentConnectionWithKey } from '@/features/models/api'
import {
SettingsForm,
SettingsSwitchContent,
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
......@@ -129,29 +135,26 @@ export function IoNetDeploymentSettingsSection({
}
return (
<SettingsSection
title={t('io.net Deployments')}
description={t('Configure io.net API key for model deployments')}
>
<SettingsSection title={t('io.net Deployments')}>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
autoComplete='off'
className='space-y-6'
>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)} autoComplete='off'>
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
isSaving={updateOption.isPending || isSubmitting}
isSaveDisabled={!isDirty}
saveLabel='Save io.net settings'
/>
<FormField
control={form.control}
name='enabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Enable io.net deployments')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Enable io.net deployments')}</FormLabel>
<FormDescription>
{t('Enable io.net model deployment service in console')}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
......@@ -159,7 +162,7 @@ export function IoNetDeploymentSettingsSection({
disabled={updateOption.isPending || isSubmitting}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
......@@ -253,16 +256,7 @@ export function IoNetDeploymentSettingsSection({
) : null}
</>
) : null}
<Button
type='submit'
disabled={!isDirty || updateOption.isPending || isSubmitting}
>
{updateOption.isPending || isSubmitting
? t('Saving...')
: t('Save io.net settings')}
</Button>
</form>
</SettingsForm>
</Form>
</SettingsSection>
)
......
......@@ -23,7 +23,6 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { parseHttpStatusCodeRules } from '@/lib/http-status-code-rules'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
......@@ -36,6 +35,12 @@ import {
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import {
SettingsForm,
SettingsSwitchContent,
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useResetForm } from '../hooks/use-reset-form'
import { useUpdateOption } from '../hooks/use-update-option'
......@@ -243,35 +248,33 @@ export function MonitoringSettingsSection({
}
return (
<SettingsSection
title={t('Monitoring & Alerts')}
description={t(
'Automatically test channels and notify users when limits are hit'
)}
>
<SettingsSection title={t('Monitoring & Alerts')}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
isSaving={updateOption.isPending}
saveLabel='Save monitoring rules'
/>
<div className='grid gap-6 md:grid-cols-2'>
<FormField
control={form.control}
name='monitor_setting.auto_test_channel_enabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Scheduled channel tests')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Scheduled channel tests')}</FormLabel>
<FormDescription>
{t('Automatically probe all channels in the background')}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
......@@ -364,22 +367,20 @@ export function MonitoringSettingsSection({
control={form.control}
name='AutomaticDisableChannelEnabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Disable on failure')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Disable on failure')}</FormLabel>
<FormDescription>
{t('Automatically disable channels when tests fail')}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
......@@ -387,22 +388,20 @@ export function MonitoringSettingsSection({
control={form.control}
name='AutomaticEnableChannelEnabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Re-enable on success')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Re-enable on success')}</FormLabel>
<FormDescription>
{t('Bring channels back online after successful checks')}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
</div>
......@@ -492,13 +491,7 @@ export function MonitoringSettingsSection({
)}
/>
</div>
<Button type='submit' disabled={updateOption.isPending}>
{updateOption.isPending
? t('Saving...')
: t('Save monitoring rules')}
</Button>
</form>
</SettingsForm>
</Form>
</SettingsSection>
)
......
......@@ -41,6 +41,8 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { SettingsForm } from '../components/settings-form-layout'
import { SettingsPageActionsPortal } from '../components/settings-page-context'
import { removeTrailingSlash } from './utils'
import {
type CatalogStore,
......@@ -451,6 +453,16 @@ export function WaffoPancakeSettingsSection(props: Props) {
return (
<div className='space-y-4 pt-4'>
<SettingsPageActionsPortal>
<Button
type='button'
size='sm'
onClick={handleSave}
disabled={saving || !chosenStoreID || !chosenProductID}
>
{saving ? t('Saving...') : t('Save Waffo Pancake settings')}
</Button>
</SettingsPageActionsPortal>
<div>
<h3 className='text-lg font-medium'>{t('Waffo Pancake MoR')}</h3>
<p className='text-muted-foreground text-sm'>
......@@ -460,9 +472,9 @@ export function WaffoPancakeSettingsSection(props: Props) {
</p>
</div>
<Form {...form}>
<form
<SettingsForm
onSubmit={(e) => e.preventDefault()}
className='space-y-4'
className='gap-y-4'
data-no-autosubmit='true'
>
{/* Blue box — webhook configuration only. */}
......@@ -685,13 +697,6 @@ export function WaffoPancakeSettingsSection(props: Props) {
) : null}
<div className='flex items-center gap-3'>
<Button
type='button'
onClick={handleSave}
disabled={saving || !chosenStoreID || !chosenProductID}
>
{saving ? t('Saving...') : t('Save Waffo Pancake settings')}
</Button>
{storeID || productID ? (
<div className='text-muted-foreground flex flex-wrap gap-x-3 gap-y-1 text-xs'>
{storeID ? (
......@@ -714,7 +719,7 @@ export function WaffoPancakeSettingsSection(props: Props) {
) : null}
</div>
</div>
</form>
</SettingsForm>
</Form>
</div>
)
......
......@@ -33,7 +33,6 @@ import {
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch'
import {
Table,
TableBody,
......@@ -43,6 +42,8 @@ import {
TableRow,
} from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { SettingsSwitchField } from '../components/settings-form-layout'
import { SettingsPageActionsPortal } from '../components/settings-page-context'
import { useUpdateOption } from '../hooks/use-update-option'
export interface WaffoSettingsValues {
......@@ -212,6 +213,16 @@ export function WaffoSettingsSection(props: Props) {
return (
<>
<div className='space-y-4 pt-4'>
<SettingsPageActionsPortal>
<Button
type='button'
size='sm'
onClick={handleSave}
disabled={loading}
>
{loading ? t('Saving...') : t('Save Waffo settings')}
</Button>
</SettingsPageActionsPortal>
<div>
<h3 className='text-lg font-medium'>
{t('Waffo Aggregator Gateway')}
......@@ -230,21 +241,19 @@ export function WaffoSettingsSection(props: Props) {
</AlertDescription>
</Alert>
<div className='grid grid-cols-2 gap-4'>
<div className='flex items-center gap-2'>
<Switch
<div className='grid gap-4 sm:grid-cols-2'>
<SettingsSwitchField
checked={form.watch('WaffoEnabled')}
onCheckedChange={(v) => form.setValue('WaffoEnabled', v)}
label={t('Enable Waffo')}
className='border-b-0 py-0'
/>
<Label>{t('Enable Waffo')}</Label>
</div>
<div className='flex items-center gap-2'>
<Switch
<SettingsSwitchField
checked={form.watch('WaffoSandbox')}
onCheckedChange={(v) => form.setValue('WaffoSandbox', v)}
label={t('Sandbox mode')}
className='border-b-0 py-0'
/>
<Label>{t('Sandbox mode')}</Label>
</div>
</div>
<div className='grid grid-cols-2 gap-4'>
......@@ -416,10 +425,6 @@ export function WaffoSettingsSection(props: Props) {
</TableBody>
</Table>
</div>
<Button onClick={handleSave} disabled={loading}>
{loading ? t('Saving...') : t('Save Changes')}
</Button>
</div>
<Dialog open={methodDialogOpen} onOpenChange={setMethodDialogOpen}>
......
......@@ -20,7 +20,6 @@ import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
......@@ -32,6 +31,12 @@ import {
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import {
SettingsForm,
SettingsSwitchContent,
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useResetForm } from '../hooks/use-reset-form'
import { useUpdateOption } from '../hooks/use-update-option'
......@@ -100,18 +105,14 @@ export function WorkerSettingsSection({
}
return (
<SettingsSection
title={t('Worker Proxy')}
description={t(
'Configure upstream worker or proxy service for outbound requests'
)}
>
<SettingsSection title={t('Worker Proxy')}>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
autoComplete='off'
className='space-y-6'
>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)} autoComplete='off'>
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
isSaving={updateOption.isPending}
saveLabel='Save Worker settings'
/>
<FormField
control={form.control}
name='WorkerUrl'
......@@ -167,33 +168,25 @@ export function WorkerSettingsSection({
control={form.control}
name='WorkerAllowHttpImageRequestEnabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Allow HTTP image requests')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Allow HTTP image requests')}</FormLabel>
<FormDescription>
{t(
'Enable when proxying workers that fetch images over HTTP.'
)}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
<Button type='submit' disabled={updateOption.isPending}>
{updateOption.isPending
? t('Saving...')
: t('Save Worker settings')}
</Button>
</form>
</SettingsForm>
</Form>
</SettingsSection>
)
......
......@@ -21,17 +21,23 @@ import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Switch } from '@/components/ui/switch'
import {
SettingsControlChildren,
SettingsForm,
SettingsSwitchContent,
SettingsControlGroup,
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
import {
......@@ -201,12 +207,16 @@ export function HeaderNavigationSection({
]
return (
<SettingsSection
title={t('Header navigation')}
description={t('Enable or disable top navigation modules globally.')}
>
<SettingsSection title={t('Header navigation')}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
onReset={resetToDefault}
isSaving={updateOption.isPending}
resetLabel='Reset to default'
saveLabel='Save navigation'
/>
<div className='grid gap-4 md:grid-cols-2'>
{simpleModules.map((module) => (
<FormField
......@@ -214,13 +224,11 @@ export function HeaderNavigationSection({
control={form.control}
name={module.key}
render={({ field }) => (
<FormItem className='flex flex-row items-start justify-between rounded-lg border p-4'>
<div className='space-y-0.5 pe-4'>
<FormLabel className='text-base'>
{module.title}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{module.title}</FormLabel>
<FormDescription>{module.description}</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
......@@ -228,7 +236,7 @@ export function HeaderNavigationSection({
/>
</FormControl>
<FormMessage />
</FormItem>
</SettingsSwitchItem>
)}
/>
))}
......@@ -236,18 +244,16 @@ export function HeaderNavigationSection({
<div className='grid gap-4 lg:grid-cols-2'>
{accessModules.map((module) => (
<div key={module.enabledKey} className='rounded-lg border p-4'>
<SettingsControlGroup key={module.enabledKey}>
<FormField
control={form.control}
name={module.enabledKey}
render={({ field }) => (
<FormItem className='flex flex-row items-start justify-between rounded-lg border p-4'>
<div className='space-y-0.5 pe-4'>
<FormLabel className='text-base'>
{module.title}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{module.title}</FormLabel>
<FormDescription>{module.description}</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
......@@ -255,7 +261,7 @@ export function HeaderNavigationSection({
/>
</FormControl>
<FormMessage />
</FormItem>
</SettingsSwitchItem>
)}
/>
......@@ -263,15 +269,14 @@ export function HeaderNavigationSection({
control={form.control}
name={module.requireAuthKey}
render={({ field }) => (
<FormItem className='mt-4 flex flex-row items-start justify-between rounded-lg border border-dashed p-4'>
<div className='space-y-0.5 pe-4'>
<FormLabel className='text-base'>
{module.requireAuthTitle}
</FormLabel>
<SettingsControlChildren>
<SettingsSwitchItem className='border-b-0 py-2'>
<SettingsSwitchContent>
<FormLabel>{module.requireAuthTitle}</FormLabel>
<FormDescription>
{module.requireAuthDescription}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
......@@ -280,22 +285,14 @@ export function HeaderNavigationSection({
/>
</FormControl>
<FormMessage />
</FormItem>
</SettingsSwitchItem>
</SettingsControlChildren>
)}
/>
</div>
</SettingsControlGroup>
))}
</div>
<div className='flex flex-wrap gap-3'>
<Button type='button' variant='outline' onClick={resetToDefault}>
{t('Reset to default')}
</Button>
<Button type='submit' disabled={updateOption.isPending}>
{updateOption.isPending ? t('Saving...') : t('Save navigation')}
</Button>
</div>
</form>
</SettingsForm>
</Form>
</SettingsSection>
)
......
......@@ -39,13 +39,19 @@ import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Switch } from '@/components/ui/switch'
import { DateTimePicker } from '@/components/datetime-picker'
import { deleteLogsBefore } from '../api'
import {
SettingsControlGroup,
SettingsForm,
SettingsSwitchContent,
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
......@@ -161,27 +167,27 @@ export function LogSettingsSection({
}
return (
<SettingsSection
title={t('Log Maintenance')}
description={t('Control log retention and clean historical data.')}
>
<SettingsSection title={t('Log Maintenance')}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
isSaving={updateOption.isPending}
saveLabel='Save log settings'
/>
<FormField
control={form.control}
name='LogConsumeEnabled'
render={({ field }) => (
<FormItem className='flex flex-row items-start justify-between rounded-lg border p-4'>
<div className='space-y-0.5 pe-4'>
<FormLabel className='text-base'>
{t('Record quota usage')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Record quota usage')}</FormLabel>
<FormDescription>
{t(
'Track per-request consumption to power usage analytics. Keeping this on increases database writes.'
)}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
......@@ -189,11 +195,11 @@ export function LogSettingsSection({
/>
</FormControl>
<FormMessage />
</FormItem>
</SettingsSwitchItem>
)}
/>
<div className='space-y-4 rounded-lg border p-4'>
<SettingsControlGroup className='space-y-3'>
<div>
<h4 className='text-sm font-medium'>{t('Clean history logs')}</h4>
<p className='text-muted-foreground text-sm'>
......@@ -223,12 +229,8 @@ export function LogSettingsSection({
{isCleaning ? t('Cleaning...') : t('Clean logs')}
</Button>
</div>
</div>
<Button type='submit' disabled={updateOption.isPending}>
{updateOption.isPending ? t('Saving...') : t('Save log settings')}
</Button>
</form>
</SettingsControlGroup>
</SettingsForm>
</Form>
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<AlertDialogContent>
......
......@@ -21,7 +21,6 @@ import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
......@@ -31,6 +30,8 @@ import {
FormMessage,
} from '@/components/ui/form'
import { Textarea } from '@/components/ui/textarea'
import { SettingsForm } from '../components/settings-form-layout'
import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
......@@ -70,14 +71,14 @@ export function NoticeSection({ defaultValue }: NoticeSectionProps) {
}
return (
<SettingsSection
title={t('System Notice')}
description={t(
'Broadcast a global banner to users. Markdown is supported.'
)}
>
<SettingsSection title={t('System Notice')}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
isSaving={updateOption.isPending}
saveLabel='Save notice'
/>
<FormField
control={form.control}
name='Notice'
......@@ -97,11 +98,7 @@ export function NoticeSection({ defaultValue }: NoticeSectionProps) {
</FormItem>
)}
/>
<Button type='submit' disabled={updateOption.isPending}>
{updateOption.isPending ? t('Saving...') : t('Save notice')}
</Button>
</form>
</SettingsForm>
</Form>
</SettingsSection>
)
......
......@@ -59,6 +59,12 @@ import {
import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch'
import { StatusBadge } from '@/components/status-badge'
import {
SettingsForm,
SettingsSwitchContent,
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useResetForm } from '../hooks/use-reset-form'
import { useUpdateOption } from '../hooks/use-update-option'
......@@ -294,14 +300,13 @@ export function PerformanceSection(props: Props) {
: 0
return (
<SettingsSection
title={t('Performance Settings')}
description={t(
'Disk cache, system performance monitoring, and operation statistics'
)}
>
<SettingsSection title={t('Performance Settings')}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
isSaving={updateOption.isPending}
/>
{/* Disk Cache Settings */}
<div>
<h4 className='font-medium'>{t('Disk Cache Settings')}</h4>
......@@ -317,15 +322,17 @@ export function PerformanceSection(props: Props) {
control={form.control}
name='performance_setting.disk_cache_enabled'
render={({ field }) => (
<FormItem className='flex items-center gap-2'>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Enable Disk Cache')}</FormLabel>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel>{t('Enable Disk Cache')}</FormLabel>
</FormItem>
</SettingsSwitchItem>
)}
/>
<FormField
......@@ -415,15 +422,17 @@ export function PerformanceSection(props: Props) {
control={form.control}
name='performance_setting.monitor_enabled'
render={({ field }) => (
<FormItem className='flex items-center gap-2'>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Enable Performance Monitoring')}</FormLabel>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel>{t('Enable Performance Monitoring')}</FormLabel>
</FormItem>
</SettingsSwitchItem>
)}
/>
<FormField
......@@ -492,15 +501,19 @@ export function PerformanceSection(props: Props) {
control={form.control}
name='perf_metrics_setting.enabled'
render={({ field }) => (
<FormItem className='flex items-center gap-2'>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>
{t('Enable model performance metrics')}
</FormLabel>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel>{t('Enable model performance metrics')}</FormLabel>
</FormItem>
</SettingsSwitchItem>
)}
/>
<FormField
......@@ -573,11 +586,7 @@ export function PerformanceSection(props: Props) {
)}
/>
</div>
<Button type='submit' disabled={updateOption.isPending}>
{updateOption.isPending ? t('Saving...') : t('Save Changes')}
</Button>
</form>
</SettingsForm>
</Form>
<Separator />
......
......@@ -19,16 +19,22 @@ For commercial licensing, please contact support@quantumnous.com
import { useEffect, useMemo } from 'react'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from '@/components/ui/form'
import { Switch } from '@/components/ui/switch'
import {
SettingsControlChildren,
SettingsForm,
SettingsSwitchContent,
SettingsControlGroup,
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
import {
......@@ -175,14 +181,16 @@ export function SidebarModulesSection({
const sections = Object.entries(config)
return (
<SettingsSection
title={t('Sidebar modules')}
description={t(
'Control which sidebar areas and modules are available to all users.'
)}
>
<SettingsSection title={t('Sidebar modules')}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
onReset={resetToDefault}
isSaving={updateOption.isPending}
resetLabel='Reset to default'
saveLabel='Save sidebar modules'
/>
{sections.map(([sectionKey, sectionConfig]) => {
const sectionInfo = sectionMeta[sectionKey] ?? {
title: toTitleCase(sectionKey),
......@@ -193,32 +201,30 @@ export function SidebarModulesSection({
)
return (
<div key={sectionKey} className='rounded-lg border p-4'>
<SettingsControlGroup key={sectionKey}>
<FormField
control={form.control}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
name={`${sectionKey}.enabled` as any}
render={({ field }) => (
<FormItem className='flex flex-row items-start justify-between rounded-lg border p-4'>
<div className='space-y-0.5 pe-4'>
<FormLabel className='text-base'>
{sectionInfo.title}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{sectionInfo.title}</FormLabel>
<FormDescription>
{sectionInfo.description}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={Boolean(field.value)}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
<div className='mt-4 grid gap-4 md:grid-cols-2'>
<SettingsControlChildren className='grid gap-3 md:grid-cols-2'>
{modules.map(([moduleKey]) => {
const moduleInfo = moduleMeta[sectionKey]?.[moduleKey] ?? {
title: toTitleCase(moduleKey),
......@@ -231,15 +237,13 @@ export function SidebarModulesSection({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
name={`${sectionKey}.${moduleKey}` as any}
render={({ field }) => (
<FormItem className='flex flex-row items-start justify-between rounded-lg border p-4'>
<div className='space-y-0.5 pe-4'>
<FormLabel className='text-base'>
{moduleInfo.title}
</FormLabel>
<SettingsSwitchItem className='border-b-0 py-2'>
<SettingsSwitchContent>
<FormLabel>{moduleInfo.title}</FormLabel>
<FormDescription>
{moduleInfo.description}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={Boolean(field.value)}
......@@ -250,27 +254,16 @@ export function SidebarModulesSection({
}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
)
})}
</div>
</div>
</SettingsControlChildren>
</SettingsControlGroup>
)
})}
<div className='flex flex-wrap gap-3'>
<Button type='button' variant='outline' onClick={resetToDefault}>
{t('Reset to default')}
</Button>
<Button type='submit' disabled={updateOption.isPending}>
{updateOption.isPending
? t('Saving...')
: t('Save sidebar modules')}
</Button>
</div>
</form>
</SettingsForm>
</Form>
</SettingsSection>
)
......
......@@ -110,10 +110,7 @@ export function UpdateCheckerSection({
return (
<>
<SettingsSection
title={t('System maintenance')}
description={t('Review current version and fetch release notes.')}
>
<SettingsSection title={t('System maintenance')}>
<div className='space-y-6'>
<div className='grid gap-4 md:grid-cols-2'>
<div className='rounded-lg border p-4'>
......
......@@ -22,7 +22,6 @@ import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
......@@ -35,6 +34,13 @@ import {
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import {
SettingsForm,
SettingsSwitchContent,
SettingsControlGroup,
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
import {
......@@ -173,15 +179,14 @@ export function ClaudeSettingsCard({ defaultValues }: ClaudeSettingsCardProps) {
}
return (
<SettingsSection
title={t('Claude')}
description={t(
'Override Anthropic headers, defaults, and thinking adapter behavior'
)}
>
<SettingsSection title={t('Claude')}>
<Form {...form}>
{/* eslint-disable-next-line react-hooks/refs */}
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
isSaving={updateOption.isPending}
/>
<FormField
control={form.control}
name='claude.model_headers_settings'
......@@ -219,29 +224,27 @@ export function ClaudeSettingsCard({ defaultValues }: ClaudeSettingsCardProps) {
)}
/>
<div className='space-y-4 rounded-lg border p-4'>
<SettingsControlGroup>
<FormField
control={form.control}
name='claude.thinking_adapter_enabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Thinking Adapter')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Thinking Adapter')}</FormLabel>
<FormDescription>
{t(
'Translate `-thinking` suffixes into Anthropic native thinking models while keeping pricing predictable.'
)}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
......@@ -267,12 +270,8 @@ export function ClaudeSettingsCard({ defaultValues }: ClaudeSettingsCardProps) {
</FormItem>
)}
/>
</div>
<Button type='submit' disabled={updateOption.isPending}>
{updateOption.isPending ? t('Saving...') : t('Save Changes')}
</Button>
</form>
</SettingsControlGroup>
</SettingsForm>
</Form>
</SettingsSection>
)
......
......@@ -22,7 +22,6 @@ import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
......@@ -35,6 +34,13 @@ import {
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import {
SettingsForm,
SettingsSwitchContent,
SettingsControlGroup,
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
import {
......@@ -226,14 +232,13 @@ export function GeminiSettingsCard({ defaultValues }: GeminiSettingsCardProps) {
)
return (
<SettingsSection
title={t('Gemini')}
description={t(
'Configure Gemini safety behavior, version overrides, and thinking adapter'
)}
>
<SettingsSection title={t('Gemini')}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
isSaving={updateOption.isPending}
/>
<FormField
control={form.control}
name='gemini.safety_settings'
......@@ -295,16 +300,14 @@ export function GeminiSettingsCard({ defaultValues }: GeminiSettingsCardProps) {
)}
/>
<div className='space-y-4 rounded-lg border p-4'>
<SettingsControlGroup>
<FormField
control={form.control}
name='gemini.thinking_adapter_enabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Thinking Adapter')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Thinking Adapter')}</FormLabel>
<FormDescription>
{t('Supports `-thinking`, `-thinking-')}
{'{{budget}}'}
......@@ -312,14 +315,14 @@ export function GeminiSettingsCard({ defaultValues }: GeminiSettingsCardProps) {
'`, and `-nothinking` suffixes while routing to the correct Gemini variant.'
)}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
......@@ -353,15 +356,15 @@ export function GeminiSettingsCard({ defaultValues }: GeminiSettingsCardProps) {
)}
</p>
)}
</div>
</SettingsControlGroup>
<FormField
control={form.control}
name='gemini.function_call_thought_signature_enabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>
{t('Enable FunctionCall thoughtSignature Fill')}
</FormLabel>
<FormDescription>
......@@ -369,14 +372,14 @@ export function GeminiSettingsCard({ defaultValues }: GeminiSettingsCardProps) {
'Fill thoughtSignature only for Gemini/Vertex channels using the OpenAI format'
)}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
......@@ -384,31 +387,25 @@ export function GeminiSettingsCard({ defaultValues }: GeminiSettingsCardProps) {
control={form.control}
name='gemini.remove_function_response_id_enabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Remove functionResponse.id field')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Remove functionResponse.id field')}</FormLabel>
<FormDescription>
{t(
'Vertex AI does not support functionResponse.id. Enable this to remove the field automatically.'
)}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
<Button type='submit' disabled={updateOption.isPending}>
{updateOption.isPending ? t('Saving...') : t('Save Changes')}
</Button>
</form>
</SettingsForm>
</Form>
</SettingsSection>
)
......
......@@ -38,6 +38,12 @@ import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { StatusBadge } from '@/components/status-badge'
import {
SettingsForm,
SettingsSwitchContent,
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
......@@ -186,36 +192,33 @@ export function GlobalSettingsCard({ defaultValues }: GlobalSettingsCardProps) {
}
return (
<SettingsSection
title={t('Global Model Configuration')}
description={t(
'Control passthrough behavior and connection keep-alive settings'
)}
>
<SettingsSection title={t('Global Model Configuration')}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
isSaving={updateOption.isPending}
/>
<FormField
control={form.control}
name='global.pass_through_request_enabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Enable Request Passthrough')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Enable Request Passthrough')}</FormLabel>
<FormDescription>
{t(
'Forward requests directly to upstream providers without any post-processing.'
)}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
......@@ -349,24 +352,22 @@ export function GlobalSettingsCard({ defaultValues }: GlobalSettingsCardProps) {
control={form.control}
name='general_setting.ping_interval_enabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Keep-alive Ping')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Keep-alive Ping')}</FormLabel>
<FormDescription>
{t(
'Periodically send ping frames to keep streaming connections active.'
)}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
......@@ -402,11 +403,7 @@ export function GlobalSettingsCard({ defaultValues }: GlobalSettingsCardProps) {
</FormItem>
)}
/>
<Button type='submit' disabled={updateOption.isPending}>
{updateOption.isPending ? t('Saving...') : t('Save Changes')}
</Button>
</form>
</SettingsForm>
</Form>
</SettingsSection>
)
......
......@@ -20,7 +20,6 @@ import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
......@@ -31,6 +30,12 @@ import {
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import {
SettingsForm,
SettingsSwitchContent,
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useResetForm } from '../hooks/use-reset-form'
import { useUpdateOption } from '../hooks/use-update-option'
......@@ -79,24 +84,19 @@ export function GrokSettingsCard(props: Props) {
const enabled = form.watch('grok.violation_deduction_enabled')
return (
<SettingsSection
title={t('Grok Settings')}
description={t('Configure xAI Grok model specific settings')}
>
<SettingsSection title={t('Grok Settings')}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
isSaving={updateOption.isPending}
/>
<FormField
control={form.control}
name='grok.violation_deduction_enabled'
render={({ field }) => (
<FormItem className='flex items-center gap-2'>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Enable violation deduction')}</FormLabel>
<FormDescription>
{t(
......@@ -111,8 +111,14 @@ export function GrokSettingsCard(props: Props) {
{t('Official documentation')}
</a>
</FormDescription>
</div>
</FormItem>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</SettingsSwitchItem>
)}
/>
......@@ -139,11 +145,7 @@ export function GrokSettingsCard(props: Props) {
</FormItem>
)}
/>
<Button type='submit' disabled={updateOption.isPending}>
{updateOption.isPending ? t('Saving...') : t('Save Changes')}
</Button>
</form>
</SettingsForm>
</Form>
</SettingsSection>
)
......
......@@ -45,6 +45,12 @@ import {
} from '@/components/ui/sheet'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import {
SettingsForm,
SettingsSwitchContent,
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { SettingsPageActionsPortal } from '../components/settings-page-context'
import { GroupRatioVisualEditor } from './group-ratio-visual-editor'
import { GroupSpecialUsableRulesEditor } from './group-special-usable-editor'
......@@ -112,6 +118,16 @@ export const GroupRatioForm = memo(function GroupRatioForm({
<GroupPricingGuide open={guideOpen} onOpenChange={setGuideOpen} />
<Form {...form}>
<SettingsPageActionsPortal>
<Button
type='button'
size='sm'
onClick={form.handleSubmit(onSave)}
disabled={isSaving}
>
{isSaving ? t('Saving...') : t('Save group ratios')}
</Button>
</SettingsPageActionsPortal>
{editMode === 'visual' ? (
<div className='space-y-6'>
<GroupRatioVisualEditor
......@@ -136,33 +152,27 @@ export const GroupRatioForm = memo(function GroupRatioForm({
control={form.control}
name='DefaultUseAutoGroup'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Default to auto groups')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Default to auto groups')}</FormLabel>
<FormDescription>
{t(
'When enabled, newly created tokens start in the first auto group.'
)}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
<Button onClick={form.handleSubmit(onSave)} disabled={isSaving}>
{isSaving ? t('Saving...') : t('Save group ratios')}
</Button>
</div>
) : (
<form onSubmit={form.handleSubmit(onSave)} className='space-y-6'>
<SettingsForm onSubmit={form.handleSubmit(onSave)}>
<FormField
control={form.control}
name='GroupRatio'
......@@ -284,31 +294,25 @@ export const GroupRatioForm = memo(function GroupRatioForm({
control={form.control}
name='DefaultUseAutoGroup'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Default to auto groups')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Default to auto groups')}</FormLabel>
<FormDescription>
{t(
'When enabled, newly created tokens start in the first auto group.'
)}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
<Button type='submit' disabled={isSaving}>
{isSaving ? t('Saving...') : t('Save group ratios')}
</Button>
</form>
</SettingsForm>
)}
</Form>
</div>
......
......@@ -21,6 +21,7 @@ import type { ModelSettings } from '../types'
import {
MODELS_DEFAULT_SECTION,
getModelsSectionContent,
getModelsSectionMeta,
} from './section-registry.tsx'
const defaultModelSettings: ModelSettings = {
......@@ -77,6 +78,7 @@ export function ModelSettings() {
defaultSettings={defaultModelSettings}
defaultSection={MODELS_DEFAULT_SECTION}
getSectionContent={getModelsSectionContent}
getSectionMeta={getModelsSectionMeta}
/>
)
}
......@@ -33,11 +33,9 @@ import {
} from '@/components/ui/collapsible'
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldTitle,
} from '@/components/ui/field'
import {
Form,
......@@ -62,9 +60,12 @@ import {
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { combineBillingExpr } from '@/features/pricing/lib/billing-expr'
import {
SettingsControlGroup,
SettingsSwitchField,
} from '../components/settings-form-layout'
import { formatPricingNumber } from './pricing-format'
import { TieredPricingEditor } from './tiered-pricing-editor'
......@@ -1005,36 +1006,29 @@ function PriceLane(props: {
const effectiveDisabled = props.disabled || !props.enabled
return (
<Field
className={cn(
'rounded-lg border p-3',
effectiveDisabled && 'bg-muted/35'
)}
<SettingsControlGroup
className={cn('space-y-3', effectiveDisabled && 'opacity-75')}
data-disabled={effectiveDisabled || undefined}
>
<div className='flex items-start justify-between gap-3'>
<FieldContent>
<FieldTitle>{props.title}</FieldTitle>
<FieldDescription>{props.description}</FieldDescription>
</FieldContent>
<Switch
<SettingsSwitchField
checked={props.enabled}
disabled={props.disabled}
onCheckedChange={props.onEnabledChange}
label={props.title}
description={props.description}
aria-label={props.title}
/>
</div>
<PriceInput
value={props.value}
placeholder={props.placeholder}
disabled={effectiveDisabled}
onChange={props.onChange}
/>
<FieldDescription>
<p className='text-muted-foreground text-xs'>
{props.enabled
? t('USD price per 1M tokens.')
: t('Disabled lanes are omitted on save.')}
</FieldDescription>
</Field>
</p>
</SettingsControlGroup>
)
}
......@@ -32,6 +32,12 @@ import {
} from '@/components/ui/form'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import {
SettingsForm,
SettingsSwitchContent,
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { SettingsPageActionsPortal } from '../components/settings-page-context'
import { ModelRatioVisualEditor } from './model-ratio-visual-editor'
type ModelFormValues = {
......@@ -99,6 +105,25 @@ export const ModelRatioForm = memo(function ModelRatioForm({
</div>
<Form {...form}>
<SettingsPageActionsPortal>
<Button
type='button'
variant='destructive'
size='sm'
onClick={onReset}
disabled={isResetting}
>
{t('Reset prices')}
</Button>
<Button
type='button'
size='sm'
onClick={form.handleSubmit(onSave)}
disabled={isSaving}
>
{isSaving ? t('Saving...') : t('Save model prices')}
</Button>
</SettingsPageActionsPortal>
{editMode === 'visual' ? (
<div className='space-y-6'>
<ModelRatioVisualEditor
......@@ -127,43 +152,27 @@ export const ModelRatioForm = memo(function ModelRatioForm({
control={form.control}
name='ExposeRatioEnabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Expose ratio API')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Expose ratio API')}</FormLabel>
<FormDescription>
{t(
'Allow clients to query configured ratios via `/api/ratio`.'
)}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
<div className='flex flex-wrap gap-4'>
<Button onClick={form.handleSubmit(onSave)} disabled={isSaving}>
{isSaving ? t('Saving...') : t('Save model prices')}
</Button>
<Button
type='button'
variant='destructive'
onClick={onReset}
disabled={isResetting}
>
{t('Reset prices')}
</Button>
</div>
</div>
) : (
<form onSubmit={form.handleSubmit(onSave)} className='space-y-6'>
<SettingsForm onSubmit={form.handleSubmit(onSave)}>
<FormField
control={form.control}
name='ModelPrice'
......@@ -318,41 +327,25 @@ export const ModelRatioForm = memo(function ModelRatioForm({
control={form.control}
name='ExposeRatioEnabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Expose ratio API')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Expose ratio API')}</FormLabel>
<FormDescription>
{t(
'Allow clients to query configured ratios via `/api/ratio`.'
)}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
<div className='flex flex-wrap gap-4'>
<Button type='submit' disabled={isSaving}>
{isSaving ? t('Saving...') : t('Save model prices')}
</Button>
<Button
type='button'
variant='destructive'
onClick={onReset}
disabled={isResetting}
>
{t('Reset prices')}
</Button>
</div>
</form>
</SettingsForm>
)}
</Form>
</div>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment