perf(web): improve dialog sizing and footer layout
- migrate frontend dialogs to the shared footer API so actions stay separated from scrollable body content. - tune dialog dimensions for model analytics, prefill groups, billing history, channel model sync, and related workflows. - update channel terminology and dialog action translations across supported locales.
This commit is contained in:
Vendored
+392
-398
@@ -21,14 +21,6 @@ import { type Resolver, useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -50,6 +42,7 @@ import {
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import {
|
||||
SettingsForm,
|
||||
SettingsSwitchContent,
|
||||
@@ -74,6 +67,8 @@ type ProviderFormDialogProps = {
|
||||
provider?: CustomOAuthProvider | null
|
||||
}
|
||||
|
||||
const PROVIDER_FORM_ID = 'custom-oauth-provider-form'
|
||||
|
||||
export function ProviderFormDialog(props: ProviderFormDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const isEditing = !!props.provider
|
||||
@@ -174,98 +169,97 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) {
|
||||
const isPending = createProvider.isPending || updateProvider.isPending
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<DialogContent className='max-h-[85vh] overflow-y-auto sm:max-w-2xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEditing ? t('Edit OAuth Provider') : t('Add OAuth Provider')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEditing
|
||||
? t('Update the configuration for this custom OAuth provider.')
|
||||
: t(
|
||||
'Configure a new custom OAuth provider for user authentication.'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Dialog
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
title={isEditing ? t('Edit OAuth Provider') : t('Add OAuth Provider')}
|
||||
description={
|
||||
isEditing
|
||||
? t('Update the configuration for this custom OAuth provider.')
|
||||
: t('Configure a new custom OAuth provider for user authentication.')
|
||||
}
|
||||
contentClassName='max-h-[85vh] overflow-y-auto sm:max-w-2xl'
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => props.onOpenChange(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit' form={PROVIDER_FORM_ID} disabled={isPending}>
|
||||
{isPending
|
||||
? t('Saving...')
|
||||
: isEditing
|
||||
? t('Update Provider')
|
||||
: t('Create Provider')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Form {...form}>
|
||||
<SettingsForm
|
||||
id={PROVIDER_FORM_ID}
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
{/* Preset Selector (only for creating) */}
|
||||
{!isEditing && <PresetSelector form={form} />}
|
||||
|
||||
<Form {...form}>
|
||||
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
|
||||
{/* Preset Selector (only for creating) */}
|
||||
{!isEditing && <PresetSelector form={form} />}
|
||||
{/* Basic Info */}
|
||||
<div className='space-y-4'>
|
||||
<h4 className='text-sm font-medium'>{t('Basic Info')}</h4>
|
||||
|
||||
{/* Basic Info */}
|
||||
<div className='space-y-4'>
|
||||
<h4 className='text-sm font-medium'>{t('Basic Info')}</h4>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='enabled'
|
||||
render={({ field }) => (
|
||||
<SettingsSwitchItem>
|
||||
<SettingsSwitchContent>
|
||||
<FormLabel>{t('Enabled')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Allow users to sign in with this provider')}
|
||||
</FormDescription>
|
||||
</SettingsSwitchContent>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</SettingsSwitchItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='enabled'
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<SettingsSwitchItem>
|
||||
<SettingsSwitchContent>
|
||||
<FormLabel>{t('Enabled')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t('Allow users to sign in with this provider')}
|
||||
</FormDescription>
|
||||
</SettingsSwitchContent>
|
||||
<FormItem>
|
||||
<FormLabel>{t('Provider Name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<Input placeholder={t('e.g. My GitLab')} {...field} />
|
||||
</FormControl>
|
||||
</SettingsSwitchItem>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Provider Name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('e.g. My GitLab')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='slug'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Slug')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('e.g. my-gitlab')} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Used in URLs and API routes')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='icon'
|
||||
name='slug'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Icon')}</FormLabel>
|
||||
<FormLabel>{t('Slug')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('Icon identifier (e.g. github, gitlab)')}
|
||||
{...field}
|
||||
/>
|
||||
<Input placeholder={t('e.g. my-gitlab')} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Optional icon identifier for the login button')}
|
||||
{t('Used in URLs and API routes')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -273,341 +267,341 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='icon'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Icon')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('Icon identifier (e.g. github, gitlab)')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Optional icon identifier for the login button')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Credentials */}
|
||||
<div className='space-y-4'>
|
||||
<h4 className='text-sm font-medium'>{t('Credentials')}</h4>
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='client_id'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Client ID')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('OAuth Client ID')}
|
||||
autoComplete='off'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Separator />
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='client_secret'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Client Secret')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder={t('OAuth Client Secret')}
|
||||
autoComplete='new-password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{/* Credentials */}
|
||||
<div className='space-y-4'>
|
||||
<h4 className='text-sm font-medium'>{t('Credentials')}</h4>
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='client_id'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Client ID')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('OAuth Client ID')}
|
||||
autoComplete='off'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='auth_style'
|
||||
name='client_secret'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Auth Style')}</FormLabel>
|
||||
<Select
|
||||
items={[
|
||||
...AUTH_STYLE_OPTIONS.map((option) => ({
|
||||
value: String(option.value),
|
||||
label: t(option.labelKey),
|
||||
})),
|
||||
]}
|
||||
value={String(field.value)}
|
||||
onValueChange={(val) => field.onChange(Number(val))}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className='w-full'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
{AUTH_STYLE_OPTIONS.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={String(option.value)}
|
||||
>
|
||||
{t(option.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'How client credentials are sent to the token endpoint'
|
||||
<FormLabel>{t('Client Secret')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder={t('OAuth Client Secret')}
|
||||
autoComplete='new-password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='auth_style'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Auth Style')}</FormLabel>
|
||||
<Select
|
||||
items={[
|
||||
...AUTH_STYLE_OPTIONS.map((option) => ({
|
||||
value: String(option.value),
|
||||
label: t(option.labelKey),
|
||||
})),
|
||||
]}
|
||||
value={String(field.value)}
|
||||
onValueChange={(val) => field.onChange(Number(val))}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className='w-full'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
{AUTH_STYLE_OPTIONS.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={String(option.value)}
|
||||
>
|
||||
{t(option.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{t('How client credentials are sent to the token endpoint')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Endpoints */}
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<h4 className='text-sm font-medium'>{t('Endpoints')}</h4>
|
||||
<DiscoveryButton form={form} />
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='well_known'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Well-Known URL')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t(
|
||||
'https://provider.com/.well-known/openid-configuration'
|
||||
)}
|
||||
</FormDescription>
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'OIDC discovery URL. Click "Auto-discover" to fetch endpoints automatically.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='authorization_endpoint'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Authorization Endpoint')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='https://provider.com/oauth/authorize'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='token_endpoint'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Token Endpoint')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='https://provider.com/oauth/token'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='user_info_endpoint'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('User Info Endpoint')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='https://provider.com/api/user'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='scopes'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Scopes')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('e.g. openid profile email')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Space-separated OAuth scopes')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Field Mapping */}
|
||||
<div className='space-y-4'>
|
||||
<h4 className='text-sm font-medium'>{t('Field Mapping')}</h4>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Map fields from the user info response to local user attributes. Supports nested paths (e.g. ocs.data.id).'
|
||||
)}
|
||||
</FormDescription>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='user_id_field'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('User ID Field')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='id' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='username_field'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Username Field')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='login' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='display_name_field'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Display Name Field')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='name' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email_field'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Email Field')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='email' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<Separator />
|
||||
|
||||
{/* Endpoints */}
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<h4 className='text-sm font-medium'>{t('Endpoints')}</h4>
|
||||
<DiscoveryButton form={form} />
|
||||
</div>
|
||||
{/* Advanced */}
|
||||
<div className='space-y-4'>
|
||||
<h4 className='text-sm font-medium'>{t('Advanced')}</h4>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='well_known'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Well-Known URL')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t(
|
||||
'https://provider.com/.well-known/openid-configuration'
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'OIDC discovery URL. Click "Auto-discover" to fetch endpoints automatically.'
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='access_policy'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Access Policy (JSON)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t(
|
||||
'Optional JSON policy to restrict access based on user info fields'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
className='min-h-[80px] font-mono text-xs'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'JSON-based access control rules. Leave empty to allow all users.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='authorization_endpoint'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Authorization Endpoint')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='https://provider.com/oauth/authorize'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='token_endpoint'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Token Endpoint')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='https://provider.com/oauth/token'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='user_info_endpoint'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('User Info Endpoint')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='https://provider.com/api/user'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='scopes'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Scopes')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('e.g. openid profile email')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Space-separated OAuth scopes')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Field Mapping */}
|
||||
<div className='space-y-4'>
|
||||
<h4 className='text-sm font-medium'>{t('Field Mapping')}</h4>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Map fields from the user info response to local user attributes. Supports nested paths (e.g. ocs.data.id).'
|
||||
)}
|
||||
</FormDescription>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='user_id_field'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('User ID Field')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='id' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='username_field'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Username Field')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='login' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='display_name_field'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Display Name Field')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='name' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email_field'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Email Field')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='email' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Advanced */}
|
||||
<div className='space-y-4'>
|
||||
<h4 className='text-sm font-medium'>{t('Advanced')}</h4>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='access_policy'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Access Policy (JSON)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t(
|
||||
'Optional JSON policy to restrict access based on user info fields'
|
||||
)}
|
||||
className='min-h-[80px] font-mono text-xs'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'JSON-based access control rules. Leave empty to allow all users.'
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='access_denied_message'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Access Denied Message')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t(
|
||||
'Custom message shown when access is denied'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='access_denied_message'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Access Denied Message')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t(
|
||||
'Custom message shown when access is denied'
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => props.onOpenChange(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit' disabled={isPending}>
|
||||
{isPending
|
||||
? t('Saving...')
|
||||
: isEditing
|
||||
? t('Update Provider')
|
||||
: t('Create Provider')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</SettingsForm>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsForm>
|
||||
</Form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
+146
-148
@@ -36,14 +36,6 @@ import {
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -72,6 +64,7 @@ import {
|
||||
} from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { DateTimePicker } from '@/components/datetime-picker'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { SettingsSwitchField } from '../components/settings-form-layout'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
@@ -105,6 +98,8 @@ const announcementSchema = z.object({
|
||||
|
||||
type AnnouncementFormValues = z.infer<typeof announcementSchema>
|
||||
|
||||
const ANNOUNCEMENT_FORM_ID = 'announcement-form'
|
||||
|
||||
const typeOptions = [
|
||||
{
|
||||
value: 'default',
|
||||
@@ -460,154 +455,157 @@ export function AnnouncementsSection({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={showDialog} onOpenChange={setShowDialog}>
|
||||
<DialogContent className='max-w-2xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingAnnouncement
|
||||
? t('Edit Announcement')
|
||||
: t('Add Announcement')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Create or update system announcements for the dashboard')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmitForm)}
|
||||
className='space-y-4'
|
||||
<Dialog
|
||||
open={showDialog}
|
||||
onOpenChange={setShowDialog}
|
||||
title={
|
||||
editingAnnouncement ? t('Edit Announcement') : t('Add Announcement')
|
||||
}
|
||||
description={t(
|
||||
'Create or update system announcements for the dashboard'
|
||||
)}
|
||||
contentClassName='max-w-2xl'
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => setShowDialog(false)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='content'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Content')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t(
|
||||
'Enter announcement content (supports Markdown/HTML)'
|
||||
)}
|
||||
rows={4}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Maximum 500 characters. Supports Markdown and HTML.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='publishDate'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Publish Date')}</FormLabel>
|
||||
<FormControl>
|
||||
<DateTimePicker
|
||||
value={field.value ? new Date(field.value) : undefined}
|
||||
onChange={(date) =>
|
||||
field.onChange(date ? date.toISOString() : '')
|
||||
}
|
||||
placeholder={t('Select publish date')}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Date and time when this announcement should be displayed'
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit' form={ANNOUNCEMENT_FORM_ID}>
|
||||
{editingAnnouncement ? t('Update') : t('Add')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id={ANNOUNCEMENT_FORM_ID}
|
||||
onSubmit={form.handleSubmit(handleSubmitForm)}
|
||||
className='space-y-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='content'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Content')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t(
|
||||
'Enter announcement content (supports Markdown/HTML)'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='type'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Type')}</FormLabel>
|
||||
<Select
|
||||
items={[
|
||||
...typeOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: (
|
||||
rows={4}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Maximum 500 characters. Supports Markdown and HTML.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='publishDate'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Publish Date')}</FormLabel>
|
||||
<FormControl>
|
||||
<DateTimePicker
|
||||
value={field.value ? new Date(field.value) : undefined}
|
||||
onChange={(date) =>
|
||||
field.onChange(date ? date.toISOString() : '')
|
||||
}
|
||||
placeholder={t('Select publish date')}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Date and time when this announcement should be displayed'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='type'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Type')}</FormLabel>
|
||||
<Select
|
||||
items={[
|
||||
...typeOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: (
|
||||
<div className='flex items-center gap-2'>
|
||||
<div
|
||||
className={`h-3 w-3 rounded-full ${option.color}`}
|
||||
/>
|
||||
{option.label}
|
||||
</div>
|
||||
),
|
||||
})),
|
||||
]}
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t('Select announcement type')}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
{typeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div
|
||||
className={`h-3 w-3 rounded-full ${option.color}`}
|
||||
/>
|
||||
{option.label}
|
||||
</div>
|
||||
),
|
||||
})),
|
||||
]}
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t('Select announcement type')}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
{typeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div
|
||||
className={`h-3 w-3 rounded-full ${option.color}`}
|
||||
/>
|
||||
{option.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='extra'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Extra Notes (Optional)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('Additional information')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Optional supplementary information (max 100 characters)'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => setShowDialog(false)}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit'>
|
||||
{editingAnnouncement ? t('Update') : t('Add')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='extra'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Extra Notes (Optional)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('Additional information')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Optional supplementary information (max 100 characters)'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
|
||||
+121
-126
@@ -36,14 +36,6 @@ import {
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -70,6 +62,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { SettingsSwitchField } from '../components/settings-form-layout'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
@@ -98,6 +91,8 @@ const createApiInfoSchema = (t: (key: string) => string) =>
|
||||
|
||||
type ApiInfoFormValues = z.infer<ReturnType<typeof createApiInfoSchema>>
|
||||
|
||||
const API_INFO_FORM_ID = 'api-info-form'
|
||||
|
||||
const colorOptions = [
|
||||
{ value: 'blue', label: 'Blue' },
|
||||
{ value: 'green', label: 'Green' },
|
||||
@@ -408,133 +403,133 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={showDialog} onOpenChange={setShowDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingApiInfo ? t('Edit API Shortcut') : t('Add API Shortcut')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Configure API documentation links for the dashboard')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmitForm)}
|
||||
className='space-y-4'
|
||||
<Dialog
|
||||
open={showDialog}
|
||||
onOpenChange={setShowDialog}
|
||||
title={editingApiInfo ? t('Edit API Shortcut') : t('Add API Shortcut')}
|
||||
description={t('Configure API documentation links for the dashboard')}
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => setShowDialog(false)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='url'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('API URL')}</FormLabel>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit' form={API_INFO_FORM_ID}>
|
||||
{editingApiInfo ? t('Update') : t('Add')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id={API_INFO_FORM_ID}
|
||||
onSubmit={form.handleSubmit(handleSubmitForm)}
|
||||
className='space-y-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='url'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('API URL')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('https://api.example.com')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='route'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Route Description')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('e.g., CN2 GIA')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='description'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Description')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t(
|
||||
'e.g., Recommended for China Mainland Users'
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='color'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Badge Color')}</FormLabel>
|
||||
<Select
|
||||
items={[
|
||||
...colorOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: (
|
||||
<div className='flex items-center gap-2'>
|
||||
<div
|
||||
className={`h-4 w-4 rounded-full ${getBgColorClass(option.value)}`}
|
||||
/>
|
||||
{option.label}
|
||||
</div>
|
||||
),
|
||||
})),
|
||||
]}
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('https://api.example.com')}
|
||||
{...field}
|
||||
/>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('Select a color')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='route'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Route Description')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('e.g., CN2 GIA')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='description'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Description')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t(
|
||||
'e.g., Recommended for China Mainland Users'
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='color'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Badge Color')}</FormLabel>
|
||||
<Select
|
||||
items={[
|
||||
...colorOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: (
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
{colorOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div
|
||||
className={`h-4 w-4 rounded-full ${getBgColorClass(option.value)}`}
|
||||
/>
|
||||
{option.label}
|
||||
</div>
|
||||
),
|
||||
})),
|
||||
]}
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('Select a color')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
{colorOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div
|
||||
className={`h-4 w-4 rounded-full ${getBgColorClass(option.value)}`}
|
||||
/>
|
||||
{option.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{t('Visual indicator color for the API card')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => setShowDialog(false)}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit'>
|
||||
{editingApiInfo ? t('Update') : t('Add')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{t('Visual indicator color for the API card')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
|
||||
@@ -22,14 +22,6 @@ import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -40,6 +32,7 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
|
||||
const createChatDialogSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
@@ -49,6 +42,8 @@ const createChatDialogSchema = (t: (key: string) => string) =>
|
||||
|
||||
type ChatDialogFormValues = z.infer<ReturnType<typeof createChatDialogSchema>>
|
||||
|
||||
const CHAT_DIALOG_FORM_ID = 'chat-dialog-form'
|
||||
|
||||
export type ChatEntryData = {
|
||||
name: string
|
||||
url: string
|
||||
@@ -97,74 +92,73 @@ export function ChatDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='sm:max-w-[500px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEditMode ? t('Edit chat preset') : t('Add chat preset')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Configure a predefined chat link for end users.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className='space-y-4'
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={isEditMode ? t('Edit chat preset') : t('Add chat preset')}
|
||||
description={t('Configure a predefined chat link for end users.')}
|
||||
contentClassName='sm:max-w-[500px]'
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Chat Client Name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('Please enter chat client name')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Display name for this chat client.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit' form={CHAT_DIALOG_FORM_ID}>
|
||||
{isEditMode ? t('Update') : t('Add')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id={CHAT_DIALOG_FORM_ID}
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className='space-y-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Chat Client Name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('Please enter chat client name')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Display name for this chat client.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='url'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('URL')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('Please enter the URL')} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('The URL for this chat client.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit'>
|
||||
{isEditMode ? t('Update') : t('Add')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='url'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('URL')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('Please enter the URL')} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('The URL for this chat client.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -35,14 +35,6 @@ import {
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -62,6 +54,7 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { SettingsSwitchField } from '../components/settings-form-layout'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
@@ -90,6 +83,8 @@ const faqSchema = z.object({
|
||||
|
||||
type FAQFormValues = z.infer<typeof faqSchema>
|
||||
|
||||
const FAQ_FORM_ID = 'faq-form'
|
||||
|
||||
export function FAQSection({ enabled, data }: FAQSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
@@ -348,79 +343,78 @@ export function FAQSection({ enabled, data }: FAQSectionProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={showDialog} onOpenChange={setShowDialog}>
|
||||
<DialogContent className='max-w-2xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingFaq ? t('Edit FAQ') : t('Add FAQ')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Create or update frequently asked questions for users')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmitForm)}
|
||||
className='space-y-4'
|
||||
<Dialog
|
||||
open={showDialog}
|
||||
onOpenChange={setShowDialog}
|
||||
title={editingFaq ? t('Edit FAQ') : t('Add FAQ')}
|
||||
description={t('Create or update frequently asked questions for users')}
|
||||
contentClassName='max-w-2xl'
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => setShowDialog(false)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='question'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Question')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('How to reset my quota?')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Maximum 200 characters')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='answer'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Answer')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t(
|
||||
'Visit Settings → General and adjust quota options...'
|
||||
)}
|
||||
rows={8}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Maximum 1000 characters. Supports Markdown and HTML.'
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit' form={FAQ_FORM_ID}>
|
||||
{editingFaq ? t('Update') : t('Add')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id={FAQ_FORM_ID}
|
||||
onSubmit={form.handleSubmit(handleSubmitForm)}
|
||||
className='space-y-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='question'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Question')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('How to reset my quota?')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Maximum 200 characters')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='answer'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Answer')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t(
|
||||
'Visit Settings → General and adjust quota options...'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => setShowDialog(false)}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit'>
|
||||
{editingFaq ? t('Update') : t('Add')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
rows={8}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Maximum 1000 characters. Supports Markdown and HTML.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
|
||||
@@ -35,14 +35,6 @@ import {
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -61,6 +53,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { SettingsSwitchField } from '../components/settings-form-layout'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
import { useUpdateOption } from '../hooks/use-update-option'
|
||||
@@ -97,6 +90,8 @@ const createUptimeKumaSchema = (t: (key: string) => string) =>
|
||||
|
||||
type UptimeKumaFormValues = z.infer<ReturnType<typeof createUptimeKumaSchema>>
|
||||
|
||||
const UPTIME_KUMA_FORM_ID = 'uptime-kuma-form'
|
||||
|
||||
export function UptimeKumaSection({ enabled, data }: UptimeKumaSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
@@ -359,96 +354,100 @@ export function UptimeKumaSection({ enabled, data }: UptimeKumaSectionProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={showDialog} onOpenChange={setShowDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingGroup
|
||||
? t('Edit Uptime Kuma Group')
|
||||
: t('Add Uptime Kuma Group')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Configure monitoring status page groups for the dashboard')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmitForm)}
|
||||
className='space-y-4'
|
||||
<Dialog
|
||||
open={showDialog}
|
||||
onOpenChange={setShowDialog}
|
||||
title={
|
||||
editingGroup
|
||||
? t('Edit Uptime Kuma Group')
|
||||
: t('Add Uptime Kuma Group')
|
||||
}
|
||||
description={t(
|
||||
'Configure monitoring status page groups for the dashboard'
|
||||
)}
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => setShowDialog(false)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='categoryName'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Category Name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('e.g., Core APIs, OpenAI, Claude')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Display name for this monitoring group (max 50 characters)'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='url'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Uptime Kuma URL')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('https://status.example.com')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Base URL of your Uptime Kuma instance')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='slug'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Status Page Slug')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('my-status')} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('The slug is appended to the URL:')} {'{url}'}
|
||||
{t('/status/')}
|
||||
{'{slug}'}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => setShowDialog(false)}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit'>
|
||||
{editingGroup ? t('Update') : t('Add')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit' form={UPTIME_KUMA_FORM_ID}>
|
||||
{editingGroup ? t('Update') : t('Add')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id={UPTIME_KUMA_FORM_ID}
|
||||
onSubmit={form.handleSubmit(handleSubmitForm)}
|
||||
className='space-y-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='categoryName'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Category Name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('e.g., Core APIs, OpenAI, Claude')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Display name for this monitoring group (max 50 characters)'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='url'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Uptime Kuma URL')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('https://status.example.com')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Base URL of your Uptime Kuma instance')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='slug'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Status Page Slug')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('my-status')} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('The slug is appended to the URL:')} {'{url}'}
|
||||
{t('/status/')}
|
||||
{'{slug}'}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
|
||||
+36
-37
@@ -20,12 +20,7 @@ import { useEffect, useMemo, useState, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { formatTimestampToDate } from '@/lib/format'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { getAffinityUsageCache } from './api'
|
||||
|
||||
function formatRate(hit: number, total: number): string {
|
||||
@@ -135,38 +130,42 @@ export function CacheStatsDialog(props: Props) {
|
||||
}, [stats, props.target, t])
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<DialogContent className='sm:max-w-lg'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Channel Affinity: Upstream Cache Hit')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'Hit criteria: If cached tokens exist in usage, it counts as a hit.'
|
||||
)}
|
||||
</p>
|
||||
{loading ? (
|
||||
<div className='text-muted-foreground py-8 text-center text-sm'>
|
||||
{t('Loading...')}
|
||||
</div>
|
||||
) : rows.length > 0 ? (
|
||||
<div className='space-y-2'>
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.key}
|
||||
className='flex justify-between border-b pb-1 text-sm'
|
||||
>
|
||||
<span className='text-muted-foreground'>{row.key}</span>
|
||||
<span className='font-medium'>{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-muted-foreground py-8 text-center text-sm'>
|
||||
{t('No data available')}
|
||||
</div>
|
||||
<Dialog
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
title={t('Channel Affinity: Upstream Cache Hit')}
|
||||
contentClassName='sm:max-w-lg'
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'Hit criteria: If cached tokens exist in usage, it counts as a hit.'
|
||||
)}
|
||||
</DialogContent>
|
||||
</p>
|
||||
{loading ? (
|
||||
<div className='text-muted-foreground py-8 text-center text-sm'>
|
||||
{t('Loading...')}
|
||||
</div>
|
||||
) : rows.length > 0 ? (
|
||||
<div className='space-y-2'>
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.key}
|
||||
className='flex justify-between gap-4 border-b pb-1 text-sm'
|
||||
>
|
||||
<span className='text-muted-foreground'>{row.key}</span>
|
||||
<span className='text-right font-medium break-all'>
|
||||
{row.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-muted-foreground py-8 text-center text-sm'>
|
||||
{t('No data available')}
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
+42
-5
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState, type ReactNode } from 'react'
|
||||
import { Edit, FileText, Plus, RefreshCw, Trash2, X } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { StatusBadge, StatusBadgeList } from '@/components/status-badge'
|
||||
import { SettingsSwitchField } from '../../components/settings-form-layout'
|
||||
import { SettingsPageActionsPortal } from '../../components/settings-page-context'
|
||||
@@ -82,6 +82,43 @@ function RuleBadgeList(props: { items: string[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ChannelAffinityConfirmDialog(props: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: ReactNode
|
||||
desc: ReactNode
|
||||
handleConfirm: () => void
|
||||
destructive?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
title={props.title}
|
||||
contentClassName='sm:max-w-md'
|
||||
contentHeight='auto'
|
||||
bodyClassName='flex items-start'
|
||||
footer={
|
||||
<>
|
||||
<Button variant='outline' onClick={() => props.onOpenChange(false)}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={props.destructive ? 'destructive' : 'default'}
|
||||
onClick={props.handleConfirm}
|
||||
>
|
||||
{t('Continue')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className='text-muted-foreground text-sm'>{props.desc}</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function serializeRules(rules: AffinityRule[]): string {
|
||||
return JSON.stringify(rules.map(({ id: _, ...rest }) => rest))
|
||||
}
|
||||
@@ -641,7 +678,7 @@ export function ChannelAffinitySection(props: Props) {
|
||||
templateKey={ruleTemplateKey}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
<ChannelAffinityConfirmDialog
|
||||
open={clearAllDialogOpen}
|
||||
onOpenChange={setClearAllDialogOpen}
|
||||
title={t('Confirm clearing all channel affinity cache')}
|
||||
@@ -653,7 +690,7 @@ export function ChannelAffinitySection(props: Props) {
|
||||
/>
|
||||
|
||||
{clearRuleName !== null && (
|
||||
<ConfirmDialog
|
||||
<ChannelAffinityConfirmDialog
|
||||
open
|
||||
onOpenChange={(v) => !v && setClearRuleName(null)}
|
||||
title={t('Confirm clearing cache for this rule')}
|
||||
@@ -663,7 +700,7 @@ export function ChannelAffinitySection(props: Props) {
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
<ChannelAffinityConfirmDialog
|
||||
open={fillTemplateDialogOpen}
|
||||
onOpenChange={setFillTemplateDialogOpen}
|
||||
title={t('Fill Codex CLI / Claude CLI Templates')}
|
||||
|
||||
+214
-216
@@ -27,13 +27,6 @@ import {
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
@@ -46,6 +39,7 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { SettingsSwitchField } from '../../components/settings-form-layout'
|
||||
import { RULE_TEMPLATES } from './constants'
|
||||
import type { AffinityRule, KeySource } from './types'
|
||||
@@ -69,6 +63,8 @@ const CONTEXT_KEY_PRESETS = [
|
||||
'specific_channel_id',
|
||||
]
|
||||
|
||||
const RULE_FORM_ID = 'channel-affinity-rule-form'
|
||||
|
||||
interface RuleFormValues {
|
||||
name: string
|
||||
model_regex_text: string
|
||||
@@ -230,228 +226,230 @@ export function RuleEditorDialog(props: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<DialogContent className='max-h-[85vh] max-w-2xl overflow-y-auto'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? t('Edit Rule') : t('Add Rule')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Dialog
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
title={isEdit ? t('Edit Rule') : t('Add Rule')}
|
||||
contentClassName='max-w-2xl'
|
||||
contentHeight='auto'
|
||||
bodyClassName='pr-2'
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => props.onOpenChange(false)}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit' form={RULE_FORM_ID}>
|
||||
{t('Save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
id={RULE_FORM_ID}
|
||||
onSubmit={form.handleSubmit(handleSave)}
|
||||
className='min-w-0 space-y-4 overflow-x-clip'
|
||||
>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Name')} *</Label>
|
||||
<Input
|
||||
placeholder='prefer-by-conversation-id'
|
||||
{...form.register('name', { required: true })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form onSubmit={form.handleSubmit(handleSave)} className='space-y-4'>
|
||||
<div className='grid gap-3 sm:grid-cols-2'>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Name')} *</Label>
|
||||
<Input
|
||||
placeholder='prefer-by-conversation-id'
|
||||
{...form.register('name', { required: true })}
|
||||
<Label>{t('Model Regex (one per line)')} *</Label>
|
||||
<Textarea
|
||||
rows={4}
|
||||
placeholder={'^gpt-4o.*$\n^claude-3.*$'}
|
||||
{...form.register('model_regex_text', { required: true })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Model Regex (one per line)')} *</Label>
|
||||
<Textarea
|
||||
rows={4}
|
||||
placeholder={'^gpt-4o.*$\n^claude-3.*$'}
|
||||
{...form.register('model_regex_text', { required: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Path Regex (one per line)')}</Label>
|
||||
<Textarea
|
||||
rows={4}
|
||||
placeholder='/v1/chat/completions'
|
||||
{...form.register('path_regex_text')}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Path Regex (one per line)')}</Label>
|
||||
<Textarea
|
||||
rows={4}
|
||||
placeholder='/v1/chat/completions'
|
||||
{...form.register('path_regex_text')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsSwitchField
|
||||
checked={form.watch('skip_retry_on_failure')}
|
||||
onCheckedChange={(v) => form.setValue('skip_retry_on_failure', v)}
|
||||
label={t('Skip retry on failure')}
|
||||
/>
|
||||
<SettingsSwitchField
|
||||
checked={form.watch('skip_retry_on_failure')}
|
||||
onCheckedChange={(v) => form.setValue('skip_retry_on_failure', v)}
|
||||
label={t('Skip retry on failure')}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
<Separator />
|
||||
|
||||
{/* Key Sources */}
|
||||
<div>
|
||||
<div className='mb-2 flex items-center justify-between'>
|
||||
<Label>{t('Key Sources')}</Label>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
setKeySources((prev) => [
|
||||
...prev,
|
||||
{ type: 'gjson', path: '' },
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus className='mr-1 h-3 w-3' />
|
||||
{t('Add')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className='text-muted-foreground mb-2 text-xs'>
|
||||
{t('Common Keys')}: {CONTEXT_KEY_PRESETS.join(', ')}
|
||||
</p>
|
||||
<div className='space-y-2'>
|
||||
{keySources.map((src, idx) => (
|
||||
<div key={idx} className='flex items-center gap-2'>
|
||||
<Select
|
||||
items={[
|
||||
...KEY_SOURCE_TYPES.map((t) => ({ value: t, label: t })),
|
||||
]}
|
||||
value={src.type}
|
||||
onValueChange={(v) => {
|
||||
if (v === null) return
|
||||
const next = [...keySources]
|
||||
next[idx] = normalizeKeySource({
|
||||
...src,
|
||||
type: v as KeySource['type'],
|
||||
})
|
||||
setKeySources(next)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className='w-[160px]'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
{KEY_SOURCE_TYPES.map((t) => (
|
||||
<SelectItem key={t} value={t}>
|
||||
{t}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
className='flex-1'
|
||||
placeholder={
|
||||
src.type === 'gjson'
|
||||
? 'metadata.conversation_id'
|
||||
: 'user_id'
|
||||
}
|
||||
value={
|
||||
src.type === 'gjson' ? src.path || '' : src.key || ''
|
||||
}
|
||||
onChange={(e) => {
|
||||
const next = [...keySources]
|
||||
if (src.type === 'gjson') {
|
||||
next[idx] = { ...src, path: e.target.value }
|
||||
} else {
|
||||
next[idx] = { ...src, key: e.target.value }
|
||||
}
|
||||
setKeySources(next)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() =>
|
||||
setKeySources((prev) => prev.filter((_, i) => i !== idx))
|
||||
}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Advanced */}
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<CollapsibleTrigger
|
||||
render={
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
className='w-full justify-start'
|
||||
/>
|
||||
}
|
||||
>
|
||||
{advancedOpen ? '▼' : '▶'} {t('Advanced Settings')}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className='space-y-3 pt-2'>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('User-Agent include (one per line)')}</Label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
placeholder='curl PostmanRuntime'
|
||||
{...form.register('user_agent_include_text')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Value Regex')}</Label>
|
||||
<Input
|
||||
placeholder='^[-0-9A-Za-z._:]{1,128}$'
|
||||
{...form.register('value_regex')}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('TTL (seconds, 0 = default)')}</Label>
|
||||
<Input
|
||||
type='number'
|
||||
min={0}
|
||||
{...form.register('ttl_seconds')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Parameter Override Template (JSON)')}</Label>
|
||||
<Textarea
|
||||
rows={5}
|
||||
placeholder='{"operations": [...]}'
|
||||
{...form.register('param_override_template_json')}
|
||||
className='font-mono text-xs'
|
||||
/>
|
||||
</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>
|
||||
|
||||
<DialogFooter>
|
||||
{/* Key Sources */}
|
||||
<div>
|
||||
<div className='mb-2 flex items-center justify-between'>
|
||||
<Label>{t('Key Sources')}</Label>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => props.onOpenChange(false)}
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
setKeySources((prev) => [...prev, { type: 'gjson', path: '' }])
|
||||
}
|
||||
>
|
||||
{t('Cancel')}
|
||||
<Plus className='mr-1 h-3 w-3' />
|
||||
{t('Add')}
|
||||
</Button>
|
||||
<Button type='submit'>{t('Save')}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</div>
|
||||
<p className='text-muted-foreground mb-2 text-xs'>
|
||||
{t('Common Keys')}: {CONTEXT_KEY_PRESETS.join(', ')}
|
||||
</p>
|
||||
<div className='space-y-2'>
|
||||
{keySources.map((src, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className='flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center'
|
||||
>
|
||||
<Select
|
||||
items={[
|
||||
...KEY_SOURCE_TYPES.map((t) => ({ value: t, label: t })),
|
||||
]}
|
||||
value={src.type}
|
||||
onValueChange={(v) => {
|
||||
if (v === null) return
|
||||
const next = [...keySources]
|
||||
next[idx] = normalizeKeySource({
|
||||
...src,
|
||||
type: v as KeySource['type'],
|
||||
})
|
||||
setKeySources(next)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className='w-full sm:w-[160px]'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
{KEY_SOURCE_TYPES.map((t) => (
|
||||
<SelectItem key={t} value={t}>
|
||||
{t}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
className='min-w-0 flex-1'
|
||||
placeholder={
|
||||
src.type === 'gjson'
|
||||
? 'metadata.conversation_id'
|
||||
: 'user_id'
|
||||
}
|
||||
value={src.type === 'gjson' ? src.path || '' : src.key || ''}
|
||||
onChange={(e) => {
|
||||
const next = [...keySources]
|
||||
if (src.type === 'gjson') {
|
||||
next[idx] = { ...src, path: e.target.value }
|
||||
} else {
|
||||
next[idx] = { ...src, key: e.target.value }
|
||||
}
|
||||
setKeySources(next)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() =>
|
||||
setKeySources((prev) => prev.filter((_, i) => i !== idx))
|
||||
}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Advanced */}
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<CollapsibleTrigger
|
||||
render={
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
className='w-full justify-start'
|
||||
/>
|
||||
}
|
||||
>
|
||||
{advancedOpen ? '▼' : '▶'} {t('Advanced Settings')}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className='space-y-3 pt-2'>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('User-Agent include (one per line)')}</Label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
placeholder='curl PostmanRuntime'
|
||||
{...form.register('user_agent_include_text')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-3 sm:grid-cols-2'>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Value Regex')}</Label>
|
||||
<Input
|
||||
placeholder='^[-0-9A-Za-z._:]{1,128}$'
|
||||
{...form.register('value_regex')}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('TTL (seconds, 0 = default)')}</Label>
|
||||
<Input
|
||||
type='number'
|
||||
min={0}
|
||||
{...form.register('ttl_seconds')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Parameter Override Template (JSON)')}</Label>
|
||||
<Textarea
|
||||
rows={5}
|
||||
placeholder='{"operations": [...]}'
|
||||
{...form.register('param_override_template_json')}
|
||||
className='font-mono text-xs'
|
||||
/>
|
||||
</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>
|
||||
</form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
+98
-102
@@ -22,14 +22,6 @@ import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -40,6 +32,7 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
|
||||
const createAmountDiscountDialogSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
@@ -57,6 +50,8 @@ type AmountDiscountDialogFormValues = z.infer<
|
||||
ReturnType<typeof createAmountDiscountDialogSchema>
|
||||
>
|
||||
|
||||
const AMOUNT_DISCOUNT_FORM_ID = 'amount-discount-form'
|
||||
|
||||
export type AmountDiscountData = {
|
||||
amount: number
|
||||
discountRate: number
|
||||
@@ -115,102 +110,103 @@ export function AmountDiscountDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='sm:max-w-[500px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEditMode ? t('Edit discount tier') : t('Add discount tier')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Set a discount rate for a specific recharge amount threshold.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className='space-y-4'
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={isEditMode ? t('Edit discount tier') : t('Add discount tier')}
|
||||
description={t(
|
||||
'Set a discount rate for a specific recharge amount threshold.'
|
||||
)}
|
||||
contentClassName='sm:max-w-[500px]'
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='amount'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Recharge Amount (USD)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
step='1'
|
||||
min='1'
|
||||
placeholder={t('e.g., 100')}
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseInt(e.target.value) || 0)
|
||||
}
|
||||
disabled={isEditMode}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{isEditMode
|
||||
? t('Amount cannot be changed when editing.')
|
||||
: t(
|
||||
'Minimum recharge amount to qualify for this discount.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit' form={AMOUNT_DISCOUNT_FORM_ID}>
|
||||
{isEditMode ? t('Update') : t('Add')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id={AMOUNT_DISCOUNT_FORM_ID}
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className='space-y-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='amount'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Recharge Amount (USD)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
step='1'
|
||||
min='1'
|
||||
placeholder={t('e.g., 100')}
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseInt(e.target.value) || 0)
|
||||
}
|
||||
disabled={isEditMode}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{isEditMode
|
||||
? t('Amount cannot be changed when editing.')
|
||||
: t(
|
||||
'Minimum recharge amount to qualify for this discount.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='discountRate'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Discount Rate')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
step='0.01'
|
||||
min='0.01'
|
||||
max='1'
|
||||
placeholder={t('e.g., 0.95')}
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseFloat(e.target.value) || 0)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Final price multiplier (0.95 = 5% discount')}
|
||||
{discountPercentage > 0 && (
|
||||
<span className='ml-1 font-medium text-green-600 dark:text-green-400'>
|
||||
= {discountPercentage}
|
||||
{t('% off')}
|
||||
</span>
|
||||
)}
|
||||
)
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit'>
|
||||
{isEditMode ? t('Update') : t('Add')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='discountRate'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Discount Rate')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
step='0.01'
|
||||
min='0.01'
|
||||
max='1'
|
||||
placeholder={t('e.g., 0.95')}
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseFloat(e.target.value) || 0)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Final price multiplier (0.95 = 5% discount')}
|
||||
{discountPercentage > 0 && (
|
||||
<span className='ml-1 font-medium text-green-600 dark:text-green-400'>
|
||||
= {discountPercentage}
|
||||
{t('% off')}
|
||||
</span>
|
||||
)}
|
||||
)
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
+123
-129
@@ -22,14 +22,6 @@ import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -48,6 +40,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import type { CreemProduct } from '@/features/wallet/types'
|
||||
import { safeNumberFieldProps } from '../utils/numeric-field'
|
||||
|
||||
@@ -61,6 +54,8 @@ const creemProductDialogSchema = z.object({
|
||||
|
||||
type CreemProductDialogFormValues = z.infer<typeof creemProductDialogSchema>
|
||||
|
||||
const CREEM_PRODUCT_FORM_ID = 'creem-product-form'
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
export type CreemProductData = CreemProduct
|
||||
|
||||
@@ -119,150 +114,149 @@ export function CreemProductDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='sm:max-w-[500px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEditMode ? t('Edit product') : t('Add product')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Configure a Creem product for user recharge options.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className='space-y-4'
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={isEditMode ? t('Edit product') : t('Add product')}
|
||||
description={t('Configure a Creem product for user recharge options.')}
|
||||
contentClassName='sm:max-w-[500px]'
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit' form={CREEM_PRODUCT_FORM_ID}>
|
||||
{isEditMode ? t('Update') : t('Add')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id={CREEM_PRODUCT_FORM_ID}
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className='space-y-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Product Name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('e.g., Basic Package')} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Display name shown to users.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='productId'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Product ID')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('e.g., prod_xxx')}
|
||||
disabled={isEditMode}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Creem product ID from your Creem dashboard.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='grid gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
name='currency'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Product Name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('e.g., Basic Package')} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Display name shown to users.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='productId'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Product ID')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('e.g., prod_xxx')}
|
||||
disabled={isEditMode}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Creem product ID from your Creem dashboard.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='grid gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='currency'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Currency')}</FormLabel>
|
||||
<Select
|
||||
items={[
|
||||
{ value: 'USD', label: 'USD ($)' },
|
||||
{ value: 'EUR', label: 'EUR (€)' },
|
||||
]}
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('Select currency')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
<SelectItem value='USD'>USD ($)</SelectItem>
|
||||
<SelectItem value='EUR'>EUR (€)</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='price'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Price')}</FormLabel>
|
||||
<FormLabel>{t('Currency')}</FormLabel>
|
||||
<Select
|
||||
items={[
|
||||
{ value: 'USD', label: 'USD ($)' },
|
||||
{ value: 'EUR', label: 'EUR (€)' },
|
||||
]}
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
step='0.01'
|
||||
min={0.01}
|
||||
placeholder='10.00'
|
||||
{...safeNumberFieldProps(field)}
|
||||
/>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('Select currency')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
<SelectItem value='USD'>USD ($)</SelectItem>
|
||||
<SelectItem value='EUR'>EUR (€)</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='quota'
|
||||
name='price'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Quota')}</FormLabel>
|
||||
<FormLabel>{t('Price')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
min={1}
|
||||
placeholder={t('e.g., 500000')}
|
||||
step='0.01'
|
||||
min={0.01}
|
||||
placeholder='10.00'
|
||||
{...safeNumberFieldProps(field)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Amount of quota to credit to user account.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit'>
|
||||
{isEditMode ? t('Update') : t('Add')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='quota'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Quota')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
min={1}
|
||||
placeholder={t('e.g., 500000')}
|
||||
{...safeNumberFieldProps(field)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Amount of quota to credit to user account.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
+121
-127
@@ -23,14 +23,6 @@ import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Combobox } from '@/components/ui/combobox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -41,6 +33,7 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
|
||||
const createPaymentMethodDialogSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
@@ -54,6 +47,8 @@ type PaymentMethodDialogFormValues = z.infer<
|
||||
ReturnType<typeof createPaymentMethodDialogSchema>
|
||||
>
|
||||
|
||||
const PAYMENT_METHOD_FORM_ID = 'payment-method-form'
|
||||
|
||||
export type PaymentMethodData = {
|
||||
name: string
|
||||
type: string
|
||||
@@ -169,134 +164,133 @@ export function PaymentMethodDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='sm:max-w-[500px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEditMode ? t('Edit payment method') : t('Add payment method')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Configure a payment method for user recharge options.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className='space-y-4'
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={isEditMode ? t('Edit payment method') : t('Add payment method')}
|
||||
description={t('Configure a payment method for user recharge options.')}
|
||||
contentClassName='sm:max-w-[500px]'
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('e.g., Alipay, WeChat')} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Display name for this payment method.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit' form={PAYMENT_METHOD_FORM_ID}>
|
||||
{isEditMode ? t('Update') : t('Add')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id={PAYMENT_METHOD_FORM_ID}
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className='space-y-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('e.g., Alipay, WeChat')} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Display name for this payment method.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='type'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Type')}</FormLabel>
|
||||
<FormControl>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='type'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Type')}</FormLabel>
|
||||
<FormControl>
|
||||
<Combobox
|
||||
options={PAYMENT_TYPES}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t('Select or enter payment type')}
|
||||
searchPlaceholder={t('Search payment types...')}
|
||||
allowCustomValue
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Select from presets or type custom identifier.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='color'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Color')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Combobox
|
||||
options={PAYMENT_TYPES}
|
||||
options={COLOR_PRESETS}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t('Select or enter payment type')}
|
||||
searchPlaceholder={t('Search payment types...')}
|
||||
placeholder={t('Select or enter color value')}
|
||||
searchPlaceholder={t('Search colors...')}
|
||||
allowCustomValue
|
||||
className='flex-1'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Select from presets or type custom identifier.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='color'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Color')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Combobox
|
||||
options={COLOR_PRESETS}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t('Select or enter color value')}
|
||||
searchPlaceholder={t('Search colors...')}
|
||||
allowCustomValue
|
||||
className='flex-1'
|
||||
{colorPreview && (
|
||||
<div
|
||||
className='size-9 shrink-0 rounded border'
|
||||
style={{ backgroundColor: colorPreview }}
|
||||
title={colorPreview}
|
||||
/>
|
||||
{colorPreview && (
|
||||
<div
|
||||
className='size-9 shrink-0 rounded border'
|
||||
style={{ backgroundColor: colorPreview }}
|
||||
title={colorPreview}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Select preset or enter custom CSS color value.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Select preset or enter custom CSS color value.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='min_topup'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Minimum top-up (optional)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
step='0.01'
|
||||
placeholder={t('e.g., 50')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Optional minimum recharge amount for this method.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit'>
|
||||
{isEditMode ? t('Update') : t('Add')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='min_topup'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Minimum top-up (optional)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
step='0.01'
|
||||
placeholder={t('e.g., 50')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Optional minimum recharge amount for this method.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
+99
-104
@@ -22,13 +22,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
@@ -41,6 +34,7 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { SettingsSwitchField } from '../components/settings-form-layout'
|
||||
|
||||
export interface WaffoSettingsValues {
|
||||
@@ -411,101 +405,16 @@ export function WaffoSettingsSection({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={methodDialogOpen} onOpenChange={setMethodDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingIdx === -1
|
||||
? t('Add payment method')
|
||||
: t('Edit payment method')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className='space-y-3'>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Display name')} *</Label>
|
||||
<Input
|
||||
value={methodForm.name}
|
||||
onChange={(e) =>
|
||||
setMethodForm((p) => ({ ...p, name: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label>{t('Icon')}</Label>
|
||||
<div className='flex items-center gap-3'>
|
||||
{methodForm.icon ? (
|
||||
<img
|
||||
src={methodForm.icon}
|
||||
alt={methodForm.name || t('Icon')}
|
||||
className='h-10 w-10 rounded border object-contain p-1'
|
||||
/>
|
||||
) : (
|
||||
<div className='bg-muted text-muted-foreground flex h-10 w-10 items-center justify-center rounded border text-xs'>
|
||||
{t('Icon')}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={iconFileInputRef}
|
||||
type='file'
|
||||
accept='image/png,image/jpeg,image/svg+xml,image/webp'
|
||||
className='hidden'
|
||||
onChange={handleIconFileChange}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => iconFileInputRef.current?.click()}
|
||||
>
|
||||
{t('Upload')}
|
||||
</Button>
|
||||
{methodForm.icon ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() =>
|
||||
setMethodForm((previous) => ({
|
||||
...previous,
|
||||
icon: '',
|
||||
}))
|
||||
}
|
||||
>
|
||||
{t('Clear')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Payment method type')}</Label>
|
||||
<Input
|
||||
value={methodForm.payMethodType}
|
||||
onChange={(e) =>
|
||||
setMethodForm((p) => ({
|
||||
...p,
|
||||
payMethodType: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder='CREDITCARD,DEBITCARD'
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Payment method name')}</Label>
|
||||
<Input
|
||||
value={methodForm.payMethodName}
|
||||
onChange={(e) =>
|
||||
setMethodForm((p) => ({
|
||||
...p,
|
||||
payMethodName: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Dialog
|
||||
open={methodDialogOpen}
|
||||
onOpenChange={setMethodDialogOpen}
|
||||
title={
|
||||
editingIdx === -1 ? t('Add payment method') : t('Edit payment method')
|
||||
}
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
@@ -516,8 +425,94 @@ export function WaffoSettingsSection({
|
||||
<Button type='button' onClick={saveMethod}>
|
||||
{t('Confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className='space-y-3'>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Display name')} *</Label>
|
||||
<Input
|
||||
value={methodForm.name}
|
||||
onChange={(e) =>
|
||||
setMethodForm((p) => ({ ...p, name: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label>{t('Icon')}</Label>
|
||||
<div className='flex items-center gap-3'>
|
||||
{methodForm.icon ? (
|
||||
<img
|
||||
src={methodForm.icon}
|
||||
alt={methodForm.name || t('Icon')}
|
||||
className='h-10 w-10 rounded border object-contain p-1'
|
||||
/>
|
||||
) : (
|
||||
<div className='bg-muted text-muted-foreground flex h-10 w-10 items-center justify-center rounded border text-xs'>
|
||||
{t('Icon')}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={iconFileInputRef}
|
||||
type='file'
|
||||
accept='image/png,image/jpeg,image/svg+xml,image/webp'
|
||||
className='hidden'
|
||||
onChange={handleIconFileChange}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => iconFileInputRef.current?.click()}
|
||||
>
|
||||
{t('Upload')}
|
||||
</Button>
|
||||
{methodForm.icon ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() =>
|
||||
setMethodForm((previous) => ({
|
||||
...previous,
|
||||
icon: '',
|
||||
}))
|
||||
}
|
||||
>
|
||||
{t('Clear')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Payment method type')}</Label>
|
||||
<Input
|
||||
value={methodForm.payMethodType}
|
||||
onChange={(e) =>
|
||||
setMethodForm((p) => ({
|
||||
...p,
|
||||
payMethodType: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder='CREDITCARD,DEBITCARD'
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label>{t('Payment method name')}</Label>
|
||||
<Input
|
||||
value={methodForm.payMethodName}
|
||||
onChange={(e) =>
|
||||
setMethodForm((p) => ({
|
||||
...p,
|
||||
payMethodName: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
|
||||
+36
-42
@@ -22,15 +22,8 @@ import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { formatTimestamp, formatTimestampToDate } from '@/lib/format'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Markdown } from '@/components/ui/markdown'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
|
||||
type ReleaseInfo = {
|
||||
@@ -140,38 +133,29 @@ export function UpdateCheckerSection({
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className='max-h-[80vh] overflow-y-auto'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{release?.tag_name
|
||||
? t('New version available: {{version}}', {
|
||||
version: release.tag_name,
|
||||
})
|
||||
: t('Release details')}
|
||||
</DialogTitle>
|
||||
{release?.published_at && (
|
||||
<DialogDescription>
|
||||
{t('Published')}{' '}
|
||||
{formatTimestampToDate(
|
||||
new Date(release.published_at).getTime(),
|
||||
'milliseconds'
|
||||
)}
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
<div className='space-y-4'>
|
||||
{release?.body ? (
|
||||
<Markdown>{release.body}</Markdown>
|
||||
) : (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t('No release notes provided.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Dialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
title={
|
||||
release?.tag_name
|
||||
? t('New version available: {{version}}', {
|
||||
version: release.tag_name,
|
||||
})
|
||||
: t('Release details')
|
||||
}
|
||||
description={
|
||||
release?.published_at
|
||||
? `${t('Published')} ${formatTimestampToDate(
|
||||
new Date(release.published_at).getTime(),
|
||||
'milliseconds'
|
||||
)}`
|
||||
: undefined
|
||||
}
|
||||
contentClassName='max-h-[80vh] overflow-y-auto'
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type='button'
|
||||
variant='secondary'
|
||||
@@ -185,8 +169,18 @@ export function UpdateCheckerSection({
|
||||
{t('Open release')}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
{release?.body ? (
|
||||
<Markdown>{release.body}</Markdown>
|
||||
) : (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t('No release notes provided.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
|
||||
+80
-85
@@ -30,14 +30,6 @@ import { Search } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
@@ -56,6 +48,7 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { DataTablePagination } from '@/components/data-table/pagination'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import type { UpstreamChannel } from '../types'
|
||||
import {
|
||||
@@ -330,87 +323,89 @@ export function ChannelSelectorDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='flex max-h-[90vh] max-w-[calc(100%-2rem)] flex-col sm:max-w-[90vw] xl:max-w-[1400px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Select Sync Channels')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Choose channels to sync upstream ratio configurations from')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='flex flex-1 flex-col gap-4 overflow-hidden'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='relative flex-1'>
|
||||
<Search className='text-muted-foreground absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2' />
|
||||
<Input
|
||||
placeholder={t('Search by name or URL...')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className='ps-8'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex-1 overflow-auto rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className='h-24 text-center'
|
||||
>
|
||||
{t('No channels found')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<DataTablePagination table={table} />
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t('Select Sync Channels')}
|
||||
description={t(
|
||||
'Choose channels to sync upstream ratio configurations from'
|
||||
)}
|
||||
contentClassName='flex max-h-[90vh] max-w-[calc(100%-2rem)] flex-col sm:max-w-[90vw] xl:max-w-[1400px]'
|
||||
contentHeight='min(72vh, 720px)'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
<>
|
||||
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleConfirm}>{t('Confirm Selection')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className='flex flex-1 flex-col gap-4 overflow-hidden'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='relative flex-1'>
|
||||
<Search className='text-muted-foreground absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2' />
|
||||
<Input
|
||||
placeholder={t('Search by name or URL...')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className='ps-8'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex-1 overflow-auto rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className='h-24 text-center'
|
||||
>
|
||||
{t('No channels found')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<DataTablePagination table={table} />
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
+141
-142
@@ -33,14 +33,6 @@ import {
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
@@ -51,6 +43,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { safeJsonParse } from '../utils/json-parser'
|
||||
|
||||
type GroupRatioVisualEditorProps = {
|
||||
@@ -677,25 +670,15 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
|
||||
/>
|
||||
|
||||
{/* Auto Group Dialog */}
|
||||
<Dialog open={autoGroupDialogOpen} onOpenChange={setAutoGroupDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Add auto group')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Add a group identifier to the auto assignment list.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='space-y-4 py-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label>{t('Group identifier')}</Label>
|
||||
<Input
|
||||
value={autoGroupInput}
|
||||
onChange={(e) => setAutoGroupInput(e.target.value)}
|
||||
placeholder={t('default')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Dialog
|
||||
open={autoGroupDialogOpen}
|
||||
onOpenChange={setAutoGroupDialogOpen}
|
||||
title={t('Add auto group')}
|
||||
description={t('Add a group identifier to the auto assignment list.')}
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => setAutoGroupDialogOpen(false)}
|
||||
@@ -703,30 +686,33 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleAutoGroupSave}>{t('Add')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className='space-y-4 py-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label>{t('Group identifier')}</Label>
|
||||
<Input
|
||||
value={autoGroupInput}
|
||||
onChange={(e) => setAutoGroupInput(e.target.value)}
|
||||
placeholder={t('default')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* User Group Dialog */}
|
||||
<Dialog open={userGroupDialogOpen} onOpenChange={setUserGroupDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Add user group')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Create a new user group to configure ratio overrides for.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='space-y-4 py-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label>{t('User group name')}</Label>
|
||||
<Input
|
||||
value={userGroupInput}
|
||||
onChange={(e) => setUserGroupInput(e.target.value)}
|
||||
placeholder={t('vip')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Dialog
|
||||
open={userGroupDialogOpen}
|
||||
onOpenChange={setUserGroupDialogOpen}
|
||||
title={t('Add user group')}
|
||||
description={t(
|
||||
'Create a new user group to configure ratio overrides for.'
|
||||
)}
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => setUserGroupDialogOpen(false)}
|
||||
@@ -734,8 +720,19 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleUserGroupSave}>{t('Add')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className='space-y-4 py-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label>{t('User group name')}</Label>
|
||||
<Input
|
||||
value={userGroupInput}
|
||||
onChange={(e) => setUserGroupInput(e.target.value)}
|
||||
placeholder={t('vip')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Group Override Dialog */}
|
||||
@@ -1016,51 +1013,52 @@ function SimpleGroupDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editData
|
||||
? t('Edit {{title}}', { title })
|
||||
: t('Add {{title}}', { title })}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Configure the ratio for this group.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='space-y-4 py-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label>{t('Group name')}</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t('default')}
|
||||
disabled={!!editData}
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label>{t('Ratio')}</Label>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
if (val === '' || !isNaN(parseFloat(val))) {
|
||||
setValue(val)
|
||||
}
|
||||
}}
|
||||
placeholder='1.0'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={
|
||||
editData
|
||||
? t('Edit {{title}}', { title })
|
||||
: t('Add {{title}}', { title })
|
||||
}
|
||||
description={t('Configure the ratio for this group.')}
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
<>
|
||||
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
{editData ? t('Update') : t('Add')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className='space-y-4 py-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label>{t('Group name')}</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t('default')}
|
||||
disabled={!!editData}
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label>{t('Ratio')}</Label>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
if (val === '' || !isNaN(parseFloat(val))) {
|
||||
setValue(val)
|
||||
}
|
||||
}}
|
||||
placeholder='1.0'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1107,65 +1105,66 @@ function GroupOverrideDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editData ? t('Edit ratio override') : t('Add ratio override')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{userGroup
|
||||
? t(
|
||||
'Configure a custom ratio for "{{userGroup}}" users when using a specific token group.',
|
||||
{ userGroup }
|
||||
)
|
||||
: t(
|
||||
'Configure a custom ratio for when users use a specific token group.'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='space-y-4 py-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label>{t('Target group')}</Label>
|
||||
<Input
|
||||
value={targetGroup}
|
||||
onChange={(e) => setTargetGroup(e.target.value)}
|
||||
placeholder={t('edit_this')}
|
||||
disabled={!!editData}
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('The token group that will have a custom ratio')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label>{t('Ratio')}</Label>
|
||||
<Input
|
||||
value={ratio}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
if (val === '' || !isNaN(parseFloat(val))) {
|
||||
setRatio(val)
|
||||
}
|
||||
}}
|
||||
placeholder='0.9'
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Multiplier applied when {{userGroup}} uses {{targetGroup}}', {
|
||||
userGroup: userGroup || t('this user group'),
|
||||
targetGroup: targetGroup || t('this token group'),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={editData ? t('Edit ratio override') : t('Add ratio override')}
|
||||
description={
|
||||
userGroup
|
||||
? t(
|
||||
'Configure a custom ratio for "{{userGroup}}" users when using a specific token group.',
|
||||
{ userGroup }
|
||||
)
|
||||
: t(
|
||||
'Configure a custom ratio for when users use a specific token group.'
|
||||
)
|
||||
}
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
<>
|
||||
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
{editData ? t('Update') : t('Add')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className='space-y-4 py-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label>{t('Target group')}</Label>
|
||||
<Input
|
||||
value={targetGroup}
|
||||
onChange={(e) => setTargetGroup(e.target.value)}
|
||||
placeholder={t('edit_this')}
|
||||
disabled={!!editData}
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('The token group that will have a custom ratio')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label>{t('Ratio')}</Label>
|
||||
<Input
|
||||
value={ratio}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
if (val === '' || !isNaN(parseFloat(val))) {
|
||||
setRatio(val)
|
||||
}
|
||||
}}
|
||||
placeholder='0.9'
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Multiplier applied when {{userGroup}} uses {{targetGroup}}', {
|
||||
userGroup: userGroup || t('this user group'),
|
||||
targetGroup: targetGroup || t('this token group'),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
+117
-123
@@ -22,14 +22,6 @@ import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -40,6 +32,7 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
|
||||
const rateLimitDialogSchema = z.object({
|
||||
groupName: z.string().min(1, 'Group name is required'),
|
||||
@@ -55,6 +48,8 @@ const rateLimitDialogSchema = z.object({
|
||||
|
||||
type RateLimitDialogFormValues = z.infer<typeof rateLimitDialogSchema>
|
||||
|
||||
const RATE_LIMIT_FORM_ID = 'rate-limit-form'
|
||||
|
||||
export type RateLimitEntryData = {
|
||||
groupName: string
|
||||
maxRequests: number
|
||||
@@ -105,126 +100,125 @@ export function RateLimitDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='sm:max-w-[500px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEditMode
|
||||
? t('Edit group rate limit')
|
||||
: t('Add group rate limit')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Configure rate limiting rules for a specific user group.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className='space-y-4'
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={
|
||||
isEditMode ? t('Edit group rate limit') : t('Add group rate limit')
|
||||
}
|
||||
description={t(
|
||||
'Configure rate limiting rules for a specific user group.'
|
||||
)}
|
||||
contentClassName='sm:max-w-[500px]'
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='groupName'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Group Name')}</FormLabel>
|
||||
<FormControl>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit' form={RATE_LIMIT_FORM_ID}>
|
||||
{isEditMode ? t('Update') : t('Add')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id={RATE_LIMIT_FORM_ID}
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className='space-y-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='groupName'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Group Name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('e.g., default, vip, premium')}
|
||||
{...field}
|
||||
disabled={isEditMode}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{isEditMode
|
||||
? t('Group name cannot be changed when editing.')
|
||||
: t('Unique identifier for this group.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='maxRequests'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Max Requests (including failures)')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Input
|
||||
placeholder={t('e.g., default, vip, premium')}
|
||||
type='number'
|
||||
min={0}
|
||||
max={2147483647}
|
||||
step={1}
|
||||
{...field}
|
||||
disabled={isEditMode}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseInt(e.target.value) || 0)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{isEditMode
|
||||
? t('Group name cannot be changed when editing.')
|
||||
: t('Unique identifier for this group.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{t('times')}
|
||||
</span>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Total requests allowed per period. 0 = unlimited.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='maxRequests'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('Max Requests (including failures)')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Input
|
||||
type='number'
|
||||
min={0}
|
||||
max={2147483647}
|
||||
step={1}
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseInt(e.target.value) || 0)
|
||||
}
|
||||
/>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{t('times')}
|
||||
</span>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Total requests allowed per period. 0 = unlimited.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='maxSuccess'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Max Successful Requests')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Input
|
||||
type='number'
|
||||
min={1}
|
||||
max={2147483647}
|
||||
step={1}
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseInt(e.target.value) || 1)
|
||||
}
|
||||
/>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{t('times')}
|
||||
</span>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Only successful requests count toward this limit.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit'>
|
||||
{isEditMode ? t('Update') : t('Add')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='maxSuccess'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Max Successful Requests')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Input
|
||||
type='number'
|
||||
min={1}
|
||||
max={2147483647}
|
||||
step={1}
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseInt(e.target.value) || 1)
|
||||
}
|
||||
/>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{t('times')}
|
||||
</span>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Only successful requests count toward this limit.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user