✨ 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:
-3
@@ -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
@@ -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
@@ -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
@@ -42,17 +42,12 @@ import {
|
||||
|
||||
const route = getRouteApi('/_authenticated/models/$section')
|
||||
|
||||
const SECTION_META: Record<
|
||||
ModelsSectionId,
|
||||
{ titleKey: string; descriptionKey: string }
|
||||
> = {
|
||||
const SECTION_META: Record<ModelsSectionId, { titleKey: string }> = {
|
||||
metadata: {
|
||||
titleKey: 'Metadata',
|
||||
descriptionKey: 'Manage model metadata and configuration',
|
||||
},
|
||||
deployments: {
|
||||
titleKey: 'Deployments',
|
||||
descriptionKey: 'Manage model deployments',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -126,9 +121,6 @@ function ModelsContent() {
|
||||
<>
|
||||
<SectionPageLayout>
|
||||
<SectionPageLayout.Title>{t(meta.titleKey)}</SectionPageLayout.Title>
|
||||
<SectionPageLayout.Description>
|
||||
{t(meta.descriptionKey)}
|
||||
</SectionPageLayout.Description>
|
||||
<SectionPageLayout.Actions>
|
||||
{activeSection === 'metadata' ? (
|
||||
<ModelsPrimaryButtons />
|
||||
|
||||
@@ -25,13 +25,11 @@ const MODELS_SECTIONS = [
|
||||
{
|
||||
id: 'metadata',
|
||||
titleKey: 'Metadata',
|
||||
descriptionKey: 'Manage model metadata and configuration',
|
||||
build: () => null, // Content is rendered directly in the page component
|
||||
},
|
||||
{
|
||||
id: 'deployments',
|
||||
titleKey: 'Deployments',
|
||||
descriptionKey: 'Manage model deployments',
|
||||
build: () => null, // Content is rendered directly in the page component
|
||||
},
|
||||
] as const
|
||||
|
||||
@@ -31,9 +31,6 @@ export function Redemptions() {
|
||||
<SectionPageLayout.Title>
|
||||
{t('Redemption Codes')}
|
||||
</SectionPageLayout.Title>
|
||||
<SectionPageLayout.Description>
|
||||
{t('Manage redemption codes for quota top-up')}
|
||||
</SectionPageLayout.Description>
|
||||
<SectionPageLayout.Actions>
|
||||
<RedemptionsPrimaryButtons />
|
||||
</SectionPageLayout.Actions>
|
||||
|
||||
@@ -38,9 +38,6 @@ function SubscriptionsContent() {
|
||||
<SectionPageLayout.Title>
|
||||
{t('Subscription Management')}
|
||||
</SectionPageLayout.Title>
|
||||
<SectionPageLayout.Description>
|
||||
{t('Manage subscription plan creation, pricing and status')}
|
||||
</SectionPageLayout.Description>
|
||||
<SectionPageLayout.Actions>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Alert variant='default' className='hidden px-3 py-2 sm:flex'>
|
||||
|
||||
@@ -21,7 +21,6 @@ import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -33,6 +32,12 @@ import {
|
||||
} from '@/components/ui/form'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
SettingsForm,
|
||||
SettingsSwitchContent,
|
||||
SettingsSwitchItem,
|
||||
} from '../components/settings-form-layout'
|
||||
import { SettingsPageFormActions } from '../components/settings-page-context'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useResetForm } from '../hooks/use-reset-form'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
@@ -100,32 +105,31 @@ export function BasicAuthSection({ defaultValues }: BasicAuthSectionProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('Basic Authentication')}
|
||||
description={t('Configure password-based login and registration')}
|
||||
>
|
||||
<SettingsSection title={t('Basic Authentication')}>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
|
||||
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<SettingsPageFormActions
|
||||
onSave={form.handleSubmit(onSubmit)}
|
||||
isSaving={updateOption.isPending}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='PasswordLoginEnabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Password Login')}
|
||||
</FormLabel>
|
||||
<SettingsSwitchItem>
|
||||
<SettingsSwitchContent>
|
||||
<FormLabel>{t('Password Login')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Allow users to log in with password')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
</SettingsSwitchContent>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</SettingsSwitchItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -133,22 +137,20 @@ export function BasicAuthSection({ defaultValues }: BasicAuthSectionProps) {
|
||||
control={form.control}
|
||||
name='RegisterEnabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Registration Enabled')}
|
||||
</FormLabel>
|
||||
<SettingsSwitchItem>
|
||||
<SettingsSwitchContent>
|
||||
<FormLabel>{t('Registration Enabled')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Allow new users to register')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
</SettingsSwitchContent>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</SettingsSwitchItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -156,22 +158,20 @@ export function BasicAuthSection({ defaultValues }: BasicAuthSectionProps) {
|
||||
control={form.control}
|
||||
name='PasswordRegisterEnabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Password Registration')}
|
||||
</FormLabel>
|
||||
<SettingsSwitchItem>
|
||||
<SettingsSwitchContent>
|
||||
<FormLabel>{t('Password Registration')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Allow registration with password')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
</SettingsSwitchContent>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</SettingsSwitchItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -179,22 +179,20 @@ export function BasicAuthSection({ defaultValues }: BasicAuthSectionProps) {
|
||||
control={form.control}
|
||||
name='EmailVerificationEnabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Email Verification')}
|
||||
</FormLabel>
|
||||
<SettingsSwitchItem>
|
||||
<SettingsSwitchContent>
|
||||
<FormLabel>{t('Email Verification')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Require email verification for new accounts')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
</SettingsSwitchContent>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</SettingsSwitchItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -202,22 +200,20 @@ export function BasicAuthSection({ defaultValues }: BasicAuthSectionProps) {
|
||||
control={form.control}
|
||||
name='EmailDomainRestrictionEnabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Email Domain Restriction')}
|
||||
</FormLabel>
|
||||
<SettingsSwitchItem>
|
||||
<SettingsSwitchContent>
|
||||
<FormLabel>{t('Email Domain Restriction')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Only allow specific email domains')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
</SettingsSwitchContent>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</SettingsSwitchItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -225,22 +221,20 @@ export function BasicAuthSection({ defaultValues }: BasicAuthSectionProps) {
|
||||
control={form.control}
|
||||
name='EmailAliasRestrictionEnabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Email Alias Restriction')}
|
||||
</FormLabel>
|
||||
<SettingsSwitchItem>
|
||||
<SettingsSwitchContent>
|
||||
<FormLabel>{t('Email Alias Restriction')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Block email aliases (e.g., user+alias@domain.com)')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
</SettingsSwitchContent>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</SettingsSwitchItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -266,11 +260,7 @@ export function BasicAuthSection({ defaultValues }: BasicAuthSectionProps) {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type='submit' disabled={updateOption.isPending}>
|
||||
{updateOption.isPending ? t('Saving...') : t('Save Changes')}
|
||||
</Button>
|
||||
</form>
|
||||
</SettingsForm>
|
||||
</Form>
|
||||
</SettingsSection>
|
||||
)
|
||||
|
||||
@@ -21,7 +21,6 @@ import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -33,6 +32,12 @@ import {
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
SettingsForm,
|
||||
SettingsSwitchContent,
|
||||
SettingsSwitchItem,
|
||||
} from '../components/settings-form-layout'
|
||||
import { SettingsPageFormActions } from '../components/settings-page-context'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
|
||||
@@ -75,40 +80,33 @@ export function BotProtectionSection({
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('Bot Protection')}
|
||||
description={t(
|
||||
'Protect login and registration with Cloudflare Turnstile'
|
||||
)}
|
||||
>
|
||||
<SettingsSection title={t('Bot Protection')}>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className='space-y-6'
|
||||
autoComplete='off'
|
||||
>
|
||||
<SettingsForm onSubmit={form.handleSubmit(onSubmit)} autoComplete='off'>
|
||||
<SettingsPageFormActions
|
||||
onSave={form.handleSubmit(onSubmit)}
|
||||
isSaving={updateOption.isPending}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='TurnstileCheckEnabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Enable Turnstile')}
|
||||
</FormLabel>
|
||||
<SettingsSwitchItem>
|
||||
<SettingsSwitchContent>
|
||||
<FormLabel>{t('Enable Turnstile')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Protect login and registration with Cloudflare Turnstile'
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
</SettingsSwitchContent>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</SettingsSwitchItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -148,11 +146,7 @@ export function BotProtectionSection({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type='submit' disabled={updateOption.isPending}>
|
||||
{updateOption.isPending ? t('Saving...') : t('Save Changes')}
|
||||
</Button>
|
||||
</form>
|
||||
</SettingsForm>
|
||||
</Form>
|
||||
</SettingsSection>
|
||||
)
|
||||
|
||||
+3
-2
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Vendored
+12
-9
@@ -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>
|
||||
|
||||
+2
-12
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
+57
-71
@@ -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
|
||||
|
||||
+8
-12
@@ -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>
|
||||
|
||||
+182
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+146
@@ -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>
|
||||
)
|
||||
|
||||
+8
-11
@@ -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'>
|
||||
|
||||
+10
-11
@@ -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>
|
||||
)
|
||||
|
||||
+19
-19
@@ -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
@@ -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'>
|
||||
|
||||
+19
-26
@@ -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' ? (
|
||||
|
||||
+29
-36
@@ -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>
|
||||
|
||||
+20
-27
@@ -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>
|
||||
</>
|
||||
|
||||
+173
-170
@@ -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>
|
||||
)
|
||||
|
||||
+28
-32
@@ -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>
|
||||
)
|
||||
|
||||
+230
-233
@@ -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
@@ -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 />
|
||||
}
|
||||
|
||||
+24
-29
@@ -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>
|
||||
)
|
||||
|
||||
Vendored
+20
-26
@@ -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>
|
||||
)
|
||||
|
||||
+29
-36
@@ -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>
|
||||
)
|
||||
|
||||
+79
-340
@@ -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 />
|
||||
|
||||
+15
-10
@@ -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>
|
||||
)
|
||||
|
||||
+25
-20
@@ -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}>
|
||||
|
||||
+19
-26
@@ -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>
|
||||
)
|
||||
|
||||
+48
-51
@@ -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>
|
||||
)
|
||||
|
||||
+22
-20
@@ -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>
|
||||
)
|
||||
|
||||
+30
-21
@@ -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 />
|
||||
|
||||
+32
-39
@@ -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>
|
||||
)
|
||||
|
||||
+1
-4
@@ -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
@@ -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
|
||||
|
||||
+19
-19
@@ -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>
|
||||
)
|
||||
|
||||
+24
-27
@@ -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
@@ -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
@@ -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
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user