Some checks failed
Publish Docker image (Multi-arch) / Build & push (amd64) (push) Has been cancelled
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Has been cancelled
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Has been cancelled
Release (Linux, macOS, Windows) / Linux Release (push) Has been cancelled
Release (Linux, macOS, Windows) / macOS Release (push) Has been cancelled
Release (Linux, macOS, Windows) / Windows Release (push) Has been cancelled
- Add System Settings entry to Admin sidebar group with Settings icon - Register /system-settings URL mapping in sidebar config - Remove 3-click hidden unlock from Frontend Theme setting, making it directly visible in System Information section
388 lines
13 KiB
TypeScript
Vendored
388 lines
13 KiB
TypeScript
Vendored
import * as z from 'zod'
|
|
import type { Resolver } from 'react-hook-form'
|
|
import { zodResolver } from '@hookform/resolvers/zod'
|
|
import { RotateCcw } from 'lucide-react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
Form,
|
|
FormControl,
|
|
FormDescription,
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormMessage,
|
|
} from '@/components/ui/form'
|
|
import { Input } from '@/components/ui/input'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { FormDirtyIndicator } from '../components/form-dirty-indicator'
|
|
import { FormNavigationGuard } from '../components/form-navigation-guard'
|
|
import { SettingsSection } from '../components/settings-section'
|
|
import { useSettingsForm } from '../hooks/use-settings-form'
|
|
import { useUpdateOption } from '../hooks/use-update-option'
|
|
|
|
const _systemInfoSchema = z.object({
|
|
theme: z.object({
|
|
frontend: z.enum(['default', 'classic']),
|
|
}),
|
|
Notice: z.string().optional(),
|
|
SystemName: z.string().min(1),
|
|
ServerAddress: z.string().optional(),
|
|
Logo: z.string().url().optional().or(z.literal('')),
|
|
Footer: z.string().optional(),
|
|
About: z.string().optional(),
|
|
HomePageContent: z.string().optional(),
|
|
legal: z.object({
|
|
user_agreement: z.string().optional(),
|
|
privacy_policy: z.string().optional(),
|
|
}),
|
|
})
|
|
|
|
type SystemInfoFormValues = z.infer<typeof _systemInfoSchema>
|
|
|
|
type SystemInfoSectionProps = {
|
|
defaultValues: SystemInfoFormValues
|
|
}
|
|
|
|
function normalizeValue(value: unknown): string {
|
|
if (value === undefined || value === null) return ''
|
|
return typeof value === 'string' ? value : String(value)
|
|
}
|
|
|
|
export function SystemInfoSection({ defaultValues }: SystemInfoSectionProps) {
|
|
const { t } = useTranslation()
|
|
const updateOption = useUpdateOption()
|
|
|
|
const normalizedDefaults: SystemInfoFormValues = {
|
|
theme: {
|
|
frontend:
|
|
defaultValues.theme?.frontend === 'classic' ? 'classic' : 'default',
|
|
},
|
|
Notice: normalizeValue(defaultValues.Notice),
|
|
SystemName: normalizeValue(defaultValues.SystemName),
|
|
ServerAddress: normalizeValue(defaultValues.ServerAddress),
|
|
Logo: normalizeValue(defaultValues.Logo),
|
|
Footer: normalizeValue(defaultValues.Footer),
|
|
About: normalizeValue(defaultValues.About),
|
|
HomePageContent: normalizeValue(defaultValues.HomePageContent),
|
|
legal: {
|
|
user_agreement: normalizeValue(defaultValues.legal?.user_agreement),
|
|
privacy_policy: normalizeValue(defaultValues.legal?.privacy_policy),
|
|
},
|
|
}
|
|
|
|
const systemInfoSchemaWithI18n = z.object({
|
|
theme: z.object({
|
|
frontend: z.enum(['default', 'classic']),
|
|
}),
|
|
Notice: z.string().optional(),
|
|
SystemName: z.string().min(1, {
|
|
error: () => t('System name is required'),
|
|
}),
|
|
ServerAddress: z.string().optional(),
|
|
Logo: z.string().url().optional().or(z.literal('')),
|
|
Footer: z.string().optional(),
|
|
About: z.string().optional(),
|
|
HomePageContent: z.string().optional(),
|
|
legal: z.object({
|
|
user_agreement: z.string().optional(),
|
|
privacy_policy: z.string().optional(),
|
|
}),
|
|
})
|
|
|
|
const { form, handleSubmit, handleReset, isDirty, isSubmitting } =
|
|
useSettingsForm<SystemInfoFormValues>({
|
|
resolver: zodResolver(systemInfoSchemaWithI18n) as Resolver<
|
|
SystemInfoFormValues,
|
|
unknown,
|
|
SystemInfoFormValues
|
|
>,
|
|
defaultValues: normalizedDefaults,
|
|
onSubmit: async (_data, changedFields) => {
|
|
for (const [key, value] of Object.entries(changedFields)) {
|
|
let v = normalizeValue(value)
|
|
if (key === 'ServerAddress') {
|
|
v = v.replace(/\/+$/, '')
|
|
}
|
|
await updateOption.mutateAsync({
|
|
key,
|
|
value: v,
|
|
})
|
|
}
|
|
},
|
|
})
|
|
|
|
return (
|
|
<>
|
|
<FormNavigationGuard when={isDirty} />
|
|
|
|
<SettingsSection
|
|
title={t('System Information')}
|
|
description={t('Configure basic system information and branding')}
|
|
>
|
|
<Form {...form}>
|
|
<form onSubmit={handleSubmit} className='space-y-6'>
|
|
<FormDirtyIndicator isDirty={isDirty} />
|
|
<FormField
|
|
control={form.control}
|
|
name='theme.frontend'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Frontend Theme')}</FormLabel>
|
|
<Select onValueChange={field.onChange} value={field.value}>
|
|
<FormControl>
|
|
<SelectTrigger className='w-full'>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
<SelectItem value='default'>
|
|
{t('Default (New Frontend)')}
|
|
</SelectItem>
|
|
<SelectItem value='classic'>
|
|
{t('Classic (Legacy Frontend)')}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<FormDescription>
|
|
{t(
|
|
'Switch between the new frontend and the classic frontend. Changes take effect after page reload.'
|
|
)}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='Notice'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Notice')}</FormLabel>
|
|
<FormControl>
|
|
<Textarea
|
|
placeholder={t(
|
|
'Enter announcement content (supports Markdown & HTML)'
|
|
)}
|
|
rows={6}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t(
|
|
'Announcement displayed to users (supports Markdown & HTML)'
|
|
)}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='SystemName'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('System Name')}</FormLabel>
|
|
<FormControl>
|
|
<Input placeholder={t('New API')} {...field} />
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t('The name displayed across the application')}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='ServerAddress'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Server Address')}</FormLabel>
|
|
<FormControl>
|
|
<Input placeholder='https://yourdomain.com' {...field} />
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t(
|
|
'The public URL of your server, used for OAuth callbacks, webhooks, and other external integrations'
|
|
)}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='Logo'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Logo URL')}</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
placeholder={t('https://example.com/logo.png')}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t('URL to your logo image (optional)')}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='Footer'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Footer')}</FormLabel>
|
|
<FormControl>
|
|
<Textarea
|
|
placeholder={t(
|
|
'© 2025 Your Company. All rights reserved.'
|
|
)}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t('Footer text displayed at the bottom of pages')}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='About'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('About')}</FormLabel>
|
|
<FormControl>
|
|
<Textarea
|
|
placeholder={t(
|
|
'Enter HTML code (e.g., <p>About us...</p>) or a URL (e.g., https://example.com) to embed as iframe'
|
|
)}
|
|
rows={4}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t(
|
|
'Supports HTML markup or iframe embedding. Enter HTML code directly, or provide a complete URL to automatically embed it as an iframe.'
|
|
)}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='HomePageContent'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Home Page Content')}</FormLabel>
|
|
<FormControl>
|
|
<Textarea
|
|
placeholder={t('Welcome to our New API...')}
|
|
rows={6}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t(
|
|
'Content displayed on the home page (supports Markdown)'
|
|
)}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='legal.user_agreement'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('User Agreement')}</FormLabel>
|
|
<FormControl>
|
|
<Textarea
|
|
placeholder={t(
|
|
'Provide Markdown, HTML, or an external URL for the user agreement'
|
|
)}
|
|
rows={6}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t(
|
|
'Leave empty to disable the agreement requirement. Supports Markdown, HTML, or a full URL to redirect users.'
|
|
)}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='legal.privacy_policy'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Privacy Policy')}</FormLabel>
|
|
<FormControl>
|
|
<Textarea
|
|
placeholder={t(
|
|
'Provide Markdown, HTML, or an external URL for the privacy policy'
|
|
)}
|
|
rows={6}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t(
|
|
'Leave empty to disable the privacy policy requirement. Supports Markdown, HTML, or a full URL to redirect users.'
|
|
)}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<div className='flex gap-2'>
|
|
<Button
|
|
type='submit'
|
|
disabled={isSubmitting || updateOption.isPending}
|
|
>
|
|
{updateOption.isPending ? t('Saving...') : t('Save Changes')}
|
|
</Button>
|
|
<Button
|
|
type='button'
|
|
variant='outline'
|
|
onClick={handleReset}
|
|
disabled={!isDirty || updateOption.isPending || isSubmitting}
|
|
>
|
|
<RotateCcw className='mr-2 h-4 w-4' />
|
|
{t('Reset')}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Form>
|
|
</SettingsSection>
|
|
</>
|
|
)
|
|
}
|