mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
feat: add multiple color themes to appearance settings
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user