CaIon b2232f4355
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
feat(ui): add system settings to sidebar and unhide frontend theme switcher
- 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
2026-04-29 20:09:07 +08:00

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>
</>
)
}