Skip to content

Commit

Permalink
feat: edit organization infos (#11)
Browse files Browse the repository at this point in the history
* fix name

* navigation

* update handle

* link and fix
  • Loading branch information
Din authored Dec 16, 2023
1 parent e7708c2 commit 0351cc4
Show file tree
Hide file tree
Showing 16 changed files with 362 additions and 53 deletions.
54 changes: 54 additions & 0 deletions @api/routes/organization/change-logo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Organizations } from '@api/database/schema'
import { authProcedure, organizationMemberMiddleware } from '@api/trpc'
import { TRPCError } from '@trpc/server'
import { eq } from 'drizzle-orm'
import { Buffer } from 'node:buffer'
import { z } from 'zod'

export const organizationChangeLogoRoute = authProcedure
.input(
z.object({
organization: z.object({
id: z.string().uuid(),
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')
}),
}),
}),
}),
)
.use(organizationMemberMiddleware)
.mutation(async ({ ctx, input }) => {
const organization = await ctx.db.query.Organizations.findFirst({
where(t, { eq }) {
return eq(t.id, input.organization.id)
},
})

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

const objectName = `organization/${input.organization.id}/logo/${input.organization.avatar.name}`
const deleteOldLogo = ctx.env.PUBLIC_BUCKET.delete(organization.logoUrl)
const uploadNewLogo = ctx.env.PUBLIC_BUCKET.put(objectName, input.organization.avatar.base64)
const updateLogoUrl = ctx.db
.update(Organizations)
.set({
logoUrl: objectName,
})
.where(eq(Organizations.id, input.organization.id))

await Promise.all([deleteOldLogo, uploadNewLogo, updateLogoUrl])
})
4 changes: 4 additions & 0 deletions @api/routes/organization/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { router } from '@api/trpc'
import { organizationChangeLogoRoute } from './change-logo'
import { organizationCreateRoute } from './create'
import { organizationDetailRoute } from './detail'
import { organizationListRoute } from './list'
import { organizationUpdateRoute } from './update'

export const organizationRouter = router({
list: organizationListRoute,
detail: organizationDetailRoute,
create: organizationCreateRoute,
update: organizationUpdateRoute,
changeLogo: organizationChangeLogoRoute,
})
23 changes: 23 additions & 0 deletions @api/routes/organization/update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Organizations } from '@api/database/schema'
import { authProcedure, organizationMemberMiddleware } from '@api/trpc'
import { eq } from 'drizzle-orm'
import { z } from 'zod'

export const organizationUpdateRoute = authProcedure
.input(
z.object({
organization: z.object({
id: z.string().uuid(),
name: z.string(),
}),
}),
)
.use(organizationMemberMiddleware)
.mutation(async ({ ctx, input }) => {
await ctx.db
.update(Organizations)
.set({
name: input.organization.name,
})
.where(eq(Organizations.id, input.organization.id))
})
21 changes: 20 additions & 1 deletion @api/trpc.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { TRPCError, initTRPC } from '@trpc/server'
import { TRPCError, experimental_standaloneMiddleware, initTRPC } from '@trpc/server'
import SuperJSON from 'superjson'
import type { Context } from './context'
import type { Db } from './lib/db'

const t = initTRPC.context<Context & { request: Request }>().create({
transformer: SuperJSON,
Expand Down Expand Up @@ -73,3 +74,21 @@ const authMiddleware = middleware(async ({ ctx, next }) => {
})

export const authProcedure = procedure.use(authMiddleware)

export const organizationMemberMiddleware = experimental_standaloneMiddleware<{
ctx: { auth: { session: { userId: string } }; db: Db } // defaults to 'object' if not defined
input: { organizationId: string } | { organization: { id: string } } // defaults to 'unknown' if not defined
}>().create(async ({ ctx, next, input }) => {
const organizationId = 'organizationId' in input ? input.organizationId : input.organization.id

const organizationMember = await ctx.db.query.OrganizationMembers.findFirst({
where(t, { and, eq }) {
return and(eq(t.organizationId, organizationId), eq(t.userId, ctx.auth.session.userId))
},
})

if (!organizationMember)
throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this organization.' })

return next()
})
4 changes: 4 additions & 0 deletions @ui/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,8 @@
table-layout: fixed;
width: 100%;
}

img {
@apply object-center object-cover;
}
}
76 changes: 76 additions & 0 deletions @web/app/(auth)/(settings)/_components/nav.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
'use client'

import { api } from '@web/lib/api'
import { isActivePathname } from '@web/lib/utils'
import Link from 'next/link'
import { usePathname, useSearchParams } from 'next/navigation'
import { Skeleton } from '@ui/ui/skeleton'
import { ViewportBlock } from '@ui/ui/viewport-block'

const staticLinks = [
{
name: 'Profile',
href: '/profile',
},
{
name: 'Notifications',
href: '/notifications',
},
]

