577 lines
18 KiB
TypeScript

'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@workspace/ui/components/accordion';
import { Button } from '@workspace/ui/components/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@workspace/ui/components/form';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@workspace/ui/components/select';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { Switch } from '@workspace/ui/components/switch';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { Icon } from '@workspace/ui/custom-components/icon';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { toast } from 'sonner';
import {
FieldConfig,
formSchema,
getLabel,
getProtocolDefaultConfig,
PROTOCOL_FIELDS,
protocols as PROTOCOLS,
ServerFormValues,
} from './form-schema';
function titleCase(s: string) {
return s.charAt(0).toUpperCase() + s.slice(1);
}
function DynamicField({
field,
control,
protocolIndex,
protocolData,
t,
}: {
field: FieldConfig;
control: any;
protocolIndex: number;
protocolData: any;
t: (key: string) => string;
}) {
const fieldName = `protocols.${protocolIndex}.${field.name}` as const;
if (field.condition && !field.condition(protocolData, {})) {
return null;
}
const commonProps = {
control,
name: fieldName,
};
switch (field.type) {
case 'input':
return (
<FormField
{...commonProps}
render={({ field: fieldProps }) => (
<FormItem>
<FormLabel>{t(field.label)}</FormLabel>
<FormControl>
<EnhancedInput
{...fieldProps}
placeholder={
field.placeholder
? typeof field.placeholder === 'function'
? field.placeholder(t, protocolData)
: field.placeholder
: undefined
}
onValueChange={(v) => fieldProps.onChange(v)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
case 'number':
return (
<FormField
{...commonProps}
render={({ field: fieldProps }) => (
<FormItem>
<FormLabel>{t(field.label)}</FormLabel>
<FormControl>
<EnhancedInput
{...fieldProps}
type='number'
min={field.min}
max={field.max}
step={field.step || 1}
suffix={field.suffix}
placeholder={field.placeholder as string}
onValueChange={(v) => fieldProps.onChange(v)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
case 'select':
if (!field.options || field.options.length <= 1) {
return null;
}
return (
<FormField
{...commonProps}
render={({ field: fieldProps }) => (
<FormItem>
<FormLabel>{t(field.label)}</FormLabel>
<FormControl>
<Select
value={fieldProps.value ?? field.defaultValue}
onValueChange={(v) => fieldProps.onChange(v)}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('please_select')} />
</SelectTrigger>
</FormControl>
<SelectContent>
{field.options?.map((option) => (
<SelectItem key={option} value={option}>
{getLabel(option)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
case 'switch':
return (
<FormField
{...commonProps}
render={({ field: fieldProps }) => (
<FormItem>
<FormLabel>{t(field.label)}</FormLabel>
<FormControl>
<div className='pt-2'>
<Switch
checked={!!fieldProps.value}
onCheckedChange={(checked) => fieldProps.onChange(checked)}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
case 'textarea':
return (
<FormField
{...commonProps}
render={({ field: fieldProps }) => (
<FormItem className='col-span-2'>
<FormLabel>{t(field.label)}</FormLabel>
<FormControl>
<textarea
{...fieldProps}
className='border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50'
placeholder={
field.placeholder
? typeof field.placeholder === 'function'
? field.placeholder(t, protocolData)
: t(field.placeholder)
: undefined
}
onChange={(e) => fieldProps.onChange(e.target.value)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
default:
return null;
}
}
function renderFieldsByGroup(
fields: FieldConfig[],
group: string,
control: any,
protocolIndex: number,
protocolData: any,
t: (key: string) => string,
) {
const groupFields = fields.filter((field) => field.group === group);
if (groupFields.length === 0) return null;
return (
<div className='grid grid-cols-2 gap-4'>
{groupFields.map((field) => (
<DynamicField
key={field.name}
field={field}
control={control}
protocolIndex={protocolIndex}
protocolData={protocolData}
t={t}
/>
))}
</div>
);
}
function renderGroupCard(
title: string,
fields: FieldConfig[],
group: string,
control: any,
protocolIndex: number,
protocolData: any,
t: (key: string) => string,
) {
const groupFields = fields.filter((field) => field.group === group);
if (groupFields.length === 0) return null;
const visibleFields = groupFields.filter(
(field) => !field.condition || field.condition(protocolData, {}),
);
if (visibleFields.length === 0) return null;
return (
<div className='relative'>
<fieldset className='border-border rounded-lg border'>
<legend className='text-foreground bg-background ml-3 px-1 py-1 text-sm font-medium'>
{t(title)}
</legend>
<div className='p-4 pt-2'>
{renderFieldsByGroup(fields, group, control, protocolIndex, protocolData, t)}
</div>
</fieldset>
</div>
);
}
export default function ServerForm(props: {
trigger: string;
title: string;
loading?: boolean;
initialValues?: Partial<ServerFormValues>;
onSubmit: (values: ServerFormValues) => Promise<boolean> | boolean;
}) {
const { trigger, title, loading, initialValues, onSubmit } = props;
const t = useTranslations('servers');
const [open, setOpen] = useState(false);
const [protocolsEnabled, setProtocolsEnabled] = useState<string[]>([]);
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
address: '',
country: '',
city: '',
ratio: 1,
protocols: [],
...initialValues,
},
});
const { control } = form;
const protocolsValues = useWatch({ control, name: 'protocols' });
useEffect(() => {
if (initialValues) {
const enabledProtocols = PROTOCOLS.filter((type) => {
const protocol = initialValues.protocols?.find((p) => p.type === type);
return protocol && protocol.port && Number(protocol.port) > 0;
});
setProtocolsEnabled(enabledProtocols);
form.reset({
name: '',
address: '',
country: '',
city: '',
ratio: 1,
...initialValues,
protocols: PROTOCOLS.map((type) => {
const existingProtocol = initialValues.protocols?.find((p) => p.type === type);
return existingProtocol || getProtocolDefaultConfig(type);
}),
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialValues]);
async function handleSubmit(values: Record<string, any>) {
const filtered = (values?.protocols || [])
.filter((p: any, index: number) => {
const port = Number(p?.port);
const protocolType = PROTOCOLS[index];
return (
protocolType &&
protocolsEnabled.includes(protocolType) &&
Number.isFinite(port) &&
port > 0 &&
port <= 65535
);
})
.map((p: any) => ({ ...p, port: Number(p.port) }));
if (filtered.length === 0) {
toast.error(t('validation_failed'));
return;
}
const result = {
name: values.name,
country: values.country,
city: values.city,
ratio: Number(values.ratio || 1),
address: values.address,
protocols: filtered,
};
const ok = await onSubmit(result);
if (ok) {
form.reset();
setOpen(false);
}
}
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button
onClick={() => {
if (!initialValues) {
const full = PROTOCOLS.map((t) => getProtocolDefaultConfig(t));
form.reset({
name: '',
address: '',
country: '',
city: '',
ratio: 1,
protocols: full,
});
setProtocolsEnabled([]);
}
setOpen(true);
}}
>
{trigger}
</Button>
</SheetTrigger>
<SheetContent className='w-[580px] max-w-full md:max-w-screen-md'>
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))]'>
<Form {...form}>
<form className='grid grid-cols-1 gap-2 px-6 pt-4'>
<div className='grid grid-cols-3 gap-2'>
<FormField
control={control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('name')}</FormLabel>
<FormControl>
<EnhancedInput {...field} onValueChange={(v) => field.onChange(v)} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name='country'
render={({ field }) => (
<FormItem>
<FormLabel>{t('country')}</FormLabel>
<FormControl>
<EnhancedInput {...field} onValueChange={(v) => field.onChange(v)} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name='city'
render={({ field }) => (
<FormItem>
<FormLabel>{t('city')}</FormLabel>
<FormControl>
<EnhancedInput {...field} onValueChange={(v) => field.onChange(v)} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='grid grid-cols-2 gap-2'>
<FormField
control={control}
name='address'
render={({ field }) => (
<FormItem>
<FormLabel>{t('address')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
placeholder={t('address_placeholder')}
onValueChange={(v) => field.onChange(v)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name='ratio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('traffic_ratio')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
type='number'
step={0.1}
min={0}
onValueChange={(v) => field.onChange(v)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='my-3'>
<h3 className='text-foreground text-sm font-semibold'>
{t('protocol_configurations')}
</h3>
<p className='text-muted-foreground mt-1 text-xs'>
{t('protocol_configurations_desc')}
</p>
</div>
<Accordion type='single' className='w-full space-y-3'>
{PROTOCOLS.map((type) => {
const i = Math.max(
0,
PROTOCOLS.findIndex((t) => t === type),
);
const current = Array.isArray(protocolsValues) ? protocolsValues[i] || {} : {};
const isEnabled = protocolsEnabled.includes(type);
const fields = PROTOCOL_FIELDS[type] || [];
return (
<AccordionItem key={type} value={type} className='mb-2 rounded-lg border'>
<AccordionTrigger className='px-4 py-3 hover:no-underline'>
<div className='flex w-full items-center justify-between'>
<div className='flex flex-col items-start'>
<span className='font-medium'>{titleCase(type)}</span>
<span className='text-muted-foreground text-xs'>
{isEnabled ? t('enabled') : t('disabled')}
</span>
</div>
<div className='flex items-center gap-3'>
{isEnabled && (
<div className='flex h-2 w-2 rounded-full bg-green-500'></div>
)}
<Switch
className='mr-2'
onClick={(e) => e.stopPropagation()}
checked={isEnabled}
onCheckedChange={(checked) => {
if (checked) {
setProtocolsEnabled([...protocolsEnabled, type]);
} else {
setProtocolsEnabled(protocolsEnabled.filter((p) => p !== type));
}
}}
/>
</div>
</div>
</AccordionTrigger>
{isEnabled && (
<AccordionContent className='px-4 pb-4 pt-0'>
<div className='-mx-4 space-y-4 rounded-b-lg border-t px-4 pt-4'>
{renderGroupCard('basic', fields, 'basic', control, i, current, t)}
{renderGroupCard('plugin', fields, 'plugin', control, i, current, t)}
{renderGroupCard(
'transport',
fields,
'transport',
control,
i,
current,
t,
)}
{renderGroupCard(
'security',
fields,
'security',
control,
i,
current,
t,
)}
{renderGroupCard('reality', fields, 'reality', control, i, current, t)}
</div>
</AccordionContent>
)}
</AccordionItem>
);
})}
</Accordion>
</form>
</Form>
</ScrollArea>
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
{t('cancel')}
</Button>
<Button
disabled={loading}
onClick={form.handleSubmit(handleSubmit, (errors) => {
console.log(errors, form.getValues());
const key = Object.keys(errors)[0] as keyof typeof errors;
if (key) toast.error(String(errors[key]?.message));
return false;
})}
>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />} {t('confirm')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}