mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-02-16 10:42:08 +00:00
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:
@@ -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.');
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
9
src/prisma/seed/util/imageUrls.ts
Normal file
9
src/prisma/seed/util/imageUrls.ts
Normal 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;
|
||||
34
src/requests/sendUploadBeerImageRequest.ts
Normal file
34
src/requests/sendUploadBeerImageRequest.ts
Normal 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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user