You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407
  1. import PasswordInput from '@/components/password-input';
  2. import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
  3. import { Button } from '@/components/ui/button';
  4. import {
  5. Form,
  6. FormControl,
  7. FormField,
  8. FormItem,
  9. FormLabel,
  10. FormMessage,
  11. } from '@/components/ui/form';
  12. import { Input } from '@/components/ui/input';
  13. import {
  14. Select,
  15. SelectContent,
  16. SelectItem,
  17. SelectTrigger,
  18. SelectValue,
  19. } from '@/components/ui/select';
  20. import { useTranslate } from '@/hooks/common-hooks';
  21. import { useFetchUserInfo, useSaveSetting } from '@/hooks/user-setting-hooks';
  22. import { TimezoneList } from '@/pages/user-setting/constants';
  23. import { rsaPsw } from '@/utils';
  24. import { transformFile2Base64 } from '@/utils/file-util';
  25. import { zodResolver } from '@hookform/resolvers/zod';
  26. import { TFunction } from 'i18next';
  27. import { Loader2Icon, Pencil, Upload } from 'lucide-react';
  28. import { useEffect, useState } from 'react';
  29. import { useForm } from 'react-hook-form';
  30. import { z } from 'zod';
  31. function defineSchema(t: TFunction<'translation', string>) {
  32. return z
  33. .object({
  34. userName: z
  35. .string()
  36. .min(1, {
  37. message: t('usernameMessage'),
  38. })
  39. .trim(),
  40. avatarUrl: z.string().trim(),
  41. timeZone: z
  42. .string()
  43. .trim()
  44. .min(1, {
  45. message: t('timezonePlaceholder'),
  46. }),
  47. email: z
  48. .string({
  49. required_error: 'Please select an email to display.',
  50. })
  51. .trim()
  52. .regex(
  53. /^[A-Za-z0-9\u4e00-\u9fa5]+@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$/,
  54. {
  55. message: 'Enter a valid email address.',
  56. },
  57. ),
  58. currPasswd: z
  59. .string()
  60. .trim()
  61. .min(1, {
  62. message: t('currentPasswordMessage'),
  63. }),
  64. newPasswd: z
  65. .string()
  66. .trim()
  67. .min(8, {
  68. message: t('confirmPasswordMessage'),
  69. }),
  70. confirmPasswd: z
  71. .string()
  72. .trim()
  73. .min(8, {
  74. message: t('newPasswordDescription'),
  75. }),
  76. })
  77. .refine((data) => data.newPasswd === data.confirmPasswd, {
  78. message: t('confirmPasswordNonMatchMessage'),
  79. path: ['confirmPasswd'],
  80. });
  81. }
  82. export default function Profile() {
  83. const [avatarFile, setAvatarFile] = useState<File | null>(null);
  84. const [avatarBase64Str, setAvatarBase64Str] = useState(''); // Avatar Image base64
  85. const { data: userInfo } = useFetchUserInfo();
  86. const { saveSetting, loading: submitLoading } = useSaveSetting();
  87. const { t } = useTranslate('setting');
  88. const FormSchema = defineSchema(t);
  89. const form = useForm<z.infer<typeof FormSchema>>({
  90. resolver: zodResolver(FormSchema),
  91. defaultValues: {
  92. userName: '',
  93. avatarUrl: '',
  94. timeZone: '',
  95. email: '',
  96. currPasswd: '',
  97. newPasswd: '',
  98. confirmPasswd: '',
  99. },
  100. });
  101. useEffect(() => {
  102. // init user info when mounted
  103. form.setValue('email', userInfo?.email); // email
  104. form.setValue('userName', userInfo?.nickname); // nickname
  105. form.setValue('timeZone', userInfo?.timezone); // time zone
  106. form.setValue('currPasswd', ''); // current password
  107. setAvatarBase64Str(userInfo?.avatar ?? '');
  108. }, [userInfo]);
  109. useEffect(() => {
  110. if (avatarFile) {
  111. // make use of img compression transformFile2Base64
  112. (async () => {
  113. setAvatarBase64Str(await transformFile2Base64(avatarFile));
  114. })();
  115. }
  116. }, [avatarFile]);
  117. function onSubmit(data: z.infer<typeof FormSchema>) {
  118. // toast('You submitted the following values', {
  119. // description: (
  120. // <pre className="mt-2 w-[320px] rounded-md bg-neutral-950 p-4">
  121. // <code className="text-white">{JSON.stringify(data, null, 2)}</code>
  122. // </pre>
  123. // ),
  124. // });
  125. // console.log('data=', data);
  126. // final submit form
  127. saveSetting({
  128. nickname: data.userName,
  129. password: rsaPsw(data.currPasswd) as string,
  130. new_password: rsaPsw(data.newPasswd) as string,
  131. avatar: avatarBase64Str,
  132. timezone: data.timeZone,
  133. });
  134. }
  135. return (
  136. <section className="p-8">
  137. <h1 className="text-3xl font-bold">{t('profile')}</h1>
  138. <div className="text-sm text-muted-foreground mb-6">
  139. {t('profileDescription')}
  140. </div>
  141. <div>
  142. <Form {...form}>
  143. <form
  144. onSubmit={form.handleSubmit(onSubmit)}
  145. className="block space-y-6"
  146. >
  147. <FormField
  148. control={form.control}
  149. name="userName"
  150. render={({ field }) => (
  151. <FormItem className=" items-center space-y-0 ">
  152. <div className="flex w-[600px]">
  153. <FormLabel className="text-sm text-muted-foreground whitespace-nowrap w-1/4">
  154. <span className="text-red-600">*</span>
  155. {t('username')}
  156. </FormLabel>
  157. <FormControl className="w-3/4">
  158. <Input
  159. placeholder=""
  160. {...field}
  161. className="bg-colors-background-inverse-weak"
  162. />
  163. </FormControl>
  164. </div>
  165. <div className="flex w-[600px] pt-1">
  166. <div className="w-1/4"></div>
  167. <FormMessage />
  168. </div>
  169. </FormItem>
  170. )}
  171. />
  172. <FormField
  173. control={form.control}
  174. name="avatarUrl"
  175. render={({ field }) => (
  176. <FormItem className="flex items-center space-y-0">
  177. <div className="flex w-[600px]">
  178. <FormLabel className="text-sm text-muted-foreground whitespace-nowrap w-1/4">
  179. Avatar
  180. </FormLabel>
  181. <FormControl className="w-3/4">
  182. <>
  183. <div className="relative group">
  184. {!avatarBase64Str ? (
  185. <div className="w-[64px] h-[64px] grid place-content-center">
  186. <div className="flex flex-col items-center">
  187. <Upload />
  188. <p>Upload</p>
  189. </div>
  190. </div>
  191. ) : (
  192. <div className="w-[64px] h-[64px] relative grid place-content-center">
  193. <Avatar className="w-[64px] h-[64px]">
  194. <AvatarImage
  195. className=" block"
  196. src={avatarBase64Str}
  197. alt=""
  198. />
  199. <AvatarFallback></AvatarFallback>
  200. </Avatar>
  201. <div className="absolute inset-0 bg-[#000]/20 group-hover:bg-[#000]/60">
  202. <Pencil
  203. size={20}
  204. className="absolute right-2 bottom-0 opacity-50 hidden group-hover:block"
  205. />
  206. </div>
  207. </div>
  208. )}
  209. <Input
  210. placeholder=""
  211. {...field}
  212. type="file"
  213. title=""
  214. accept="image/*"
  215. className="absolute top-0 left-0 w-full h-full opacity-0 cursor-pointer"
  216. onChange={(ev) => {
  217. const file = ev.target?.files?.[0];
  218. if (
  219. /\.(jpg|jpeg|png|webp|bmp)$/i.test(
  220. file?.name ?? '',
  221. )
  222. ) {
  223. setAvatarFile(file!);
  224. }
  225. ev.target.value = '';
  226. }}
  227. />
  228. </div>
  229. </>
  230. </FormControl>
  231. </div>
  232. <div className="flex w-[600px] pt-1">
  233. <div className="w-1/4"></div>
  234. <FormMessage />
  235. </div>
  236. </FormItem>
  237. )}
  238. />
  239. <FormField
  240. control={form.control}
  241. name="timeZone"
  242. render={({ field }) => (
  243. <FormItem className="items-center space-y-0">
  244. <div className="flex w-[600px]">
  245. <FormLabel className="text-sm text-muted-foreground whitespace-nowrap w-1/4">
  246. <span className="text-red-600">*</span>
  247. {t('timezone')}
  248. </FormLabel>
  249. <Select onValueChange={field.onChange} value={field.value}>
  250. <FormControl className="w-3/4">
  251. <SelectTrigger>
  252. <SelectValue placeholder="Select a timeZone" />
  253. </SelectTrigger>
  254. </FormControl>
  255. <SelectContent>
  256. {TimezoneList.map((timeStr) => (
  257. <SelectItem key={timeStr} value={timeStr}>
  258. {timeStr}
  259. </SelectItem>
  260. ))}
  261. </SelectContent>
  262. </Select>
  263. </div>
  264. <div className="flex w-[600px] pt-1">
  265. <div className="w-1/4"></div>
  266. <FormMessage />
  267. </div>
  268. </FormItem>
  269. )}
  270. />
  271. <FormField
  272. control={form.control}
  273. name="email"
  274. render={({ field }) => (
  275. <div>
  276. <FormItem className="items-center space-y-0">
  277. <div className="flex w-[600px]">
  278. <FormLabel className="text-sm text-muted-foreground whitespace-nowrap w-1/4">
  279. {t('email')}
  280. </FormLabel>
  281. <FormControl className="w-3/4">
  282. <Input
  283. placeholder="Alex@gmail.com"
  284. disabled
  285. {...field}
  286. />
  287. </FormControl>
  288. </div>
  289. <div className="flex w-[600px] pt-1">
  290. <div className="w-1/4"></div>
  291. <FormMessage />
  292. </div>
  293. </FormItem>
  294. <div className="flex w-[600px] pt-1">
  295. <p className="w-1/4">&nbsp;</p>
  296. <p className="text-sm text-muted-foreground whitespace-nowrap w-3/4">
  297. {t('emailDescription')}
  298. </p>
  299. </div>
  300. </div>
  301. )}
  302. />
  303. <div className="h-[10px]"></div>
  304. <div className="pb-6">
  305. <h1 className="text-3xl font-bold">{t('password')}</h1>
  306. <div className="text-sm text-muted-foreground">
  307. {t('passwordDescription')}
  308. </div>
  309. </div>
  310. <div className="h-0 overflow-hidden absolute">
  311. <input type="password" className=" w-0 height-0 opacity-0" />
  312. </div>
  313. <FormField
  314. control={form.control}
  315. name="currPasswd"
  316. render={({ field }) => (
  317. <FormItem className=" items-center space-y-0">
  318. <div className="flex w-[600px]">
  319. <FormLabel className="text-sm text-muted-foreground whitespace-nowrap w-2/5">
  320. <span className="text-red-600">*</span>
  321. {t('currentPassword')}
  322. </FormLabel>
  323. <FormControl className="w-3/5">
  324. <PasswordInput {...field} />
  325. </FormControl>
  326. </div>
  327. <div className="flex w-[600px] pt-1">
  328. <div className="min-w-[170px] max-w-[170px]"></div>
  329. <FormMessage />
  330. </div>
  331. </FormItem>
  332. )}
  333. />
  334. <FormField
  335. control={form.control}
  336. name="newPasswd"
  337. render={({ field }) => (
  338. <FormItem className=" items-center space-y-0">
  339. <div className="flex w-[600px]">
  340. <FormLabel className="text-sm text-muted-foreground whitespace-nowrap w-2/5">
  341. <span className="text-red-600">*</span>
  342. {t('newPassword')}
  343. </FormLabel>
  344. <FormControl className="w-3/5">
  345. <PasswordInput {...field} />
  346. </FormControl>
  347. </div>
  348. <div className="flex w-[600px] pt-1">
  349. <div className="min-w-[170px] max-w-[170px]"></div>
  350. <FormMessage />
  351. </div>
  352. </FormItem>
  353. )}
  354. />
  355. <FormField
  356. control={form.control}
  357. name="confirmPasswd"
  358. render={({ field }) => (
  359. <FormItem className=" items-center space-y-0">
  360. <div className="flex w-[600px]">
  361. <FormLabel className="text-sm text-muted-foreground whitespace-nowrap w-2/5">
  362. <span className="text-red-600">*</span>
  363. {t('confirmPassword')}
  364. </FormLabel>
  365. <FormControl className="w-3/5">
  366. <PasswordInput
  367. {...field}
  368. onBlur={() => {
  369. form.trigger('confirmPasswd');
  370. }}
  371. onChange={(ev) => {
  372. form.setValue(
  373. 'confirmPasswd',
  374. ev.target.value.trim(),
  375. );
  376. }}
  377. />
  378. </FormControl>
  379. </div>
  380. <div className="flex w-[600px] pt-1">
  381. <div className="min-w-[170px] max-w-[170px]">&nbsp;</div>
  382. <FormMessage />
  383. </div>
  384. </FormItem>
  385. )}
  386. />
  387. <div className="w-[600px] text-right space-x-4">
  388. <Button variant="secondary">{t('cancel')}</Button>
  389. <Button type="submit" disabled={submitLoading}>
  390. {submitLoading && <Loader2Icon className="animate-spin" />}
  391. {t('save', { keyPrefix: 'common' })}
  392. </Button>
  393. </div>
  394. </form>
  395. </Form>
  396. </div>
  397. </section>
  398. );
  399. }