상품을 등록하는 form을 구현할 계획이고 아래의 기능들도 모두 구현되어야 한다.
위의 이미지처럼 form을 만들었다.
이미지를 업로드할 수 있는 input의 코드를 더 살펴보자.
ProductManagement
export default function ProductManagement() {
const multipleImgRef = useRef<HTMLInputElement>(null);
const [previewImgs, setPreviewImgs] = useState<string[]>(Array(ImageMaxCnt).fill("")); // ImageMaxCnt: 이미지를 올릴 수 있는 최대 장수를 변수로 설정해 외부에서 선언
const [imgFiles, setImgFiles] = useState<File[]>([]); // 이미지 File 형식을 담은 배열
...
const handleMultipleImgAddBtn = () => {
multipleImgRef.current?.click();
};
const handleMultipleImgInput = () => {
// 파일 입력 요소에서 선택된 파일들을 가져옴
const files = multipleImgRef.current?.files;
if (!files) return; // 파일이 선택되지 않았으면 함수 종료
const filesArray = Array.from(files); // files는 객체이기 때문에 배열로 변환
// 기존 이미지 파일 배열에 새로운 파일 배열을 추가하고, 최대 이미지 개수를 초과하지 않도록 자름
setImgFiles((prevImgFiles) =>
[...prevImgFiles, ...filesArray].slice(0, ImageMaxCnt)
);
// 기존 이미지 URL 리스트를 복사
const updatePreviewImgs = [...previewImgs];
// 새로운 파일에서 생성된 이미지 URL 배열 생성
const imgUrls = filesArray.map((file) => URL.createObjectURL(file));
for (const imgUrl of imgUrls) {
// 빈 자리(빈 문자열)의 인덱스를 찾음
const emptyIndex = updatePreviewImgs.indexOf("");
if (emptyIndex !== -1) {
// 빈 자리가 있으면 해당 자리에 이미지 URL을 삽입
updatePreviewImgs[emptyIndex] = imgUrl;
}
}
// 이미지 URL 리스트 상태를 업데이트
setPreviewImgs(updatePreviewImgs);
};
return(
...
<div>
<input
type="file"
id="image"
name="image"
accept=".jpg,.jpeg,.png,.gif"
ref={multipleImgRef}
onChange={handleMultipleImgInput}
multiple // ⏪️
/>
{previewImgs.map((img, idx) => (
<div key={idx}>
<ImagePreview
src={img}
alt="미리보기"
idx={idx}
img={img}
onClick={handleMultipleImgAddBtn}
/>
</div>
))}
</div>;
)
multiple
을 추가한다.onClick={handleMultipleImgAddBtn}
을 통해 input에 설정된 ref={multipleImgRef}
이 클릭되도록 한다.previewImgs
는 이미지 미리보기를 위한 배열로 이미지 파일이 선택되면 onChange={handleMultipleImgInput}
을 통해 imgs에 미리보기를 위한 url이 담긴다.imgFiles
는 File 형식의 이미지를 담은 배열로 Storage에 이미지를 업로드하려면 File 형식을 전달해줘야 하기 때문
이미지 미리보기가 생성되면 삭제 버튼이 활성화되는데 여기에 handleImgDelete
를 설정해 이미지를 삭제한다.
const handleImgDelete = (id: number) => {
setPreviewImgs([...previewImgs.filter((_, idx) => idx !== id), ""]);
setImgFiles([...imgFiles.filter((_, idx) => idx !== id)]);
};
예전에 드래그 앤 드랍 구현을 위해 react-beautiful-dnd를 사용해서 구현한 적이 있는데, 이번에는 라이브러리 없이 구현해보았다.
dragstart
: 사용자가 요소나 텍스트 블록을 드래그하기 시작했을 때 발생dragEnter
: 드래그한 요소나 텍스트 블록을 적합한 드롭 대상 위에 올라갔을 때 발생drop
: 요소나 텍스트 블록을 적합한 드롭 대상에 드롭했을 때 발생export default function ProductManagement() {
...
// 드래그 중인 이미지 인덱스를 저장하기 위한 useRef
const dragImgIdx = useRef<number | null>(null);
const dragOverImgIdx = useRef<number | null>(null);
// 드래그 시작 시 호출되는 함수
const dragStart = (e: DragEvent, position: number) => {
dragImgIdx.current = position;
};
// 드래그 중 다른 이미지 위로 들어왔을 때 호출되는 함수
const dragEnter = (e: DragEvent, position: number) => {
dragOverImgIdx.current = position;
};
// 드래그가 끝났을 때 호출되는 함수(이미지 미리보기 배열과 이미지 파일이 담긴 배열을 둘 다 업데이트)
const drop = (e: DragEvent) => {
// 현재 이미지 리스트와 파일 리스트 복사
const newImgList = [...previewImgs];
const newImgFileList = [...imgFiles];
// 드래그된 이미지와 파일 값 저장
const dragImgValue = newImgList[dragImgIdx.current!];
const dragImgFileValue = newImgFileList[dragImgIdx.current!];
// 드래그된 이미지를 리스트에서 제거
newImgList.splice(dragImgIdx.current!, 1);
// 드래그된 이미지를 새로운 위치에 삽입
newImgList.splice(dragOverImgIdx.current!, 0, dragImgValue);
// 드래그된 파일을 리스트에서 제거
newImgFileList.splice(dragImgIdx.current!, 1);
// 드래그된 파일을 새로운 위치에 삽입
newImgFileList.splice(dragOverImgIdx.current!, 0, dragImgFileValue);
// 드래그 인덱스를 초기화
dragImgIdx.current = null;
dragOverImgIdx.current = null;
// 상태 업데이트
setPreviewImgs(newImgList);
setImgFiles(newImgFileList);
};
...
return (
<div>
<form onSubmit={handleSubmit}>
...
{previewImgs.map((img, idx) => (
<div key={idx}>
<ImagePreview
...
onDragStart={dragStart}
onDragEnter={dragEnter}
onDragEnd={drop}
/>
</div>
))}
</div>
</form>
</div>
);
}
ImagePreview
export default function ImagePreview({
...
onDragStart,
onDragEnter,
onDragEnd,
}: Props) {
return (
<>
<button type="button" onClick={onClick}>
<Image src={ImageIcon} alt="이미지 추가" />
</button>
{img && (
<>
<Image
...
draggable
onDragStart={(e) => onDragStart(e, idx)}
onDragEnter={(e) => onDragEnter(e, idx)}
onDragEnd={onDragEnd}
onDragOver={(e) => e.preventDefault()}
/>
<button type="button" onClick={() => onDelete(idx)}>
<Image src={DeleteIcon} alt="이미지 삭제" />
</button>
</>
)}
</>
);
}
라이브러리 없이 구현하고 느낀점은 라이브러리를 썼을 때보다 간단하게 구현이 가능했지만 드래그앤드랍 시 애니메이션이 깔끔한 react-beautiful-dnd가 그리워졌다.. 일단은 이렇게 구현해놓고 나중에 라이브러리를 도입하거나 직접 구현할 예정이다.
이미지 외의 상품 이름, 가격 등은 onBlur(focus가 해제될 때)가 발생하면 productInfo
에 담기도록 해줬다.
const handleInput = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
setProductInfo((prev) => ({ ...prev, [name]: value }));
};
ProductManagement
import { v4 } from "uuid";
...
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const uploadedImgUrls = await handleUploadStorage(imgFiles);
const error = await addProductAction({
...productInfo,
image: uploadedImgUrls,
});
if (error) {
console.error("상품 등록 실패", error);
return;
}
console.log("상품 등록 성공!");
};
const handleUploadStorage = async (files: File[]) => {
const filefolder = v4(); // 고유의 파일명을 생성
const uploadedImgUrls = [];
// storage에 이미지 업로드
for (let i = 0; i < files.length; i++) {
const { data, error } = await supabase.storage
.from("Image")
.upload(`products/${filefolder}/${i}`, files[i]);
if (error) {
console.error("이미지 업로드에 실패했습니다.", error);
return;
}
// storage에 담긴 이미지의 publicUrl 가져오기
const res = await supabase.storage.from("Image").getPublicUrl(data.path);
uploadedImgUrls.push(res.data.publicUrl);
}
return uploadedImgUrls;
};
handleSubmit
실행addProductAction
에 입력된 상품 정보와 이미지를 넣어서 실행export async function addProductAction(formData: Product) {
const supabase = createClient();
const image = formData.image;
const { error } = await supabase
.from("product")
.insert({ ...formData, image });
if (error) {
return error;
}
redirect("/sellercenter/product");
}
product에 접근하기 위해서 policy 설정이 필요한데, auth에 동일한 id가 있고, user_type이 SELLER인 경우에만 접근이 가능하도록 policy 설정을 해줬다.
product의 column에는 상품을 등록한 유저(판매자)의 id도 같이 저장되어야 하는데
seller_id 컬럼의 Data Type => uuid, Default Value => auth.uid()
Foreign Key는 아래처럼 설정해줬다.
이렇게 설정하고 상품등록을 진행하면 현재 로그인한 유저의 id가 자동으로 product 데이터베이스에 함께 담기게 된다.
로그인과 회원가입을 구현할 때 Server Action을 사용해서 formData를 좀 더 쉽게 관리할 수 있었기 때문에 상품등록에서도 Server Action을 사용하려고 했으나, 이미지가 여러 장일 때 폼에서 이미지를 삭제하는 과정에서 Server Action에서 받아온 formData의 이미지가 업데이트가 안되는 문제가 있어 Server Action을 사용하지 않았다.(Server Action을 활용하고싶어서 여러 방법을 시도해봤지만 실패했다 😞) 더 간단한 폼이라면 Server Action을 활용하는 것을 추천한다! 참고