feat: support user appearance preferences with persisted theme and language #85

This commit is contained in:
rustmailer
2025-12-30 15:09:41 +08:00
parent 75cae51be9
commit 455e6b1a75
39 changed files with 1080 additions and 83 deletions
+31 -25
View File
@@ -14,33 +14,33 @@ export interface UserRole {
}
export function getPermissions(t: (key: string) => string) {
return [
// 1. Global Management
{ label: t('permission.system.access'), value: 'system:access' },
{ label: t('permission.system.root'), value: 'system:root' },
{ label: t('permission.user.manage'), value: 'user:manage' },
{ label: t('permission.user.view'), value: 'user:view' },
{ label: t('permission.token.manage'), value: 'token:manage' },
{ label: t('permission.account.create'), value: 'account:create' },
return [
// 1. Global Management
{ label: t('permission.system.access'), value: 'system:access' },
{ label: t('permission.system.root'), value: 'system:root' },
{ label: t('permission.user.manage'), value: 'user:manage' },
{ label: t('permission.user.view'), value: 'user:view' },
{ label: t('permission.token.manage'), value: 'token:manage' },
{ label: t('permission.account.create'), value: 'account:create' },
// 2. Global "ALL" Scoped (Admin)
{ label: t('permission.account.manage_all'), value: 'account:manage:all' },
{ label: t('permission.data.read_all'), value: 'data:read:all' },
{ label: t('permission.data.manage_all'), value: 'data:manage:all' },
{ label: t('permission.data.raw_download_all'), value: 'data:raw:download:all' },
{ label: t('permission.data.delete_all'), value: 'data:delete:all' },
{ label: t('permission.data.export_batch_all'), value: 'data:export:batch:all' },
// 2. Global "ALL" Scoped (Admin)
{ label: t('permission.account.manage_all'), value: 'account:manage:all' },
{ label: t('permission.data.read_all'), value: 'data:read:all' },
{ label: t('permission.data.manage_all'), value: 'data:manage:all' },
{ label: t('permission.data.raw_download_all'), value: 'data:raw:download:all' },
{ label: t('permission.data.delete_all'), value: 'data:delete:all' },
{ label: t('permission.data.export_batch_all'), value: 'data:export:batch:all' },
// 3. Scoped / Limited
{ label: t('permission.account.manage'), value: 'account:manage' },
{ label: t('permission.account.read_details'), value: 'account:read_details' },
{ label: t('permission.data.read'), value: 'data:read' },
{ label: t('permission.data.manage'), value: 'data:manage' },
{ label: t('permission.data.raw_download'), value: 'data:raw:download' },
{ label: t('permission.data.delete'), value: 'data:delete' },
{ label: t('permission.data.export_batch'), value: 'data:export:batch' },
{ label: t('permission.data.import_batch'), value: 'data:import:batch' },
]
// 3. Scoped / Limited
{ label: t('permission.account.manage'), value: 'account:manage' },
{ label: t('permission.account.read_details'), value: 'account:read_details' },
{ label: t('permission.data.read'), value: 'data:read' },
{ label: t('permission.data.manage'), value: 'data:manage' },
{ label: t('permission.data.raw_download'), value: 'data:raw:download' },
{ label: t('permission.data.delete'), value: 'data:delete' },
{ label: t('permission.data.export_batch'), value: 'data:export:batch' },
{ label: t('permission.data.import_batch'), value: 'data:import:batch' },
]
}
export interface RateLimit {
@@ -85,10 +85,16 @@ export interface User {
created_at: number;
updated_at: number;
}
type Theme = 'dark' | 'light'
export interface LoginResult {
success: boolean;
error_message?: string | null;
access_token?: string | null;
theme?: Theme,
language?: string,
}
@@ -43,6 +43,7 @@ import { useTranslation } from 'react-i18next'
import i18n from '@/i18n'
import { Loader2, LogIn } from 'lucide-react'
import { login } from '@/api/users/api'
import { useTheme } from '@/context/theme-context'
type UserAuthFormProps = HTMLAttributes<HTMLDivElement>
@@ -59,6 +60,7 @@ const getFormSchema = (t: (key: string, options?: Record<string, any>) => string
export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
const [isLoading, setIsLoading] = useState(false)
const { setTheme } = useTheme();
const navigate = useNavigate()
const { t } = useTranslation()
@@ -86,6 +88,15 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
onSuccess: (result) => {
if (result.success) {
setToken(result);
if (result.theme) {
setTheme(result.theme);
}
if (result.language) {
i18n.changeLanguage(result.language);
}
navigate({ to: redirect });
} else {
toast({
@@ -0,0 +1,239 @@
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { CaretSortIcon, CheckIcon } from '@radix-ui/react-icons'
import { zodResolver } from '@hookform/resolvers/zod'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { useTheme } 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'
import { useCurrentUser } from '@/hooks/use-current-user'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { update_user } from '@/api/users/api'
import { toast } from '@/hooks/use-toast'
import { AxiosError } from 'axios'
const languages = [
{ value: 'ar', label: 'العربية' },
{ value: 'da', label: 'Dansk' },
{ value: 'de', label: 'Deutsch' },
{ value: 'en', label: 'English' },
{ value: 'es', label: 'Español' },
{ value: 'fi', label: 'Suomi' },
{ value: 'fr', label: 'Français' },
{ value: 'it', label: 'Italiano' },
{ value: 'jp', label: '日本語' },
{ value: 'ko', label: '한국어' },
{ value: 'nl', label: 'Nederlands' },
{ value: 'no', label: 'Norsk' },
{ value: 'pl', label: 'Polski' },
{ value: 'pt', label: 'Português' },
{ value: 'ru', label: 'Русский' },
{ value: 'sv', label: 'Svenska' },
{ value: 'zh', label: '中文' },
{ value: 'zh-tw', label: '繁體中文' },
]
const appearanceSchema = (t: (key: string) => string) => z.object({
theme: z.enum(['light', 'dark'], {
required_error: t('settings.appearance.validation.theme.required'),
}),
language: z.string({
required_error: t('settings.appearance.validation.language.required'),
})
})
type AppearanceFormValues = z.infer<ReturnType<typeof appearanceSchema>>
export function AppearanceForm() {
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',
language: i18n.language || 'en',
},
})
const mutation = useMutation({
mutationFn: async (values: AppearanceFormValues) => {
return update_user(user!.id, values)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['current-user'] })
toast({ title: t('settings.profile.toast.updated') })
},
onError: (err: AxiosError) => {
toast({
variant: 'destructive',
title: t('settings.profile.toast.update_failed'),
description: (err.response?.data as any)?.message || err.message,
})
},
});
function onSubmit(data: AppearanceFormValues) {
i18n.changeLanguage(data.language);
setTheme(data.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"
>
<FormField
control={form.control}
name='language'
render={({ field }) => (
<FormItem className='flex flex-col'>
<FormLabel>{t('settings.appearance.field.language')}</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant='outline'
role='combobox'
className={cn(
'w-[400px] justify-between',
!field.value && 'text-muted-foreground'
)}
>
{field.value
? languages.find((l) => l.value === field.value)?.label
: t('settings.appearance.placeholder.select_language')}
<CaretSortIcon className='ms-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className='w-[400px] p-0' align="start">
<Command>
<CommandInput placeholder={t('settings.appearance.command.search')} />
<CommandEmpty>{t('settings.appearance.command.no_results')}</CommandEmpty>
<CommandList>
<CommandGroup>
{languages.map((language) => (
<CommandItem
value={language.label}
key={language.value}
onSelect={() => {
form.setValue('language', language.value)
}}
>
<CheckIcon
className={cn(
'mr-2 h-4 w-4',
language.value === field.value ? 'opacity-100' : 'opacity-0'
)}
/>
{language.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
{t('settings.appearance.description.language')}
</FormDescription>
<FormMessage />
</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 />
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className='grid max-w-md grid-cols-2 gap-8 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]' />
</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>
</RadioGroup>
</FormItem>
)}
/>
<div className="flex justify-start pt-4">
<Button type='submit'>
{t('settings.appearance.button.update')}
</Button>
</div>
</form>
</Form>
</div>
)
}
@@ -0,0 +1,7 @@
import { AppearanceForm } from './appearance-form'
export function SettingsAppearance() {
return (
<AppearanceForm />
)
}
+6 -1
View File
@@ -20,7 +20,7 @@
import { Outlet } from '@tanstack/react-router'
import { Main } from '@/components/layout/main'
import SidebarNav from './components/sidebar-nav'
import { KeyRound, SettingsIcon, UserCog, Waypoints } from 'lucide-react'
import { KeyRound, Palette, SettingsIcon, UserCog, Waypoints } from 'lucide-react'
import { FixedHeader } from '@/components/layout/fixed-header'
import { useCurrentUser } from '@/hooks/use-current-user'
import { useTranslation } from 'react-i18next'
@@ -36,6 +36,11 @@ export default function Settings() {
href: '/settings/profile',
icon: <UserCog size={18} />,
},
{
title: t('settings.appearance.title'),
href: '/settings/appearance',
icon: <Palette size={18} />,
},
{
title: t('settings.sidebar.apiTokens'),
href: '/settings/api-tokens',
+25
View File
@@ -718,6 +718,31 @@
"interval": "الفاصل الزمني"
},
"settings": {
"appearance": {
"title": "المظهر",
"field": {
"language": "لغة الواجهة",
"theme": "سمة الواجهة"
},
"description": {
"language": "اختر اللغة المستخدمة في واجهة الويب.",
"theme": "اختر بين الوضع الفاتح أو الداكن."
},
"placeholder": {
"select_language": "اختر اللغة"
},
"command": {
"search": "بحث عن لغة...",
"no_results": "لم يتم العثور على لغة."
},
"theme": {
"light": "فاتح",
"dark": "داكن"
},
"button": {
"update": "تحديث التفضيلات"
}
},
"title": "الإعدادات",
"general": "عام",
"proxy": "وكيل",
+33
View File
@@ -718,6 +718,39 @@
"interval": "Interval"
},
"settings": {
"appearance": {
"title": "Udseende",
"field": {
"language": "Grænsefladesprog",
"theme": "Grænsefladetema"
},
"description": {
"language": "Vælg det sprog, der skal bruges i webbrugerfladen.",
"theme": "Vælg mellem lys eller mørk tilstand for brugerfladen."
},
"placeholder": {
"select_language": "Vælg sprog"
},
"command": {
"search": "Søg efter sprog...",
"no_results": "Intet sprog fundet."
},
"theme": {
"light": "Lys",
"dark": "Mørk"
},
"button": {
"update": "Opdater indstillinger"
},
"validation": {
"language": {
"required": "Vælg venligst et sprog."
},
"theme": {
"required": "Vælg venligst et tema."
}
}
},
"title": "Indstillinger",
"general": "Generelt",
"proxy": "Proxy",
+25
View File
@@ -718,6 +718,31 @@
"interval": "Intervall"
},
"settings": {
"appearance": {
"title": "Erscheinungsbild",
"field": {
"language": "Sprache der Benutzeroberfläche",
"theme": "Design"
},
"description": {
"language": "Wählen Sie die Sprache für die Web-Oberfläche.",
"theme": "Wählen Sie zwischen hellem oder dunklem Modus."
},
"placeholder": {
"select_language": "Sprache wählen"
},
"command": {
"search": "Sprache suchen...",
"no_results": "Keine Sprache gefunden."
},
"theme": {
"light": "Hell",
"dark": "Dunkel"
},
"button": {
"update": "Einstellungen aktualisieren"
}
},
"title": "Einstellungen",
"general": "Allgemein",
"proxy": "Proxy",
+33
View File
@@ -718,6 +718,39 @@
"interval": "Interval"
},
"settings": {
"appearance": {
"title": "Appearance",
"field": {
"language": "Language",
"theme": "Interface Theme"
},
"description": {
"language": "Select the language used in the Web UI.",
"theme": "Choose between light or dark mode for the interface."
},
"placeholder": {
"select_language": "Select language"
},
"command": {
"search": "Search language...",
"no_results": "No language found."
},
"theme": {
"light": "Light",
"dark": "Dark"
},
"button": {
"update": "Update preferences"
},
"validation": {
"language": {
"required": "Please select a language."
},
"theme": {
"required": "Please select a theme."
}
}
},
"title": "Settings",
"general": "General",
"proxy": "Proxy",
+25
View File
@@ -718,6 +718,31 @@
"interval": "Intervalo"
},
"settings": {
"appearance": {
"title": "Apariencia",
"field": {
"language": "Idioma de la interfaz",
"theme": "Tema de la interfaz"
},
"description": {
"language": "Seleccione el idioma de la interfaz web.",
"theme": "Elija entre el modo claro u oscuro."
},
"placeholder": {
"select_language": "Seleccionar idioma"
},
"command": {
"search": "Buscar idioma...",
"no_results": "No se encontró el idioma."
},
"theme": {
"light": "Claro",
"dark": "Oscuro"
},
"button": {
"update": "Actualizar preferencias"
}
},
"title": "Configuración",
"general": "General",
"proxy": "Proxy",
+33
View File
@@ -718,6 +718,39 @@
"interval": "Väli"
},
"settings": {
"appearance": {
"title": "Ulkoasu",
"field": {
"language": "Käyttöliittymän kieli",
"theme": "Käyttöliittymän teema"
},
"description": {
"language": "Valitse Web-käyttöliittymässä käytettävä kieli.",
"theme": "Valitse käyttöliittymän vaalea tai tumma tila."
},
"placeholder": {
"select_language": "Valitse kieli"
},
"command": {
"search": "Hae kieltä...",
"no_results": "Kieltä ei löytynyt."
},
"theme": {
"light": "Vaalea",
"dark": "Tumma"
},
"button": {
"update": "Päivitä asetukset"
},
"validation": {
"language": {
"required": "Valitse kieli."
},
"theme": {
"required": "Valitse teema."
}
}
},
"title": "Asetukset",
"general": "Yleinen",
"proxy": "Välityspalvelin",
+25
View File
@@ -718,6 +718,31 @@
"interval": "Intervalle"
},
"settings": {
"appearance": {
"title": "Apparence",
"field": {
"language": "Langue de l'interface",
"theme": "Thème de l'interface"
},
"description": {
"language": "Choisissez la langue de l'interface web.",
"theme": "Choisissez entre le mode clair ou sombre."
},
"placeholder": {
"select_language": "Choisir une langue"
},
"command": {
"search": "Rechercher une langue...",
"no_results": "Aucune langue trouvée."
},
"theme": {
"light": "Clair",
"dark": "Sombre"
},
"button": {
"update": "Mettre à jour les préférences"
}
},
"title": "Paramètres",
"general": "Général",
"proxy": "Proxy",
+25
View File
@@ -718,6 +718,31 @@
"interval": "Intervallo"
},
"settings": {
"appearance": {
"title": "Aspetto",
"field": {
"language": "Lingua dell'interfaccia",
"theme": "Tema dell'interfaccia"
},
"description": {
"language": "Seleziona la lingua per l'interfaccia web.",
"theme": "Scegli tra la modalità chiara o scura."
},
"placeholder": {
"select_language": "Seleziona lingua"
},
"command": {
"search": "Cerca lingua...",
"no_results": "Nessuna lingua trovata."
},
"theme": {
"light": "Chiaro",
"dark": "Scuro"
},
"button": {
"update": "Aggiorna preferenze"
}
},
"title": "Impostazioni",
"general": "Generale",
"proxy": "Proxy",
+25
View File
@@ -718,6 +718,31 @@
"interval": "間隔"
},
"settings": {
"appearance": {
"title": "外観",
"field": {
"language": "表示言語",
"theme": "インターフェーステーマ"
},
"description": {
"language": "Web UIで使用する言語を選択します。",
"theme": "ライトモードまたはダークモードを選択します。"
},
"placeholder": {
"select_language": "言語を選択"
},
"command": {
"search": "言語を検索...",
"no_results": "言語が見つかりません。"
},
"theme": {
"light": "ライト",
"dark": "ダーク"
},
"button": {
"update": "設定を更新"
}
},
"title": "設定",
"general": "一般",
"proxy": "プロキシ",
+25
View File
@@ -718,6 +718,31 @@
"interval": "간격"
},
"settings": {
"appearance": {
"title": "외관",
"field": {
"language": "인터페이스 언어",
"theme": "인터페이스 테마"
},
"description": {
"language": "웹 UI에서 사용할 언어를 선택하세요.",
"theme": "라이트 모드와 다크 모드 중에서 선택하세요."
},
"placeholder": {
"select_language": "언어 선택"
},
"command": {
"search": "언어 검색...",
"no_results": "언어를 찾을 수 없습니다."
},
"theme": {
"light": "라이트",
"dark": "다크"
},
"button": {
"update": "설정 업데이트"
}
},
"title": "설정",
"general": "일반",
"proxy": "프록시",
+33
View File
@@ -718,6 +718,39 @@
"interval": "Interval"
},
"settings": {
"appearance": {
"title": "Uiterlijk",
"field": {
"language": "Interfacetaal",
"theme": "Interfacethema"
},
"description": {
"language": "Selecteer de taal voor de webinterface.",
"theme": "Kies tussen de lichte of donkere modus voor de interface."
},
"placeholder": {
"select_language": "Selecteer taal"
},
"command": {
"search": "Taal zoeken...",
"no_results": "Geen taal gevonden."
},
"theme": {
"light": "Licht",
"dark": "Donker"
},
"button": {
"update": "Voorkeuren bijwerken"
},
"validation": {
"language": {
"required": "Selecteer een taal."
},
"theme": {
"required": "Selecteer een thema."
}
}
},
"title": "Instellingen",
"general": "Algemeen",
"proxy": "Proxy",
+33
View File
@@ -718,6 +718,39 @@
"interval": "Intervall"
},
"settings": {
"appearance": {
"title": "Utseende",
"field": {
"language": "Grensesnittspråk",
"theme": "Grensesnittema"
},
"description": {
"language": "Velg språket som skal brukes i webgrensesnittet.",
"theme": "Velg mellom lys eller mørk modus for grensesnittet."
},
"placeholder": {
"select_language": "Velg språk"
},
"command": {
"search": "Søk etter språk...",
"no_results": "Fant ingen språk."
},
"theme": {
"light": "Lys",
"dark": "Mørk"
},
"button": {
"update": "Oppdater innstillinger"
},
"validation": {
"language": {
"required": "Vennligst velg et språk."
},
"theme": {
"required": "Vennligst velg et tema."
}
}
},
"title": "Innstillinger",
"general": "Generelt",
"proxy": "Proxy",
+25
View File
@@ -718,6 +718,31 @@
"interval": "Interval"
},
"settings": {
"appearance": {
"title": "Wygląd",
"field": {
"language": "Język interfejsu",
"theme": "Motyw interfejsu"
},
"description": {
"language": "Wybierz język interfejsu webowego.",
"theme": "Wybierz tryb jasny lub ciemny."
},
"placeholder": {
"select_language": "Wybierz język"
},
"command": {
"search": "Szukaj języka...",
"no_results": "Nie znaleziono języka."
},
"theme": {
"light": "Jasny",
"dark": "Ciemny"
},
"button": {
"update": "Aktualizuj preferencje"
}
},
"title": "Ustawienia",
"general": "Ogólne",
"proxy": "Proxy",
+25
View File
@@ -718,6 +718,31 @@
"interval": "Intervalo"
},
"settings": {
"appearance": {
"title": "Aparência",
"field": {
"language": "Idioma da interface",
"theme": "Tema da interface"
},
"description": {
"language": "Selecione o idioma da interface web.",
"theme": "Escolha entre o modo claro ou escuro."
},
"placeholder": {
"select_language": "Selecionar idioma"
},
"command": {
"search": "Pesquisar idioma...",
"no_results": "Nenhum idioma encontrado."
},
"theme": {
"light": "Claro",
"dark": "Escuro"
},
"button": {
"update": "Atualizar preferências"
}
},
"title": "Configurações",
"general": "Geral",
"proxy": "Proxy",
+25
View File
@@ -718,6 +718,31 @@
"interval": "Интервал"
},
"settings": {
"appearance": {
"title": "Внешний вид",
"field": {
"language": "Язык интерфейса",
"theme": "Тема интерфейса"
},
"description": {
"language": "Выберите язык веб-интерфейса.",
"theme": "Выберите светлый или темный режим."
},
"placeholder": {
"select_language": "Выберите язык"
},
"command": {
"search": "Поиск языка...",
"no_results": "Язык не найден."
},
"theme": {
"light": "Светлая",
"dark": "Темная"
},
"button": {
"update": "Обновить настройки"
}
},
"title": "Настройки",
"general": "Общие",
"proxy": "Прокси",
+33
View File
@@ -718,6 +718,39 @@
"interval": "Intervall"
},
"settings": {
"appearance": {
"title": "Utseende",
"field": {
"language": "Gränssnittsspråk",
"theme": "Gränssnittstema"
},
"description": {
"language": "Välj språk för webbanvändargränssnittet.",
"theme": "Välj mellan ljust eller mörkt läge för gränssnittet."
},
"placeholder": {
"select_language": "Välj språk"
},
"command": {
"search": "Sök språk...",
"no_results": "Inget språk hittades."
},
"theme": {
"light": "Ljust",
"dark": "Mörkt"
},
"button": {
"update": "Uppdatera inställningar"
},
"validation": {
"language": {
"required": "Välj ett språk."
},
"theme": {
"required": "Välj ett tema."
}
}
},
"title": "Inställningar",
"general": "Allmänt",
"proxy": "Proxy",
+33
View File
@@ -711,6 +711,39 @@
"interval": "間隔"
},
"settings": {
"appearance": {
"title": "外觀設置",
"field": {
"language": "介面语言",
"theme": "介面主題"
},
"description": {
"language": "選擇網頁介面顯示的语言。",
"theme": "為介面選擇淺色或深色顯示模式。"
},
"placeholder": {
"select_language": "選擇語言"
},
"command": {
"search": "搜尋語言...",
"no_results": "未找到相關語言。"
},
"theme": {
"light": "淺色",
"dark": "深色"
},
"button": {
"update": "更新偏好設置"
},
"validation": {
"language": {
"required": "請選擇一種語言。"
},
"theme": {
"required": "請選擇一個主題。"
}
}
},
"title": "設定",
"general": "一般",
"proxy": "代理",
+33
View File
@@ -718,6 +718,39 @@
"interval": "间隔"
},
"settings": {
"appearance": {
"title": "外观设置",
"field": {
"language": "语言",
"theme": "界面主题"
},
"description": {
"language": "选择 Web 界面显示的语言。",
"theme": "为界面选择浅色或深色显示模式。"
},
"placeholder": {
"select_language": "选择语言"
},
"command": {
"search": "搜索语言...",
"no_results": "未找到相关语言。"
},
"theme": {
"light": "浅色",
"dark": "深色"
},
"button": {
"update": "更新偏好设置"
},
"validation": {
"language": {
"required": "请选择一种语言。"
},
"theme": {
"required": "请选择一个主题。"
}
}
},
"title": "设置",
"general": "常规",
"proxy": "网络代理",
+35
View File
@@ -70,6 +70,9 @@ const AuthenticatedSettingsProfileLazyImport = createFileRoute(
const AuthenticatedSettingsConfigurationsLazyImport = createFileRoute(
'/_authenticated/settings/configurations',
)()
const AuthenticatedSettingsAppearanceLazyImport = createFileRoute(
'/_authenticated/settings/appearance',
)()
const AuthenticatedSettingsApiTokensLazyImport = createFileRoute(
'/_authenticated/settings/api-tokens',
)()
@@ -282,6 +285,17 @@ const AuthenticatedSettingsConfigurationsLazyRoute =
),
)
const AuthenticatedSettingsAppearanceLazyRoute =
AuthenticatedSettingsAppearanceLazyImport.update({
id: '/appearance',
path: '/appearance',
getParentRoute: () => AuthenticatedSettingsRouteLazyRoute,
} as any).lazy(() =>
import('./routes/_authenticated/settings/appearance.lazy').then(
(d) => d.Route,
),
)
const AuthenticatedSettingsApiTokensLazyRoute =
AuthenticatedSettingsApiTokensLazyImport.update({
id: '/api-tokens',
@@ -381,6 +395,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedSettingsApiTokensLazyImport
parentRoute: typeof AuthenticatedSettingsRouteLazyImport
}
'/_authenticated/settings/appearance': {
id: '/_authenticated/settings/appearance'
path: '/appearance'
fullPath: '/settings/appearance'
preLoaderRoute: typeof AuthenticatedSettingsAppearanceLazyImport
parentRoute: typeof AuthenticatedSettingsRouteLazyImport
}
'/_authenticated/settings/configurations': {
id: '/_authenticated/settings/configurations'
path: '/configurations'
@@ -479,6 +500,7 @@ declare module '@tanstack/react-router' {
interface AuthenticatedSettingsRouteLazyRouteChildren {
AuthenticatedSettingsApiTokensLazyRoute: typeof AuthenticatedSettingsApiTokensLazyRoute
AuthenticatedSettingsAppearanceLazyRoute: typeof AuthenticatedSettingsAppearanceLazyRoute
AuthenticatedSettingsConfigurationsLazyRoute: typeof AuthenticatedSettingsConfigurationsLazyRoute
AuthenticatedSettingsProfileLazyRoute: typeof AuthenticatedSettingsProfileLazyRoute
AuthenticatedSettingsProxyLazyRoute: typeof AuthenticatedSettingsProxyLazyRoute
@@ -489,6 +511,8 @@ const AuthenticatedSettingsRouteLazyRouteChildren: AuthenticatedSettingsRouteLaz
{
AuthenticatedSettingsApiTokensLazyRoute:
AuthenticatedSettingsApiTokensLazyRoute,
AuthenticatedSettingsAppearanceLazyRoute:
AuthenticatedSettingsAppearanceLazyRoute,
AuthenticatedSettingsConfigurationsLazyRoute:
AuthenticatedSettingsConfigurationsLazyRoute,
AuthenticatedSettingsProfileLazyRoute:
@@ -562,6 +586,7 @@ export interface FileRoutesByFullPath {
'/503': typeof errors503LazyRoute
'/': typeof AuthenticatedIndexRoute
'/settings/api-tokens': typeof AuthenticatedSettingsApiTokensLazyRoute
'/settings/appearance': typeof AuthenticatedSettingsAppearanceLazyRoute
'/settings/configurations': typeof AuthenticatedSettingsConfigurationsLazyRoute
'/settings/profile': typeof AuthenticatedSettingsProfileLazyRoute
'/settings/proxy': typeof AuthenticatedSettingsProxyLazyRoute
@@ -586,6 +611,7 @@ export interface FileRoutesByTo {
'/503': typeof errors503LazyRoute
'/': typeof AuthenticatedIndexRoute
'/settings/api-tokens': typeof AuthenticatedSettingsApiTokensLazyRoute
'/settings/appearance': typeof AuthenticatedSettingsAppearanceLazyRoute
'/settings/configurations': typeof AuthenticatedSettingsConfigurationsLazyRoute
'/settings/profile': typeof AuthenticatedSettingsProfileLazyRoute
'/settings/proxy': typeof AuthenticatedSettingsProxyLazyRoute
@@ -615,6 +641,7 @@ export interface FileRoutesById {
'/(errors)/503': typeof errors503LazyRoute
'/_authenticated/': typeof AuthenticatedIndexRoute
'/_authenticated/settings/api-tokens': typeof AuthenticatedSettingsApiTokensLazyRoute
'/_authenticated/settings/appearance': typeof AuthenticatedSettingsAppearanceLazyRoute
'/_authenticated/settings/configurations': typeof AuthenticatedSettingsConfigurationsLazyRoute
'/_authenticated/settings/profile': typeof AuthenticatedSettingsProfileLazyRoute
'/_authenticated/settings/proxy': typeof AuthenticatedSettingsProxyLazyRoute
@@ -644,6 +671,7 @@ export interface FileRouteTypes {
| '/503'
| '/'
| '/settings/api-tokens'
| '/settings/appearance'
| '/settings/configurations'
| '/settings/profile'
| '/settings/proxy'
@@ -667,6 +695,7 @@ export interface FileRouteTypes {
| '/503'
| '/'
| '/settings/api-tokens'
| '/settings/appearance'
| '/settings/configurations'
| '/settings/profile'
| '/settings/proxy'
@@ -694,6 +723,7 @@ export interface FileRouteTypes {
| '/(errors)/503'
| '/_authenticated/'
| '/_authenticated/settings/api-tokens'
| '/_authenticated/settings/appearance'
| '/_authenticated/settings/configurations'
| '/_authenticated/settings/profile'
| '/_authenticated/settings/proxy'
@@ -777,6 +807,7 @@ export const routeTree = rootRoute
"parent": "/_authenticated",
"children": [
"/_authenticated/settings/api-tokens",
"/_authenticated/settings/appearance",
"/_authenticated/settings/configurations",
"/_authenticated/settings/profile",
"/_authenticated/settings/proxy",
@@ -815,6 +846,10 @@ export const routeTree = rootRoute
"filePath": "_authenticated/settings/api-tokens.lazy.tsx",
"parent": "/_authenticated/settings"
},
"/_authenticated/settings/appearance": {
"filePath": "_authenticated/settings/appearance.lazy.tsx",
"parent": "/_authenticated/settings"
},
"/_authenticated/settings/configurations": {
"filePath": "_authenticated/settings/configurations.lazy.tsx",
"parent": "/_authenticated/settings"
@@ -0,0 +1,27 @@
//
// Copyright (c) 2025 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 { createLazyFileRoute } from '@tanstack/react-router'
import { SettingsAppearance } from '@/features/settings/appearance'
export const Route = createLazyFileRoute('/_authenticated/settings/appearance')(
{
component: SettingsAppearance,
},
)