feat(settings): implement profile and appearance settings page (#277)

* feat(settings): implement profile and appearance settings page

Replace the stub settings page with functional profile management
(display name, password change) and appearance controls (theme via
next-themes, language selector via i18next).

Agent-Profile: https://agent-kanban.dev/agents/b724a773425e397c

* fix(settings): fix JSON indent, language selector, remove test bloat

- Fix missing 2-space indent on settings.profile.section in both locales
- Use i18n.resolvedLanguage instead of i18n.language to handle
  browser-detected locales like en-US normalizing to configured en/zh
- Delete settings-locale.test.ts (redundant with component rendering
  and existing locale parity tests)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jasper Van
2026-04-12 01:13:17 -04:00
committed by GitHub
parent fa99b31b07
commit 5c453bcae4
4 changed files with 219 additions and 12 deletions
+17 -1
View File
@@ -155,7 +155,23 @@
"admin.storages.capacityHint": "Maximum storage space. 0 means unlimited.",
"admin.storages.customHostPlaceholder": "Optional",
"settings.title": "Settings",
"settings.placeholder": "Profile and appearance settings will be here.",
"settings.profile.section": "Profile",
"settings.profile.displayName": "Display Name",
"settings.profile.email": "Email",
"settings.profile.emailReadonly": "Email cannot be changed",
"settings.profile.changePassword": "Change Password",
"settings.profile.currentPassword": "Current Password",
"settings.profile.newPassword": "New Password",
"settings.profile.confirmPassword": "Confirm Password",
"settings.profile.saved": "Profile updated",
"settings.profile.passwordChanged": "Password changed successfully",
"settings.profile.passwordMismatch": "Passwords do not match",
"settings.appearance.section": "Appearance",
"settings.appearance.theme": "Theme",
"settings.appearance.themeSystem": "System",
"settings.appearance.themeLight": "Light",
"settings.appearance.themeDark": "Dark",
"settings.appearance.language": "Language",
"preview.download": "Download",
"preview.close": "Close",
"preview.unsupported": "Preview is not available for this file type.",
+17 -1
View File
@@ -155,7 +155,23 @@
"admin.storages.capacityHint": "最大存储空间,0 表示不限制。",
"admin.storages.customHostPlaceholder": "可选",
"settings.title": "设置",
"settings.placeholder": "个人资料和外观设置将在这里。",
"settings.profile.section": "个人资料",
"settings.profile.displayName": "显示名称",
"settings.profile.email": "邮箱",
"settings.profile.emailReadonly": "邮箱不可更改",
"settings.profile.changePassword": "修改密码",
"settings.profile.currentPassword": "当前密码",
"settings.profile.newPassword": "新密码",
"settings.profile.confirmPassword": "确认密码",
"settings.profile.saved": "个人资料已更新",
"settings.profile.passwordChanged": "密码修改成功",
"settings.profile.passwordMismatch": "两次密码输入不一致",
"settings.appearance.section": "外观",
"settings.appearance.theme": "主题",
"settings.appearance.themeSystem": "跟随系统",
"settings.appearance.themeLight": "浅色",
"settings.appearance.themeDark": "深色",
"settings.appearance.language": "语言",
"preview.download": "下载",
"preview.close": "关闭",
"preview.unsupported": "此文件类型暂不支持预览。",
+7 -4
View File
@@ -1,5 +1,6 @@
import type { QueryClient } from '@tanstack/react-query'
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
import { ThemeProvider } from 'next-themes'
import { Toaster } from '@/components/ui/sonner'
import { TooltipProvider } from '@/components/ui/tooltip'
@@ -9,9 +10,11 @@ interface RouterContext {
export const Route = createRootRouteWithContext<RouterContext>()({
component: () => (
<TooltipProvider>
<Outlet />
<Toaster />
</TooltipProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<TooltipProvider>
<Outlet />
<Toaster />
</TooltipProvider>
</ThemeProvider>
),
})
+178 -6
View File
@@ -1,18 +1,190 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useMutation } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { Settings } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { z } from 'zod'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { authClient, useSession } from '@/lib/auth-client'
export const Route = createFileRoute('/_authenticated/settings/')({
component: SettingsPage,
})
function SettingsPage() {
const profileSchema = z.object({
displayName: z.string().min(1).max(100),
})
const passwordSchema = z
.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(8),
confirmPassword: z.string().min(1),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: 'passwords_mismatch',
path: ['confirmPassword'],
})
type ProfileFormValues = z.infer<typeof profileSchema>
type PasswordFormValues = z.infer<typeof passwordSchema>
function ProfileForm() {
const { t } = useTranslation()
const { data: session } = useSession()
const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileSchema),
defaultValues: { displayName: '' },
})
useEffect(() => {
if (session?.user?.name) {
form.reset({ displayName: session.user.name })
}
}, [session?.user?.name, form])
const mutation = useMutation({
mutationFn: async (values: ProfileFormValues) => {
const { error } = await authClient.updateUser({ name: values.displayName })
if (error) throw error
},
onSuccess: () => toast.success(t('settings.profile.saved')),
onError: (err) => toast.error(err.message ?? String(err)),
})
return (
<div className="flex flex-col items-center justify-center gap-4 py-20 text-muted-foreground">
<Settings className="h-16 w-16" />
<h2 className="text-xl font-medium">{t('settings.title')}</h2>
<p className="text-sm">{t('settings.placeholder')}</p>
<form onSubmit={form.handleSubmit((v) => mutation.mutate(v))} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="displayName">{t('settings.profile.displayName')}</Label>
<Input id="displayName" {...form.register('displayName')} />
{form.formState.errors.displayName && (
<p className="text-xs text-destructive">{form.formState.errors.displayName.message}</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="email">{t('settings.profile.email')}</Label>
<Input id="email" value={session?.user?.email ?? ''} disabled />
<p className="text-xs text-muted-foreground">{t('settings.profile.emailReadonly')}</p>
</div>
<Button type="submit" disabled={!form.formState.isDirty || mutation.isPending}>
{mutation.isPending ? t('common.loading') : t('common.save')}
</Button>
</form>
)
}
function ChangePasswordForm() {
const { t } = useTranslation()
const form = useForm<PasswordFormValues>({
resolver: zodResolver(passwordSchema),
defaultValues: { currentPassword: '', newPassword: '', confirmPassword: '' },
})
const mutation = useMutation({
mutationFn: async (values: PasswordFormValues) => {
const { error } = await authClient.changePassword({
currentPassword: values.currentPassword,
newPassword: values.newPassword,
})
if (error) throw error
},
onSuccess: () => {
toast.success(t('settings.profile.passwordChanged'))
form.reset()
},
onError: (err) => toast.error(err.message ?? String(err)),
})
return (
<form onSubmit={form.handleSubmit((v) => mutation.mutate(v))} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="currentPassword">{t('settings.profile.currentPassword')}</Label>
<Input id="currentPassword" type="password" {...form.register('currentPassword')} />
</div>
<div className="space-y-1.5">
<Label htmlFor="newPassword">{t('settings.profile.newPassword')}</Label>
<Input id="newPassword" type="password" {...form.register('newPassword')} />
</div>
<div className="space-y-1.5">
<Label htmlFor="confirmPassword">{t('settings.profile.confirmPassword')}</Label>
<Input id="confirmPassword" type="password" {...form.register('confirmPassword')} />
{form.formState.errors.confirmPassword && (
<p className="text-xs text-destructive">{t('settings.profile.passwordMismatch')}</p>
)}
</div>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? t('common.loading') : t('settings.profile.changePassword')}
</Button>
</form>
)
}
function AppearanceSection() {
const { t, i18n } = useTranslation()
const { theme, setTheme } = useTheme()
return (
<div className="space-y-4 rounded-md border p-4">
<h3 className="text-sm font-medium text-muted-foreground">{t('settings.appearance.section')}</h3>
<div className="space-y-1.5">
<Label>{t('settings.appearance.theme')}</Label>
<Select value={theme} onValueChange={setTheme}>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="system">{t('settings.appearance.themeSystem')}</SelectItem>
<SelectItem value="light">{t('settings.appearance.themeLight')}</SelectItem>
<SelectItem value="dark">{t('settings.appearance.themeDark')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>{t('settings.appearance.language')}</Label>
<Select value={i18n.resolvedLanguage} onValueChange={(lang) => i18n.changeLanguage(lang)}>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">English</SelectItem>
<SelectItem value="zh"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
)
}
function SettingsPage() {
const { t } = useTranslation()
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold">{t('settings.title')}</h2>
<div className="max-w-lg space-y-6">
<div className="space-y-4 rounded-md border p-4">
<h3 className="text-sm font-medium text-muted-foreground">{t('settings.profile.section')}</h3>
<ProfileForm />
<h3 className="text-sm font-medium text-muted-foreground">{t('settings.profile.changePassword')}</h3>
<ChangePasswordForm />
</div>
<AppearanceSection />
</div>
</div>
)
}