Skip to content

Commit fc40232

Browse files
authored
Merge pull request TabbyML#1737 from TabbyML/upload-avatar
feat(ui): frontend implementation for avatar uploading in profile
2 parents dfe2ab8 + 55c05c3 commit fc40232

File tree

11 files changed

+266
-15
lines changed

11 files changed

+266
-15
lines changed
Lines changed: 113 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,125 @@
1-
import NiceAvatar, { genConfig } from 'react-nice-avatar'
1+
'use client'
22

3+
import { ChangeEvent, useState } from 'react'
4+
import { toast } from 'sonner'
5+
6+
import { graphql } from '@/lib/gql/generates'
37
import { useMe } from '@/lib/hooks/use-me'
8+
import { useMutation } from '@/lib/tabby/gql'
9+
import { delay } from '@/lib/utils'
10+
import { Button } from '@/components/ui/button'
11+
import { IconCloudUpload, IconSpinner } from '@/components/ui/icons'
12+
import { Separator } from '@/components/ui/separator'
13+
import { mutateAvatar, UserAvatar } from '@/components/user-avatar'
14+
15+
const uploadUserAvatarMutation = graphql(/* GraphQL */ `
16+
mutation uploadUserAvatarBase64($id: ID!, $avatarBase64: String!) {
17+
uploadUserAvatarBase64(id: $id, avatarBase64: $avatarBase64)
18+
}
19+
`)
20+
21+
const MAX_UPLOAD_SIZE_KB = 500
422

523
export const Avatar = () => {
24+
const [isSubmitting, setIsSubmitting] = useState(false)
25+
const [uploadedImgString, setUploadedImgString] = useState('')
626
const [{ data }] = useMe()
7-
27+
const uploadUserAvatar = useMutation(uploadUserAvatarMutation, {
28+
onError(err) {
29+
toast.error(err.message)
30+
}
31+
})
832
if (!data?.me?.email) return null
933

10-
const config = genConfig(data?.me?.email)
34+
const onPreviewAvatar = (e: ChangeEvent<HTMLInputElement>) => {
35+
const file = e.target.files ? e.target.files[0] : null
36+
37+
if (file) {
38+
const fileSizeInKB = parseFloat((file.size / 1024).toFixed(2))
39+
if (fileSizeInKB > MAX_UPLOAD_SIZE_KB) {
40+
return toast.error(
41+
`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.`
42+
)
43+
}
44+
45+
const reader = new FileReader()
46+
47+
reader.onloadend = () => {
48+
const imageString = reader.result as string
49+
setUploadedImgString(imageString)
50+
}
51+
52+
reader.readAsDataURL(file)
53+
}
54+
}
55+
56+
const onUploadAvatar = async () => {
57+
setIsSubmitting(true)
58+
59+
const response = await uploadUserAvatar({
60+
avatarBase64: uploadedImgString.split(',')[1],
61+
id: data.me.id
62+
})
63+
64+
if (response?.data?.uploadUserAvatarBase64 === true) {
65+
await delay(1000)
66+
mutateAvatar(data.me.id)
67+
toast.success('Successfully updated your profile picture!')
68+
69+
await delay(200)
70+
setUploadedImgString('')
71+
}
72+
73+
setIsSubmitting(false)
74+
}
1175

1276
return (
13-
<div className="flex h-16 w-16 rounded-full border">
14-
<NiceAvatar className="w-full" {...config} />
77+
<div className="grid gap-6">
78+
<div className="relative">
79+
<label
80+
htmlFor="avatar-file"
81+
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"
82+
>
83+
<IconCloudUpload />
84+
</label>
85+
<input
86+
id="avatar-file"
87+
type="file"
88+
accept="image/*"
89+
className="hidden"
90+
onChange={onPreviewAvatar}
91+
/>
92+
{uploadedImgString && (
93+
<img
94+
src={uploadedImgString}
95+
className="absolute left-0 top-0 z-10 h-16 w-16 rounded-full border object-cover"
96+
alt="avatar to be uploaded"
97+
/>
98+
)}
99+
<UserAvatar className="relative h-16 w-16 border" />
100+
</div>
101+
102+
<Separator />
103+
104+
<div className="flex items-center justify-between">
105+
<Button
106+
type="submit"
107+
disabled={!uploadedImgString || isSubmitting}
108+
onClick={onUploadAvatar}
109+
className="mr-5 w-40"
110+
>
111+
{isSubmitting && (
112+
<IconSpinner className="mr-2 h-4 w-4 animate-spin" />
113+
)}
114+
Save Changes
115+
</Button>
116+
117+
<div className="mt-1.5 flex flex-1 justify-end">
118+
<p className=" text-xs text-muted-foreground lg:text-sm">
119+
{`Square image recommended. Accepted file types: .png, .jpg. Max file size: ${MAX_UPLOAD_SIZE_KB}KB.`}
120+
</p>
121+
</div>
122+
</div>
15123
</div>
16124
)
17125
}

