-
Notifications
You must be signed in to change notification settings - Fork 1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1737 from TabbyML/upload-avatar
feat(ui): frontend implementation for avatar uploading in profile
- Loading branch information
Showing
11 changed files
with
266 additions
and
15 deletions.
There are no files selected for viewing
118 changes: 113 additions & 5 deletions
118
ee/tabby-ui/app/(dashboard)/profile/components/avatar.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<HTMLInputElement>) => { | ||
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 ( | ||
<div className="flex h-16 w-16 rounded-full border"> | ||
<NiceAvatar className="w-full" {...config} /> | ||
<div className="grid gap-6"> | ||
<div className="relative"> | ||
<label | ||
htmlFor="avatar-file" | ||
className="absolute left-0 top-0 z-20 flex h-16 w-16 cursor-pointer items-center justify-center rounded-full bg-background/90 opacity-0 transition-all hover:opacity-100" | ||
> | ||
<IconCloudUpload /> | ||
</label> | ||
<input | ||
id="avatar-file" | ||
type="file" | ||
accept="image/*" | ||
className="hidden" | ||
onChange={onPreviewAvatar} | ||
/> | ||
{uploadedImgString && ( | ||
<img | ||
src={uploadedImgString} | ||
className="absolute left-0 top-0 z-10 h-16 w-16 rounded-full border object-cover" | ||
alt="avatar to be uploaded" | ||
/> | ||
)} | ||
<UserAvatar className="relative h-16 w-16 border" /> | ||
</div> | ||
|
||
<Separator /> | ||
|
||
<div className="flex items-center justify-between"> | ||
<Button | ||
type="submit" | ||
disabled={!uploadedImgString || isSubmitting} | ||
onClick={onUploadAvatar} | ||
className="mr-5 w-40" | ||
> | ||
{isSubmitting && ( | ||
<IconSpinner className="mr-2 h-4 w-4 animate-spin" /> | ||
)} | ||
Save Changes | ||
</Button> | ||
|
||
<div className="mt-1.5 flex flex-1 justify-end"> | ||
<p className=" text-xs text-muted-foreground lg:text-sm"> | ||
{`Square image recommended. Accepted file types: .png, .jpg. Max file size: ${MAX_UPLOAD_SIZE_KB}KB.`} | ||
</p> | ||
</div> | ||
</div> | ||
</div> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<typeof AvatarPrimitive.Root>, | ||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> | ||
>(({ className, ...props }, ref) => ( | ||
<AvatarPrimitive.Root | ||
ref={ref} | ||
className={cn( | ||
'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', | ||
className | ||
)} | ||
{...props} | ||
/> | ||
)) | ||
Avatar.displayName = AvatarPrimitive.Root.displayName | ||
|
||
const AvatarImage = React.forwardRef< | ||
React.ElementRef<typeof AvatarPrimitive.Image>, | ||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image> | ||
>(({ className, ...props }, ref) => ( | ||
<AvatarPrimitive.Image | ||
ref={ref} | ||
className={cn('aspect-square h-full w-full', className)} | ||
{...props} | ||
/> | ||
)) | ||
AvatarImage.displayName = AvatarPrimitive.Image.displayName | ||
|
||
const AvatarFallback = React.forwardRef< | ||
React.ElementRef<typeof AvatarPrimitive.Fallback>, | ||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback> | ||
>(({ className, ...props }, ref) => ( | ||
<AvatarPrimitive.Fallback | ||
ref={ref} | ||
className={cn( | ||
'flex h-full w-full items-center justify-center rounded-full bg-muted', | ||
className | ||
)} | ||
{...props} | ||
/> | ||
)) | ||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName | ||
|
||
export { Avatar, AvatarImage, AvatarFallback } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 <Skeleton className={cn('h-16 w-16 rounded-full', className)} /> | ||
} | ||
|
||
if (!avatarImageSrc) { | ||
const config = genConfig(data.me.email) | ||
return <NiceAvatar className={cn('h-16 w-16', className)} {...config} /> | ||
} | ||
|
||
return ( | ||
<AvatarComponent className={cn('h-16 w-16', className)}> | ||
<AvatarImage | ||
src={avatarImageSrc} | ||
alt={data.me.email} | ||
className="object-cover" | ||
/> | ||
<AvatarFallback>{data.me?.email.substring(0, 2)}</AvatarFallback> | ||
</AvatarComponent> | ||
) | ||
} | ||
|
||
export const mutateAvatar = (userId: string) => { | ||
mutate(`/avatar/${userId}`) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -8,6 +8,7 @@ | |
path /hub | ||
path /repositories/* | ||
path /oauth/* | ||
path /avatar/* | ||
|
||
path /swagger-ui | ||
path /swagger-ui/* | ||
|