diff --git a/ee/tabby-ui/app/(dashboard)/profile/components/avatar.tsx b/ee/tabby-ui/app/(dashboard)/profile/components/avatar.tsx index afff226acda8..a576261f384a 100644 --- a/ee/tabby-ui/app/(dashboard)/profile/components/avatar.tsx +++ b/ee/tabby-ui/app/(dashboard)/profile/components/avatar.tsx @@ -1,17 +1,125 @@ -import NiceAvatar, { genConfig } from 'react-nice-avatar' +'use client' +import { ChangeEvent, useState } from 'react' +import { toast } from 'sonner' + +import { graphql } from '@/lib/gql/generates' import { useMe } from '@/lib/hooks/use-me' +import { useMutation } from '@/lib/tabby/gql' +import { delay } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { IconCloudUpload, IconSpinner } from '@/components/ui/icons' +import { Separator } from '@/components/ui/separator' +import { mutateAvatar, UserAvatar } from '@/components/user-avatar' + +const uploadUserAvatarMutation = graphql(/* GraphQL */ ` + mutation uploadUserAvatarBase64($id: ID!, $avatarBase64: String!) { + uploadUserAvatarBase64(id: $id, avatarBase64: $avatarBase64) + } +`) + +const MAX_UPLOAD_SIZE_KB = 500 export const Avatar = () => { + const [isSubmitting, setIsSubmitting] = useState(false) + const [uploadedImgString, setUploadedImgString] = useState('') const [{ data }] = useMe() - + const uploadUserAvatar = useMutation(uploadUserAvatarMutation, { + onError(err) { + toast.error(err.message) + } + }) if (!data?.me?.email) return null - const config = genConfig(data?.me?.email) + const onPreviewAvatar = (e: ChangeEvent) => { + const file = e.target.files ? e.target.files[0] : null + + if (file) { + const fileSizeInKB = parseFloat((file.size / 1024).toFixed(2)) + if (fileSizeInKB > MAX_UPLOAD_SIZE_KB) { + return toast.error( + `The image you are attempting to upload is too large. Please ensure the file size is under ${MAX_UPLOAD_SIZE_KB}KB and try again.` + ) + } + + const reader = new FileReader() + + reader.onloadend = () => { + const imageString = reader.result as string + setUploadedImgString(imageString) + } + + reader.readAsDataURL(file) + } + } + + const onUploadAvatar = async () => { + setIsSubmitting(true) + + const response = await uploadUserAvatar({ + avatarBase64: uploadedImgString.split(',')[1], + id: data.me.id + }) + + if (response?.data?.uploadUserAvatarBase64 === true) { + await delay(1000) + mutateAvatar(data.me.id) + toast.success('Successfully updated your profile picture!') + + await delay(200) + setUploadedImgString('') + } + + setIsSubmitting(false) + } return ( -
- +
+
+ + + {uploadedImgString && ( + avatar to be uploaded + )} + +
+ + + +
+ + +
+

+ {`Square image recommended. Accepted file types: .png, .jpg. Max file size: ${MAX_UPLOAD_SIZE_KB}KB.`} +

+
+
) } diff --git a/ee/tabby-ui/app/(dashboard)/profile/components/change-password.tsx b/ee/tabby-ui/app/(dashboard)/profile/components/change-password.tsx index 5a1b0f934021..7bc922bebc12 100644 --- a/ee/tabby-ui/app/(dashboard)/profile/components/change-password.tsx +++ b/ee/tabby-ui/app/(dashboard)/profile/components/change-password.tsx @@ -132,11 +132,11 @@ const ChangePasswordForm: React.FC = ({
-
diff --git a/ee/tabby-ui/app/(dashboard)/profile/components/profile.tsx b/ee/tabby-ui/app/(dashboard)/profile/components/profile.tsx index 445fa1b1c5f0..5f9567003b50 100644 --- a/ee/tabby-ui/app/(dashboard)/profile/components/profile.tsx +++ b/ee/tabby-ui/app/(dashboard)/profile/components/profile.tsx @@ -20,7 +20,7 @@ export default function Profile() { diff --git a/ee/tabby-ui/components/ui/avatar.tsx b/ee/tabby-ui/components/ui/avatar.tsx new file mode 100644 index 000000000000..4ea9b93066b3 --- /dev/null +++ b/ee/tabby-ui/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +'use client' + +import * as React from 'react' +import * as AvatarPrimitive from '@radix-ui/react-avatar' + +import { cn } from '@/lib/utils' + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/ee/tabby-ui/components/ui/icons.tsx b/ee/tabby-ui/components/ui/icons.tsx index eb50cdacacfc..f69d5bda6469 100644 --- a/ee/tabby-ui/components/ui/icons.tsx +++ b/ee/tabby-ui/components/ui/icons.tsx @@ -1188,6 +1188,26 @@ function IconFolderGit({ className, ...props }: React.ComponentProps<'svg'>) { ) } +function IconCloudUpload({ className, ...props }: React.ComponentProps<'svg'>) { + return ( + + + + + + ) +} + export { IconEdit, IconNextChat, @@ -1248,5 +1268,6 @@ export { IconCheckCircled, IconCrossCircled, IconInfoCircled, - IconFolderGit + IconFolderGit, + IconCloudUpload } diff --git a/ee/tabby-ui/components/user-avatar.tsx b/ee/tabby-ui/components/user-avatar.tsx new file mode 100644 index 000000000000..5c8ff57eb20d --- /dev/null +++ b/ee/tabby-ui/components/user-avatar.tsx @@ -0,0 +1,57 @@ +import NiceAvatar, { genConfig } from 'react-nice-avatar' +import { mutate } from 'swr' +import useSWRImmutable from 'swr/immutable' + +import { useMe } from '@/lib/hooks/use-me' +import fetcher from '@/lib/tabby/fetcher' +import { cn } from '@/lib/utils' +import { + Avatar as AvatarComponent, + AvatarFallback, + AvatarImage +} from '@/components/ui/avatar' +import { Skeleton } from '@/components/ui/skeleton' + +export function UserAvatar({ className }: { className?: string }) { + const [{ data }] = useMe() + const avatarUrl = !data?.me?.email ? null : `/avatar/${data.me.id}` + const { data: avatarImageSrc, isLoading } = useSWRImmutable( + avatarUrl, + (url: string) => { + return fetcher(url, { + responseFormatter: async response => { + if (!response.ok) return undefined + const blob = await response.blob() + const buffer = Buffer.from(await blob.arrayBuffer()) + return `data:image/png;base64,${buffer.toString('base64')}` + } + }) + } + ) + + if (!data?.me?.email) return null + + if (isLoading) { + return + } + + if (!avatarImageSrc) { + const config = genConfig(data.me.email) + return + } + + return ( + + + {data.me?.email.substring(0, 2)} + + ) +} + +export const mutateAvatar = (userId: string) => { + mutate(`/avatar/${userId}`) +} diff --git a/ee/tabby-ui/components/user-panel.tsx b/ee/tabby-ui/components/user-panel.tsx index 3a38cf8af6cf..78400d2f608b 100644 --- a/ee/tabby-ui/components/user-panel.tsx +++ b/ee/tabby-ui/components/user-panel.tsx @@ -1,5 +1,4 @@ import React from 'react' -import NiceAvatar, { genConfig } from 'react-nice-avatar' import { useMe } from '@/lib/hooks/use-me' import { useIsChatEnabled } from '@/lib/hooks/use-server-info' @@ -12,6 +11,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { UserAvatar } from '@/components/user-avatar' import { IconBackpack, @@ -39,14 +39,10 @@ export default function UserPanel() { return } - const config = genConfig(user.email) - return ( - - - + {user.email} diff --git a/ee/tabby-ui/lib/utils.ts b/ee/tabby-ui/lib/utils.ts index 6fef265023e9..2a8b334b1c9b 100644 --- a/ee/tabby-ui/lib/utils.ts +++ b/ee/tabby-ui/lib/utils.ts @@ -72,3 +72,9 @@ export function truncateText( export const isClientSide = () => { return typeof window !== 'undefined' } + +export const delay = (ms: number) => { + return new Promise(resolve => { + setTimeout(() => resolve(null), ms) + }) +} diff --git a/ee/tabby-ui/package.json b/ee/tabby-ui/package.json index 7b115dae7fb1..1ed6a1a5f2a4 100644 --- a/ee/tabby-ui/package.json +++ b/ee/tabby-ui/package.json @@ -26,6 +26,7 @@ "@curvenote/ansi-to-react": "^7.0.0", "@hookform/resolvers": "^3.3.2", "@radix-ui/react-alert-dialog": "1.0.4", + "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-collapsible": "^1.0.3", "@radix-ui/react-dialog": "1.0.4", diff --git a/ee/tabby-ui/yarn.lock b/ee/tabby-ui/yarn.lock index ae6c7aeaaa6a..25737c430d4a 100644 --- a/ee/tabby-ui/yarn.lock +++ b/ee/tabby-ui/yarn.lock @@ -1874,6 +1874,17 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-primitive" "1.0.3" +"@radix-ui/react-avatar@^1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@radix-ui/react-avatar/-/react-avatar-1.0.4.tgz#de9a5349d9e3de7bbe990334c4d2011acbbb9623" + integrity sha512-kVK2K7ZD3wwj3qhle0ElXhOjbezIgyl2hVvgwfIdexL3rN6zJmy5AqqIf+D31lxVppdzV8CjAfZ6PklkmInZLw== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-use-callback-ref" "1.0.1" + "@radix-ui/react-use-layout-effect" "1.0.1" + "@radix-ui/react-checkbox@^1.0.4": version "1.0.4" resolved "https://registry.yarnpkg.com/@radix-ui/react-checkbox/-/react-checkbox-1.0.4.tgz#98f22c38d5010dd6df4c5744cac74087e3275f4b" diff --git a/ee/tabby-webserver/development/Caddyfile b/ee/tabby-webserver/development/Caddyfile index 7658b00fbbc9..9b01b074b1f3 100644 --- a/ee/tabby-webserver/development/Caddyfile +++ b/ee/tabby-webserver/development/Caddyfile @@ -8,6 +8,7 @@ path /hub path /repositories/* path /oauth/* + path /avatar/* path /swagger-ui path /swagger-ui/*