feat: add multiple color themes to appearance settings

This commit is contained in:
rustmailer
2026-05-10 04:11:43 +08:00
parent 41322c8357
commit c07e39495a
5 changed files with 811 additions and 214 deletions
@@ -1,22 +1,3 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { CaretSortIcon, CheckIcon } from '@radix-ui/react-icons'
@@ -32,8 +13,8 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { useTheme } from '@/context/theme-context'
import { RadioGroup } from '@/components/ui/radio-group'
import { useTheme, Theme } from '@/context/theme-context'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { useTranslation } from 'react-i18next'
@@ -42,7 +23,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import { update_user } from '@/api/users/api'
import { toast } from '@/hooks/use-toast'
import { AxiosError } from 'axios'
import { Card } from '@/components/ui/card'
const languages = [
{ value: 'ar', label: 'العربية' },
@@ -65,8 +46,28 @@ const languages = [
{ value: 'zh-tw', label: '繁體中文' },
]
const LIGHT_THEMES = [
{ value: 'light', labelKey: 'theme.default', background: '#ffffff', color: '#0f172a', muted: '#cbd5e1' },
{ value: 'rose-light', labelKey: 'theme.rose', background: '#fff1f2', color: '#e11d48', muted: '#fda4af' },
{ value: 'orange-light', labelKey: 'theme.orange', background: '#fff7ed', color: '#ea580c', muted: '#fdba74' },
{ value: 'green-light', labelKey: 'theme.green', background: '#f0fdf4', color: '#16a34a', muted: '#86efac' },
{ value: 'yellow-light', labelKey: 'theme.yellow', background: '#fefce8', color: '#ca8a04', muted: '#fde047' },
{ value: 'blue-light', labelKey: 'theme.blue', background: '#eff6ff', color: '#2563eb', muted: '#bfdbfe' },
] as const
const DARK_THEMES = [
{ value: 'dark', labelKey: 'theme.default', background: '#020817', color: '#94a3b8', muted: '#334155' },
{ value: 'rose-dark', labelKey: 'theme.rose', background: '#1f1115', color: '#e11d48', muted: '#881337' },
{ value: 'orange-dark', labelKey: 'theme.orange', background: '#1c120d', color: '#f97316', muted: '#9a3412' },
{ value: 'green-dark', labelKey: 'theme.green', background: '#0d1b12', color: '#22c55e', muted: '#166534' },
{ value: 'yellow-dark', labelKey: 'theme.yellow', background: '#1c1608', color: '#facc15', muted: '#a16207' },
{ value: 'blue-dark', labelKey: 'theme.blue', background: '#172554', color: '#60a5fa', muted: '#1e3a8a' }
] as const
const ALL_THEME_VALUES = [...LIGHT_THEMES.map(t => t.value), ...DARK_THEMES.map(t => t.value)] as [string, ...string[]]
const appearanceSchema = (t: (key: string) => string) => z.object({
theme: z.enum(['light', 'dark'], {
theme: z.enum(ALL_THEME_VALUES, {
required_error: t('settings.appearance.validation.theme.required'),
}),
language: z.string({
@@ -76,19 +77,30 @@ const appearanceSchema = (t: (key: string) => string) => z.object({
type AppearanceFormValues = z.infer<ReturnType<typeof appearanceSchema>>
function ThemePreview({ background, primary, muted }: { background: string; primary: string; muted: string }) {
return (
<div className='flex h-12 w-full flex-col gap-1.5 rounded-md border p-2 shadow-sm' style={{ backgroundColor: background, borderColor: 'transparent' }}>
<div className='flex gap-1'>
<span className='h-1.5 w-1.5 rounded-full' style={{ backgroundColor: muted }} />
<span className='h-1.5 w-1.5 rounded-full' style={{ backgroundColor: primary }} />
</div>
<div className='h-1.5 w-3/4 rounded-sm' style={{ backgroundColor: primary }} />
<div className='h-1.5 w-1/2 rounded-sm opacity-50' style={{ backgroundColor: muted }} />
</div>
)
}
export function AppearanceForm() {
const { data: user } = useCurrentUser();
const queryClient = useQueryClient();
const { t, i18n } = useTranslation();
const { theme, setTheme } = useTheme();
const { data: user } = useCurrentUser()
const queryClient = useQueryClient()
const { t, i18n } = useTranslation()
const { theme, setTheme } = useTheme()
const form = useForm<AppearanceFormValues>({
resolver: zodResolver(appearanceSchema(t)),
mode: 'onChange',
defaultValues: {
theme: (theme as 'light' | 'dark') || 'light',
theme: (theme as any) || 'light',
language: i18n.language || 'en',
},
})
@@ -108,21 +120,18 @@ export function AppearanceForm() {
description: (err.response?.data as any)?.message || err.message,
})
},
});
})
function onSubmit(data: AppearanceFormValues) {
i18n.changeLanguage(data.language);
setTheme(data.theme);
mutation.mutate(data);
i18n.changeLanguage(data.language)
setTheme(data.theme as Theme)
mutation.mutate(data)
}
return (
<div className="w-full max-w-6xl ml-0 px-4">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8 w-full max-w-screen-xl mx-auto px-4 md:px-6"
>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-10 w-full max-w-screen-xl mx-auto px-4 md:px-6">
<FormField
control={form.control}
name='language'
@@ -182,66 +191,61 @@ export function AppearanceForm() {
</FormItem>
)}
/>
<FormField
control={form.control}
name='theme'
render={({ field }) => (
<FormItem className="space-y-1">
<FormLabel>{t('settings.appearance.field.theme')}</FormLabel>
<FormDescription>
{t('settings.appearance.description.theme')}
</FormDescription>
<FormMessage />
<FormItem className="space-y-4">
<div>
<FormLabel className="text-base">{t('settings.appearance.field.theme')}</FormLabel>
<FormDescription>{t('settings.appearance.description.theme')}</FormDescription>
</div>
<RadioGroup
onValueChange={field.onChange}
onValueChange={(v) => {
setTheme(v as Theme);
field.onChange(v)
}}
defaultValue={field.value}
className='grid max-w-md grid-cols-2 gap-8 pt-2'
className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4 pt-2'
>
<FormItem>
<FormLabel className='[&:has([data-state=checked])>div]:border-primary cursor-pointer'>
<FormControl>
<RadioGroupItem value='light' className='sr-only' />
</FormControl>
<div className='items-center rounded-md border-2 border-muted p-1 hover:border-accent'>
<div className='space-y-2 rounded-sm bg-[#ecedef] p-2'>
<div className='space-y-2 rounded-md bg-white p-2 shadow-sm'>
<div className='h-2 w-[80px] rounded-lg bg-[#ecedef]' />
<div className='h-2 w-[100px] rounded-lg bg-[#ecedef]' />
</div>
<div className='flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm'>
<div className='h-4 w-4 rounded-full bg-[#ecedef]' />
<div className='h-2 w-[100px] rounded-lg bg-[#ecedef]' />
{[...LIGHT_THEMES, ...DARK_THEMES].map((item) => {
const selected = field.value === item.value
return (
<Card
key={item.value}
onClick={() => {
setTheme(item.value as Theme)
field.onChange(item.value)
}}
className={cn(
"cursor-pointer overflow-hidden border-2 transition-all hover:shadow-md",
selected ? "border-primary shadow-md" : "border-muted opacity-80"
)}
>
<div className="p-2">
<ThemePreview
background={item.background}
primary={item.color}
muted={item.muted}
/>
<div className="mt-2 flex items-center justify-between">
<span className="text-[11px] font-medium uppercase truncate">
{t(item.labelKey)}
</span>
{selected && (
<CheckIcon className="h-3.5 w-3.5 text-primary" />
)}
</div>
</div>
</div>
<span className='block w-full p-2 text-center font-normal'>
{t('settings.appearance.theme.light')}
</span>
</FormLabel>
</FormItem>
<FormItem>
<FormLabel className='[&:has([data-state=checked])>div]:border-primary cursor-pointer'>
<FormControl>
<RadioGroupItem value='dark' className='sr-only' />
</FormControl>
<div className='items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground'>
<div className='space-y-2 rounded-sm bg-slate-950 p-2'>
<div className='space-y-2 rounded-md bg-slate-800 p-2 shadow-sm'>
<div className='h-2 w-[80px] rounded-lg bg-slate-400' />
<div className='h-2 w-[100px] rounded-lg bg-slate-400' />
</div>
<div className='flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm'>
<div className='h-4 w-4 rounded-full bg-slate-400' />
<div className='h-2 w-[100px] rounded-lg bg-slate-400' />
</div>
</div>
</div>
<span className='block w-full p-2 text-center font-normal'>
{t('settings.appearance.theme.dark')}
</span>
</FormLabel>
</FormItem>
</Card>
)
})}
</RadioGroup>
<FormMessage />
</FormItem>
)}
/>