Skip to content

Commit 3878082

Browse files
committed
feat: update profile
1 parent 0393a39 commit 3878082

File tree

10 files changed

+210
-27
lines changed

10 files changed

+210
-27
lines changed

bun.lockb

1.02 KB
Binary file not shown.

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"@tanstack/react-query-devtools": "^5.0.3",
2929
"@types/jsonwebtoken": "^9.0.4",
3030
"@types/lodash": "^4.14.200",
31+
"@types/mime": "^3.0.3",
3132
"@types/node": "^20.8.7",
3233
"@types/react": "^18.2.31",
3334
"@types/react-dom": "^18.2.14",
@@ -37,6 +38,7 @@
3738
"commitlint": "^18.0.0",
3839
"dayjs": "^1.11.10",
3940
"encoding": "^0.1.13",
41+
"env-paths": "^3.0.0",
4042
"eslint": "^8.52.0",
4143
"eslint-config-next": "13.5.6",
4244
"framer-motion": "^10.16.4",
@@ -51,6 +53,7 @@
5153
"lint-staged": "^15.0.2",
5254
"lodash": "^4.17.21",
5355
"match-sorter": "^6.3.1",
56+
"mime": "^3.0.0",
5457
"monaco-editor": "^0.44.0",
5558
"monaco-themes": "^0.4.4",
5659
"next": "^13.5.6",

src/app/api/avatar/route.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { NextResponse } from 'next/server'
2+
import fs from 'node:fs/promises'
3+
import { resolveAvatarPath } from '~/helpers/server/data'
4+
5+
export const POST = async (req: Request) => {
6+
const formData = await req.formData()
7+
const avatar = formData.get('avatar') as File
8+
9+
const avatarPath = resolveAvatarPath(avatar.name)
10+
11+
try {
12+
await fs.writeFile(avatarPath, Buffer.from(await avatar.arrayBuffer()))
13+
14+
return NextResponse.json({ url: `/avatar/${avatar.name}` })
15+
} catch (err) {
16+
return NextResponse.json({ message: (err as Error).message }, { status: 503 })
17+
}
18+
}

src/app/avatar/[name]/route.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import mime from 'mime'
2+
import { NextResponse } from 'next/server'
3+
import fs from 'node:fs/promises'
4+
import { resolveAvatarPath } from '~/helpers/server/data'
5+
6+
export const GET = async (_request: Request, { params }: { params: { name: string } }) => {
7+
const { name } = params
8+
const avatarPath = resolveAvatarPath(name)
9+
10+
try {
11+
await fs.access(avatarPath, fs.constants.F_OK)
12+
13+
const fileContent = await fs.readFile(avatarPath)
14+
const fileType = mime.getType(avatarPath) || ''
15+
16+
return new NextResponse(fileContent, { headers: { 'Content-Type': fileType } })
17+
} catch {
18+
return new NextResponse(null, { status: 404 })
19+
}
20+
}

src/bootstrap.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import envPaths from 'env-paths'
2+
import fs from 'node:fs/promises'
3+
import { resolveAvatarDataPath } from '~/helpers/server/data'
4+
5+
export const bootstrap = async () => {
6+
const { data } = envPaths('daed')
7+
8+
await fs.mkdir(data, { recursive: true })
9+
await fs.mkdir(resolveAvatarDataPath(), { recursive: true })
10+
}

src/components/Header.tsx

