Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 190 additions & 0 deletions components/ImageUploadModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import Image from 'next/image';
import { useEffect, useRef, useState } from 'react';

import Modal from './Modal';

interface ImageUploadModalProps {
isOpen: boolean;
onClose: () => void;
}

/**
* 이미지 업로드 모달 컴포넌트
* @param isOpen 모달 오픈 여부
* @param onClose 모달 닫기 함수
* @returns ImageUploadModal 컴포넌트
*/

const ImageUploadModal = ({ isOpen, onClose }: ImageUploadModalProps) => {
// input 요소를 참조하기 위한 ref
const fileInputRef = useRef<HTMLInputElement>(null);

// 선택한 이미지의 미리보기 url
const [previewUrl, setPreviewUrl] = useState<string | null>(null);

const [isDragging, setIsDragging] = useState(false); // 드래그 중인지 여부

const [isUpload, setIsUpload] = useState(false);

// 카메라 아이콘 클릭시 input 요소 클릭이 되도록
const handleCameraClick = () => {
fileInputRef.current?.click();
};

// 파일 선택시
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// 파일을 url로 변환하여 미리보기 생성
const imageUrl = URL.createObjectURL(file);
setPreviewUrl(imageUrl);
}
};

// 드래그 관련 이벤트
// 드래그 시작
const handleDragEnter = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation(); // 이벤트 전파 중지
setIsDragging(true);
};
// 드래그가 영역을 벗어날 때
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
// 드래그 중
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
};
// 드래그로 파일을 드랍했을 때
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);

const file = e.dataTransfer.files?.[0];
if (file) {
const imageUrl = URL.createObjectURL(file);
setPreviewUrl(imageUrl);
}
};

const handleImageUpload = async () => {
try {
setIsUpload(true);
// 이미지 업로그 api 통신 로직
await new Promise((resolve) => setTimeout(resolve, 1000));
setIsUpload(false);
alert('이미지 업로드 성공');
onClose();
} catch (error) {
console.error('이미지 업로드 error:', error);
alert('이미지 업로드 중 오류가 발생했습니다.');
} finally {
setIsUpload(false);
}
};

useEffect(() => {
return () => {
// 이전 미리보기 url 정리
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
setPreviewUrl(null);
}
};
}, [previewUrl, isOpen]);

return (
<Modal isOpen={isOpen} onClose={onClose}>
<div className="flex flex-col gap-4">
<p className="text-lg font-bold">이미지</p>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept="image/*"
className="hidden"
/>

{previewUrl ? (
<div className="relative h-40 w-full overflow-hidden rounded-lg">
<Image
src={previewUrl}
alt="선택된 이미지"
layout="fill" // 부모 요소 크기에 맞게
objectFit="contain" // 비율 유지
/>
</div>
) : (
<div
onClick={handleCameraClick}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
className={`flex h-40 w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed ${
isDragging
? 'border-green-400 bg-green-50'
: 'border-gray-300 bg-gray-100 hover:border-gray-400'
}`}
>
<Image
src="/icon/icon-camera.png"
alt="카메라 아이콘"
width={40}
height={40}
/>
<p className="text-sm text-gray-500">
클릭 또는 이미지를 드래그하여 올려주세요
</p>
</div>
)}
<div className="flex justify-end">
<button
className={`w-40 rounded-custom px-6 py-2 text-background ${
previewUrl
? 'cursor-pointer bg-green-200'
: 'cursor-not-allowed bg-gray-300'
}`}
disabled={!previewUrl || isUpload}
onClick={handleImageUpload}
>
{isUpload ? (
<div className="flex items-center justify-center">
<span className="mr-2">확인 중</span>
<svg
className="h-5 w-5 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Comment on lines +159 to +178
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

svg 는 파일로 빼거나 혹은 코드로 빼는것은 어떠세요?

</div>
) : (
'삽입하기'
)}
</button>
Comment on lines +147 to +183
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기도 기존 Button 컴포넌트 이용해주세요!

</div>
</div>
</Modal>
);
};

export default ImageUploadModal;
74 changes: 74 additions & 0 deletions components/Modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import Image from 'next/image';
import { useEffect } from 'react';

interface ModalProps {
isOpen: boolean; // 모달 오픈 여부
onClose: () => void; // 모달 닫기 함수
children: React.ReactNode; // 모달 내용
width?: string; // 모달 너비
}

/**
* 모달 공통 레이아웃 컴포넌트
* @param isOpen 모달 오픈 여부
* @param onClose 모달 닫기 함수
* @param children 모달 내용
* @param width 모달 너비
* @returns Modal 컴포넌트
*/

const Modal = ({
isOpen,
onClose,
children,
width = 'w-11/12 ta:w-3/4 pc:w-1/2',
}: ModalProps) => {
// 모달이 닫혀있으면 null 반환
if (!isOpen) return null;

// 배경 클릭시 모달 닫기
const handleBackGroundClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
Comment on lines +29 to +34
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

배경을 클릭하면 무조건 닫히는것 보다 옵션으로 선택에 따라 조절할 수 있으면 합니다.
이미지 모달이나 입력 모달의 경우 데이터를 입력했는데 의도치 않게 바깥을 클릭했는데 모달이 닫히면 좋지못한 사용자 경험이라 생각됩니다.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

배경을 클릭하면 무조건 닫히는것 보다 옵션으로 선택에 따라 조절할 수 있으면 합니다. 이미지 모달이나 입력 모달의 경우 데이터를 입력했는데 의도치 않게 바깥을 클릭했는데 모달이 닫히면 좋지못한 사용자 경험이라 생각됩니다.

오 저도 생각치도 못했던 부분이었는데 좋은 의견인것같아요!! 👍🏻
일반적인 모달의 경우는 bg 클릭해도 닫히게끔하고 input이나 사용자의 입력값이 필요한 모달에서는 bg 클릭해도 안닫히게끔!!
너무 좋은 의견인것같습니다👏🏻


// esc 키로 모달 닫기
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', handleEsc);
return () => window.removeEventListener('keydown', handleEsc);
}, []);
Comment on lines +36 to +45
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오 생각치못했던 방식인데 추가 기능까지 👍🏻 좋은것같습니다!!


return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
onClick={handleBackGroundClick}
>
<div
className={`${width} relative max-h-[90vh] overflow-y-auto rounded-lg bg-white shadow-xl`}
>
{/* 닫기 버튼 영역 */}
<div
className="absolute right-0 top-0 m-2 cursor-pointer"
onClick={onClose}
>
<Image
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

최적화를 위한 Image 컴포넌트를 사용하셨군요!! 저도 icon image 사용한 부분에서 까먹고 있었는데! 배워갑니당 👍🏻

src="/icon/icon-close.svg"
alt="닫기 아이콘"
width={22}
height={22}
/>
</div>
{/* 컨텐츠 영역 */}
<div className="p-6">{children}</div>
</div>
</div>
);
};

export default Modal;
Loading
Loading