Skip to content

Commit

Permalink
Integrate AWS S3 and CloudFront for handling user avatar updates
Browse files Browse the repository at this point in the history
  • Loading branch information
bombies committed Oct 31, 2023
1 parent 422f577 commit e453727
Show file tree
Hide file tree
Showing 23 changed files with 4,186 additions and 1,752 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,5 @@ yarn-error.log*
next-env.d.ts

.idea

/private
5,352 changes: 3,620 additions & 1,732 deletions package-lock.json

Large diffs are not rendered by default.

11 changes: 10 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,22 @@
},
"dependencies": {
"@auth/prisma-adapter": "^1.0.5",
"@aws-sdk/client-cloudfront": "^3.438.0",
"@aws-sdk/client-s3": "^3.438.0",
"@aws-sdk/cloudfront-signer": "^3.433.0",
"@aws-sdk/credential-providers": "^3.438.0",
"@aws-sdk/s3-request-presigner": "^3.438.0",
"@nextui-org/react": "^2.1.13",
"@prisma/client": "^5.5.2",
"@theinternetfolks/snowflake": "^1.3.0",
"@uidotdev/usehooks": "^2.4.1",
"aws-sdk": "^2.1483.0",
"axios": "^1.5.1",
"bcrypt": "^5.1.1",
"framer-motion": "^10.16.4",
"next": "^13.5.6",
"next-auth": "^4.24.3",
"next-connect": "^1.0.0-next.4",
"next-nprogress-bar": "^2.1.2",
"next-themes": "^0.2.1",
"react": "^18.2.0",
Expand All @@ -28,11 +35,13 @@
"react-hot-toast": "^2.4.1",
"sass": "^1.69.4",
"swr": "^2.2.4",
"zod": "^3.22.4"
"zod": "^3.22.4",
"zod-form-data": "^2.0.2"
},
"devDependencies": {
"@next-auth/prisma-adapter": "^1.0.7",
"@types/bcrypt": "^5.0.1",
"@types/multer": "^1.4.9",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
"use client"

import {FC, Fragment} from "react";
import {FC, Fragment, useCallback, useState} from "react";
import {useMemberData} from "@/app/(site)/components/providers/user-data/UserProvider";
import {Avatar, Spacer, Spinner} from "@nextui-org/react";
import {Spacer, Spinner} from "@nextui-org/react";
import Card from "@/app/(site)/components/Card";
import {CardBody} from "@nextui-org/card";
import EditableInput from "@/app/(site)/components/inputs/editable/EditableInput";
import {USERNAME_REGEX} from "@/app/api/auth/register/register.dto";
import {EditIcon} from "@nextui-org/shared-icons";
import EditableMemberAvatar from "@/app/(site)/components/inputs/editable/EditableAvatar";
import toast from "react-hot-toast";
import UpdateSelfMember from "@/app/(site)/hooks/user/UpdateSelfMember";
import {PatchSelfDto} from "@/app/api/me/self-user.dto";
import {handleAxiosError} from "@/utils/client/client-utils";

const EditableUserProfile: FC = () => {
const {trigger: update, isMutating: isUpdating} = UpdateSelfMember()
const [optimisticAvatarSrc, setOptimisticAvatarSrc] = useState<string>()
const {
memberData: {
data: member,
Expand All @@ -20,6 +27,12 @@ const EditableUserProfile: FC = () => {
}
} = useMemberData()

const doUpdate = useCallback(async (dto: PatchSelfDto) => (
update({body: dto})
.then(res => res.data)
.catch(handleAxiosError)
), [update])

return (
<Card
className="w-1/2 laptop:w-5/6"
Expand All @@ -35,10 +48,35 @@ const EditableUserProfile: FC = () => {
(
<Fragment>
<div className="flex gap-8">
<Avatar
src={member?.image ?? undefined}
isBordered
<EditableMemberAvatar
srcOverride={optimisticAvatarSrc}
editEnabled={member !== undefined}
member={member}
className="w-24 h-24"
isBordered

onUploadStart={async (file) => {
const fileBlob = new Blob([Buffer.from(await file.arrayBuffer())])
const fileSrc = URL.createObjectURL(fileBlob)
setOptimisticAvatarSrc(fileSrc)

toast.success("Updated your avatar!")
}}

onUploadSuccess={async (key) => {
if (editMemberData)
await editMemberData(
() => doUpdate({image: key}),
{
...member!,
image: key
}
)
}}

onUploadError={(error) => {
toast.error(error)
}}
/>
<div className="flex flex-col justify-center">
<h3 className="capitalize font-semibold text-2xl">{member?.firstName} {member?.lastName}</h3>
Expand Down Expand Up @@ -71,7 +109,8 @@ const EditableUserProfile: FC = () => {

}}
>
<p className="flex gap-2">{member?.username} <EditIcon className="self-center" /></p>
<p className="flex gap-2">{member?.username} <EditIcon
className="self-center"/></p>
</EditableInput>
</div>
<div className="flex phone:flex-col gap-24 phone:gap-4">
Expand All @@ -88,7 +127,8 @@ const EditableUserProfile: FC = () => {

}}
>
<p className="capitalize flex gap-2">{member?.firstName} <EditIcon className="self-center" /></p>
<p className="capitalize flex gap-2">{member?.firstName} <EditIcon
className="self-center"/></p>
</EditableInput>
</div>
<div>
Expand All @@ -104,7 +144,8 @@ const EditableUserProfile: FC = () => {

}}
>
<p className="capitalize flex gap-2">{member?.lastName} <EditIcon className="self-center" /></p>
<p className="capitalize flex gap-2">{member?.lastName} <EditIcon
className="self-center"/></p>
</EditableInput>
</div>
</div>
Expand Down
114 changes: 114 additions & 0 deletions src/app/(site)/components/FileUpload.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"use client"

import {FC, Fragment, ReactElement, RefObject, useRef} from "react";
import {Input} from "@nextui-org/input";
import MediaType from "@/app/api/utils/MediaType";
import toast from "react-hot-toast";
import UploadS3File from "@/app/(site)/hooks/s3/UploadS3File";

export type FileUploadProps = {
uploadKey?: string,
/**
* Used to update an image to prevent the cluttering
* of the S3 bucket and CloudFront CDN.
*/
oldKey?: string,
/**
* Must end with a forward slash.
*/
uploadPath?: string,
isPublicObject?: boolean,
onUploadStart?: (file: File) => void,
onUploadSuccess?: (key: string) => void,
onUploadError?: (error: string) => void,
onFileRemove?: () => void,
disabled?: boolean,
fileTypes: MediaType[]
children?: (inputRef: RefObject<HTMLInputElement>) => ReactElement | ReactElement[],
showToast?: boolean,
toastOptions?: {
uploadingMsg?: string,
successMsg?: string,
errorHandler?: (error: string) => any
}
}

export const FileUpload: FC<FileUploadProps> = ({
uploadKey,
oldKey,
uploadPath,
onUploadStart,
onUploadSuccess,
onUploadError,
onFileRemove,
fileTypes,
disabled,
children,
showToast,
toastOptions,
isPublicObject
}) => {
const {trigger: triggerUpload} = UploadS3File()
const inputRef = useRef<HTMLInputElement>(null)

return (
<Fragment>
<Input
ref={inputRef}
type="file"
className="hidden"
accept={fileTypes.join(",")}
isDisabled={disabled}
onChange={async (e) => {
e.preventDefault()

const upload = async () => {
const allFiles = e.target.files;
if (!allFiles || !allFiles.length) {
if (onFileRemove)
onFileRemove();
return Promise.resolve();
}
const file = allFiles[0];
if (onUploadStart)
onUploadStart(file);

return triggerUpload({
body: {
file,
key: uploadKey,
oldKey,
path: uploadPath,
isPublic: isPublicObject
}
}).then((res) => {
if (onUploadSuccess)
onUploadSuccess(res.data.key.replace("avatars/", ""));
}
).catch(e => {
if (onUploadError)
onUploadError(e.message);
});
};

if (showToast) {
const defaultErrorHandler = (msg: string) => `There was an error uploading a new avatar: ${msg}`;
await toast.promise(
upload(),
{
loading: toastOptions?.uploadingMsg ?? "Uploading new avatar...",
success: toastOptions?.successMsg ?? "Successfully updated new avatar!",
error: toastOptions?.errorHandler ?? defaultErrorHandler
}
)
} else await upload()

e.target.files = null
}}
/>
{children && children(inputRef)}
</Fragment>
)
}

export default FileUpload
4 changes: 3 additions & 1 deletion src/app/(site)/components/UserProfile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import Image from "@/app/(site)/components/Image";
import HomeIcon from "@/app/(site)/components/icons/HomeIcon";
import Dropdown from "@/app/(site)/components/Dropdown";
import {useMemberData} from "@/app/(site)/components/providers/user-data/UserProvider";
import useCloudFrontUrl from "@/app/(site)/hooks/s3/useCloudFrontUrl";

type Props = {
placement?: OverlayPlacement
}

const UserProfile: FC<Props> = ({placement}) => {
const {memberData: {data: member}} = useMemberData()
const {data: memberImage, isLoading: memberImageLoading} = useCloudFrontUrl(member && `avatars/${member?.id}`)

return (
<Dropdown
Expand All @@ -30,7 +32,7 @@ const UserProfile: FC<Props> = ({placement}) => {
isBordered
as="button"
className="transition-transform"
src={member?.image ?? undefined}
src={memberImage?.url ?? undefined}
classNames={{
name: "capitalize font-semibold"
}}
Expand Down
72 changes: 72 additions & 0 deletions src/app/(site)/components/inputs/editable/EditableAvatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import {FC} from "react";
import FileUpload, {FileUploadProps} from "@/app/(site)/components/FileUpload";
import MediaType from "@/app/api/utils/MediaType";
import {Member} from "@prisma/client";
import useCloudFrontUrl from "@/app/(site)/hooks/s3/useCloudFrontUrl";
import {Avatar, AvatarProps, Skeleton} from "@nextui-org/react";
import clsx from "clsx";
import {AnimatePresence} from "framer-motion";

type Props = {
editEnabled?: boolean,
member?: Member,
avatarUrl?: string,
srcOverride?: string,
}
& Omit<AvatarProps, "src" | "onClick">
& Pick<FileUploadProps, "onFileRemove" | "onUploadSuccess" | "onUploadError" | "onUploadStart">

const EditableMemberAvatar: FC<Props> = ({
editEnabled,
member,
avatarUrl,
onFileRemove,
onUploadSuccess,
onUploadStart,
onUploadError,
srcOverride,
...avatarProps
}) => {
const {
data: fetchedAvatarUrl,
isLoading: avatarIsLoading
} = useCloudFrontUrl(avatarUrl === undefined && member?.image ? `avatars/${member.image}` : null)


return (
<FileUpload
oldKey={member?.image ?? undefined}
isPublicObject
uploadPath="avatars/"
disabled={!editEnabled}
fileTypes={[MediaType.JPEG, MediaType.PNG, MediaType.WEBP]}
onUploadStart={onUploadStart}
onUploadError={onUploadError}
onUploadSuccess={onUploadSuccess}
onFileRemove={onFileRemove}
>
{(ref) => (
<AnimatePresence>
{avatarIsLoading ? (
<Skeleton
isLoaded={!avatarIsLoading}
className={clsx("rounded-full", avatarProps.className)}>
</Skeleton>
) : (
<Avatar
src={srcOverride ?? (avatarUrl ?? fetchedAvatarUrl?.url)}
onClick={() => ref.current?.click()}
classNames={{
base: clsx(editEnabled && "cursor-pointer"),
}}
{...avatarProps}
/>
)}

</AnimatePresence>
)}
</FileUpload>
)
}

export default EditableMemberAvatar
Loading

0 comments on commit e453727

Please sign in to comment.