Skip to content

Commit

Permalink
Merge pull request #1737 from TabbyML/upload-avatar
Browse files Browse the repository at this point in the history
feat(ui): frontend implementation for avatar uploading in profile
  • Loading branch information
wwayne authored Mar 29, 2024
2 parents dfe2ab8 + 55c05c3 commit fc40232
Show file tree
Hide file tree
Showing 11 changed files with 266 additions and 15 deletions.
118 changes: 113 additions & 5 deletions ee/tabby-ui/app/(dashboard)/profile/components/avatar.tsx
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>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,11 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({
<FormMessage />
<Separator />
<div className="flex">
<Button type="submit" disabled={isSubmitting}>
<Button type="submit" disabled={isSubmitting} className="w-40">
{isSubmitting && (
<IconSpinner className="mr-2 h-4 w-4 animate-spin" />
)}
Update password
Save Changes
</Button>
</div>
</form>
Expand Down
2 changes: 1 addition & 1 deletion ee/tabby-ui/app/(dashboard)/profile/components/profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export default function Profile() {
<ProfileCard
title="Your Avatar"
description="This is your avatar image."
footer="The avatar customization feature will be available in a future release."
footerClassname="pb-0"
>
<Avatar />
</ProfileCard>
Expand Down
50 changes: 50 additions & 0 deletions ee/tabby-ui/components/ui/avatar.tsx
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 }
23 changes: 22 additions & 1 deletion ee/tabby-ui/components/ui/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1188,6 +1188,26 @@ function IconFolderGit({ className, ...props }: React.ComponentProps<'svg'>) {
)
}

function IconCloudUpload({ className, ...props }: React.ComponentProps<'svg'>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn('h-4 w-4', className)}
{...props}
>
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" />
<path d="M12 12v9" />
<path d="m16 16-4-4-4 4" />
</svg>
)
}

export {
IconEdit,
IconNextChat,
Expand Down Expand Up @@ -1248,5 +1268,6 @@ export {
IconCheckCircled,
IconCrossCircled,
IconInfoCircled,
IconFolderGit
IconFolderGit,
IconCloudUpload
}
57 changes: 57 additions & 0 deletions ee/tabby-ui/components/user-avatar.tsx
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}`)
}
8 changes: 2 additions & 6 deletions ee/tabby-ui/components/user-panel.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -12,6 +11,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { UserAvatar } from '@/components/user-avatar'

import {
IconBackpack,
Expand Down Expand Up @@ -39,14 +39,10 @@ export default function UserPanel() {
return
}

const config = genConfig(user.email)

return (
<DropdownMenu>
<DropdownMenuTrigger>
<span className="flex h-10 w-10 rounded-full border">
<NiceAvatar className="w-full" {...config} />
</span>
<UserAvatar className="h-10 w-10 border" />
</DropdownMenuTrigger>
<DropdownMenuContent collisionPadding={{ right: 16 }}>
<DropdownMenuLabel>{user.email}</DropdownMenuLabel>
Expand Down
6 changes: 6 additions & 0 deletions ee/tabby-ui/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
1 change: 1 addition & 0 deletions ee/tabby-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions ee/tabby-ui/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions ee/tabby-webserver/development/Caddyfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
path /hub
path /repositories/*
path /oauth/*
path /avatar/*

path /swagger-ui
path /swagger-ui/*
Expand Down

0 comments on commit fc40232

Please sign in to comment.