| from flask_restful import fields # type: ignore | from flask_restful import fields # type: ignore | ||||
| from libs.helper import TimestampField | |||||
| from libs.helper import AvatarUrlField, TimestampField | |||||
| simple_account_fields = {"id": fields.String, "name": fields.String, "email": fields.String} | simple_account_fields = {"id": fields.String, "name": fields.String, "email": fields.String} | ||||
| "id": fields.String, | "id": fields.String, | ||||
| "name": fields.String, | "name": fields.String, | ||||
| "avatar": fields.String, | "avatar": fields.String, | ||||
| "avatar_url": AvatarUrlField, | |||||
| "email": fields.String, | "email": fields.String, | ||||
| "is_password_set": fields.Boolean, | "is_password_set": fields.Boolean, | ||||
| "interface_language": fields.String, | "interface_language": fields.String, | ||||
| "id": fields.String, | "id": fields.String, | ||||
| "name": fields.String, | "name": fields.String, | ||||
| "avatar": fields.String, | "avatar": fields.String, | ||||
| "avatar_url": AvatarUrlField, | |||||
| "email": fields.String, | "email": fields.String, | ||||
| "last_login_at": TimestampField, | "last_login_at": TimestampField, | ||||
| "last_active_at": TimestampField, | "last_active_at": TimestampField, |
| return None | return None | ||||
| class AvatarUrlField(fields.Raw): | |||||
| def output(self, key, obj): | |||||
| if obj is None: | |||||
| return None | |||||
| from models.account import Account | |||||
| if isinstance(obj, Account) and obj.avatar is not None: | |||||
| return file_helpers.get_signed_file_url(obj.avatar) | |||||
| return None | |||||
| class TimestampField(fields.Raw): | class TimestampField(fields.Raw): | ||||
| def format(self, value) -> int: | def format(self, value) -> int: | ||||
| return int(value.timestamp()) | return int(value.timestamp()) |
| 'use client' | |||||
| import type { Area } from 'react-easy-crop' | |||||
| import React, { useCallback, useState } from 'react' | |||||
| import { useTranslation } from 'react-i18next' | |||||
| import { useContext } from 'use-context-selector' | |||||
| import { RiPencilLine } from '@remixicon/react' | |||||
| import { updateUserProfile } from '@/service/common' | |||||
| import { ToastContext } from '@/app/components/base/toast' | |||||
| import ImageInput, { type OnImageInput } from '@/app/components/base/app-icon-picker/ImageInput' | |||||
| import Modal from '@/app/components/base/modal' | |||||
| import Divider from '@/app/components/base/divider' | |||||
| import Button from '@/app/components/base/button' | |||||
| import Avatar, { type AvatarProps } from '@/app/components/base/avatar' | |||||
| import { useLocalFileUploader } from '@/app/components/base/image-uploader/hooks' | |||||
| import type { ImageFile } from '@/types/app' | |||||
| import getCroppedImg from '@/app/components/base/app-icon-picker/utils' | |||||
| import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config' | |||||
| type InputImageInfo = { file: File } | { tempUrl: string; croppedAreaPixels: Area; fileName: string } | |||||
| type AvatarWithEditProps = AvatarProps & { onSave?: () => void } | |||||
| const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => { | |||||
| const { t } = useTranslation() | |||||
| const { notify } = useContext(ToastContext) | |||||
| const [inputImageInfo, setInputImageInfo] = useState<InputImageInfo>() | |||||
| const [isShowAvatarPicker, setIsShowAvatarPicker] = useState(false) | |||||
| const [uploading, setUploading] = useState(false) | |||||
| const handleImageInput: OnImageInput = useCallback(async (isCropped: boolean, fileOrTempUrl: string | File, croppedAreaPixels?: Area, fileName?: string) => { | |||||
| setInputImageInfo( | |||||
| isCropped | |||||
| ? { tempUrl: fileOrTempUrl as string, croppedAreaPixels: croppedAreaPixels!, fileName: fileName! } | |||||
| : { file: fileOrTempUrl as File }, | |||||
| ) | |||||
| }, [setInputImageInfo]) | |||||
| const handleSaveAvatar = useCallback(async (uploadedFileId: string) => { | |||||
| try { | |||||
| await updateUserProfile({ url: 'account/avatar', body: { avatar: uploadedFileId } }) | |||||
| notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') }) | |||||
| setIsShowAvatarPicker(false) | |||||
| onSave?.() | |||||
| } | |||||
| catch (e) { | |||||
| notify({ type: 'error', message: (e as Error).message }) | |||||
| } | |||||
| }, [notify, onSave, t]) | |||||
| const { handleLocalFileUpload } = useLocalFileUploader({ | |||||
| limit: 3, | |||||
| disabled: false, | |||||
| onUpload: (imageFile: ImageFile) => { | |||||
| if (imageFile.progress === 100) { | |||||
| setUploading(false) | |||||
| setInputImageInfo(undefined) | |||||
| handleSaveAvatar(imageFile.fileId) | |||||
| } | |||||
| // Error | |||||
| if (imageFile.progress === -1) | |||||
| setUploading(false) | |||||
| }, | |||||
| }) | |||||
| const handleSelect = useCallback(async () => { | |||||
| if (!inputImageInfo) | |||||
| return | |||||
| setUploading(true) | |||||
| if ('file' in inputImageInfo) { | |||||
| handleLocalFileUpload(inputImageInfo.file) | |||||
| return | |||||
| } | |||||
| const blob = await getCroppedImg(inputImageInfo.tempUrl, inputImageInfo.croppedAreaPixels, inputImageInfo.fileName) | |||||
| const file = new File([blob], inputImageInfo.fileName, { type: blob.type }) | |||||
| handleLocalFileUpload(file) | |||||
| }, [handleLocalFileUpload, inputImageInfo]) | |||||
| if (DISABLE_UPLOAD_IMAGE_AS_ICON) | |||||
| return <Avatar {...props} /> | |||||
| return ( | |||||
| <> | |||||
| <div> | |||||
| <div className="relative group"> | |||||
| <Avatar {...props} /> | |||||
| <div | |||||
| onClick={() => { setIsShowAvatarPicker(true) }} | |||||
| className="absolute inset-0 bg-black bg-opacity-50 rounded-full opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer flex items-center justify-center" | |||||
| > | |||||
| <span className="text-white text-xs"> | |||||
| <RiPencilLine /> | |||||
| </span> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| <Modal | |||||
| closable | |||||
| className="!w-[362px] !p-0" | |||||
| isShow={isShowAvatarPicker} | |||||
| onClose={() => setIsShowAvatarPicker(false)} | |||||
| > | |||||
| <ImageInput onImageInput={handleImageInput} cropShape='round' /> | |||||
| <Divider className='m-0' /> | |||||
| <div className='w-full flex items-center justify-center p-3 gap-2'> | |||||
| <Button className='w-full' onClick={() => setIsShowAvatarPicker(false)}> | |||||
| {t('app.iconPicker.cancel')} | |||||
| </Button> | |||||
| <Button variant="primary" className='w-full' disabled={uploading || !inputImageInfo} loading={uploading} onClick={handleSelect}> | |||||
| {t('app.iconPicker.ok')} | |||||
| </Button> | |||||
| </div> | |||||
| </Modal> | |||||
| </> | |||||
| ) | |||||
| } | |||||
| export default AvatarWithEdit |
| import { useContext } from 'use-context-selector' | import { useContext } from 'use-context-selector' | ||||
| import DeleteAccount from '../delete-account' | import DeleteAccount from '../delete-account' | ||||
| import s from './index.module.css' | import s from './index.module.css' | ||||
| import AvatarWithEdit from './AvatarWithEdit' | |||||
| import Collapse from '@/app/components/header/account-setting/collapse' | import Collapse from '@/app/components/header/account-setting/collapse' | ||||
| import type { IItem } from '@/app/components/header/account-setting/collapse' | import type { IItem } from '@/app/components/header/account-setting/collapse' | ||||
| import Modal from '@/app/components/base/modal' | import Modal from '@/app/components/base/modal' | ||||
| import { useAppContext } from '@/context/app-context' | import { useAppContext } from '@/context/app-context' | ||||
| import { ToastContext } from '@/app/components/base/toast' | import { ToastContext } from '@/app/components/base/toast' | ||||
| import AppIcon from '@/app/components/base/app-icon' | import AppIcon from '@/app/components/base/app-icon' | ||||
| import Avatar from '@/app/components/base/avatar' | |||||
| import { IS_CE_EDITION } from '@/config' | import { IS_CE_EDITION } from '@/config' | ||||
| import Input from '@/app/components/base/input' | import Input from '@/app/components/base/input' | ||||
| <h4 className='title-2xl-semi-bold text-text-primary'>{t('common.account.myAccount')}</h4> | <h4 className='title-2xl-semi-bold text-text-primary'>{t('common.account.myAccount')}</h4> | ||||
| </div> | </div> | ||||
| <div className='mb-8 p-6 rounded-xl flex items-center bg-gradient-to-r from-background-gradient-bg-fill-chat-bg-2 to-background-gradient-bg-fill-chat-bg-1'> | <div className='mb-8 p-6 rounded-xl flex items-center bg-gradient-to-r from-background-gradient-bg-fill-chat-bg-2 to-background-gradient-bg-fill-chat-bg-1'> | ||||
| <Avatar name={userProfile.name} size={64} /> | |||||
| <AvatarWithEdit avatar={userProfile.avatar_url} name={userProfile.name} onSave={ mutateUserProfile } size={64} /> | |||||
| <div className='ml-4'> | <div className='ml-4'> | ||||
| <p className='system-xl-semibold text-text-primary'>{userProfile.name}</p> | <p className='system-xl-semibold text-text-primary'>{userProfile.name}</p> | ||||
| <p className='system-xs-regular text-text-tertiary'>{userProfile.email}</p> | <p className='system-xs-regular text-text-tertiary'>{userProfile.email}</p> |
| ${open && 'bg-components-panel-bg-blur'} | ${open && 'bg-components-panel-bg-blur'} | ||||
| `} | `} | ||||
| > | > | ||||
| <Avatar name={userProfile.name} size={32} /> | |||||
| <Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={32} /> | |||||
| </Menu.Button> | </Menu.Button> | ||||
| </div> | </div> | ||||
| <Transition | <Transition | ||||
| <div className='system-md-medium text-text-primary break-all'>{userProfile.name}</div> | <div className='system-md-medium text-text-primary break-all'>{userProfile.name}</div> | ||||
| <div className='system-xs-regular text-text-tertiary break-all'>{userProfile.email}</div> | <div className='system-xs-regular text-text-tertiary break-all'>{userProfile.email}</div> | ||||
| </div> | </div> | ||||
| <Avatar name={userProfile.name} size={32} /> | |||||
| <Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={32} /> | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| </Menu.Item> | </Menu.Item> |
| suggestedQuestions={suggestedQuestions} | suggestedQuestions={suggestedQuestions} | ||||
| onSend={doSend} | onSend={doSend} | ||||
| showPromptLog | showPromptLog | ||||
| questionIcon={<Avatar name={userProfile.name} size={40} />} | |||||
| questionIcon={<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={40} />} | |||||
| allToolIcons={allToolIcons} | allToolIcons={allToolIcons} | ||||
| hideLogModal | hideLogModal | ||||
| noSpacing | noSpacing |
| onRegenerate={doRegenerate} | onRegenerate={doRegenerate} | ||||
| onStopResponding={handleStop} | onStopResponding={handleStop} | ||||
| showPromptLog | showPromptLog | ||||
| questionIcon={<Avatar name={userProfile.name} size={40} />} | |||||
| questionIcon={<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={40} />} | |||||
| allToolIcons={allToolIcons} | allToolIcons={allToolIcons} | ||||
| onAnnotationEdited={handleAnnotationEdited} | onAnnotationEdited={handleAnnotationEdited} | ||||
| onAnnotationAdded={handleAnnotationAdded} | onAnnotationAdded={handleAnnotationAdded} |
| import type { ChangeEvent, FC } from 'react' | import type { ChangeEvent, FC } from 'react' | ||||
| import { createRef, useEffect, useState } from 'react' | import { createRef, useEffect, useState } from 'react' | ||||
| import type { Area } from 'react-easy-crop' | |||||
| import Cropper from 'react-easy-crop' | |||||
| import Cropper, { type Area, type CropperProps } from 'react-easy-crop' | |||||
| import classNames from 'classnames' | import classNames from 'classnames' | ||||
| import { ImagePlus } from '../icons/src/vender/line/images' | import { ImagePlus } from '../icons/src/vender/line/images' | ||||
| type UploaderProps = { | type UploaderProps = { | ||||
| className?: string | className?: string | ||||
| cropShape?: CropperProps['cropShape'] | |||||
| onImageInput?: OnImageInput | onImageInput?: OnImageInput | ||||
| } | } | ||||
| const ImageInput: FC<UploaderProps> = ({ | const ImageInput: FC<UploaderProps> = ({ | ||||
| className, | className, | ||||
| cropShape, | |||||
| onImageInput, | onImageInput, | ||||
| }) => { | }) => { | ||||
| const [inputImage, setInputImage] = useState<{ file: File; url: string }>() | const [inputImage, setInputImage] = useState<{ file: File; url: string }>() | ||||
| crop={crop} | crop={crop} | ||||
| zoom={zoom} | zoom={zoom} | ||||
| aspect={1} | aspect={1} | ||||
| cropShape={cropShape} | |||||
| onCropChange={setCrop} | onCropChange={setCrop} | ||||
| onCropComplete={onCropComplete} | onCropComplete={onCropComplete} | ||||
| onZoomChange={setZoom} | onZoomChange={setZoom} |
| import { useState } from 'react' | import { useState } from 'react' | ||||
| import cn from '@/utils/classnames' | import cn from '@/utils/classnames' | ||||
| type AvatarProps = { | |||||
| export type AvatarProps = { | |||||
| name: string | name: string | ||||
| avatar?: string | |||||
| avatar: string | null | |||||
| size?: number | size?: number | ||||
| className?: string | className?: string | ||||
| textClassName?: string | textClassName?: string |
| > | > | ||||
| {permission === 'only_me' && ( | {permission === 'only_me' && ( | ||||
| <div className={cn('flex items-center px-3 py-[6px] rounded-lg bg-gray-100 cursor-pointer hover:bg-gray-200', open && 'bg-gray-200', disabled && 'hover:!bg-gray-100 !cursor-default')}> | <div className={cn('flex items-center px-3 py-[6px] rounded-lg bg-gray-100 cursor-pointer hover:bg-gray-200', open && 'bg-gray-200', disabled && 'hover:!bg-gray-100 !cursor-default')}> | ||||
| <Avatar name={userProfile.name} className='shrink-0 mr-2' size={24} /> | |||||
| <Avatar avatar={userProfile.avatar_url} name={userProfile.name} className='shrink-0 mr-2' size={24} /> | |||||
| <div className='grow mr-2 text-gray-900 text-sm leading-5'>{t('datasetSettings.form.permissionsOnlyMe')}</div> | <div className='grow mr-2 text-gray-900 text-sm leading-5'>{t('datasetSettings.form.permissionsOnlyMe')}</div> | ||||
| {!disabled && <RiArrowDownSLine className='shrink-0 w-4 h-4 text-gray-700' />} | {!disabled && <RiArrowDownSLine className='shrink-0 w-4 h-4 text-gray-700' />} | ||||
| </div> | </div> | ||||
| setOpen(false) | setOpen(false) | ||||
| }}> | }}> | ||||
| <div className='flex items-center gap-2'> | <div className='flex items-center gap-2'> | ||||
| <Avatar name={userProfile.name} className='shrink-0 mr-2' size={24} /> | |||||
| <Avatar avatar={userProfile.avatar_url} name={userProfile.name} className='shrink-0 mr-2' size={24} /> | |||||
| <div className='grow mr-2 text-gray-900 text-sm leading-5'>{t('datasetSettings.form.permissionsOnlyMe')}</div> | <div className='grow mr-2 text-gray-900 text-sm leading-5'>{t('datasetSettings.form.permissionsOnlyMe')}</div> | ||||
| {permission === 'only_me' && <Check className='w-4 h-4 text-primary-600' />} | {permission === 'only_me' && <Check className='w-4 h-4 text-primary-600' />} | ||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| {showMe && ( | {showMe && ( | ||||
| <div className='pl-3 pr-[10px] py-1 flex gap-2 items-center rounded-lg'> | <div className='pl-3 pr-[10px] py-1 flex gap-2 items-center rounded-lg'> | ||||
| <Avatar name={userProfile.name} className='shrink-0' size={24} /> | |||||
| <Avatar avatar={userProfile.avatar_url} name={userProfile.name} className='shrink-0' size={24} /> | |||||
| <div className='grow'> | <div className='grow'> | ||||
| <div className='text-[13px] text-gray-700 font-medium leading-[18px] truncate'> | <div className='text-[13px] text-gray-700 font-medium leading-[18px] truncate'> | ||||
| {userProfile.name} | {userProfile.name} | ||||
| )} | )} | ||||
| {filteredMemberList.map(member => ( | {filteredMemberList.map(member => ( | ||||
| <div key={member.id} className='pl-3 pr-[10px] py-1 flex gap-2 items-center rounded-lg hover:bg-gray-100 cursor-pointer' onClick={() => selectMember(member)}> | <div key={member.id} className='pl-3 pr-[10px] py-1 flex gap-2 items-center rounded-lg hover:bg-gray-100 cursor-pointer' onClick={() => selectMember(member)}> | ||||
| <Avatar name={member.name} className='shrink-0' size={24} /> | |||||
| <Avatar avatar={userProfile.avatar_url} name={member.name} className='shrink-0' size={24} /> | |||||
| <div className='grow'> | <div className='grow'> | ||||
| <div className='text-[13px] text-gray-700 font-medium leading-[18px] truncate'>{member.name}</div> | <div className='text-[13px] text-gray-700 font-medium leading-[18px] truncate'>{member.name}</div> | ||||
| <div className='text-xs text-gray-500 leading-[18px] truncate'>{member.email}</div> | <div className='text-xs text-gray-500 leading-[18px] truncate'>{member.email}</div> |
| ${open && 'bg-gray-200'} | ${open && 'bg-gray-200'} | ||||
| `} | `} | ||||
| > | > | ||||
| <Avatar name={userProfile.name} className='sm:mr-2 mr-0' size={32} /> | |||||
| <Avatar avatar={userProfile.avatar_url} name={userProfile.name} className='sm:mr-2 mr-0' size={32} /> | |||||
| {!isMobile && <> | {!isMobile && <> | ||||
| {userProfile.name} | {userProfile.name} | ||||
| <RiArrowDownSLine className="w-3 h-3 ml-1 text-gray-700" /> | <RiArrowDownSLine className="w-3 h-3 ml-1 text-gray-700" /> | ||||
| > | > | ||||
| <Menu.Item disabled> | <Menu.Item disabled> | ||||
| <div className='flex flex-nowrap items-center px-4 py-[13px]'> | <div className='flex flex-nowrap items-center px-4 py-[13px]'> | ||||
| <Avatar name={userProfile.name} size={36} className='mr-3' /> | |||||
| <Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={36} className='mr-3' /> | |||||
| <div className='grow'> | <div className='grow'> | ||||
| <div className='system-md-medium text-text-primary break-all'>{userProfile.name}</div> | <div className='system-md-medium text-text-primary break-all'>{userProfile.name}</div> | ||||
| <div className='system-xs-regular text-text-tertiary break-all'>{userProfile.email}</div> | <div className='system-xs-regular text-text-tertiary break-all'>{userProfile.email}</div> |
| accounts.map(account => ( | accounts.map(account => ( | ||||
| <div key={account.id} className='flex border-b border-divider-subtle'> | <div key={account.id} className='flex border-b border-divider-subtle'> | ||||
| <div className='grow flex items-center py-2 px-3'> | <div className='grow flex items-center py-2 px-3'> | ||||
| <Avatar size={24} className='mr-2' name={account.name} /> | |||||
| <Avatar avatar={account.avatar_url} size={24} className='mr-2' name={account.name} /> | |||||
| <div className=''> | <div className=''> | ||||
| <div className='text-text-secondary system-sm-medium'> | <div className='text-text-secondary system-sm-medium'> | ||||
| {account.name} | {account.name} |
| name: string | name: string | ||||
| email: string | email: string | ||||
| avatar: string | avatar: string | ||||
| avatar_url: string | null | |||||
| is_password_set: boolean | is_password_set: boolean | ||||
| interface_language?: string | interface_language?: string | ||||
| interface_theme?: string | interface_theme?: string | ||||
| trial_end_reason: null | 'trial_exceeded' | 'using_custom' | trial_end_reason: null | 'trial_exceeded' | 'using_custom' | ||||
| } | } | ||||
| export type Member = Pick<UserProfileResponse, 'id' | 'name' | 'email' | 'last_login_at' | 'last_active_at' | 'created_at'> & { | |||||
| export type Member = Pick<UserProfileResponse, 'id' | 'name' | 'email' | 'last_login_at' | 'last_active_at' | 'created_at' | 'avatar_url'> & { | |||||
| avatar: string | avatar: string | ||||
| status: 'pending' | 'active' | 'banned' | 'closed' | status: 'pending' | 'active' | 'banned' | 'closed' | ||||
| role: 'owner' | 'admin' | 'editor' | 'normal' | 'dataset_operator' | role: 'owner' | 'admin' | 'editor' | 'normal' | 'dataset_operator' |