#11 Product Upload
1. product 생성 버튼 삽입 및 상품 추가 페이지 생성
*(tabs)/products/page.tsx
import ProductList from "@/components/product-list";
import db from "@/lib/db";
import { PlusIcon } from "@heroicons/react/24/solid";
import { Prisma } from "@prisma/client";
import Link from "next/link";
async function getInitialProducts() {
const products = await db.product.findMany({
select: {
title: true,
price: true,
created_at: true,
photo: true,
id: true,
},
take: 1,
orderBy: {
created_at: "desc",
},
});
return products;
}
export type InitialProducts = Prisma.PromiseReturnType<
typeof getInitialProducts
>;
export default async function Products() {
const initialProducts = await getInitialProducts();
return (
<div>
<ProductList initialProducts={initialProducts} />
<Link
href="/products/add"
className="bg-orange-500 flex items-center justify-center rounded-full size-16 fixed bottom-24 right-8 text-white transition-colors hover:bg-orange-400"
>
<PlusIcon className="size-10" />
</Link>
</div>
);
}
1) 버튼 추가함
*products/add/page.tsx
"use client";
import Button from "@/components/button";
import Input from "@/components/input";
import { PhotoIcon } from "@heroicons/react/24/solid";
import { useState } from "react";
import { uploadProduct } from "./actions";
export default function AddProduct() {
const [preview, setPreview] = useState("");
const onImageChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const {
target: { files },
} = event;
if (!files) {
return;
}
const file = files[0];
const url = URL.createObjectURL(file);
setPreview(url);
};
return (
<div>
<form action={uploadProduct} className="p-5 flex flex-col gap-5">
<label
htmlFor="photo"
className="border-2 aspect-square flex items-center justify-center flex-col text-neutral-300 border-neutral-300 rounded-md border-dashed cursor-pointer bg-center bg-cover"
style={{
backgroundImage: `url(${preview})`,
}}
>
{preview === "" ? (
<>
<PhotoIcon className="w-20" />
<div className="text-neutral-400 text-sm">
사진을 추가해주세요.
</div>
</>
) : null}
</label>
<input
onChange={onImageChange}
type="file"
id="photo"
name="photo"
accept="image/*"
className="hidden"
/>
<Input name="title" required placeholder="제목" type="text" />
<Input name="price" type="number" required placeholder="가격" />
<Input
name="description"
type="text"
required
placeholder="자세한 설명"
/>
<Button text="작성 완료" />
</form>
</div>
);
}
1) 아래 사진과 같이 프리뷰로 사진을 볼 수 있게 해두었다.
2) label과 input트릭을 사용해서 이미지 input이 안보이게하여 깔끔하게 보인다.
3) 첨부파일에는 반드시 이미지 파일이어야 하며, 용량이 너무 커서는 안된다. 해당 코드에는 이런 내용이 구축되지 않았다. 아래 코드 참고해서 실제 서비스에는 이부분도 추가하자. (zod 사용)
https://github.com/kyu1204/carrot-market-clone/commit/6e8bdfdbe40656e6d90f1da5ddedcb963ad59f9f
https://github.com/0626na/carrot-market/commit/d7094fbb68b1ae0eadf58b822546f37fb18273ac
4) 실제 파일은 db에 업로드 되면 안된다. 관련해서 아래에 계속 내용 설명 예정.
*products/add/actions.tsx
"use server";
export async function uploadProduct(formData: FormData) {
const data = {
photo: formData.get("photo"),
title: formData.get("title"),
price: formData.get("price"),
description: formData.get("description"),
};
console.log(data);
}
1) 콘솔값을 찍어보면 값을 잘 받아온다.
2. cloudflare 서비스 (유료) 를 이용해서 이미지를 업로드해보자.
-이건 스터디 목적으로 한달 해보는거지만, 실제 서비스에 쓸지는 미지수 (서비스 초기에는 S3 무료 서비스 쓰는게 나을듯)
-이미지 최적화?, 사진 편집, 품질 변경 등 S3에서 할 수 없는 이미지 관련 추가 기능들을 쓸 수 있다고함.
-한달에 5$
-아래 화면에서 api 토큰받자
*아래 stream및 Images 부분 템플릿 사용 클릭 (추 후 강의에서 stream 강의도 이걸로 함)
이어서 토큰값이 나오면 .env파일에 저장하고, 개요부분에 있는 계정 ID와 계정 해시도 .env 파일에 저장하자.
*products/add/actions.ts
"use server";
import { z } from "zod";
import db from "@/lib/db";
import getSession from "@/lib/session";
import { redirect } from "next/navigation";
const productSchema = z.object({
photo: z.string({
required_error: "Photo is required",
}),
title: z.string({
required_error: "Title is required",
}),
description: z.string({
required_error: "Description is required",
}),
price: z.coerce.number({
required_error: "Price is required",
}),
});
export async function uploadProduct(_: any, formData: FormData) {
const data = {
photo: formData.get("photo"),
title: formData.get("title"),
price: formData.get("price"),
description: formData.get("description"),
};
const result = productSchema.safeParse(data);
if (!result.success) {
return result.error.flatten();
} else {
const session = await getSession();
if (session.id) {
const product = await db.product.create({
data: {
title: result.data.title,
description: result.data.description,
price: result.data.price,
photo: result.data.photo,
user: {
connect: {
id: session.id,
},
},
},
select: {
id: true,
},
});
redirect(`/products/${product.id}`);
//redirect("/products")
}
}
}
export async function getUploadUrl() {
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${process.env.CLOUDFLARE_ACCOUNT_ID}/images/v2/direct_upload`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CLOUDFLARE_API_KEY}`,
},
}
);
const data = await response.json();
return data;
}
1) 클라우드플레어에 이미지를 업로드 하기 위해서는, POST로 위와 같이 데이터를 보내야 한다. 그리고 응답값으로, url 주소를 얻어오자.
*products/add/page.tsx
"use client";
import Button from "@/components/button";
import Input from "@/components/input";
import { PhotoIcon } from "@heroicons/react/24/solid";
import { useState } from "react";
import { getUploadUrl, uploadProduct } from "./actions";
import { useFormState } from "react-dom";
export default function AddProduct() {
const [preview, setPreview] = useState("");
const [uploadUrl, setUploadUrl] = useState("");
const [photoId, setImageId] = useState("");
const onImageChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const {
target: { files },
} = event;
if (!files) {
return;
}
const file = files[0];
const url = URL.createObjectURL(file);
setPreview(url);
const { success, result } = await getUploadUrl();
if (success) {
const { id, uploadURL } = result;
setUploadUrl(uploadURL);
setImageId(id);
}
};
const interceptAction = async (_: any, formData: FormData) => {
const file = formData.get("photo");
if (!file) {
return;
}
const cloudflareForm = new FormData();
cloudflareForm.append("file", file);
const response = await fetch(uploadUrl, {
method: "post",
body: cloudflareForm,
});
console.log(await response.text());
if (response.status !== 200) {
return;
}
const photoUrl = `https://imagedelivery.net/aSbksvJjax-AUC7qVnaC4A/${photoId}`;
formData.set("photo", photoUrl);
return uploadProduct(_, formData);
};
const [state, action] = useFormState(interceptAction, null);
return (
<div>
<form action={action} className="p-5 flex flex-col gap-5">
<label
htmlFor="photo"
className="border-2 aspect-square flex items-center justify-center flex-col text-neutral-300 border-neutral-300 rounded-md border-dashed cursor-pointer bg-center bg-cover"
style={{
backgroundImage: `url(${preview})`,
}}
>
{preview === "" ? (
<>
<PhotoIcon className="w-20" />
<div className="text-neutral-400 text-sm">
사진을 추가해주세요.
{state?.fieldErrors.photo}
</div>
</>
) : null}
</label>
<input
onChange={onImageChange}
type="file"
id="photo"
name="photo"
accept="image/*"
className="hidden"
/>
<Input
name="title"
required
placeholder="제목"
type="text"
errors={state?.fieldErrors.title}
/>
<Input
name="price"
type="number"
required
placeholder="가격"
errors={state?.fieldErrors.price}
/>
<Input
name="description"
type="text"
required
placeholder="자세한 설명"
errors={state?.fieldErrors.description}
/>
<Button text="작성 완료" />
</form>
</div>
);
}
1) 로직 플로우
1. <onImageChange함수부분>
- 스크린 화면에서 이미지를 올리는 순간 (최종 발송 전임), file url을 가지고오고, 스크린에 보여줌
- 위 actions 부분에 작성한 getUploadUrl 함수를 통해서, 클라우드 플레어에 POST로 보낸 값에 대한 응답값을 받아오고, 거기서 이미지 id값과, uploadURL값을 가지고옴 (아직까지는 무슨 사진을 보내는지는 POST 데이터에 담지 않은 상태임, 1회용으로 내가 보낼 URL 값을 받아오는 것이다.)
3. 최종 action 버튼을 누르면 중간에 interceptAction함수가 먼저 실행 됨
<interceptAction함수>
- 위에서 추출한 1회용 uploadURL주소에 fetch를 통해 POST를 보내는데, 이때 body값에는 form형식에서 가지고온 photo file값을 담는다. (클라우드폼에서 원하는 형식임) / POST로 보내고 200으로 상태값 처리
- 그리고 클라우드 플레어에 저장된 사진을 받기 위해서는 개요 부분 이미지 제공 URL 부분을 사용 해야 한다
해당 형식은 https://imagedelivery.net/dGGUSNmPRJm6ENhe7q2fhw/<image_id>/<variant_name>
이것과 같은데, image_id 부분에는 getUploadUrl 함수에서 추출한 id 값이 들어가고, 뒤에 variant에는 avatar나 public을 넣으면 된다. variant는 클라우드 플레어에서 제공하는 것인데, 내가 사전에 세팅한 이미지 세팅값을 사용 할 수 있다.(혹은, url 주소를 통해, 내가 직접 width나 height, blur 같은 기능들을 직접 추가해줄 수 있다. 아래 유연한 변형 부분 활성화
(https://developers.cloudflare.com/images/transform-images/transform-via-url/)
Images에 가서, avatar를 추가해보자
그리고 최종적으로 db에 저장하기 위해서 formData의 photo값(string값 필요)을 photoUrl 값으로 변경해주고 uploadProduct를 실행해준다. 그 이후 uploadProduct의 코드 실행은 똑같다.
이미지 업로드 할 때, 또 에러가 나니
next.config.mjs파일에 아래와 같이 hostname을 추가해주자.
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
hostname: "avatars.githubusercontent.com",
},
{
hostname: "imagedelivery.net",
},
],
},
};
export default nextConfig;
이제 product화면에는 클라우드 플레어에 올린 썸네일용 이미지 (avatar)가 사용될 것이고, 세부 상품 화면에는 public이미지를 사용 하게 해보자.
*components/list-products.tsx
import { formatToTimeAgo, formatToWon } from "@/lib/utils";
import Image from "next/image";
import Link from "next/link";
interface ListProductProps {
title: string;
price: number;
created_at: Date;
photo: string;
id: number;
}
export default function ListProduct({
title,
price,
created_at,
photo,
id,
}: ListProductProps) {
return (
<Link href={`/products/${id}`} className="flex gap-5">
<div className="relative size-28 rounded-md overflow-hidden">
<Image
fill
src={`${photo}/avatar`}
className="object-cover"
alt={title}
/>
</div>
<div className="flex flex-col gap-1 *:text-white">
<span className="text-lg">{title}</span>
<span className="text-sm text-neutral-500">
{formatToTimeAgo(created_at.toString())}
</span>
<span className="text-lg font-semibold">{formatToWon(price)}원</span>
</div>
</Link>
);
}
1) 이미지 src부분에 avatar url 추가
*products/[id]/page.tsx
import db from "@/lib/db";
import getSession from "@/lib/session";
import { formatToWon } from "@/lib/utils";
import { UserIcon } from "@heroicons/react/24/solid";
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
async function getIsOwner(userId: number) {
const session = await getSession();
if (session.id) {
return session.id === userId;
}
return false;
}
async function getProduct(id: number) {
const product = await db.product.findUnique({
where: {
id,
},
include: {
user: {
select: {
username: true,
avatar: true,
},
},
},
});
return product;
}
export default async function ProductDetail({
params,
}: {
params: { id: string };
}) {
const id = Number(params.id);
if (isNaN(id)) {
return notFound();
}
const product = await getProduct(id);
if (!product) {
return notFound();
}
const isOwner = await getIsOwner(product.userId);
return (
<div>
<div className="relative aspect-square">
<Image
className="object-cover"
fill
src={`${product.photo}/public`}
alt={product.title}
/>
</div>
<div className="p-5 flex items-center gap-3 border-b border-neutral-700">
<div className="size-10 overflow-hidden rounded-full">
{product.user.avatar !== null ? (
<Image
src={product.user.avatar}
width={40}
height={40}
alt={product.user.username}
/>
) : (
<UserIcon />
)}
</div>
<div>
<h3>{product.user.username}</h3>
</div>
</div>
<div className="p-5">
<h1 className="text-2xl font-semibold">{product.title}</h1>
<p>{product.description}</p>
</div>
<div className="fixed w-full bottom-0 left-0 p-5 pb-10 bg-neutral-800 flex justify-between items-center">
<span className="font-semibold text-xl">
{formatToWon(product.price)}원
</span>
{isOwner ? (
<button className="bg-red-500 px-5 py-2.5 rounded-md text-white font-semibold">
Delete product
</button>
) : null}
<Link
className="bg-orange-500 px-5 py-2.5 rounded-md text-white font-semibold"
href={``}
>
채팅하기
</Link>
</div>
</div>
);
}
1) 이미지 src 부분에 /public 추가
3. REACT HOOK FORM으로 FORM을 대체해보자. (선택 사항)
npm i react-hook-form 설치
npm i @hookform/resolvers 설치
*products/add/schema.ts
import { z } from "zod";
export const productSchema = z.object({
photo: z.string({
required_error: "Photo is required",
}),
title: z.string({
required_error: "Title is required!!!!!",
}),
description: z.string({
required_error: "Description is required",
}),
price: z.coerce.number({
required_error: "Price is required",
}),
});
export type ProductType = z.infer<typeof productSchema>;
1) zod부분을 별도 파일에 스키마를 정의해주자.
2) ProductType부분, z.infer이부분은, 프론트단에서 타입 정의 할 때 쓰인다. (자동완성 기능도 이거 때문에 생김)
*products/add/actions.ts
"use server";
import db from "@/lib/db";
import getSession from "@/lib/session";
import { redirect } from "next/navigation";
import { productSchema } from "./schema";
export async function uploadProduct(formData: FormData) {
const data = {
photo: formData.get("photo"),
title: formData.get("title"),
price: formData.get("price"),
description: formData.get("description"),
};
const result = productSchema.safeParse(data);
if (!result.success) {
return result.error.flatten();
} else {
const session = await getSession();
if (session.id) {
const product = await db.product.create({
data: {
title: result.data.title,
description: result.data.description,
price: result.data.price,
photo: result.data.photo,
user: {
connect: {
id: session.id,
},
},
},
select: {
id: true,
},
});
redirect(`/products/${product.id}`);
//redirect("/products")
}
}
}
export async function getUploadUrl() {
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${process.env.CLOUDFLARE_ACCOUNT_ID}/images/v2/direct_upload`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CLOUDFLARE_API_KEY}`,
},
}
);
const data = await response.json();
return data;
}
1) 기존 zod 부분을 별도 스키마에 정의했기 때문에 그부분이 actions 파일에서 빠지고, productSchema로 불러와서 데이터를 검증한다.
*products/add/page.tsx
"use client";
import Button from "@/components/button";
import Input from "@/components/input";
import { PhotoIcon } from "@heroicons/react/24/solid";
import { useState } from "react";
import { getUploadUrl, uploadProduct } from "./actions";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { ProductType, productSchema } from "./schema";
export default function AddProduct() {
const [preview, setPreview] = useState("");
const [uploadUrl, setUploadUrl] = useState("");
const [file, setFile] = useState<File | null>(null);
const {
register,
handleSubmit,
setValue,
formState: { errors },
} = useForm<ProductType>({
resolver: zodResolver(productSchema),
});
const onImageChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const {
target: { files },
} = event;
if (!files) {
return;
}
const file = files[0];
const url = URL.createObjectURL(file);
setPreview(url);
setFile(file);
const { success, result } = await getUploadUrl();
if (success) {
const { id, uploadURL } = result;
setUploadUrl(uploadURL);
setValue(
"photo",
`https://imagedelivery.net/aSbksvJjax-AUC7qVnaC4A/${id}`
);
}
};
const onSubmit = handleSubmit(async (data: ProductType) => {
if (!file) {
return;
}
const cloudflareForm = new FormData();
cloudflareForm.append("file", file);
const response = await fetch(uploadUrl, {
method: "post",
body: cloudflareForm,
});
if (response.status !== 200) {
return;
}
const formData = new FormData();
formData.append("title", data.title);
formData.append("price", data.price + "");
formData.append("description", data.description);
formData.append("photo", data.photo);
return uploadProduct(formData);
});
const onValid = async () => {
await onSubmit();
};
console.log(register("title"));
return (
<div>
<form action={onValid} className="p-5 flex flex-col gap-5">
<label
htmlFor="photo"
className="border-2 aspect-square flex items-center justify-center flex-col text-neutral-300 border-neutral-300 rounded-md border-dashed cursor-pointer bg-center bg-cover"
style={{
backgroundImage: `url(${preview})`,
}}
>
{preview === "" ? (
<>
<PhotoIcon className="w-20" />
<div className="text-neutral-400 text-sm">
사진을 추가해주세요.
{errors.photo?.message}
</div>
</>
) : null}
</label>
<input
onChange={onImageChange}
type="file"
id="photo"
name="photo"
accept="image/*"
className="hidden"
/>
<Input
required
placeholder="제목"
type="text"
{...register("title")}
errors={[errors.title?.message ?? ""]}
/>
<Input
type="number"
required
placeholder="가격"
{...register("price")}
errors={[errors.price?.message ?? ""]}
/>
<Input
type="text"
required
placeholder="자세한 설명"
{...register("description")}
errors={[errors.description?.message ?? ""]}
/>
<Button text="작성 완료" />
</form>
</div>
);
}
RHF로 바꿔서 생긴 변동사항에 대해서만 설명하자면,
1) 이미지를 올린순간 setValue를 통해, input data의 "photo"값이 할당됨
2) 폼을 제출하게 되면 먼저 resolver: zodResolver(productSchema)를 통해, 프론트단에서 zod 스키마 내용대로 에러를 검증한다. form에 문제가 없다면 handleSubmit이 실행되고, 마지막 부분에 uploadProduct로 formdata를 보내면서 서버쪽 작업을 진행한다. 이 때 errors가 발생되면 이를 프론트단에 setError를 통해 표시 해줄수있다.
*components/input.tsx
import { ForwardedRef, InputHTMLAttributes, forwardRef } from "react";
interface InputProps {
name: string;
errors?: string[];
}
const _Input = (
{
name,
errors = [],
...rest
}: InputProps & InputHTMLAttributes<HTMLInputElement>,
ref: ForwardedRef<HTMLInputElement>
) => {
return (
<div className="flex flex-col gap-2">
<input
ref={ref}
name={name}
className="bg-transparent rounded-md w-full h-10 focus:outline-none ring-2 focus:ring-4 transition ring-neutral-200 focus:ring-orange-500 border-none placeholder:text-neutral-400"
{...rest}
/>
{errors.map((error, index) => (
<span key={index} className="text-red-500 font-medium">
{error}
</span>
))}
</div>
);
};
export default forwardRef(_Input);
1) 부모에서 해당 Input과 연결시키기 위해서는 ref를 Input에 넣어줘야하고, forwardRef로 해당 컴포넌트를 보내줘야한다.