ee/tabby-ui/app/(dashboard)/profile/components/change-password.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,11 +132,11 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({
132132
<FormMessage />
133133
<Separator />
134134
<div className="flex">
135-
<Button type="submit" disabled={isSubmitting}>
135+
<Button type="submit" disabled={isSubmitting} className="w-40">
136136
{isSubmitting && (
137137
<IconSpinner className="mr-2 h-4 w-4 animate-spin" />
138138
)}
139-
Update password
139+
Save Changes
140140
</Button>
141141
</div>
142142
</form>

ee/tabby-ui/app/(dashboard)/profile/components/profile.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export default function Profile() {
2020
<ProfileCard
2121
title="Your Avatar"
2222
description="This is your avatar image."
23-
footer="The avatar customization feature will be available in a future release."
23+
footerClassname="pb-0"
2424
>
2525
<Avatar />
2626
</ProfileCard>
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
'use client'
2+
3+
import * as React from 'react'
4+
import * as AvatarPrimitive from '@radix-ui/react-avatar'
5+
6+
import { cn } from '@/lib/utils'
7+
8+
const Avatar = React.forwardRef<
9+
React.ElementRef<typeof AvatarPrimitive.Root>,
10+
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
11+
>(({ className, ...props }, ref) => (
12+
<AvatarPrimitive.Root
13+
ref={ref}
14+
className={cn(
15+
'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full',
16+
className
17+
)}
18+
{...props}
19+
/>
20+
))
21+
Avatar.displayName = AvatarPrimitive.Root.displayName
22+
23+
const AvatarImage = React.forwardRef<
24+
React.ElementRef<typeof AvatarPrimitive.Image>,
25+
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
26+
>(({ className, ...props }, ref) => (
27+
<AvatarPrimitive.Image
28+
ref={ref}
29+
className={cn('aspect-square h-full w-full', className)}
30+
{...props}
31+
/>
32+
))
33+
AvatarImage.displayName = AvatarPrimitive.Image.displayName
34+
35+
const AvatarFallback = React.forwardRef<
36+
React.ElementRef<typeof AvatarPrimitive.Fallback>,
37+
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
38+
>(({ className, ...props }, ref) => (
39+
<AvatarPrimitive.Fallback
40+
ref={ref}
41+
className={cn(
42+
'flex h-full w-full items-center justify-center rounded-full bg-muted',
43+
className
44+
)}
45+
{...props}
46+
/>
47+
))
48+
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
49+
50+
export { Avatar, AvatarImage, AvatarFallback }

ee/tabby-ui/components/ui/icons.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1188,6 +1188,26 @@ function IconFolderGit({ className, ...props }: React.ComponentProps<'svg'>) {
11881188
)
11891189
}
11901190

1191+
function IconCloudUpload({ className, ...props }: React.ComponentProps<'svg'>) {
1192+
return (
1193+
<svg
1194+
xmlns="http://www.w3.org/2000/svg"
1195+
viewBox="0 0 24 24"
1196+
fill="none"
1197+
stroke="currentColor"
1198+
strokeWidth="2"
1199+
strokeLinecap="round"
1200+
strokeLinejoin="round"
1201+
className={cn('h-4 w-4', className)}
1202+
{...props}
1203+
>
1204+
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" />
1205+
<path d="M12 12v9" />
1206+
<path d="m16 16-4-4-4 4" />
1207+
</svg>
1208+
)
1209+
}
1210+
11911211
export {
11921212
IconEdit,
11931213
IconNextChat,
@@ -1248,5 +1268,6 @@ export {
12481268
IconCheckCircled,
12491269
IconCrossCircled,
12501270
IconInfoCircled,
1251-
IconFolderGit
1271+
IconFolderGit,
1272+
IconCloudUpload
12521273
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import NiceAvatar, { genConfig } from 'react-nice-avatar'
2+
import { mutate } from 'swr'
3+
import useSWRImmutable from 'swr/immutable'
4+
5+
import { useMe } from '@/lib/hooks/use-me'
6+
import fetcher from '@/lib/tabby/fetcher'
7+
import { cn } from '@/lib/utils'
8+
import {
9+
Avatar as AvatarComponent,
10+
AvatarFallback,
11+
AvatarImage
12+
} from '@/components/ui/avatar'
13+
import { Skeleton } from '@/components/ui/skeleton'
14+
15+
export function UserAvatar({ className }: { className?: string }) {
16+
const [{ data }] = useMe()
17+
const avatarUrl = !data?.me?.email ? null : `/avatar/${data.me.id}`
18+
const { data: avatarImageSrc, isLoading } = useSWRImmutable(
19+
avatarUrl,
20+
(url: string) => {
21+
return fetcher(url, {
22+
responseFormatter: async response => {
23+
if (!response.ok) return undefined
24+
const blob = await response.blob()
25+
const buffer = Buffer.from(await blob.arrayBuffer())
26+
return `data:image/png;base64,${buffer.toString('base64')}`
27+
}
28+
})
29+
}
30+
)
31+
32+
if (!data?.me?.email) return null
33+
34+
if (isLoading) {
35+
return <Skeleton className={cn('h-16 w-16 rounded-full', className)} />
36+
}
37+
38+
if (!avatarImageSrc) {
39+
const config = genConfig(data.me.email)
40+
return <NiceAvatar className={cn('h-16 w-16', className)} {...config} />
41+
}
42+
43+
return (
44+
<AvatarComponent className={cn('h-16 w-16', className)}>
45+
<AvatarImage
46+
src={avatarImageSrc}
47+
alt={data.me.email}
48+
className="object-cover"
49+
/>
50+
<AvatarFallback>{data.me?.email.substring(0, 2)}</AvatarFallback>
51+
</AvatarComponent>
52+
)
53+
}
54+
55+
export const mutateAvatar = (userId: string) => {
56+
mutate(`/avatar/${userId}`)
57+
}

ee/tabby-ui/components/user-panel.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import React from 'react'
2-
import NiceAvatar, { genConfig } from 'react-nice-avatar'
32

43
import { useMe } from '@/lib/hooks/use-me'
54
import { useIsChatEnabled } from '@/lib/hooks/use-server-info'
@@ -12,6 +11,7 @@ import {
1211
DropdownMenuSeparator,
1312
DropdownMenuTrigger
1413
} from '@/components/ui/dropdown-menu'
14+
import { UserAvatar } from '@/components/user-avatar'
1515

1616
import {
1717
IconBackpack,
@@ -39,14 +39,10 @@ export default function UserPanel() {
3939
return
4040
}
4141

42-
const config = genConfig(user.email)
43-
4442
return (
4543
<DropdownMenu>
4644
<DropdownMenuTrigger>
47-
<span className="flex h-10 w-10 rounded-full border">
48-
<NiceAvatar className="w-full" {...config} />
49-
</span>
45+
<UserAvatar className="h-10 w-10 border" />
5046
</DropdownMenuTrigger>
5147
<DropdownMenuContent collisionPadding={{ right: 16 }}>
5248
<DropdownMenuLabel>{user.email}</DropdownMenuLabel>

ee/tabby-ui/lib/utils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,9 @@ export function truncateText(
7272
export const isClientSide = () => {
7373
return typeof window !== 'undefined'
7474
}
75+
76+
export const delay = (ms: number) => {
77+
return new Promise(resolve => {
78+
setTimeout(() => resolve(null), ms)
79+
})
80+
}

ee/tabby-ui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"@curvenote/ansi-to-react": "^7.0.0",
2727
"@hookform/resolvers": "^3.3.2",
2828
"@radix-ui/react-alert-dialog": "1.0.4",
29+
"@radix-ui/react-avatar": "^1.0.4",
2930
"@radix-ui/react-checkbox": "^1.0.4",
3031
"@radix-ui/react-collapsible": "^1.0.3",
3132
"@radix-ui/react-dialog": "1.0.4",

ee/tabby-ui/yarn.lock

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1874,6 +1874,17 @@
18741874
"@babel/runtime" "^7.13.10"
18751875
"@radix-ui/react-primitive" "1.0.3"
18761876

1877+
"@radix-ui/react-avatar@^1.0.4":
1878+
version "1.0.4"
1879+
resolved "https://registry.yarnpkg.com/@radix-ui/react-avatar/-/react-avatar-1.0.4.tgz#de9a5349d9e3de7bbe990334c4d2011acbbb9623"
1880+
integrity sha512-kVK2K7ZD3wwj3qhle0ElXhOjbezIgyl2hVvgwfIdexL3rN6zJmy5AqqIf+D31lxVppdzV8CjAfZ6PklkmInZLw==
1881+
dependencies:
1882+
"@babel/runtime" "^7.13.10"
1883+
"@radix-ui/react-context" "1.0.1"
1884+
"@radix-ui/react-primitive" "1.0.3"
1885+
"@radix-ui/react-use-callback-ref" "1.0.1"
1886+
"@radix-ui/react-use-layout-effect" "1.0.1"
1887+
18771888
"@radix-ui/react-checkbox@^1.0.4":
18781889
version "1.0.4"
18791890
resolved "https://registry.yarnpkg.com/@radix-ui/react-checkbox/-/react-checkbox-1.0.4.tgz#98f22c38d5010dd6df4c5744cac74087e3275f4b"

0 commit comments

Comments
 (0)