Before you learn
🔗 [React & Next.js] 프로필 이미지 (업로드, 미리 보기, 삭제)
= 1개의 이미지 (업로드, 미리 보기, 삭제)이므로 먼저 보고 오는 것을 추천합니다.
영상
[React & Next.js] 다중 이미지 (업로드, 미리 보기, 삭제)
파일 구조
register.jsx > HouseImages.jsx
미리 보기
거의 프로필 이미지(한 개의 이미지)의 미리 보기와 비슷하다.
다른 점은 Base64 Data URL로 화면에 보여줘야 하는데,
File -> Base64 Data URL로 변환할 때
여러 개의 File이다 보니 한 번에 변환되지 않아
반복문(for문 등...)을 통해 하나씩 변환해 주어야 한다.
(File -> Base64 Data URL)
삭제
유저가 선택한 이미지의 index를 받아
해당 이미지의 index만을 Array.splice로 제외시켜 삭제한다. (filter로 구현해도 된다.)
// HouseImages.jsx
import DeleteBtn from "./Buttons/DeleteBtn";
// 숙소 등록 페이지에서 사진 미리보기 및 삭제 기능
export default function HouseImages({ houseImages, setHouseImages }) {
// const [houseImages, setHouseImages] = useState([]); <-- 부모 컴포넌트에 있음
// 이미지 선택
const handleHouseImages = async (event) => {
// File 전체
const files = event.target.files;
const imgUrls = [];
// File을 선택하지 않은 경우
if (files.length === 0) {
return;
} else {
// File을 선택했다면 기존 값 초기화 시키기
setHouseImages([]);
// 여러 개의 File을 한 번에 Base64 Data URL 변환시킬 수 없기 때문에,
// for문을 통해서 작업한다.
for (let i = 0; i < files.length; i++) {
const file = files[i];
const reader = new FileReader();
// file을 Base64 Data URL로 변환
// 아래 작업은 console.log를 했을 때 비동기 작업이 완료되지 않아 undefined로 나타난다.
reader.readAsDataURL(file);
// 변환된 Base64 Data URL의 onload가 완료된 후 실행
reader.onload = (event) => {
// 변환된 Base64 Data URL의 onload가 완료 2, 진행 중 1, 실패 0 을 반환한다.
if (reader.readyState === 2) {
const imgUrl = event.target.result;
imgUrls.push(imgUrl);
// 모든 파일을 처리한 경우, 상태 업데이트
if (imgUrls.length === files.length) {
setHouseImages(imgUrls);
}
}
};
}
}
};
// 이미지 삭제
const deleteImage = (index) => {
// = ['imageURL1', 'imageURL2', 'imageURL3', ... ]
const newHouseImages = [...houseImages];
// 선택한 index의 이미지부터 하나만 선택된다. (filter를 사용해도 된다.)
newHouseImages.splice(index, 1);
// 만약 index가 1이면 -> ['imageURL1', 'imageURL3', ... ]
setHouseImages(newHouseImages);
};
return (
<div>
<label
htmlFor="img"
className="w-full cursor-pointer text-gray-600 hover:border-[var(--brand-color)] hover:text-[var(--brand-color)] flex items-center justify-center border-2 border-dashed border-gray-300 h-48 rounded-md"
>
<svg
className="h-12 w-12"
stroke="currentColor"
fill="none"
viewBox="0 0 48 48"
aria-hidden="true"
>
<path
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<input
required
id="img"
className="hidden"
type="file"
accept="image/*"
onChange={handleHouseImages}
multiple
/>
</label>
{houseImages.length !== 0 ? (
<div className="mt-8 w-full">
...
<div className="flex flex-row gap-x-4 overflow-x-scroll scrollbar-hide">
{houseImages.map((el, index) => (
<div key={index} className="relative flex-shrink-0 ">
<img src={el} className="w-[100px] aspect-square rounded-lg" />
<div className="absolute top-1 left-1">
<DeleteBtn onClick={() => deleteImage(index)} />
</div>
</div>
))}
</div>
</div>
) : null}
</div>
);
}
업로드
"Content-Type" : "multipart/form-data" 형식으로 서버에 전송해야 한다.
업로드를 하려면
한 개의 이미지(프로필 이미지)와 비슷하고,
미리 보기에서 했던 것을 반대로 하면 된다.
단, 여기서도 마찬가지로 한 번에 변환시킬 수 없기에
반복문(for문 등...)을 통해 하나씩 변환시켜 주어야 한다.
(Base64 Data URL -> File)
만약 미리 보기 기능을 구현하지 않고, 업로드 기능은 구현한다면,
유저가 선택한 파일 그 자체를 바로 윗 형식에 맞춰서 보내면 되기에
Base64 Data URL -> File로 변환할 필요가 없다.
// register.jsx
import axios from "axios";
import HouseImages from "@/components/HouseImages";
import { useState } from "react";
export default function Register() {
// 숙소 사진
const [houseImages, setHouseImages] = useState([]);
// Base64 데이터 URL(imageUrl)를 Blob으로 변환하는 함수
const convertDataURLToFile = async (dataURL, fileName) => {
// Base64 데이터 URL을 Blob으로 변환하여 axios를 사용하여 파일을 가져옵니다.
const response = await axios.get(dataURL, {
responseType: "blob",
});
const blob = response.data;
// Blob을 File 객체로 변환합니다.
const houseImgFile = new File([blob], fileName, { type: blob.type });
return houseImgFile; // 변환된 File 객체 반환
};
// 등록 버튼 클릭 시 실행되는 함수
const onClickSubmitBtn = async () => {
// API 요청을 보내기 위한 FormData 객체 생성
const formData = new FormData();
// stay 데이터(이미지를 제외한 나머지 데이터)를 JSON 형식으로 추가
formData.append(
"stay",
new Blob(
[
JSON.stringify({
// 이미지를 제외한 다른 데이터(ex: houseName)를 여기에 추가합니다.
}),
],
{ type: "application/json" }
)
);
// 이미지 파일을 FormData에 추가
if (houseImages.length >= 1) {
// houseImages 배열의 각 요소를 순회하며 처리
for (let i = 0; i < houseImages.length; i++) {
// Base64 데이터 URL을 File 객체로 변환
const houseImgFile = await convertDataURLToFile(
houseImages[i],
`house_images_${i}`
);
// FormData에 파일을 추가합니다.
formData.append("image", houseImgFile);
}
}
try {
// 서버 API 호출
const response = await axios.post(
`${process.env.NEXT_PUBLIC_SERVER_URL}/stays?categoryName=${category}`,
formData,
{
headers: {
"Content-Type": "multipart/form-data",
"ngrok-skip-browser-warning": "69420",
},
}
);
console.log(response); // API 응답 로그
} catch (error) {
alert("숙소 등록을 실패했습니다."); // 에러 알림
console.log("에러", error); // 에러 로그
}
};
return (
<form onSubmit={(event) => event.preventDefault()}>
<section>
{/* 다른 폼 컨트롤들 */}
<section>
{/* HouseImages 컴포넌트를 통해 숙소 이미지 선택 */}
<HouseImages
setHouseImages={setHouseImages}
houseImages={houseImages}
/>
</section>
{/* 다른 폼 컨트롤들 */}
</section>
{/* 등록 버튼 */}
<button
type="submit"
onClick={onClickSubmitBtn}
>
등록하기
</button>
</form>
);
}