Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: upload avatar #10

Merged
merged 2 commits into from
Dec 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions @api/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ export const envSchema = z.object({
RESEND_API_KEY: z.string(),
RESEND_FROM: z.string(),
TURNSTILE_SECRET_KEY: z.string(),
PUBLIC_BUCKET: z.custom<R2Bucket>((value) => {
return typeof value === 'object' && value !== null
}),
})

export type Env = z.infer<typeof envSchema>
1 change: 1 addition & 0 deletions @api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"deploy:production": "wrangler deploy --env production"
},
"dependencies": {
"@dinstack/shared": "workspace:^",
"@neondatabase/serverless": "^0.6.0",
"@trpc/server": "^10.44.1",
"arctic": "^0.3.6",
Expand Down
10 changes: 1 addition & 9 deletions @api/routes/auth/oauth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { OauthAccounts, Users, oauthAccountProviders } from '@api/database/schema'
import { OauthAccounts, oauthAccountProviders } from '@api/database/schema'
import { createUser } from '@api/lib/db'
import { uppercaseFirstLetter } from '@api/lib/utils'
import { authProcedure, procedure, router } from '@api/trpc'
Expand Down Expand Up @@ -50,14 +50,6 @@ export const authOauthRouter = router({
})

if (oauthAccount) {
await ctx.db
.update(Users)
.set({
name: oauthUser.name,
avatarUrl: oauthUser.avatarUrl,
})
.where(eq(Users.id, oauthAccount.userId))

const organizationMember = oauthAccount.organizationMembers[0]
if (!organizationMember) {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to find organization member' })
Expand Down
45 changes: 45 additions & 0 deletions @api/routes/auth/profile.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Users } from '@api/database/schema'
import { authProcedure, router } from '@api/trpc'
import { TRPCError } from '@trpc/server'
import { eq } from 'drizzle-orm'
import { Buffer } from 'node:buffer'
import { z } from 'zod'

export const authProfileRouter = router({
Expand All @@ -20,4 +22,47 @@ export const authProfileRouter = router({
})
.where(eq(Users.id, ctx.auth.session.userId))
}),
updateAvatarUrl: authProcedure
.input(
z.object({
avatar: z.object({
name: z
.string()
.max(1234)
.transform((name) => name.replace(/[^a-zA-Z0-9.-_]/gi, '-')),
base64: z
.string()
.max(1024 * 1024) // 1 MB
.transform((base64) => {
return Buffer.from(base64, 'base64')
}),
}),
}),
)
.mutation(async ({ ctx, input }) => {
const user = await ctx.db.query.Users.findFirst({
where(t, { eq }) {
return eq(t.id, ctx.auth.session.userId)
},
})

if (!user) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'User not found',
})
}

const objectName = `user/${ctx.auth.session.userId}/avatar/${input.avatar.name}`
const deleteOldAvatar = ctx.env.PUBLIC_BUCKET.delete(user.avatarUrl)
const uploadNewAvatar = ctx.env.PUBLIC_BUCKET.put(objectName, input.avatar.base64)
const updateAvatarUrl = ctx.db
.update(Users)
.set({
avatarUrl: objectName,
})
.where(eq(Users.id, ctx.auth.session.userId))

await Promise.all([deleteOldAvatar, uploadNewAvatar, updateAvatarUrl])
}),
})
9 changes: 7 additions & 2 deletions @api/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@
"compilerOptions": {
"outDir": "build",
"paths": {
"@api/*": ["./*"]
"@api/*": ["./*"],
"@shared/*": ["../@shared/*"]
}
},
"exclude": ["drizzle.config.ts", "scripts", "build"],
"references": []
"references": [
{
"path": "../@shared/tsconfig.json"
}
]
}
15 changes: 15 additions & 0 deletions @api/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,21 @@ export default {
})
}

if (!response && url.pathname.startsWith('/public') && request.method.toUpperCase() === 'GET') {
const objectName = url.pathname.replace('/public/', '')
const object = await env.PUBLIC_BUCKET.get(objectName)

if (object) {
const headers = new Headers()
object.writeHttpMetadata(headers)
headers.set('etag', object.httpEtag)

return new Response(object.body, {
headers,
})
}
}

