mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-01 15:49:00 +08:00
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:
@@ -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.",
|
||||
|
||||
@@ -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": "此文件类型暂不支持预览。",
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user