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:
QuentinHsu
2026-06-06 21:49:33 +08:00
parent 7a5348caa3
commit 2eaa943d9f
80 changed files with 8137 additions and 8517 deletions
@@ -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>
)
}
@@ -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}>
@@ -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}>
@@ -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>
)
}
@@ -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')}
@@ -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&#10;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&#10;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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
</>
)
@@ -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>
</>
)
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}