response ??= new Response(
JSON.stringify({
message: 'Not found',
Expand Down
12 changes: 12 additions & 0 deletions @api/wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ WEB_URL = "http://localhost:3000"
GOOGLE_REDIRECT_URL = "http://localhost:3000/oauth/google/callback"
TURNSTILE_SECRET_KEY = "1x0000000000000000000000000000000AA"

[[env.local.r2_buckets]]
binding = 'PUBLIC_BUCKET'
bucket_name = 'dinstack-public-local'

# ------------------------------------------------------------------------------------------------------------

# [env.preview.route]
Expand All @@ -24,6 +28,10 @@ WORKER_ENV = "development"
WEB_URL = "https://preview.example.com"
GOOGLE_REDIRECT_URL = "https://preview.example.com/oauth/google/callback"

[[env.preview.r2_buckets]]
binding = 'PUBLIC_BUCKET'
bucket_name = 'dinstack-public-preview'

# ------------------------------------------------------------------------------------------------------------

[env.production]
Expand All @@ -38,4 +46,8 @@ WORKER_ENV = "production"
WEB_URL = "https://example.com"
GOOGLE_REDIRECT_URL = "https://example.com/oauth/google/callback"

[[env.production.r2_buckets]]
binding = 'PUBLIC_BUCKET'
bucket_name = 'dinstack-public-production'

# ------------------------------------------------------------------------------------------------------------
1 change: 1 addition & 0 deletions @shared/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# `@dinstack/shared`
11 changes: 11 additions & 0 deletions @shared/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "@dinstack/shared",
"version": "0.0.0",
"private": true,
"scripts": {
"build": "tsc --build && tsc-alias"
},
"devDependencies": {
"@dinstack/tsconfig": "workspace:^"
}
}
10 changes: 10 additions & 0 deletions @shared/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": "@dinstack/tsconfig/base.json",
"compilerOptions": {
"outDir": "build",
"paths": {
"@shared/*": ["./*"]
}
},
"exclude": ["build", "configs/tailwind.config.ts"]
}
1 change: 1 addition & 0 deletions @web/.env.local
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
NEXT_PUBLIC_API_URL=http://localhost:8000
NEXT_PUBLIC_PUBLIC_BUCKET_URL=http://localhost:8000/public/
NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
2 changes: 2 additions & 0 deletions @web/.env.preview
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
NEXT_PUBLIC_API_URL=https://preview.api.example.com
NEXT_PUBLIC_PUBLIC_BUCKET_URL=https://preview.api.example.com/public/
NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAAAOd1-P2R_2ooD0h

3 changes: 2 additions & 1 deletion @web/.env.production
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAAAOd1-P2R_2ooD0h
NEXT_PUBLIC_PUBLIC_BUCKET_URL=https://api.example.com/public/
NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAAAOd1-P2R_2ooD0h
4 changes: 2 additions & 2 deletions @web/app/(auth)/_components/navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { LogoDropdownMenu } from '@web/components/logo-dropdown-menu'
import { ProfileDropdownMenu } from '@web/components/profile-dropdown-menu'
import { ThemeToggle } from '@web/components/theme-toggle'
import { api } from '@web/lib/api'
import { isActivePathname } from '@web/lib/utils'
import { constructPublicResourceUrl, isActivePathname } from '@web/lib/utils'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { match } from 'ts-pattern'
Expand Down Expand Up @@ -101,7 +101,7 @@ function ProfileButton() {
>
<div className="flex gap-3">
<img
src={query.data.session.organizationMember.organization.logoUrl}
src={constructPublicResourceUrl(query.data.session.organizationMember.organization.logoUrl)}
className="h-9 w-9 rounded-md flex-shrink-0"
alt={query.data.session.organizationMember.organization.name}
/>
Expand Down
51 changes: 47 additions & 4 deletions @web/app/(auth)/profile/_components/personal-infos-form.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
'use client'

import { api } from '@web/lib/api'
import { useId } from 'react'
import { constructPublicResourceUrl } from '@web/lib/utils'
import imageCompression from 'browser-image-compression'
import { Base64 } from 'js-base64'
import { useId, useRef } from 'react'
import { match } from 'ts-pattern'
import { Button } from '@ui/ui/button'
import { GeneralError } from '@ui/ui/general-error'
Expand Down Expand Up @@ -41,13 +44,12 @@ export function PersonalInfosForm() {
<div className="space-y-8">
<div className="flex items-center gap-8">
<img
src={query.data.session.organizationMember.user.avatarUrl}
src={constructPublicResourceUrl(query.data.session.organizationMember.user.avatarUrl)}
alt={query.data.session.organizationMember.user.name}
className="h-24 w-24 flex-none rounded-lg bg-background object-cover"
/>
<div>
{/* TODO: implement */}
<Button type="button">Change avatar</Button>
<AvatarChangeButton />
<p className="mt-2 text-xs leading-5 text-muted-foreground">JPG, GIF or PNG. 1MB max.</p>
</div>
</div>
Expand Down Expand Up @@ -87,3 +89,44 @@ export function PersonalInfosForm() {
</div>
)
}

export function AvatarChangeButton() {
const inputRef = useRef<HTMLInputElement>(null)
const mutation = api.auth.profile.updateAvatarUrl.useMutation()

const onChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return

const compressedImage = await imageCompression(file, {
maxSizeMB: 1,
maxWidthOrHeight: 200,
useWebWorker: true,
})

const avatarBase64 = Base64.fromUint8Array(new Uint8Array(await compressedImage.arrayBuffer()))
mutation.mutate({
avatar: {
name: compressedImage.name,
base64: avatarBase64,
},
})
}

return (
<>
<Button
type="button"
className="gap-2"
disabled={mutation.isLoading}
onClick={() => {
inputRef.current?.click()
}}
>
Change avatar
<MutationStatusIcon status={mutation.status} />
</Button>
<input ref={inputRef} type="file" className="hidden" accept="image/*" onChange={onChange} />
</>
)
}
3 changes: 2 additions & 1 deletion @web/components/profile-dropdown-menu.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ExitIcon, PersonIcon, PlusIcon } from '@radix-ui/react-icons'
import { authAtom } from '@web/atoms/auth'
import { api } from '@web/lib/api'
import { constructPublicResourceUrl } from '@web/lib/utils'
import { useAtom } from 'jotai'
import { RESET } from 'jotai/utils'
import Link from 'next/link'
Expand Down Expand Up @@ -167,7 +168,7 @@ function WorkspaceListItem(props: {
<div className="h-9 w-9 rounded-md bg-accent flex items-center justify-center flex-shrink-0">
<MutationStatusIcon status={mutation.status}>
<img
src={props.organization.logoUrl}
src={constructPublicResourceUrl(props.organization.logoUrl)}
className="h-9 w-9 rounded-md object-center"
alt={props.organization.name}
/>
Expand Down
2 changes: 2 additions & 0 deletions @web/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const env = createEnv({
*/
client: {
NEXT_PUBLIC_API_URL: z.string().url(),
NEXT_PUBLIC_PUBLIC_BUCKET_URL: z.string().url(),
NEXT_PUBLIC_TURNSTILE_SITE_KEY: z.string(),
},
/*
Expand All @@ -24,6 +25,7 @@ export const env = createEnv({
*/
runtimeEnv: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
NEXT_PUBLIC_PUBLIC_BUCKET_URL: process.env.NEXT_PUBLIC_PUBLIC_BUCKET_URL,
NEXT_PUBLIC_TURNSTILE_SITE_KEY: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
},
})
15 changes: 15 additions & 0 deletions @web/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
import { env } from '@web/env'

export function isActivePathname(pathname: string, currentPathname: string) {
return `${currentPathname}/`.startsWith(`${pathname}/`)
}

export function uppercaseFirstLetter(string: string) {
return string.charAt(0).toUpperCase() + string.slice(1)
}

export function constructPublicResourceUrl(url: string): string {
return new URL(url, env.NEXT_PUBLIC_PUBLIC_BUCKET_URL).toString()
}

export function convertFileToBase64(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
})
}
3 changes: 3 additions & 0 deletions @web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
},
"dependencies": {
"@dinstack/api": "workspace:^",
"@dinstack/shared": "workspace:^",
"@dinstack/ui": "workspace:^",
"@marsidev/react-turnstile": "^0.4.0",
"@radix-ui/react-icons": "^1.3.0",
Expand All @@ -19,8 +20,10 @@
"@tanstack/react-query": "^4.36.1",
"@trpc/client": "^10.44.1",
"@trpc/react-query": "^10.44.1",
"browser-image-compression": "^2.0.2",
"framer-motion": "^10.16.16",
"jotai": "^2.6.0",
"js-base64": "^3.7.5",
"next": "14.0.3",
"next-themes": "^0.2.1",
"oslo": "^0.23.5",
Expand Down
4 changes: 4 additions & 0 deletions @web/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"paths": {
"@web/*": ["./*"],
"@api/*": ["../@api/*"],
"@shared/*": ["../@shared/*"],
"@ui/*": ["../@ui/*"],
"@ui/_/../ui/*": ["../@ui/ui/*"]
},
Expand All @@ -23,6 +24,9 @@
{
"path": "../@api/tsconfig.json"
},
{
"path": "../@shared/tsconfig.json"
},
{
"path": "../@ui/tsconfig.json"
}
Expand Down
Loading
Loading