mirror of
				https://github.com/usememos/memos.git
				synced 2025-11-01 01:06:04 +08:00 
			
		
		
		
	feat: add avatar to user in frontend (#1108)
This commit is contained in:
		
							parent
							
								
									096a71c58b
								
							
						
					
					
						commit
						bcee0bbf3a
					
				
					 11 changed files with 120 additions and 80 deletions
				
			
		|  | @ -3,6 +3,7 @@ import { useUserStore } from "../../store/module"; | |||
| import { showCommonDialog } from "../Dialog/CommonDialog"; | ||||
| import showChangePasswordDialog from "../ChangePasswordDialog"; | ||||
| import showUpdateAccountDialog from "../UpdateAccountDialog"; | ||||
| import UserAvatar from "../UserAvatar"; | ||||
| import "../../less/settings/my-account-section.less"; | ||||
| 
 | ||||
| const MyAccountSection = () => { | ||||
|  | @ -30,14 +31,15 @@ const MyAccountSection = () => { | |||
|     <> | ||||
|       <div className="section-container account-section-container"> | ||||
|         <p className="title-text">{t("setting.account-section.title")}</p> | ||||
|         <div className="flex flex-row justify-start items-end"> | ||||
|         <div className="flex flex-row justify-start items-center"> | ||||
|           <UserAvatar className="mr-2" avatarUrl={user.avatarUrl} /> | ||||
|           <span className="text-2xl leading-10 font-medium">{user.nickname}</span> | ||||
|           <span className="text-base ml-1 text-gray-500 leading-8">({user.username})</span> | ||||
|           <span className="text-base ml-1 text-gray-500 leading-10">({user.username})</span> | ||||
|         </div> | ||||
|         <div className="flex flex-row justify-start items-center text-base text-gray-600">{user.email}</div> | ||||
|         <div className="w-full flex flex-row justify-start items-center mt-2 space-x-2"> | ||||
|           <button className="btn-normal" onClick={showUpdateAccountDialog}> | ||||
|             {t("setting.account-section.update-information")} | ||||
|             {t("common.edit")} | ||||
|           </button> | ||||
|           <button className="btn-normal" onClick={showChangePasswordDialog}> | ||||
|             {t("setting.account-section.change-password")} | ||||
|  |  | |||
|  | @ -3,9 +3,11 @@ import { useEffect, useState } from "react"; | |||
| import { useTranslation } from "react-i18next"; | ||||
| import { useUserStore } from "../store/module"; | ||||
| import { validate, ValidatorConfig } from "../helpers/validator"; | ||||
| import { convertFileToBase64 } from "../helpers/utils"; | ||||
| import Icon from "./Icon"; | ||||
| import { generateDialog } from "./Dialog"; | ||||
| import toastHelper from "./Toast"; | ||||
| import UserAvatar from "./UserAvatar"; | ||||
| 
 | ||||
| const validateConfig: ValidatorConfig = { | ||||
|   minLength: 4, | ||||
|  | @ -17,6 +19,7 @@ const validateConfig: ValidatorConfig = { | |||
| type Props = DialogProps; | ||||
| 
 | ||||
| interface State { | ||||
|   avatarUrl: string; | ||||
|   username: string; | ||||
|   nickname: string; | ||||
|   email: string; | ||||
|  | @ -27,6 +30,7 @@ const UpdateAccountDialog: React.FC<Props> = ({ destroy }: Props) => { | |||
|   const userStore = useUserStore(); | ||||
|   const user = userStore.state.user as User; | ||||
|   const [state, setState] = useState<State>({ | ||||
|     avatarUrl: user.avatarUrl, | ||||
|     username: user.username, | ||||
|     nickname: user.nickname, | ||||
|     email: user.email, | ||||
|  | @ -40,6 +44,31 @@ const UpdateAccountDialog: React.FC<Props> = ({ destroy }: Props) => { | |||
|     destroy(); | ||||
|   }; | ||||
| 
 | ||||
|   const handleAvatarChanged = async (e: React.ChangeEvent<HTMLInputElement>) => { | ||||
|     const files = e.target.files; | ||||
|     if (files && files.length > 0) { | ||||
|       const image = files[0]; | ||||
|       if (image.size > 2 * 1024 * 1024) { | ||||
|         toastHelper.error("Max file size is 2MB"); | ||||
|         return; | ||||
|       } | ||||
|       try { | ||||
|         const base64 = await convertFileToBase64(image); | ||||
|         setState((state) => { | ||||
|           return { | ||||
|             ...state, | ||||
|             avatarUrl: base64, | ||||
|           }; | ||||
|         }); | ||||
|       } catch (error) { | ||||
|         console.error(error); | ||||
|         toastHelper.error(`Failed to convert image to base64`); | ||||
|       } | ||||
|     } else { | ||||
|       toastHelper.error("Image not found"); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const handleNicknameChanged = (e: React.ChangeEvent<HTMLInputElement>) => { | ||||
|     setState((state) => { | ||||
|       return { | ||||
|  | @ -48,6 +77,7 @@ const UpdateAccountDialog: React.FC<Props> = ({ destroy }: Props) => { | |||
|       }; | ||||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|   const handleUsernameChanged = (e: React.ChangeEvent<HTMLInputElement>) => { | ||||
|     setState((state) => { | ||||
|       return { | ||||
|  | @ -56,6 +86,7 @@ const UpdateAccountDialog: React.FC<Props> = ({ destroy }: Props) => { | |||
|       }; | ||||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|   const handleEmailChanged = (e: React.ChangeEvent<HTMLInputElement>) => { | ||||
|     setState((state) => { | ||||
|       return { | ||||
|  | @ -82,6 +113,9 @@ const UpdateAccountDialog: React.FC<Props> = ({ destroy }: Props) => { | |||
|       const userPatch: UserPatch = { | ||||
|         id: user.id, | ||||
|       }; | ||||
|       if (!isEqual(user.avatarUrl, state.avatarUrl)) { | ||||
|         userPatch.avatarUrl = state.avatarUrl; | ||||
|       } | ||||
|       if (!isEqual(user.nickname, state.nickname)) { | ||||
|         userPatch.nickname = state.nickname; | ||||
|       } | ||||
|  | @ -108,23 +142,30 @@ const UpdateAccountDialog: React.FC<Props> = ({ destroy }: Props) => { | |||
|           <Icon.X /> | ||||
|         </button> | ||||
|       </div> | ||||
|       <div className="dialog-content-container"> | ||||
|         <p className="text-sm mb-1"> | ||||
|           {t("common.nickname")} | ||||
|           <span className="text-sm text-gray-400 ml-1">(Display in the banner)</span> | ||||
|         </p> | ||||
|         <input type="text" className="input-text" value={state.nickname} onChange={handleNicknameChanged} /> | ||||
|         <p className="text-sm mb-1 mt-2"> | ||||
|       <div className="dialog-content-container space-y-2"> | ||||
|         <div className="w-full flex flex-row justify-start items-center"> | ||||
|           <span className="text-sm mr-2">{t("common.avatar")}</span> | ||||
|           <label className="relative cursor-pointer hover:opacity-80"> | ||||
|             <UserAvatar className="!w-12 !h-12" avatarUrl={state.avatarUrl} /> | ||||
|             <input type="file" accept="image/*" className="absolute invisible w-full h-full inset-0" onChange={handleAvatarChanged} /> | ||||
|           </label> | ||||
|         </div> | ||||
|         <p className="text-sm"> | ||||
|           {t("common.username")} | ||||
|           <span className="text-sm text-gray-400 ml-1">(Using to sign in)</span> | ||||
|         </p> | ||||
|         <input type="text" className="input-text" value={state.username} onChange={handleUsernameChanged} /> | ||||
|         <p className="text-sm mb-1 mt-2"> | ||||
|         <p className="text-sm"> | ||||
|           {t("common.nickname")} | ||||
|           <span className="text-sm text-gray-400 ml-1">(Display in the banner)</span> | ||||
|         </p> | ||||
|         <input type="text" className="input-text" value={state.nickname} onChange={handleNicknameChanged} /> | ||||
|         <p className="text-sm"> | ||||
|           {t("common.email")} | ||||
|           <span className="text-sm text-gray-400 ml-1">(Optional)</span> | ||||
|         </p> | ||||
|         <input type="text" className="input-text" value={state.email} onChange={handleEmailChanged} /> | ||||
|         <div className="mt-4 w-full flex flex-row justify-end items-center space-x-2"> | ||||
|         <div className="pt-2 w-full flex flex-row justify-end items-center space-x-2"> | ||||
|           <span className="btn-text" onClick={handleCloseBtnClick}> | ||||
|             {t("common.cancel")} | ||||
|           </span> | ||||
|  |  | |||
							
								
								
									
										17
									
								
								web/src/components/UserAvatar.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								web/src/components/UserAvatar.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,17 @@ | |||
| import { MEMOS_LOGO_URL } from "../helpers/consts"; | ||||
| 
 | ||||
| interface Props { | ||||
|   avatarUrl?: string; | ||||
|   className?: string; | ||||
| } | ||||
| 
 | ||||
| const UserAvatar = (props: Props) => { | ||||
|   const { avatarUrl, className } = props; | ||||
|   return ( | ||||
|     <div className={`${className ?? ""} w-8 h-8 rounded-full bg-gray-100 dark:bg-zinc-800`}> | ||||
|       <img className="w-full h-full" src={avatarUrl || MEMOS_LOGO_URL} alt="" /> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default UserAvatar; | ||||
|  | @ -3,11 +3,10 @@ import { useTranslation } from "react-i18next"; | |||
| import { useLocationStore, useMemoStore, useTagStore, useUserStore } from "../store/module"; | ||||
| import { getMemoStats } from "../helpers/api"; | ||||
| import * as utils from "../helpers/utils"; | ||||
| import Icon from "./Icon"; | ||||
| import Dropdown from "./common/Dropdown"; | ||||
| import showArchivedMemoDialog from "./ArchivedMemoDialog"; | ||||
| import showAboutSiteDialog from "./AboutSiteDialog"; | ||||
| import "../less/user-banner.less"; | ||||
| import UserAvatar from "./UserAvatar"; | ||||
| 
 | ||||
| const UserBanner = () => { | ||||
|   const { t } = useTranslation(); | ||||
|  | @ -65,20 +64,29 @@ const UserBanner = () => { | |||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <div className="user-banner-container"> | ||||
|         <div className="username-container" onClick={handleUsernameClick}> | ||||
|           <span className="username-text">{username}</span> | ||||
|           {!isVisitorMode && user?.role === "HOST" ? <span className="tag">MOD</span> : null} | ||||
|         </div> | ||||
|       <div className="flex flex-row justify-between items-center relative w-full h-auto px-3 flex-nowrap shrink-0"> | ||||
|         <Dropdown | ||||
|           trigger={<Icon.MoreHorizontal className="ml-2 w-5 h-auto cursor-pointer dark:text-gray-200" />} | ||||
|           actionsClassName="min-w-36" | ||||
|           className="w-full" | ||||
|           trigger={ | ||||
|             <div | ||||
|               className="px-2 py-1 max-w-full flex flex-row justify-start items-center cursor-pointer rounded hover:shadow hover:bg-white dark:hover:bg-zinc-700" | ||||
|               onClick={handleUsernameClick} | ||||
|             > | ||||
|               <UserAvatar avatarUrl={user?.avatarUrl} /> | ||||
|               <span className="px-1 text-lg font-medium text-slate-800 dark:text-gray-200 shrink truncate">{username}</span> | ||||
|               {!isVisitorMode && user?.role === "HOST" ? ( | ||||
|                 <span className="text-xs px-1 bg-blue-600 dark:bg-blue-800 rounded text-white dark:text-gray-200 shadow">MOD</span> | ||||
|               ) : null} | ||||
|             </div> | ||||
|           } | ||||
|           actionsClassName="min-w-[128px] max-w-full" | ||||
|           positionClassName="top-full mt-2" | ||||
|           actions={ | ||||
|             <> | ||||
|               {!userStore.isVisitorMode() && ( | ||||
|                 <> | ||||
|                   <button | ||||
|                     className="w-full px-3 whitespace-nowrap text-left leading-10 cursor-pointer rounded dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-zinc-800" | ||||
|                     className="w-full px-3 truncate text-left leading-10 cursor-pointer rounded dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-zinc-800" | ||||
|                     onClick={handleArchivedBtnClick} | ||||
|                   > | ||||
|                     <span className="mr-1">🗃️</span> {t("sidebar.archived")} | ||||
|  | @ -86,14 +94,14 @@ const UserBanner = () => { | |||
|                 </> | ||||
|               )} | ||||
|               <button | ||||
|                 className="w-full px-3 whitespace-nowrap text-left leading-10 cursor-pointer rounded dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-zinc-800" | ||||
|                 className="w-full px-3 truncate text-left leading-10 cursor-pointer rounded dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-zinc-800" | ||||
|                 onClick={handleAboutBtnClick} | ||||
|               > | ||||
|                 <span className="mr-1">🤠</span> {t("common.about")} | ||||
|               </button> | ||||
|               {!userStore.isVisitorMode() && ( | ||||
|                 <button | ||||
|                   className="w-full px-3 whitespace-nowrap text-left leading-10 cursor-pointer rounded dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-zinc-800" | ||||
|                   className="w-full px-3 truncate text-left leading-10 cursor-pointer rounded dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-zinc-800" | ||||
|                   onClick={handleSignOutBtnClick} | ||||
|                 > | ||||
|                   <span className="mr-1">👋</span> {t("common.sign-out")} | ||||
|  | @ -103,18 +111,18 @@ const UserBanner = () => { | |||
|           } | ||||
|         /> | ||||
|       </div> | ||||
|       <div className="amount-text-container"> | ||||
|         <div className="status-text memos-text"> | ||||
|           <span className="amount-text">{memoAmount}</span> | ||||
|           <span className="type-text">{t("amount-text.memo", { count: memoAmount })}</span> | ||||
|       <div className="flex flex-row justify-between items-start w-full px-6 select-none shrink-0 pb-2"> | ||||
|         <div className="flex flex-col justify-start items-start"> | ||||
|           <span className="font-bold text-2xl opacity-80 leading-10 text-slate-600 dark:text-gray-300">{memoAmount}</span> | ||||
|           <span className="text-gray-400 text-xs font-mono">{t("amount-text.memo", { count: memoAmount })}</span> | ||||
|         </div> | ||||
|         <div className="status-text tags-text"> | ||||
|           <span className="amount-text">{tags.length}</span> | ||||
|           <span className="type-text">{t("amount-text.tag", { count: tags.length })}</span> | ||||
|         <div className="flex flex-col justify-start items-start"> | ||||
|           <span className="font-bold text-2xl opacity-80 leading-10 text-slate-600 dark:text-gray-300">{tags.length}</span> | ||||
|           <span className="text-gray-400 text-xs font-mono">{t("amount-text.tag", { count: tags.length })}</span> | ||||
|         </div> | ||||
|         <div className="status-text duration-text"> | ||||
|           <span className="amount-text">{createdDays}</span> | ||||
|           <span className="type-text">{t("amount-text.day", { count: createdDays })}</span> | ||||
|         <div className="flex flex-col justify-start items-start"> | ||||
|           <span className="font-bold text-2xl opacity-80 leading-10 text-slate-600 dark:text-gray-300">{createdDays}</span> | ||||
|           <span className="text-gray-400 text-xs font-mono">{t("amount-text.day", { count: createdDays })}</span> | ||||
|         </div> | ||||
|       </div> | ||||
|     </> | ||||
|  |  | |||
|  | @ -44,8 +44,8 @@ const Dropdown: React.FC<Props> = (props: Props) => { | |||
|       )} | ||||
|       <div | ||||
|         className={`w-auto absolute flex flex-col justify-start items-start bg-white dark:bg-zinc-700 z-10 p-1 rounded-md shadow ${ | ||||
|           actionsClassName ?? "" | ||||
|         } ${dropdownStatus ? "" : "!hidden"} ${positionClassName ?? "top-full right-0 mt-1"}`}
 | ||||
|           dropdownStatus ? "" : "!hidden" | ||||
|         } ${actionsClassName ?? ""} ${positionClassName ?? "top-full right-0 mt-1"}`}
 | ||||
|       > | ||||
|         {actions} | ||||
|       </div> | ||||
|  |  | |||
|  | @ -23,3 +23,5 @@ export const TAB_SPACE_WIDTH = 2; | |||
| 
 | ||||
| // default fetch memo amount
 | ||||
| export const DEFAULT_MEMO_LIMIT = 30; | ||||
| 
 | ||||
| export const MEMOS_LOGO_URL = "https://usememos.com/logo.png"; | ||||
|  |  | |||
|  | @ -148,3 +148,12 @@ export function getSystemColorScheme() { | |||
|     return "light"; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export function convertFileToBase64(file: File): Promise<string> { | ||||
|   return new Promise<string>((resolve, reject) => { | ||||
|     const reader = new FileReader(); | ||||
|     reader.readAsDataURL(file); | ||||
|     reader.onload = () => resolve(reader.result?.toString() || ""); | ||||
|     reader.onerror = (error) => reject(error); | ||||
|   }); | ||||
| } | ||||
|  |  | |||
|  | @ -1,5 +1,6 @@ | |||
| .toast-list-container { | ||||
|   @apply flex flex-col justify-start items-end fixed top-2 right-4 z-1000 max-h-full; | ||||
|   @apply flex flex-col justify-start items-end fixed top-2 right-4 max-h-full; | ||||
|   z-index: 9999; | ||||
| 
 | ||||
|   > .toast-wrapper { | ||||
|     @apply flex flex-col justify-start items-start relative left-full invisible text-base cursor-pointer shadow-lg rounded bg-white mt-6 py-2 px-4; | ||||
|  |  | |||
|  | @ -1,43 +0,0 @@ | |||
| .user-banner-container { | ||||
|   @apply flex flex-row justify-between items-center relative w-full h-10 px-6 flex-nowrap mb-1 shrink-0; | ||||
| 
 | ||||
|   > .username-container { | ||||
|     @apply shrink flex flex-row justify-start items-center flex-nowrap truncate; | ||||
| 
 | ||||
|     > .username-text { | ||||
|       @apply font-bold text-lg pr-1 text-slate-800 dark:text-gray-200 cursor-pointer shrink truncate; | ||||
|     } | ||||
| 
 | ||||
|     > .tag { | ||||
|       @apply text-xs px-1 bg-blue-600 dark:bg-blue-800 rounded text-white dark:text-gray-200 shadow; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   > .action-btn { | ||||
|     @apply shrink-0 select-none border-none; | ||||
| 
 | ||||
|     &.menu-popup-btn { | ||||
|       @apply flex flex-col justify-center items-center w-9 h-10 -mr-2 cursor-pointer; | ||||
| 
 | ||||
|       > .icon-img { | ||||
|         @apply w-5 h-auto; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .amount-text-container { | ||||
|   @apply flex flex-row justify-between items-start w-full px-6 select-none shrink-0 pb-4; | ||||
| 
 | ||||
|   > .status-text { | ||||
|     @apply flex flex-col justify-start items-start; | ||||
| 
 | ||||
|     > .amount-text { | ||||
|       @apply font-bold text-2xl opacity-80 leading-10 text-slate-600 dark:text-gray-300; | ||||
|     } | ||||
| 
 | ||||
|     > .type-text { | ||||
|       @apply text-gray-400 text-xs font-mono; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | @ -7,6 +7,7 @@ | |||
|     "repeat-password": "Repeat the password", | ||||
|     "new-password": "New password", | ||||
|     "repeat-new-password": "Repeat the new password", | ||||
|     "avatar": "Avatar", | ||||
|     "username": "Username", | ||||
|     "nickname": "Nickname", | ||||
|     "save": "Save", | ||||
|  |  | |||
							
								
								
									
										2
									
								
								web/src/types/modules/user.d.ts
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								web/src/types/modules/user.d.ts
									
										
									
									
										vendored
									
									
								
							|  | @ -13,6 +13,7 @@ interface User { | |||
|   email: string; | ||||
|   nickname: string; | ||||
|   openId: string; | ||||
|   avatarUrl: string; | ||||
|   userSettingList: UserSetting[]; | ||||
| 
 | ||||
|   setting: Setting; | ||||
|  | @ -31,6 +32,7 @@ interface UserPatch { | |||
|   username?: string; | ||||
|   email?: string; | ||||
|   nickname?: string; | ||||
|   avatarUrl?: string; | ||||
|   password?: string; | ||||
|   resetOpenId?: boolean; | ||||
| } | ||||
|  |  | |||
		Loading…
	
	Add table
		
		Reference in a new issue