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.
This commit is contained in:
t0ng7u
2026-05-25 00:34:26 +08:00
parent 92a0959448
commit b08febaa3c
97 changed files with 2420 additions and 3032 deletions
-3
View File
@@ -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>
+1 -10
View File
@@ -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,
},
-3
View File
@@ -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>
+1 -9
View File
@@ -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 />
-2
View File
@@ -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
-3
View File
@@ -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>
-3
View File
@@ -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>
)
}
@@ -50,6 +50,11 @@ import {
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,
@@ -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,10 +21,8 @@ import * as z from 'zod'
import axios from 'axios'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { RotateCcw } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
@@ -39,6 +37,12 @@ import { Switch } from '@/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
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 { useUpdateOption } from '../hooks/use-update-option'
@@ -69,6 +73,9 @@ const oauthSchema = z.object({
WeChatAccountQRCodeImageURL: z.string().optional(),
})
const oauthTabContentClassName =
'grid min-w-0 gap-x-5 gap-y-6 lg:grid-cols-2 [&>[data-slot=form-item]]:min-w-0 lg:[&>[data-slot=form-item]:has([data-slot=switch])]:col-span-2'
type OAuthFormValues = z.infer<typeof oauthSchema>
type OAuthSectionProps = {
@@ -250,12 +257,15 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) {
<>
<FormNavigationGuard when={form.formState.isDirty} />
<SettingsSection
title={t('OAuth Integrations')}
description={t('Configure third-party authentication providers')}
>
<SettingsSection title={t('OAuth Integrations')}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
onReset={handleReset}
isSaving={updateOption.isPending}
isResetDisabled={!form.formState.isDirty}
/>
<FormDirtyIndicator isDirty={form.formState.isDirty} />
<Tabs value={activeTab} onValueChange={setActiveTab}>
@@ -268,27 +278,25 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) {
<TabsTrigger value='wechat'>{t('WeChat')}</TabsTrigger>
</TabsList>
<TabsContent value='github' className='space-y-4'>
<TabsContent value='github' className={oauthTabContentClassName}>
<FormField
control={form.control}
name='GitHubOAuthEnabled'
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 GitHub OAuth')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Enable GitHub OAuth')}</FormLabel>
<FormDescription>
{t('Allow users to sign in with GitHub')}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
@@ -330,27 +338,25 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) {
/>
</TabsContent>
<TabsContent value='discord' className='space-y-4'>
<TabsContent value='discord' className={oauthTabContentClassName}>
<FormField
control={form.control}
name='discord.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 Discord OAuth')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Enable Discord OAuth')}</FormLabel>
<FormDescription>
{t('Allow users to sign in with Discord')}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
@@ -393,27 +399,25 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) {
/>
</TabsContent>
<TabsContent value='oidc' className='space-y-4'>
<TabsContent value='oidc' className={oauthTabContentClassName}>
<FormField
control={form.control}
name='oidc.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 OIDC')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Enable OIDC')}</FormLabel>
<FormDescription>
{t('Allow users to sign in with OpenID Connect')}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
@@ -537,27 +541,28 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) {
/>
</TabsContent>
<TabsContent value='telegram' className='space-y-4'>
<TabsContent
value='telegram'
className={oauthTabContentClassName}
>
<FormField
control={form.control}
name='TelegramOAuthEnabled'
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 Telegram OAuth')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Enable Telegram OAuth')}</FormLabel>
<FormDescription>
{t('Allow users to sign in with Telegram')}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
@@ -599,27 +604,25 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) {
/>
</TabsContent>
<TabsContent value='linuxdo' className='space-y-4'>
<TabsContent value='linuxdo' className={oauthTabContentClassName}>
<FormField
control={form.control}
name='LinuxDOOAuthEnabled'
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 LinuxDO OAuth')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Enable LinuxDO OAuth')}</FormLabel>
<FormDescription>
{t('Allow users to sign in with LinuxDO')}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
@@ -678,27 +681,25 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) {
/>
</TabsContent>
<TabsContent value='wechat' className='space-y-4'>
<TabsContent value='wechat' className={oauthTabContentClassName}>
<FormField
control={form.control}
name='WeChatAuthEnabled'
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 WeChat Auth')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Enable WeChat Auth')}</FormLabel>
<FormDescription>
{t('Allow users to sign in with WeChat')}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
@@ -758,22 +759,7 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) {
/>
</TabsContent>
</Tabs>
<div className='flex gap-2'>
<Button type='submit' disabled={updateOption.isPending}>
{updateOption.isPending ? t('Saving...') : t('Save Changes')}
</Button>
<Button
type='button'
variant='outline'
onClick={handleReset}
disabled={!form.formState.isDirty || updateOption.isPending}
>
<RotateCcw className='mr-2 h-4 w-4' />
{t('Reset')}
</Button>
</div>
</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,
@@ -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>
@@ -0,0 +1,182 @@
/*
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}
/>
)
}
@@ -0,0 +1,146 @@
/*
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>
</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'>
<h3
{...titleProps}
className={
titleProps?.className
? `text-base font-semibold ${titleProps.className}`
: 'text-base font-semibold'
}
>
{title}
</h3>
{description && (
<p className='text-muted-foreground text-sm'>{description}</p>
)}
</div>
<section className={cn('flex flex-col gap-4', className)}>
{!suppressHeader && (
<div className='flex flex-col gap-1'>
<h3
{...titleProps}
className={cn('text-base font-semibold', titleProps?.className)}
>
{title}
</h3>
</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'>
+42 -75
View File
@@ -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',
})
function resolveContentSettings(
settings: ContentSettings,
raw: SystemOption[] | undefined
): ContentSettings {
if (!raw || raw.length === 0) return settings
const settings = useMemo(() => {
const resolved = getOptionValue(data?.data, defaultContentSettings)
const optionMap = new Map(raw.map((item) => [item.key, item.value]))
const next = { ...settings }
const optionMap = new Map(
(data?.data ?? []).map((item) => [item.key, item.value])
)
const legacyMap = [
{ current: 'console_setting.announcements', legacy: 'Announcements' },
{ current: 'console_setting.api_info', legacy: 'ApiInfo' },
{ current: 'console_setting.faq', legacy: 'FAQ' },
] as const
if (!optionMap.has('console_setting.announcements')) {
const legacy = optionMap.get('Announcements')
if (legacy !== undefined) {
resolved['console_setting.announcements'] = legacy
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,
},
])
}
}
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)
if (!optionMap.has('console_setting.uptime_kuma_groups')) {
const legacyUrl = optionMap.get('UptimeKumaUrl')
const legacySlug = optionMap.get('UptimeKumaSlug')
if (legacyUrl && legacySlug) {
next['console_setting.uptime_kuma_groups'] = JSON.stringify([
{ id: 1, categoryName: 'Legacy', url: legacyUrl, slug: legacySlug },
])
}
}
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
checked={switchOnSuccess}
onCheckedChange={setSwitchOnSuccess}
/>
<Label>{t('Switch affinity on success')}</Label>
<span className='text-muted-foreground text-xs'>
{t(
'If the affinity channel fails and retry succeeds on another channel, update affinity to the successful channel.'
)}
</span>
</div>
<SettingsSwitchField
checked={switchOnSuccess}
onCheckedChange={setSwitchOnSuccess}
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.'
)}
/>
<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
checked={form.watch('skip_retry_on_failure')}
onCheckedChange={(v) => form.setValue('skip_retry_on_failure', v)}
/>
<Label>{t('Skip retry on failure')}</Label>
</div>
<SettingsSwitchField
checked={form.watch('skip_retry_on_failure')}
onCheckedChange={(v) => form.setValue('skip_retry_on_failure', v)}
label={t('Skip retry on failure')}
/>
<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
checked={form.watch('include_using_group')}
onCheckedChange={(v) =>
form.setValue('include_using_group', v)
}
/>
<Label className='text-xs'>{t('Include Group')}</Label>
</div>
<div className='flex items-center gap-2'>
<Switch
checked={form.watch('include_model_name')}
onCheckedChange={(v) =>
form.setValue('include_model_name', v)
}
/>
<Label className='text-xs'>{t('Include Model')}</Label>
</div>
<div className='flex items-center gap-2'>
<Switch
checked={form.watch('include_rule_name')}
onCheckedChange={(v) =>
form.setValue('include_rule_name', v)
}
/>
<Label className='text-xs'>{t('Include Rule Name')}</Label>
</div>
<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'
/>
<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'
/>
<SettingsSwitchField
checked={form.watch('include_rule_name')}
onCheckedChange={(v) => form.setValue('include_rule_name', v)}
label={t('Include Rule Name')}
className='border-b-0 py-0'
/>
</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,177 +115,176 @@ 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} />
<FormField
control={form.control}
name='QuotaForNewUser'
render={({ field }) => (
<FormItem>
<FormLabel>{t('New User Quota')}</FormLabel>
<FormControl>
<Input
type='number'
value={field.value ?? ''}
onChange={handleNumberChange(field.onChange)}
name={field.name}
onBlur={field.onBlur}
ref={field.ref}
/>
</FormControl>
<FormDescription>
{t('Initial quota given to new users')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='PreConsumedQuota'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Pre-Consumed Quota')}</FormLabel>
<FormControl>
<Input
type='number'
value={field.value ?? ''}
onChange={handleNumberChange(field.onChange)}
name={field.name}
onBlur={field.onBlur}
ref={field.ref}
/>
</FormControl>
<FormDescription>
{t('Quota consumed before charging users')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='QuotaForInviter'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Inviter Reward')}</FormLabel>
<FormControl>
<Input
type='number'
value={field.value ?? ''}
onChange={handleNumberChange(field.onChange)}
name={field.name}
onBlur={field.onBlur}
ref={field.ref}
/>
</FormControl>
<FormDescription>
{t('Quota given to users who invite others')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='QuotaForInvitee'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Invitee Reward')}</FormLabel>
<FormControl>
<Input
type='number'
value={field.value ?? ''}
onChange={handleNumberChange(field.onChange)}
name={field.name}
onBlur={field.onBlur}
ref={field.ref}
/>
</FormControl>
<FormDescription>
{t('Quota given to invited users')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<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>
<SettingsFormGrid>
<FormField
control={form.control}
name='QuotaForNewUser'
render={({ field }) => (
<FormItem>
<FormLabel>{t('New User Quota')}</FormLabel>
<FormControl>
<Input
type='number'
value={field.value ?? ''}
onChange={handleNumberChange(field.onChange)}
name={field.name}
onBlur={field.onBlur}
ref={field.ref}
/>
</FormControl>
<FormDescription>
{t(
'When enabled, zero-cost models also pre-consume quota before final settlement.'
)}
{t('Initial quota given to new users')}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={updateOption.isPending}
/>
</FormControl>
</FormItem>
)}
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='TopUpLink'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Top-Up Link')}</FormLabel>
<FormControl>
<Input
placeholder={t('https://example.com/topup')}
{...field}
/>
</FormControl>
<FormDescription>
{t('External link for users to purchase quota')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='PreConsumedQuota'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Pre-Consumed Quota')}</FormLabel>
<FormControl>
<Input
type='number'
value={field.value ?? ''}
onChange={handleNumberChange(field.onChange)}
name={field.name}
onBlur={field.onBlur}
ref={field.ref}
/>
</FormControl>
<FormDescription>
{t('Quota consumed before charging users')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='general_setting.docs_link'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Documentation Link')}</FormLabel>
<FormControl>
<Input
placeholder={t('https://docs.example.com')}
{...field}
/>
</FormControl>
<FormDescription>
{t('Link to your documentation site')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='QuotaForInviter'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Inviter Reward')}</FormLabel>
<FormControl>
<Input
type='number'
value={field.value ?? ''}
onChange={handleNumberChange(field.onChange)}
name={field.name}
onBlur={field.onBlur}
ref={field.ref}
/>
</FormControl>
<FormDescription>
{t('Quota given to users who invite others')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button
type='submit'
disabled={updateOption.isPending || isSubmitting}
>
{updateOption.isPending ? t('Saving...') : t('Save Changes')}
</Button>
</form>
<FormField
control={form.control}
name='QuotaForInvitee'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Invitee Reward')}</FormLabel>
<FormControl>
<Input
type='number'
value={field.value ?? ''}
onChange={handleNumberChange(field.onChange)}
name={field.name}
onBlur={field.onBlur}
ref={field.ref}
/>
</FormControl>
<FormDescription>
{t('Quota given to invited users')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<SettingsFormGridItem span='full'>
<FormField
control={form.control}
name='quota_setting.enable_free_model_pre_consume'
render={({ field }) => (
<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>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={updateOption.isPending}
/>
</FormControl>
</SettingsSwitchItem>
)}
/>
</SettingsFormGridItem>
<FormField
control={form.control}
name='TopUpLink'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Top-Up Link')}</FormLabel>
<FormControl>
<Input
placeholder={t('https://example.com/topup')}
{...field}
/>
</FormControl>
<FormDescription>
{t('External link for users to purchase quota')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='general_setting.docs_link'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Documentation Link')}</FormLabel>
<FormControl>
<Input
placeholder={t('https://docs.example.com')}
{...field}
/>
</FormControl>
<FormDescription>
{t('Link to your documentation site')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</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,250 +143,243 @@ 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} />
<FormField
control={form.control}
name='theme.frontend'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Frontend Theme')}</FormLabel>
<Select
items={[
{ value: 'default', label: t('Default (New Frontend)') },
{
value: 'classic',
label: t('Classic (Legacy Frontend)'),
},
]}
onValueChange={field.onChange}
value={field.value}
>
<SettingsFormGrid>
<FormField
control={form.control}
name='theme.frontend'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Frontend Theme')}</FormLabel>
<Select
items={[
{
value: 'default',
label: t('Default (New Frontend)'),
},
{
value: 'classic',
label: t('Classic (Legacy Frontend)'),
},
]}
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger className='w-full'>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectItem value='default'>
{t('Default (New Frontend)')}
</SelectItem>
<SelectItem value='classic'>
{t('Classic (Legacy Frontend)')}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FormDescription>
{t(
'Switch between the new frontend and the classic frontend. Changes take effect after page reload.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='SystemName'
render={({ field }) => (
<FormItem>
<FormLabel>{t('System Name')}</FormLabel>
<FormControl>
<SelectTrigger className='w-full'>
<SelectValue />
</SelectTrigger>
<Input placeholder={t('New API')} {...field} />
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectItem value='default'>
{t('Default (New Frontend)')}
</SelectItem>
<SelectItem value='classic'>
{t('Classic (Legacy Frontend)')}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FormDescription>
{t(
'Switch between the new frontend and the classic frontend. Changes take effect after page reload.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormDescription>
{t('The name displayed across the application')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='SystemName'
render={({ field }) => (
<FormItem>
<FormLabel>{t('System Name')}</FormLabel>
<FormControl>
<Input placeholder={t('New API')} {...field} />
</FormControl>
<FormDescription>
{t('The name displayed across the application')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='ServerAddress'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Server Address')}</FormLabel>
<FormControl>
<Input placeholder='https://yourdomain.com' {...field} />
</FormControl>
<FormDescription>
{t(
'The public URL of your server, used for OAuth callbacks, webhooks, and other external integrations'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='Logo'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Logo URL')}</FormLabel>
<FormControl>
<Input
placeholder={t('https://example.com/logo.png')}
{...field}
/>
</FormControl>
<FormDescription>
{t('URL to your logo image (optional)')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='Footer'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Footer')}</FormLabel>
<FormControl>
<Textarea
placeholder={t(
'© 2025 Your Company. All rights reserved.'
<FormField
control={form.control}
name='ServerAddress'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Server Address')}</FormLabel>
<FormControl>
<Input placeholder='https://yourdomain.com' {...field} />
</FormControl>
<FormDescription>
{t(
'The public URL of your server, used for OAuth callbacks, webhooks, and other external integrations'
)}
{...field}
/>
</FormControl>
<FormDescription>
{t('Footer text displayed at the bottom of pages')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='About'
render={({ field }) => (
<FormItem>
<FormLabel>{t('About')}</FormLabel>
<FormControl>
<Textarea
placeholder={t(
'Enter HTML code (e.g., <p>About us...</p>) or a URL (e.g., https://example.com) to embed as iframe'
<FormField
control={form.control}
name='Logo'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Logo URL')}</FormLabel>
<FormControl>
<Input
placeholder={t('https://example.com/logo.png')}
{...field}
/>
</FormControl>
<FormDescription>
{t('URL to your logo image (optional)')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='Footer'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Footer')}</FormLabel>
<FormControl>
<Textarea
placeholder={t(
'© 2025 Your Company. All rights reserved.'
)}
rows={4}
{...field}
/>
</FormControl>
<FormDescription>
{t('Footer text displayed at the bottom of pages')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='About'
render={({ field }) => (
<FormItem>
<FormLabel>{t('About')}</FormLabel>
<FormControl>
<Textarea
placeholder={t(
'Enter HTML code (e.g., <p>About us...</p>) or a URL (e.g., https://example.com) to embed as iframe'
)}
rows={4}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.'
)}
rows={4}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='HomePageContent'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Home Page Content')}</FormLabel>
<FormControl>
<Textarea
placeholder={t('Welcome to our New API...')}
rows={6}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Content displayed on the home page (supports Markdown)'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<SettingsFormGridItem span='full'>
<FormField
control={form.control}
name='HomePageContent'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Home Page Content')}</FormLabel>
<FormControl>
<Textarea
placeholder={t('Welcome to our New API...')}
rows={6}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Content displayed on the home page (supports Markdown)'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormGridItem>
<FormField
control={form.control}
name='legal.user_agreement'
render={({ field }) => (
<FormItem>
<FormLabel>{t('User Agreement')}</FormLabel>
<FormControl>
<Textarea
placeholder={t(
'Provide Markdown, HTML, or an external URL for the user agreement'
<FormField
control={form.control}
name='legal.user_agreement'
render={({ field }) => (
<FormItem>
<FormLabel>{t('User Agreement')}</FormLabel>
<FormControl>
<Textarea
placeholder={t(
'Provide Markdown, HTML, or an external URL for the user agreement'
)}
rows={6}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Leave empty to disable the agreement requirement. Supports Markdown, HTML, or a full URL to redirect users.'
)}
rows={6}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Leave empty to disable the agreement requirement. Supports Markdown, HTML, or a full URL to redirect users.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='legal.privacy_policy'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Privacy Policy')}</FormLabel>
<FormControl>
<Textarea
placeholder={t(
'Provide Markdown, HTML, or an external URL for the privacy policy'
<FormField
control={form.control}
name='legal.privacy_policy'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Privacy Policy')}</FormLabel>
<FormControl>
<Textarea
placeholder={t(
'Provide Markdown, HTML, or an external URL for the privacy policy'
)}
rows={6}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Leave empty to disable the privacy policy requirement. Supports Markdown, HTML, or a full URL to redirect users.'
)}
rows={6}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Leave empty to disable the privacy policy requirement. Supports Markdown, HTML, or a full URL to redirect users.'
)}
</FormDescription>
<FormMessage />
</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>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormGrid>
</SettingsForm>
</Form>
</SettingsSection>
</>
+1 -8
View File
@@ -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>
)
@@ -47,6 +47,12 @@ import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { RiskAcknowledgementDialog } from '@/components/risk-acknowledgement-dialog'
import { confirmPaymentCompliance } from '../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'
import { AmountDiscountVisualEditor } from './amount-discount-visual-editor'
@@ -278,25 +284,67 @@ export function PaymentSettingsSection({
})
}, [defaultsSignature, form])
const saveGeneralSettings = async () => {
const values = form.getValues()
const onSubmit = async (values: PaymentFormValues) => {
const sanitized = {
Price: values.Price as number,
MinTopUp: values.MinTopUp as number,
PayAddress: removeTrailingSlash(values.PayAddress),
EpayId: values.EpayId.trim(),
EpayKey: values.EpayKey.trim(),
Price: values.Price,
MinTopUp: values.MinTopUp,
CustomCallbackAddress: removeTrailingSlash(values.CustomCallbackAddress),
PayMethods: values.PayMethods.trim(),
AmountOptions: values.AmountOptions.trim(),
AmountDiscount: values.AmountDiscount.trim(),
StripeApiSecret: values.StripeApiSecret.trim(),
StripeWebhookSecret: values.StripeWebhookSecret.trim(),
StripePriceId: values.StripePriceId.trim(),
StripeUnitPrice: values.StripeUnitPrice,
StripeMinTopUp: values.StripeMinTopUp,
StripePromotionCodesEnabled: values.StripePromotionCodesEnabled,
CreemApiKey: values.CreemApiKey.trim(),
CreemWebhookSecret: values.CreemWebhookSecret.trim(),
CreemTestMode: values.CreemTestMode,
CreemProducts: values.CreemProducts.trim(),
}
const initial = {
PayAddress: removeTrailingSlash(initialRef.current.PayAddress),
EpayId: initialRef.current.EpayId.trim(),
EpayKey: initialRef.current.EpayKey.trim(),
Price: initialRef.current.Price,
MinTopUp: initialRef.current.MinTopUp,
CustomCallbackAddress: removeTrailingSlash(
initialRef.current.CustomCallbackAddress
),
PayMethods: initialRef.current.PayMethods.trim(),
AmountOptions: initialRef.current.AmountOptions.trim(),
AmountDiscount: initialRef.current.AmountDiscount.trim(),
StripeApiSecret: initialRef.current.StripeApiSecret.trim(),
StripeWebhookSecret: initialRef.current.StripeWebhookSecret.trim(),
StripePriceId: initialRef.current.StripePriceId.trim(),
StripeUnitPrice: initialRef.current.StripeUnitPrice,
StripeMinTopUp: initialRef.current.StripeMinTopUp,
StripePromotionCodesEnabled:
initialRef.current.StripePromotionCodesEnabled,
CreemApiKey: initialRef.current.CreemApiKey.trim(),
CreemWebhookSecret: initialRef.current.CreemWebhookSecret.trim(),
CreemTestMode: initialRef.current.CreemTestMode,
CreemProducts: initialRef.current.CreemProducts.trim(),
}
const updates: Array<{ key: string; value: string | number }> = []
const updates: Array<{ key: string; value: string | number | boolean }> = []
if (sanitized.PayAddress !== initial.PayAddress) {
updates.push({ key: 'PayAddress', value: sanitized.PayAddress })
}
if (sanitized.EpayId !== initial.EpayId) {
updates.push({ key: 'EpayId', value: sanitized.EpayId })
}
if (sanitized.EpayKey && sanitized.EpayKey !== initial.EpayKey) {
updates.push({ key: 'EpayKey', value: sanitized.EpayKey })
}
if (sanitized.Price !== initial.Price) {
updates.push({ key: 'Price', value: sanitized.Price })
@@ -306,6 +354,13 @@ export function PaymentSettingsSection({
updates.push({ key: 'MinTopUp', value: sanitized.MinTopUp })
}
if (sanitized.CustomCallbackAddress !== initial.CustomCallbackAddress) {
updates.push({
key: 'CustomCallbackAddress',
value: sanitized.CustomCallbackAddress,
})
}
if (
normalizeJsonForComparison(sanitized.PayMethods) !==
normalizeJsonForComparison(initial.PayMethods)
@@ -333,87 +388,6 @@ export function PaymentSettingsSection({
})
}
if (updates.length === 0) {
return
}
for (const update of updates) {
await updateOption.mutateAsync(update)
}
}
const saveEpaySettings = async () => {
const values = form.getValues()
const sanitized = {
PayAddress: removeTrailingSlash(values.PayAddress),
EpayId: values.EpayId.trim(),
EpayKey: values.EpayKey.trim(),
CustomCallbackAddress: removeTrailingSlash(values.CustomCallbackAddress),
}
const initial = {
PayAddress: removeTrailingSlash(initialRef.current.PayAddress),
EpayId: initialRef.current.EpayId.trim(),
EpayKey: initialRef.current.EpayKey.trim(),
CustomCallbackAddress: removeTrailingSlash(
initialRef.current.CustomCallbackAddress
),
}
const updates: Array<{ key: string; value: string }> = []
if (sanitized.PayAddress !== initial.PayAddress) {
updates.push({ key: 'PayAddress', value: sanitized.PayAddress })
}
if (sanitized.EpayId !== initial.EpayId) {
updates.push({ key: 'EpayId', value: sanitized.EpayId })
}
if (sanitized.EpayKey && sanitized.EpayKey !== initial.EpayKey) {
updates.push({ key: 'EpayKey', value: sanitized.EpayKey })
}
if (sanitized.CustomCallbackAddress !== initial.CustomCallbackAddress) {
updates.push({
key: 'CustomCallbackAddress',
value: sanitized.CustomCallbackAddress,
})
}
if (updates.length === 0) {
return
}
for (const update of updates) {
await updateOption.mutateAsync(update)
}
}
const saveStripeSettings = async () => {
const values = form.getValues()
const sanitized = {
StripeApiSecret: values.StripeApiSecret.trim(),
StripeWebhookSecret: values.StripeWebhookSecret.trim(),
StripePriceId: values.StripePriceId.trim(),
StripeUnitPrice: values.StripeUnitPrice as number,
StripeMinTopUp: values.StripeMinTopUp as number,
StripePromotionCodesEnabled:
values.StripePromotionCodesEnabled as boolean,
}
const initial = {
StripeApiSecret: initialRef.current.StripeApiSecret.trim(),
StripeWebhookSecret: initialRef.current.StripeWebhookSecret.trim(),
StripePriceId: initialRef.current.StripePriceId.trim(),
StripeUnitPrice: initialRef.current.StripeUnitPrice,
StripeMinTopUp: initialRef.current.StripeMinTopUp,
StripePromotionCodesEnabled:
initialRef.current.StripePromotionCodesEnabled,
}
const updates: Array<{ key: string; value: string | number | boolean }> = []
if (
sanitized.StripeApiSecret &&
sanitized.StripeApiSecret !== initial.StripeApiSecret
@@ -453,33 +427,6 @@ export function PaymentSettingsSection({
})
}
if (updates.length === 0) {
return
}
for (const update of updates) {
await updateOption.mutateAsync(update)
}
}
const saveCreemSettings = async () => {
const values = form.getValues()
const sanitized = {
CreemApiKey: values.CreemApiKey.trim(),
CreemWebhookSecret: values.CreemWebhookSecret.trim(),
CreemTestMode: values.CreemTestMode as boolean,
CreemProducts: values.CreemProducts.trim(),
}
const initial = {
CreemApiKey: initialRef.current.CreemApiKey.trim(),
CreemWebhookSecret: initialRef.current.CreemWebhookSecret.trim(),
CreemTestMode: initialRef.current.CreemTestMode,
CreemProducts: initialRef.current.CreemProducts.trim(),
}
const updates: Array<{ key: string; value: string | boolean }> = []
if (
sanitized.CreemApiKey &&
sanitized.CreemApiKey !== initial.CreemApiKey
@@ -508,162 +455,13 @@ export function PaymentSettingsSection({
updates.push({ key: 'CreemProducts', value: sanitized.CreemProducts })
}
if (updates.length === 0) {
return
}
for (const update of updates) {
await updateOption.mutateAsync(update)
}
}
const onSubmit = async (values: PaymentFormValues) => {
const sanitized = {
PayAddress: removeTrailingSlash(values.PayAddress),
EpayId: values.EpayId.trim(),
EpayKey: values.EpayKey.trim(),
Price: values.Price,
MinTopUp: values.MinTopUp,
CustomCallbackAddress: removeTrailingSlash(values.CustomCallbackAddress),
PayMethods: values.PayMethods.trim(),
AmountOptions: values.AmountOptions.trim(),
AmountDiscount: values.AmountDiscount.trim(),
StripeApiSecret: values.StripeApiSecret.trim(),
StripeWebhookSecret: values.StripeWebhookSecret.trim(),
StripePriceId: values.StripePriceId.trim(),
StripeUnitPrice: values.StripeUnitPrice,
StripeMinTopUp: values.StripeMinTopUp,
StripePromotionCodesEnabled: values.StripePromotionCodesEnabled,
}
const initial = {
PayAddress: removeTrailingSlash(initialRef.current.PayAddress),
EpayId: initialRef.current.EpayId.trim(),
EpayKey: initialRef.current.EpayKey.trim(),
Price: initialRef.current.Price,
MinTopUp: initialRef.current.MinTopUp,
CustomCallbackAddress: removeTrailingSlash(
initialRef.current.CustomCallbackAddress
),
PayMethods: initialRef.current.PayMethods.trim(),
AmountOptions: initialRef.current.AmountOptions.trim(),
AmountDiscount: initialRef.current.AmountDiscount.trim(),
StripeApiSecret: initialRef.current.StripeApiSecret.trim(),
StripeWebhookSecret: initialRef.current.StripeWebhookSecret.trim(),
StripePriceId: initialRef.current.StripePriceId.trim(),
StripeUnitPrice: initialRef.current.StripeUnitPrice,
StripeMinTopUp: initialRef.current.StripeMinTopUp,
StripePromotionCodesEnabled:
initialRef.current.StripePromotionCodesEnabled,
}
const updates: Array<{ key: string; value: string | number | boolean }> = []
if (sanitized.PayAddress !== initial.PayAddress) {
updates.push({ key: 'PayAddress', value: sanitized.PayAddress })
}
if (sanitized.EpayId !== initial.EpayId) {
updates.push({ key: 'EpayId', value: sanitized.EpayId })
}
if (sanitized.EpayKey && sanitized.EpayKey !== initial.EpayKey) {
updates.push({ key: 'EpayKey', value: sanitized.EpayKey })
}
if (sanitized.Price !== initial.Price) {
updates.push({ key: 'Price', value: sanitized.Price })
}
if (sanitized.MinTopUp !== initial.MinTopUp) {
updates.push({ key: 'MinTopUp', value: sanitized.MinTopUp })
}
if (sanitized.CustomCallbackAddress !== initial.CustomCallbackAddress) {
updates.push({
key: 'CustomCallbackAddress',
value: sanitized.CustomCallbackAddress,
})
}
if (
normalizeJsonForComparison(sanitized.PayMethods) !==
normalizeJsonForComparison(initial.PayMethods)
) {
updates.push({ key: 'PayMethods', value: sanitized.PayMethods })
}
if (
normalizeJsonForComparison(sanitized.AmountOptions) !==
normalizeJsonForComparison(initial.AmountOptions)
) {
updates.push({
key: 'payment_setting.amount_options',
value: sanitized.AmountOptions,
})
}
if (
normalizeJsonForComparison(sanitized.AmountDiscount) !==
normalizeJsonForComparison(initial.AmountDiscount)
) {
updates.push({
key: 'payment_setting.amount_discount',
value: sanitized.AmountDiscount,
})
}
if (
sanitized.StripeApiSecret &&
sanitized.StripeApiSecret !== initial.StripeApiSecret
) {
updates.push({ key: 'StripeApiSecret', value: sanitized.StripeApiSecret })
}
if (
sanitized.StripeWebhookSecret &&
sanitized.StripeWebhookSecret !== initial.StripeWebhookSecret
) {
updates.push({
key: 'StripeWebhookSecret',
value: sanitized.StripeWebhookSecret,
})
}
if (sanitized.StripePriceId !== initial.StripePriceId) {
updates.push({ key: 'StripePriceId', value: sanitized.StripePriceId })
}
if (sanitized.StripeUnitPrice !== initial.StripeUnitPrice) {
updates.push({ key: 'StripeUnitPrice', value: sanitized.StripeUnitPrice })
}
if (sanitized.StripeMinTopUp !== initial.StripeMinTopUp) {
updates.push({ key: 'StripeMinTopUp', value: sanitized.StripeMinTopUp })
}
if (
sanitized.StripePromotionCodesEnabled !==
initial.StripePromotionCodesEnabled
) {
updates.push({
key: 'StripePromotionCodesEnabled',
value: sanitized.StripePromotionCodesEnabled,
})
}
for (const update of updates) {
await updateOption.mutateAsync(update)
}
}
return (
<SettingsSection
title={t('Payment Gateway')}
description={t(
'Configure recharge pricing and payment gateway integrations'
)}
>
<SettingsSection title={t('Payment Gateway')}>
{!complianceConfirmed ? (
<Alert variant='destructive' className='mb-6'>
<ShieldAlert className='h-4 w-4' />
@@ -729,14 +527,19 @@ export function PaymentSettingsSection({
{/* eslint-disable react-hooks/refs */}
<Form {...form}>
<form
<SettingsForm
onSubmit={form.handleSubmit(onSubmit)}
className={cn(
'space-y-8',
'gap-y-8',
!complianceConfirmed && 'pointer-events-none opacity-40'
)}
data-no-autosubmit='true'
>
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
isSaving={updateOption.isPending}
saveLabel='Save all settings'
/>
<div className='space-y-4'>
<div>
<h3 className='text-lg font-medium'>{t('General Settings')}</h3>
@@ -964,20 +767,6 @@ export function PaymentSettingsSection({
)}
/>
</div>
<Button
type='button'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
saveGeneralSettings()
}}
disabled={updateOption.isPending}
>
{updateOption.isPending
? t('Saving...')
: t('Save general settings')}
</Button>
</div>
<Separator />
@@ -1079,20 +868,6 @@ export function PaymentSettingsSection({
)}
/>
</div>
<Button
type='button'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
saveEpaySettings()
}}
disabled={updateOption.isPending}
>
{updateOption.isPending
? t('Saving...')
: t('Save Epay settings')}
</Button>
</div>
<Separator />
@@ -1266,39 +1041,23 @@ export function PaymentSettingsSection({
control={form.control}
name='StripePromotionCodesEnabled'
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('Promotion codes')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Promotion codes')}</FormLabel>
<FormDescription>
{t('Allow users to enter promo codes')}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
</div>
<Button
type='button'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
saveStripeSettings()
}}
disabled={updateOption.isPending}
>
{updateOption.isPending
? t('Saving...')
: t('Save Stripe settings')}
</Button>
</div>
<Separator />
@@ -1378,22 +1137,20 @@ export function PaymentSettingsSection({
control={form.control}
name='CreemTestMode'
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('Test Mode')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Test Mode')}</FormLabel>
<FormDescription>
{t('Enable test mode for Creem payments')}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
@@ -1448,26 +1205,8 @@ export function PaymentSettingsSection({
</FormItem>
)}
/>
<Button
type='button'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
saveCreemSettings()
}}
disabled={updateOption.isPending}
>
{updateOption.isPending
? t('Saving...')
: t('Save Creem settings')}
</Button>
</div>
<Button type='submit' disabled={updateOption.isPending}>
{updateOption.isPending ? t('Saving...') : t('Save all settings')}
</Button>
</form>
</SettingsForm>
</Form>
<Separator />
@@ -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
checked={form.watch('WaffoEnabled')}
onCheckedChange={(v) => form.setValue('WaffoEnabled', v)}
/>
<Label>{t('Enable Waffo')}</Label>
</div>
<div className='flex items-center gap-2'>
<Switch
checked={form.watch('WaffoSandbox')}
onCheckedChange={(v) => form.setValue('WaffoSandbox', v)}
/>
<Label>{t('Sandbox mode')}</Label>
</div>
<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'
/>
<SettingsSwitchField
checked={form.watch('WaffoSandbox')}
onCheckedChange={(v) => form.setValue('WaffoSandbox', v)}
label={t('Sandbox mode')}
className='border-b-0 py-0'
/>
</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,39 +269,30 @@ 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>
<FormDescription>
{module.requireAuthDescription}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={!form.watch(module.requireAuthDependsOn)}
/>
</FormControl>
<FormMessage />
</FormItem>
<SettingsControlChildren>
<SettingsSwitchItem className='border-b-0 py-2'>
<SettingsSwitchContent>
<FormLabel>{module.requireAuthTitle}</FormLabel>
<FormDescription>
{module.requireAuthDescription}
</FormDescription>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={!form.watch(module.requireAuthDependsOn)}
/>
</FormControl>
<FormMessage />
</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
checked={props.enabled}
disabled={props.disabled}
onCheckedChange={props.onEnabledChange}
aria-label={props.title}
/>
</div>
<SettingsSwitchField
checked={props.enabled}
disabled={props.disabled}
onCheckedChange={props.onEnabledChange}
label={props.title}
description={props.description}
aria-label={props.title}
/>
<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>
@@ -204,7 +204,6 @@ type RatioSettingsCardProps = {
groupDefaults: GroupFormValues
toolPricesDefault: string
titleKey?: string
descriptionKey?: string
visibleTabs?: RatioTabId[]
}
@@ -213,7 +212,6 @@ export function RatioSettingsCard({
groupDefaults,
toolPricesDefault,
titleKey = 'Pricing Ratios',
descriptionKey = 'Configure model, caching, and group ratios used for billing',
visibleTabs = ['models', 'groups', 'tool-prices', 'upstream-sync'],
}: RatioSettingsCardProps) {
const { t } = useTranslation()
@@ -497,7 +495,7 @@ export function RatioSettingsCard({
}
return (
<SettingsSection title={t(titleKey)} description={t(descriptionKey)}>
<SettingsSection title={t(titleKey)}>
{visibleTabs.length === 1 ? (
renderTabContent(defaultTab)
) : (
@@ -39,7 +39,6 @@ const MODELS_SECTIONS = [
{
id: 'global',
titleKey: 'Global Model Configuration',
descriptionKey: 'Configure global model settings',
build: (settings: ModelSettings) => (
<GlobalSettingsCard
defaultValues={{
@@ -68,7 +67,6 @@ const MODELS_SECTIONS = [
{
id: 'gemini',
titleKey: 'Gemini',
descriptionKey: 'Configure Gemini model settings',
build: (settings: ModelSettings) => (
<GeminiSettingsCard
defaultValues={{
@@ -93,7 +91,6 @@ const MODELS_SECTIONS = [
{
id: 'claude',
titleKey: 'Claude',
descriptionKey: 'Configure Claude model settings',
build: (settings: ModelSettings) => (
<ClaudeSettingsCard
defaultValues={{
@@ -112,7 +109,6 @@ const MODELS_SECTIONS = [
{
id: 'grok',
titleKey: 'Grok',
descriptionKey: 'Configure xAI Grok model settings',
build: (settings: ModelSettings) => (
<GrokSettingsCard
defaultValues={{
@@ -127,7 +123,6 @@ const MODELS_SECTIONS = [
{
id: 'channel-affinity',
titleKey: 'Channel Affinity',
descriptionKey: 'Configure channel affinity (sticky routing) rules',
build: (settings: ModelSettings) => (
<ChannelAffinitySection
defaultValues={{
@@ -148,7 +143,6 @@ const MODELS_SECTIONS = [
{
id: 'model-deployment',
titleKey: 'Model Deployment',
descriptionKey: 'Configure model deployment provider settings',
build: (settings: ModelSettings) => (
<IoNetDeploymentSettingsSection
defaultValues={{
@@ -173,3 +167,4 @@ export const MODELS_SECTION_IDS = modelsRegistry.sectionIds
export const MODELS_DEFAULT_SECTION = modelsRegistry.defaultSection
export const getModelsSectionNavItems = modelsRegistry.getSectionNavItems
export const getModelsSectionContent = modelsRegistry.getSectionContent
export const getModelsSectionMeta = modelsRegistry.getSectionMeta
+14 -42
View File
@@ -16,15 +16,13 @@ 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 { useStatus } from '@/hooks/use-status'
import { getOptionValue, useSystemOptions } from '../hooks/use-system-options'
import { SettingsPage } from '../components/settings-page'
import type { OperationsSettings } from '../types'
import {
OPERATIONS_DEFAULT_SECTION,
getOperationsSectionContent,
getOperationsSectionMeta,
} from './section-registry.tsx'
const defaultOperationsSettings: OperationsSettings = {
@@ -68,46 +66,20 @@ const defaultOperationsSettings: OperationsSettings = {
}
export function OperationsSettings() {
const { t } = useTranslation()
const { data, isLoading } = useSystemOptions()
const { status } = useStatus()
const params = useParams({
from: '/_authenticated/system-settings/operations/$section',
})
const settings = useMemo(
() => getOptionValue(data?.data, defaultOperationsSettings),
[data?.data]
)
if (isLoading) {
return (
<div className='text-muted-foreground flex h-full w-full flex-1 items-center justify-center'>
{t('Loading maintenance settings...')}
</div>
)
}
const activeSection = (params?.section ?? OPERATIONS_DEFAULT_SECTION) as
| 'behavior'
| 'monitoring'
| 'email'
| 'worker'
| 'logs'
| 'performance'
| 'update-checker'
const sectionContent = getOperationsSectionContent(
activeSection,
settings,
status?.version as string | undefined,
status?.start_time as number | null | undefined
)
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/operations/$section'
defaultSettings={defaultOperationsSettings}
defaultSection={OPERATIONS_DEFAULT_SECTION}
getSectionContent={getOperationsSectionContent}
getSectionMeta={getOperationsSectionMeta}
extraArgs={[
status?.version as string | undefined,
status?.start_time as number | null | undefined,
]}
loadingMessage='Loading maintenance settings...'
/>
)
}
@@ -30,7 +30,6 @@ const OPERATIONS_SECTIONS = [
{
id: 'behavior',
titleKey: 'System Behavior',
descriptionKey: 'Configure system-wide behavior and defaults',
build: (settings: OperationsSettings) => (
<SystemBehaviorSection
defaultValues={{
@@ -45,7 +44,6 @@ const OPERATIONS_SECTIONS = [
{
id: 'monitoring',
titleKey: 'Monitoring & Alerts',
descriptionKey: 'Configure channel monitoring and automation',
build: (settings: OperationsSettings) => (
<MonitoringSettingsSection
defaultValues={{
@@ -68,7 +66,6 @@ const OPERATIONS_SECTIONS = [
{
id: 'email',
titleKey: 'SMTP Email',
descriptionKey: 'Configure SMTP email settings',
build: (settings: OperationsSettings) => (
<EmailSettingsSection
defaultValues={{
@@ -86,7 +83,6 @@ const OPERATIONS_SECTIONS = [
{
id: 'worker',
titleKey: 'Worker Proxy',
descriptionKey: 'Configure worker service settings',
build: (settings: OperationsSettings) => (
<WorkerSettingsSection
defaultValues={{
@@ -101,7 +97,6 @@ const OPERATIONS_SECTIONS = [
{
id: 'logs',
titleKey: 'Log Maintenance',
descriptionKey: 'Configure log consumption settings',
build: (settings: OperationsSettings) => (
<LogSettingsSection
defaultEnabled={Boolean(settings.LogConsumeEnabled)}
@@ -111,7 +106,6 @@ const OPERATIONS_SECTIONS = [
{
id: 'performance',
titleKey: 'Performance',
descriptionKey: 'Disk cache, system monitoring and performance stats',
build: (settings: OperationsSettings) => (
<PerformanceSection
defaultValues={{
@@ -146,7 +140,6 @@ const OPERATIONS_SECTIONS = [
{
id: 'update-checker',
titleKey: 'System maintenance',
descriptionKey: 'Check for system updates',
build: (
_settings: OperationsSettings,
currentVersion?: string | null,
@@ -178,3 +171,4 @@ export const OPERATIONS_DEFAULT_SECTION = operationsRegistry.defaultSection
export const getOperationsSectionNavItems =
operationsRegistry.getSectionNavItems
export const getOperationsSectionContent = operationsRegistry.getSectionContent
export const getOperationsSectionMeta = operationsRegistry.getSectionMeta
@@ -35,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 { useUpdateOption } from '../hooks/use-update-option'
import { RateLimitVisualEditor } from './rate-limit-visual-editor'
@@ -107,36 +113,34 @@ export function RateLimitSection({ defaultValues }: RateLimitSectionProps) {
}
return (
<SettingsSection
title={t('Rate Limiting')}
description={t(
'Control request frequency to prevent abuse and manage system load.'
)}
>
<SettingsSection title={t('Rate Limiting')}>
<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 rate limits'
/>
<FormField
control={form.control}
name='ModelRequestRateLimitEnabled'
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 rate limiting')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Enable rate limiting')}</FormLabel>
<FormDescription>
{t(
'This controls model request rate limiting. Web/API route throttling is configured by environment variables and may still return 429.'
)}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
@@ -306,11 +310,7 @@ export function RateLimitSection({ defaultValues }: RateLimitSectionProps) {
</FormItem>
)}
/>
<Button type='submit' disabled={updateOption.isPending}>
{updateOption.isPending ? t('Saving...') : t('Save rate limits')}
</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 { 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 { useUpdateOption } from '../hooks/use-update-option'
@@ -74,35 +79,35 @@ export function SensitiveWordsSection({
}
return (
<SettingsSection
title={t('Sensitive Words')}
description={t('Configure keyword filtering for prompts and responses.')}
>
<SettingsSection title={t('Sensitive Words')}>
<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 sensitive words'
/>
<div className='space-y-4'>
<FormField
control={form.control}
name='CheckSensitiveEnabled'
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 filtering')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Enable filtering')}</FormLabel>
<FormDescription>
{t(
'Blocks messages when sensitive keywords are detected.'
)}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
@@ -110,24 +115,22 @@ export function SensitiveWordsSection({
control={form.control}
name='CheckSensitiveOnPromptEnabled'
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('Inspect user prompts')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Inspect user prompts')}</FormLabel>
<FormDescription>
{t(
'When enabled, prompts are scanned before reaching upstream models.'
)}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
</div>
@@ -154,13 +157,7 @@ export function SensitiveWordsSection({
</FormItem>
)}
/>
<Button type='submit' disabled={updateOption.isPending}>
{updateOption.isPending
? t('Saving...')
: t('Save sensitive words')}
</Button>
</form>
</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,
@@ -43,6 +42,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 { useUpdateOption } from '../hooks/use-update-option'
@@ -198,34 +203,32 @@ export function SSRFSection({ defaultValues }: SSRFSectionProps) {
const ipFilterMode = form.watch('fetch_setting.ip_filter_mode')
return (
<SettingsSection
title={t('SSRF Protection')}
description={t(
'Prevent server-side request forgery attacks by controlling outbound requests.'
)}
>
<SettingsSection title={t('SSRF Protection')}>
<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 SSRF settings'
/>
<FormField
control={form.control}
name='fetch_setting.enable_ssrf_protection'
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 SSRF Protection')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Enable SSRF Protection')}</FormLabel>
<FormDescription>
{t('Prevent server-side request forgery attacks')}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
@@ -233,24 +236,22 @@ export function SSRFSection({ defaultValues }: SSRFSectionProps) {
control={form.control}
name='fetch_setting.allow_private_ip'
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 Private IPs')}
</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Allow Private IPs')}</FormLabel>
<FormDescription>
{t(
'Allow requests to private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)'
)}
</FormDescription>
</div>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
</SettingsSwitchItem>
)}
/>
@@ -408,9 +409,9 @@ export function SSRFSection({ defaultValues }: SSRFSectionProps) {
control={form.control}
name='fetch_setting.apply_ip_filter_for_domain'
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('Apply IP Filter to Resolved Domains')}
</FormLabel>
<FormDescription>
@@ -418,21 +419,17 @@ export function SSRFSection({ defaultValues }: SSRFSectionProps) {
'Check resolved IPs against IP filters even when accessing by domain'
)}
</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 SSRF settings')}
</Button>
</form>
</SettingsForm>
</Form>
</SettingsSection>
)
@@ -21,6 +21,7 @@ import type { SecuritySettings } from '../types'
import {
SECURITY_DEFAULT_SECTION,
getSecuritySectionContent,
getSecuritySectionMeta,
} from './section-registry.tsx'
const defaultSecuritySettings: SecuritySettings = {
@@ -49,6 +50,7 @@ export function SecuritySettings() {
defaultSettings={defaultSecuritySettings}
defaultSection={SECURITY_DEFAULT_SECTION}
getSectionContent={getSecuritySectionContent}
getSectionMeta={getSecuritySectionMeta}
/>
)
}
@@ -26,7 +26,6 @@ const SECURITY_SECTIONS = [
{
id: 'rate-limit',
titleKey: 'Rate Limiting',
descriptionKey: 'Configure model request rate limiting',
build: (settings: SecuritySettings) => (
<RateLimitSection
defaultValues={{
@@ -44,7 +43,6 @@ const SECURITY_SECTIONS = [
{
id: 'sensitive-words',
titleKey: 'Sensitive Words',
descriptionKey: 'Configure sensitive word filtering',
build: (settings: SecuritySettings) => (
<SensitiveWordsSection
defaultValues={{
@@ -58,7 +56,6 @@ const SECURITY_SECTIONS = [
{
id: 'ssrf',
titleKey: 'SSRF Protection',
descriptionKey: 'Configure SSRF (Server-Side Request Forgery) protection',
build: (settings: SecuritySettings) => (
<SSRFSection
defaultValues={{
@@ -98,3 +95,4 @@ export const SECURITY_SECTION_IDS = securityRegistry.sectionIds
export const SECURITY_DEFAULT_SECTION = securityRegistry.defaultSection
export const getSecuritySectionNavItems = securityRegistry.getSectionNavItems
export const getSecuritySectionContent = securityRegistry.getSectionContent
export const getSecuritySectionMeta = securityRegistry.getSectionMeta
@@ -21,6 +21,7 @@ import type { SiteSettings } from '../types'
import {
SITE_DEFAULT_SECTION,
getSiteSectionContent,
getSiteSectionMeta,
} from './section-registry.tsx'
const defaultSiteSettings: SiteSettings = {
@@ -45,6 +46,7 @@ export function SiteSettings() {
defaultSettings={defaultSiteSettings}
defaultSection={SITE_DEFAULT_SECTION}
getSectionContent={getSiteSectionContent}
getSectionMeta={getSiteSectionMeta}
/>
)
}
@@ -33,7 +33,6 @@ const SITE_SECTIONS = [
{
id: 'system-info',
titleKey: 'System Information',
descriptionKey: 'Configure basic system information and branding',
build: (settings: SiteSettings) => (
<SystemInfoSection
defaultValues={{
@@ -57,7 +56,6 @@ const SITE_SECTIONS = [
{
id: 'notice',
titleKey: 'System Notice',
descriptionKey: 'Configure system maintenance notice',
build: (settings: SiteSettings) => (
<NoticeSection defaultValue={settings.Notice ?? ''} />
),
@@ -65,7 +63,6 @@ const SITE_SECTIONS = [
{
id: 'header-navigation',
titleKey: 'Header navigation',
descriptionKey: 'Configure header navigation modules',
build: (settings: SiteSettings) => {
const headerNavConfig = parseHeaderNavModules(settings.HeaderNavModules)
const headerNavSerialized = serializeHeaderNavModules(headerNavConfig)
@@ -80,7 +77,6 @@ const SITE_SECTIONS = [
{
id: 'sidebar-modules',
titleKey: 'Sidebar modules',
descriptionKey: 'Configure sidebar modules for admin',
build: (settings: SiteSettings) => {
const sidebarConfig = parseSidebarModulesAdmin(
settings.SidebarModulesAdmin
@@ -109,3 +105,4 @@ export const SITE_SECTION_IDS = siteRegistry.sectionIds
export const SITE_DEFAULT_SECTION = siteRegistry.defaultSection
export const getSiteSectionNavItems = siteRegistry.getSectionNavItems
export const getSiteSectionContent = siteRegistry.getSectionContent
export const getSiteSectionMeta = siteRegistry.getSectionMeta
@@ -25,7 +25,6 @@ import type { TFunction } from 'i18next'
export type SectionDefinition<TSettings, TExtraArgs extends unknown[] = []> = {
id: string
titleKey: string
descriptionKey: string
build: (settings: TSettings, ...extraArgs: TExtraArgs) => ReactNode
}
@@ -82,9 +81,13 @@ export function createSectionRegistry<
settings: TSettings,
...extraArgs: TExtraArgs
) {
return getSectionMeta(sectionId).build(settings, ...extraArgs)
}
function getSectionMeta(sectionId: SectionId) {
const section =
sections.find((item) => item.id === sectionId) ?? sections[0]
return section.build(settings, ...extraArgs)
return section
}
return {
@@ -92,5 +95,6 @@ export function createSectionRegistry<
defaultSection,
getSectionNavItems,
getSectionContent,
getSectionMeta,
}
}
+1 -10
View File
@@ -39,21 +39,15 @@ import {
const route = getRouteApi('/_authenticated/usage-logs/$section')
const TASK_LOG_SECTIONS = ['drawing', 'task'] as const
const SECTION_META: Record<
UsageLogsSectionId,
{ titleKey: string; descriptionKey: string }
> = {
const SECTION_META: Record<UsageLogsSectionId, { titleKey: string }> = {
common: {
titleKey: 'Common Logs',
descriptionKey: 'View and manage your API usage logs',
},
drawing: {
titleKey: 'Drawing Logs',
descriptionKey: 'View and manage your drawing logs',
},
task: {
titleKey: 'Task Logs',
descriptionKey: 'View and manage your task logs',
},
}
@@ -120,9 +114,6 @@ function UsageLogsContent() {
<SectionPageLayout.Title>
{t(pageMeta.titleKey)}
</SectionPageLayout.Title>
<SectionPageLayout.Description>
{t(pageMeta.descriptionKey)}
</SectionPageLayout.Description>
<SectionPageLayout.Content>
<div className='space-y-4'>
{showTaskSwitcher && (
@@ -25,19 +25,16 @@ const USAGE_LOGS_SECTIONS = [
{
id: 'common',
titleKey: 'Common Logs',
descriptionKey: 'View and manage your API usage logs',
build: () => null, // Content is rendered directly in the page component
},
{
id: 'drawing',
titleKey: 'Drawing Logs',
descriptionKey: 'View and manage your drawing logs',
build: () => null, // Content is rendered directly in the page component
},
{
id: 'task',
titleKey: 'Task Logs',
descriptionKey: 'View and manage your task logs',
build: () => null, // Content is rendered directly in the page component
},
] as const
-3
View File
@@ -32,9 +32,6 @@ function UsersContent() {
<>
<SectionPageLayout>
<SectionPageLayout.Title>{t('Users')}</SectionPageLayout.Title>
<SectionPageLayout.Description>
{t('Manage users and their permissions')}
</SectionPageLayout.Description>
<SectionPageLayout.Actions>
<UsersPrimaryButtons />
</SectionPageLayout.Actions>
-3
View File
@@ -261,9 +261,6 @@ export function Wallet(props: WalletProps) {
<>
<SectionPageLayout>
<SectionPageLayout.Title>{t('Wallet')}</SectionPageLayout.Title>
<SectionPageLayout.Description>
{t('Manage your balance and payment methods')}
</SectionPageLayout.Description>
<SectionPageLayout.Content>
<div className='mx-auto flex w-full max-w-7xl flex-col gap-4 sm:gap-5'>
<WalletStatsCard user={user} loading={userLoading} />