Lines changed: 135 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import {
1212
DropdownTrigger,
1313
Input,
1414
Link,
15-
Modal,
1615
ModalBody,
1716
ModalContent,
1817
ModalHeader,
@@ -23,6 +22,7 @@ import {
2322
NavbarMenu,
2423
NavbarMenuItem,
2524
NavbarMenuToggle,
25+
Spinner,
2626
useDisclosure
2727
} from '@nextui-org/react'
2828
import i18n from 'i18next'
@@ -31,20 +31,89 @@ import { useTheme } from 'next-themes'
3131
import NextLink from 'next/link'
3232
import { usePathname, useRouter } from 'next/navigation'
3333
import { FC, useEffect, useState } from 'react'
34-
import { useForm } from 'react-hook-form'
34+
import { Controller, FormProvider, useForm } from 'react-hook-form'
3535
import { useTranslation } from 'react-i18next'
3636
import { z } from 'zod'
37+
import { useUpdateAvatarMutation, useUpdateNameMutation } from '~/apis/mutation'
3738
import { useUserQuery } from '~/apis/query'
3839
import { LogoText } from '~/components/LogoText'
39-
import { ModalConfirmFormFooter } from '~/components/Modal'
40+
import { Modal, ModalConfirmFormFooter, ModalSubmitFormFooter } from '~/components/Modal'
4041
import { updatePasswordFormDefault, useUpdatePasswordSchemaWithRefine } from '~/schemas/account'
4142

43+
const AvatarUploader: FC<{ name: string }> = ({ name }) => {
44+
const [uploading, setUploading] = useState(false)
45+
46+
return (
47+
<Controller
48+
name={name}
49+
render={({ field }) => (
50+
<div className="flex items-center justify-center">
51+
<div className="flex h-32 w-32 items-center justify-center">
52+
{uploading ? <Spinner /> : <Avatar size="lg" as="label" htmlFor="avatar" showFallback src={field.value} />}
53+
</div>
54+
55+
<input
56+
id="avatar"
57+
type="file"
58+
hidden
59+
multiple={false}
60+
onChange={async (e) => {
61+
setUploading(true)
62+
63+
const file = e.target.files?.item(0)
64+
65+
if (!file) return
66+
67+
const formData = new FormData()
68+
formData.append('avatar', file)
69+
70+
try {
71+
const { url } = await ky.post('/api/avatar', { body: formData }).json<{ url: string }>()
72+
73+
field.onChange(url)
74+
} finally {
75+
setUploading(false)
76+
}
77+
}}
78+
/>
79+
</div>
80+
)}
81+
/>
82+
)
83+
}
84+
4285
export const Header: FC = () => {
4386
const { t } = useTranslation()
4487
const { theme: curTheme, setTheme } = useTheme()
88+
const pathname = usePathname()
89+
const router = useRouter()
90+
91+
const userQuery = useUserQuery()
4592

4693
const [isMenuOpen, setIsMenuOpen] = useState(false)
4794

95+
const {
96+
isOpen: isUpdateProfileOpen,
97+
onOpen: onUpdateProfileOpen,
98+
onClose: onUpdateProfileClose,
99+
onOpenChange: onUpdateProfileOpenChange
100+
} = useDisclosure()
101+
102+
const updateProfileSchema = z.object({
103+
name: z.string().min(4).max(20),
104+
avatar: z.string().min(1)
105+
})
106+
107+
const updateProfileForm = useForm<z.infer<typeof updateProfileSchema>>({
108+
resolver: zodResolver(updateProfileSchema),
109+
defaultValues: { name: '', avatar: '' }
110+
})
111+
112+
const updateProfileFormDirty = Object.values(updateProfileForm.formState.dirtyFields).some((dirty) => dirty)
113+
114+
const updateNameMutation = useUpdateNameMutation()
115+
const updateAvatarMutation = useUpdateAvatarMutation()
116+
48117
const {
49118
isOpen: isUpdatePasswordOpen,
50119
onOpen: onUpdatePasswordOpen,
@@ -59,10 +128,6 @@ export const Header: FC = () => {
59128
defaultValues: updatePasswordFormDefault
60129
})
61130

62-
const pathname = usePathname()
63-
const router = useRouter()
64-
const userQuery = useUserQuery()
65-
66131
const navigationMenus = [
67132
{ name: t('primitives.network'), route: '/network' },
68133
{ name: t('primitives.rule'), route: '/rule' }
@@ -107,6 +172,7 @@ export const Header: FC = () => {
107172
className="transition-transform"
108173
color="secondary"
109174
size="sm"
175+
showFallback
110176
name={userQuery.data?.user.name || userQuery.data?.user.username}
111177
src={userQuery.data?.user.avatar || ''}
112178
/>
@@ -116,6 +182,13 @@ export const Header: FC = () => {
116182
aria-label="Profile Actions"
117183
variant="flat"
118184
onAction={(key) => {
185+
if (key === 'profile') {
186+
updateProfileForm.reset({
187+
name: userQuery.data?.user.name || '',
188+
avatar: userQuery.data?.user.avatar || ''
189+
})
190+
onUpdateProfileOpen()
191+
}
119192
if (key === 'update-password') onUpdatePasswordOpen()
120193
}}
121194
>
@@ -129,7 +202,9 @@ export const Header: FC = () => {
129202
</DropdownItem>
130203

131204
<DropdownItem key="update-password" textValue="update-password">
132-
{t('actions.updatePassword')}
205+
{t('actions.update', {
206+
resourceName: t('form.fields.password')
207+
})}
133208
</DropdownItem>
134209
</DropdownSection>
135210

@@ -197,6 +272,51 @@ export const Header: FC = () => {
197272
))}
198273
</NavbarMenu>
199274

275+
<Modal isOpen={isUpdateProfileOpen} onOpenChange={onUpdateProfileOpenChange}>
276+
<FormProvider {...updateProfileForm}>
277+
<form
278+
onSubmit={updateProfileForm.handleSubmit(async (values) => {
279+
try {
280+
if (values.name !== userQuery.data?.user.name) {
281+
await updateNameMutation.mutateAsync(values.name)
282+
}
283+
284+
if (values.avatar !== userQuery.data?.user.avatar) {
285+
await updateAvatarMutation.mutateAsync(values.avatar)
286+
}
287+
288+
await userQuery.refetch()
289+
290+
onUpdateProfileClose()
291+
} catch {}
292+
})}
293+
>
294+
<ModalContent>
295+
<ModalHeader>{t('actions.update', { resourceName: t('primitives.profile') })}</ModalHeader>
296+
297+
<ModalBody>
298+
<div className="flex flex-col gap-4">
299+
<Input
300+
label={t('form.fields.name')}
301+
placeholder={t('form.fields.name')}
302+
errorMessage={updateProfileForm.formState.errors.name?.message}
303+
{...updateProfileForm.register('name')}
304+
/>
305+
306+
<AvatarUploader name="avatar" />
307+
</div>
308+
</ModalBody>
309+
310+
<ModalSubmitFormFooter
311+
reset={updateProfileForm.reset}
312+
isResetDisabled={!updateProfileFormDirty}
313+
isSubmitting={updateProfileForm.formState.isSubmitting}
314+
/>
315+
</ModalContent>
316+
</form>
317+
</FormProvider>
318+
</Modal>
319+
200320
<Modal isOpen={isUpdatePasswordOpen} onOpenChange={onUpdatePasswordOpenChange}>
201321
<form
202322
onSubmit={updatePasswordForm.handleSubmit(async (values) => {
@@ -210,30 +330,30 @@ export const Header: FC = () => {
210330
})}
211331
>
212332
<ModalContent>
213-
<ModalHeader>{t('actions.updatePassword')}</ModalHeader>
333+
<ModalHeader>{t('actions.update', { resourceName: t('form.fields.password') })}</ModalHeader>
214334

215335
<ModalBody>
216336
<div className="flex flex-col gap-4">
217337
<Input
218338
type="password"
219-
label={t('primitives.currentPassword')}
220-
placeholder={t('primitives.currentPassword')}
339+
label={t('form.fields.currentPassword')}
340+
placeholder={t('form.fields.currentPassword')}
221341
errorMessage={updatePasswordForm.formState.errors.currentPassword?.message}
222342
{...updatePasswordForm.register('currentPassword')}
223343
/>
224344

225345
<Input
226346
type="password"
227-
label={t('primitives.newPassword')}
228-
placeholder={t('primitives.newPassword')}
347+
label={t('form.fields.newPassword')}
348+
placeholder={t('form.fields.newPassword')}
229349
errorMessage={updatePasswordForm.formState.errors.newPassword?.message}
230350
{...updatePasswordForm.register('newPassword')}
231351
/>
232352

233353
<Input
234354
type="password"
235-
label={t('primitives.confirmPassword')}
236-
placeholder={t('primitives.confirmPassword')}
355+
label={t('form.fields.confirmPassword')}
356+
placeholder={t('form.fields.confirmPassword')}
237357
errorMessage={updatePasswordForm.formState.errors.confirmPassword?.message}
238358
{...updatePasswordForm.register('confirmPassword')}
239359
/>

src/helpers/server/data.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import envPaths from 'env-paths'
2+
import path from 'node:path'
3+
4+
export const resolveAvatarDataPath = () => path.join(envPaths('daed').data, 'avatars')
5+
6+
export const resolveAvatarPath = (avatarName: string) => path.join(resolveAvatarDataPath(), avatarName)

src/i18n/locales/en-US.json

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
"endpointURL": "Endpoint URL",
66
"username": "Username",
77
"password": "Password",
8+
"currentPassword": "Current Password",
9+
"newPassword": "New Password",
10+
"confirmPassword": "Confirm Password",
811
"tproxyPort": "Transparent Proxy Port",
912
"tproxyPortProtect": "Transparent Proxy Port Protect",
1013
"soMarkFromDae": "Set SO_MARK For dae",
@@ -83,7 +86,7 @@
8386
"submit": "Submit",
8487
"refresh": "Refresh",
8588
"loading": "Loading {{resourceName}}...",
86-
"updatePassword": "Update Password"
89+
"update": "Update {{resourceName}}"
8790
},
8891
"primitives": {
8992
"create": "Create {{resourceName}}",
@@ -109,6 +112,7 @@
109112
"settings": "Settings",
110113
"username": "Username: {{username}}",
111114
"accountName": "Account Name: {{accountName}}",
115+
"profile": "Profile",
112116
"english": "English",
113117
"chineseSimplified": "Chinese Simplified",
114118
"name": "Name",
@@ -117,9 +121,6 @@
117121
"updatedAt": "Updated At",
118122
"address": "Address",
119123
"action": "Action",
120-
"policy": "Policy",
121-
"currentPassword": "Current Password",
122-
"newPassword": "New Password",
123-
"confirmPassword": "Confirm Password"
124+
"policy": "Policy"
124125
}
125126
}

src/i18n/locales/zh-Hans.json

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
"endpointURL": "接口地址",
66
"username": "用户名",
77
"password": "密码",
8+
"currentPassword": "当前密码",
9+
"newPassword": "新密码",
10+
"confirmPassword": "确认密码",
811
"tproxyPort": "透明代理端口",
912
"tproxyPortProtect": "透明代理端口保护",
1013
"soMarkFromDae": "为 dae 设置套接字标记",
@@ -83,7 +86,7 @@
8386
"submit": "提交",
8487
"refresh": "刷新",
8588
"loading": "正在加载{{resourceName}}...",
86-
"updatePassword": "修改密码"
89+
"update": "修改{{resourceName}}"
8790
},
8891
"primitives": {
8992
"create": "创建{{resourceName}}",
@@ -109,6 +112,7 @@
109112
"settings": "设置",
110113
"username": "登录账户:{{username}}",
111114
"accountName": "用户名:{{accountName}}",
115+
"profile": "个人资料",
112116
"english": "英文",
113117
"chineseSimplified": "简中",
114118
"name": "名称",
@@ -117,9 +121,6 @@
117121
"updatedAt": "更新时间",
118122
"address": "地址",
119123
"action": "动作",
120-
"policy": "策略",
121-
"currentPassword": "当前密码",
122-
"newPassword": "新密码",
123-
"confirmPassword": "确认密码"
124+
"policy": "策略"
124125
}
125126
}

0 commit comments

Comments
 (0)