export function Nav() {
const pathname = usePathname()
const searchParams = useSearchParams()
const query = api.organization.list.useInfiniteQuery(
{
limit: 6,
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
},
)

const organizationLinks =
query.data?.pages.flatMap((page) =>
page.items.map((organization) => ({
name: organization.name,
id: organization.id,
href: `organization?id=${organization.id}`,
})),
) ?? []

return (
<nav className="flex py-4">
<ul
role="list"
className="flex min-w-full flex-none gap-x-6 px-4 text-sm font-semibold leading-6 text-muted-foreground sm:px-6 lg:px-8"
>
{staticLinks.map((link) => (
<li key={link.name}>
<Link href={link.href} className={isActivePathname(link.href, pathname) ? 'text-primary' : ''}>
{link.name}
</Link>
</li>
))}

{organizationLinks.map((link) => (
<li key={link.name}>
<Link
href={link.href}
className={
isActivePathname(link.href, pathname) && searchParams.get('id') === link.id ? 'text-primary' : ''
}
>
{link.name}
</Link>
</li>
))}

<li>
{!query.isFetching && query.hasNextPage && <ViewportBlock onEnterViewport={() => query.fetchNextPage()} />}
{(query.hasNextPage || query.isLoading) && <Skeleton className="w-36 h-6" />}
</li>
</ul>
</nav>
)
}
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
'use client'

import { api } from '@web/lib/api'
import { constructPublicResourceUrl } from '@web/lib/utils'
import imageCompression from 'browser-image-compression'
import { Base64 } from 'js-base64'
import { useSearchParams } from 'next/navigation'
import { useId, useRef } from 'react'
import { match } from 'ts-pattern'
import { z } from 'zod'
import { Button } from '@ui/ui/button'
import { GeneralError } from '@ui/ui/general-error'
import { GeneralSkeleton } from '@ui/ui/general-skeleton'
import { Input } from '@ui/ui/input'
import { Label } from '@ui/ui/label'
import { MutationStatusIcon } from '@ui/ui/mutation-status-icon'

export function OrganizationInfosForm() {
const searchParams = useSearchParams()
const organizationId = z.string().uuid().parse(searchParams.get('id'))

const nameId = useId()
const query = api.organization.detail.useQuery({
organizationId,
})
const mutation = api.organization.update.useMutation()

const action = (form: FormData) => {
const name = form.get('name') as string

mutation.mutate({
organization: {
id: organizationId,
name,
},
})
}

return (
<div className="@container">
<section className="grid grid-cols-1 gap-x-8 gap-y-10 px-4 py-16 @2xl:grid-cols-3">
<div>
<h2 className="font-semibold leading-7">Organization Information</h2>
<p className="mt-1 text-sm leading-6 text-muted-foreground">Edit information about your organization.</p>
</div>

<div className="@2xl:col-span-2 max-w-xl">
{match(query)
.with({ status: 'loading' }, () => <GeneralSkeleton count={5} />)
.with({ status: 'error' }, () => <GeneralError />)
.with({ status: 'success' }, (query) => (
<form action={action}>
<div className="space-y-8">
<div className="flex items-center gap-8">
<img
src={constructPublicResourceUrl(query.data.organization.logoUrl)}
alt={query.data.organization.name}
className="h-24 w-24 flex-none rounded-lg bg-background object-cover"
/>
<div>
<LogoChangeButton />
<p className="mt-2 text-xs leading-5 text-muted-foreground">JPG, GIF or PNG. 1MB max.</p>
</div>
</div>

<div>
<Label htmlFor={nameId}>Name</Label>
<div className="mt-2">
<Input
id={nameId}
key={query.data.organization.id}
name="name"
defaultValue={query.data.organization.name}
/>
</div>
</div>
</div>

<div className="mt-8">
<Button disabled={mutation.isLoading} className="gap-2">
Save
<MutationStatusIcon status={mutation.status} />
</Button>
</div>
</form>
))
.exhaustive()}
</div>
</section>
</div>
)
}

export function LogoChangeButton() {
const searchParams = useSearchParams()
const organizationId = z.string().uuid().parse(searchParams.get('id'))
const inputRef = useRef<HTMLInputElement>(null)
const mutation = api.organization.changeLogo.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({
organization: {
id: organizationId,
avatar: {
name: compressedImage.name,
base64: avatarBase64,
},
},
})
}

return (
<>
<Button
type="button"
className="gap-2"
disabled={mutation.isLoading}
onClick={() => {
inputRef.current?.click()
}}
>
Change logo
<MutationStatusIcon status={mutation.status} />
</Button>
<input ref={inputRef} type="file" className="hidden" accept="image/*" onChange={onChange} />
</>
)
}
16 changes: 16 additions & 0 deletions @web/app/(auth)/(settings)/organization/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { Metadata } from 'next'
import { OrganizationInfosForm } from './_components/organization-infos-form'

export const metadata: Metadata = {
title: 'Organization',
}

export default function OrganizationPage() {
return (
<main className="p-4">
<div className="mx-auto max-w-7xl divide-y">
<OrganizationInfosForm />
</div>
</main>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,12 @@ export function PersonalInfosForm() {
<div>
<Label htmlFor={nameId}>Name</Label>
<div className="mt-2">
<Input id={nameId} name="name" defaultValue={query.data.session.organizationMember.user.name} />
<Input
id={nameId}
name="name"
key={query.data.session.organizationMember.user.id}
defaultValue={query.data.session.organizationMember.user.name}
/>
</div>
</div>

Expand All @@ -66,6 +71,7 @@ export function PersonalInfosForm() {
<div className="mt-2">
<Input
id={emailId}
key={query.data.session.organizationMember.user.id}
type="email"
name="email"
disabled
Expand Down
File renamed without changes.
Loading

0 comments on commit 0351cc4

Please sign in to comment.