feat: client app can now upload images for beer posts

Updated seed to incorporate cloudinary images, downgraded multer to recent LTS, update tsconfig to allow spread operator usage on FileList.
This commit is contained in:
Aaron William Po
2023-05-07 21:05:21 -04:00
parent b457508a9b
commit f21dc905fe
15 changed files with 305 additions and 392 deletions

View File

@@ -39,6 +39,7 @@ const CommentCardDropdown: FC<CommentCardDropdownProps> = ({
<button
type="button"
onClick={() => {
// eslint-disable-next-line no-alert
alert('This feature is not yet implemented.');
}}
>

View File

@@ -1,12 +1,14 @@
import sendCreateBeerPostRequest from '@/requests/sendCreateBeerPostRequest';
import CreateBeerPostValidationSchema from '@/services/BeerPost/schema/CreateBeerPostValidationSchema';
import { zodResolver } from '@hookform/resolvers/zod';
import { BeerType } from '@prisma/client';
import router from 'next/router';
import { FunctionComponent, useState } from 'react';
import { useForm, SubmitHandler } from 'react-hook-form';
import { useForm, SubmitHandler, FieldError } from 'react-hook-form';
import { z } from 'zod';
import BreweryPostQueryResult from '@/services/BreweryPost/types/BreweryPostQueryResult';
import CreateBeerPostValidationSchema from '@/services/BeerPost/schema/CreateBeerPostValidationSchema';
import sendCreateBeerPostRequest from '@/requests/sendCreateBeerPostRequest';
import UploadImageValidationSchema from '@/services/types/ImageSchema/UploadImageValidationSchema';
import sendUploadBeerImagesRequest from '@/requests/sendUploadBeerImageRequest';
import ErrorAlert from './ui/alerts/ErrorAlert';
import Button from './ui/forms/Button';
import FormError from './ui/forms/FormError';
@@ -17,32 +19,52 @@ import FormSelect from './ui/forms/FormSelect';
import FormTextArea from './ui/forms/FormTextArea';
import FormTextInput from './ui/forms/FormTextInput';
type CreateBeerPostSchema = z.infer<typeof CreateBeerPostValidationSchema>;
interface BeerFormProps {
breweries: z.infer<typeof BreweryPostQueryResult>[];
types: BeerType[];
}
const CreateBeerPostWithImagesValidationSchema = CreateBeerPostValidationSchema.merge(
UploadImageValidationSchema,
);
const CreateBeerPostForm: FunctionComponent<BeerFormProps> = ({
breweries = [],
types = [],
}) => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<CreateBeerPostSchema>({
resolver: zodResolver(CreateBeerPostValidationSchema),
const { register, handleSubmit, formState } = useForm<
z.infer<typeof CreateBeerPostWithImagesValidationSchema>
>({
resolver: zodResolver(CreateBeerPostWithImagesValidationSchema),
});
const { errors, isSubmitting } = formState;
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const onSubmit: SubmitHandler<CreateBeerPostSchema> = async (data) => {
const onSubmit: SubmitHandler<
z.infer<typeof CreateBeerPostWithImagesValidationSchema>
> = async (data) => {
if (!(data.images instanceof FileList)) {
return;
}
const { breweryId, typeId, name, abv, ibu, description } = data;
try {
setIsSubmitting(true);
const response = await sendCreateBeerPostRequest(data);
const response = await sendCreateBeerPostRequest({
breweryId,
typeId,
name,
abv,
ibu,
description,
});
await sendUploadBeerImagesRequest({
beerPost: response,
images: data.images,
});
router.push(`/beers/${response.id}`);
} catch (e) {
if (!(e instanceof Error)) {
@@ -160,6 +182,19 @@ const CreateBeerPostForm: FunctionComponent<BeerFormProps> = ({
/>
</FormSegment>
<FormInfo>
<FormLabel htmlFor="images">Images</FormLabel>
<FormError>{(errors.images as FieldError | undefined)?.message}</FormError>
</FormInfo>
<FormSegment>
<input
type="file"
{...register('images')}
multiple
className="file-input-bordered file-input w-full"
/>
</FormSegment>
<div className="mt-6">
<Button type="submit" isSubmitting={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Submit'}

View File

@@ -59,7 +59,11 @@ const EditBeerPostForm: FC<EditBeerPostFormProps> = ({ previousValues }) => {
router.push('/beers');
}
} catch (e) {
console.error(e);
if (!(e instanceof Error)) {
setError('Something went wrong');
return;
}
setError(e.message);
}
};
return (

View File

@@ -29,7 +29,9 @@ const fileFilter: multer.Options['fileFilter'] = (req, file, cb) => {
};
const uploadMiddleware = expressWrapper(
multer({ storage, fileFilter, limits: { files: 3 } }).array('images'),
multer({ storage, fileFilter, limits: { files: 5, fileSize: 15 * 1024 * 1024 } }).array(
'images',
),
);
const BeerPostImageValidationSchema = z.object({

View File

@@ -46,9 +46,9 @@ const registerUser = async (req: RegisterUserRequest, res: NextApiResponse) => {
await sendConfirmationEmail(user);
res.status(200).json({
res.status(201).json({
success: true,
statusCode: 200,
statusCode: 201,
message: 'User registered successfully.',
payload: user,
});

View File

@@ -17,7 +17,7 @@ const ProtectedPage: NextPage = () => {
const isDesktop = useMediaQuery('(min-width: 768px)');
return (
<div className="flex h-full flex-col items-center justify-center space-y-3 text-center">
<div className="flex h-full flex-col items-center justify-center space-y-3 bg-primary text-center">
{isLoading && <Spinner size={isDesktop ? 'xl' : 'md'} />}
{user && !isLoading && (
<>
@@ -27,9 +27,7 @@ const ProtectedPage: NextPage = () => {
{isEvening && 'evening'}
{`, ${user?.firstName}!`}
</h1>
<h2 className="text-xl font-bold lg:text-4xl">
Welcome to the Biergarten App!
</h2>
<h2 className="text-xl font-bold lg:text-4xl">Welcome to the Biergarten App!</h2>
</>
)}
</div>

View File

@@ -2,6 +2,7 @@
import { faker } from '@faker-js/faker';
import { BeerPost, User } from '@prisma/client';
import DBClient from '../../DBClient';
import imageUrls from '../util/imageUrls';
interface CreateNewBeerImagesArgs {
numberOfImages: number;
@@ -31,9 +32,10 @@ const createNewBeerImages = async ({
const user = users[Math.floor(Math.random() * users.length)];
const caption = faker.lorem.sentence();
const alt = faker.lorem.sentence();
const path = imageUrls[Math.floor(Math.random() * imageUrls.length)];
beerImageData.push({
path: 'https://picsum.photos/5000/5000',
path,
alt,
caption,
beerPostId: beerPost.id,

View File

@@ -2,6 +2,7 @@
import { faker } from '@faker-js/faker';
import { BreweryPost, User } from '@prisma/client';
import DBClient from '../../DBClient';
import imageUrls from '../util/imageUrls';
interface CreateBreweryImagesArgs {
numberOfImages: number;
@@ -32,9 +33,10 @@ const createNewBreweryImages = async ({
for (let i = 0; i < numberOfImages; i++) {
const breweryPost = breweryPosts[Math.floor(Math.random() * breweryPosts.length)];
const user = users[Math.floor(Math.random() * users.length)];
const path = imageUrls[Math.floor(Math.random() * imageUrls.length)];
breweryImageData.push({
path: 'https://picsum.photos/5000/5000',
path,
alt: 'Placeholder brewery image.',
caption: 'Placeholder brewery image caption.',
breweryPostId: breweryPost.id,

View File

@@ -0,0 +1,9 @@
const imageUrls = [
'https://res.cloudinary.com/dxie9b7na/image/upload/v1683482214/cld-sample.jpg',
'https://res.cloudinary.com/dxie9b7na/image/upload/v1683482214/cld-sample-2.jpg',
'https://res.cloudinary.com/dxie9b7na/image/upload/v1683482214/cld-sample-3.jpg',
'https://res.cloudinary.com/dxie9b7na/image/upload/v1683482214/cld-sample-4.jpg',
'https://res.cloudinary.com/dxie9b7na/image/upload/v1683482214/cld-sample-5.jpg',
] as const;
export default imageUrls;

View File

@@ -0,0 +1,34 @@
import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult';
import { z } from 'zod';
interface SendUploadBeerImagesRequestArgs {
beerPost: z.infer<typeof beerPostQueryResult>;
images: FileList;
}
const sendUploadBeerImagesRequest = async ({
beerPost,
images,
}: SendUploadBeerImagesRequestArgs) => {
const formData = new FormData();
[...images].forEach((file) => {
formData.append('images', file);
});
formData.append('caption', `Image of ${beerPost.name}`);
formData.append('alt', beerPost.name);
const uploadResponse = await fetch(`/api/beers/${beerPost.id}/images`, {
method: 'POST',
body: formData,
});
if (!uploadResponse.ok) {
throw new Error('Failed to upload images');
}
return uploadResponse.json();
};
export default sendUploadBeerImagesRequest;

View File

@@ -0,0 +1,31 @@
import { z } from 'zod';
const UploadImageValidationSchema = z.object({
images: z
.instanceof(typeof FileList !== 'undefined' ? FileList : Object)
.refine((fileList) => fileList instanceof FileList, {
message: 'You must submit this form in a web browser.',
})
.refine((fileList) => (fileList as FileList).length > 0, {
message: 'You must upload at least one file.',
})
.refine((fileList) => (fileList as FileList).length < 5, {
message: 'You can only upload up to 5 files at a time.',
})
.refine(
(fileList) =>
[...(fileList as FileList)]
.map((file) => file.type)
.every((fileType) => fileType.startsWith('image/')),
{ message: 'You must upload only images.' },
)
.refine(
(fileList) =>
[...(fileList as FileList)]
.map((file) => file.size)
.every((fileSize) => fileSize < 15 * 1024 * 1024),
{ message: 'You must upload images smaller than 15MB.' },
),
});
export default UploadImageValidationSchema;