Skip to content

Commit dce139b

Browse files
authored
Merge pull request #23 from B2A5/feat/imageInput
Feat/image input
2 parents a680bcc + e71a687 commit dce139b

File tree

2 files changed

+145
-0
lines changed

2 files changed

+145
-0
lines changed

apps/web/src/app/globals.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
/* 백그라운드 색상 */
5656
--color-modal: rgba(255, 255, 255, 0.95);
5757
--color-overlay: rgba(0, 0, 0, 0.6);
58+
--color-light-gray: #fafafa;
5859

5960
/* 에러 색상 */
6061
--color-error-normal: #f44336;
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
'use client';
2+
3+
import React, { useEffect, useRef, useState } from 'react';
4+
import { Plus, X } from 'lucide-react';
5+
import { useToast } from '@/hooks/ui/useToast';
6+
7+
interface ImageInputProps {
8+
onFileSelect?: (files: File[]) => void;
9+
}
10+
11+
/**
12+
* ImageInput - 다중 이미지 업로드 컴포넌트
13+
*
14+
* - 최대 4장까지 이미지 업로드 가능
15+
* - 이미지 선택 시 미리보기로 표시
16+
* - 같은 파일 다시 선택해도 반응
17+
*/
18+
export function ImageInput({ onFileSelect }: ImageInputProps) {
19+
const [images, setImages] = useState<File[]>([]);
20+
const [previewUrls, setPreviewUrls] = useState<string[]>([]);
21+
const fileInputRef = useRef<HTMLInputElement | null>(null);
22+
const toast = useToast();
23+
24+
// 이미지 → 미리보기 URL 생성
25+
useEffect(() => {
26+
previewUrls.forEach((url) => URL.revokeObjectURL(url));
27+
28+
const newUrls = images
29+
.filter((file): file is File => file instanceof File)
30+
.map((file) => URL.createObjectURL(file));
31+
32+
setPreviewUrls(newUrls);
33+
}, [images]);
34+
35+
const handleImageClick = () => {
36+
fileInputRef.current?.click();
37+
};
38+
39+
const handleFileChange = (
40+
e: React.ChangeEvent<HTMLInputElement>,
41+
) => {
42+
if (!e.target.files) return;
43+
44+
const files = Array.from(e.target.files).filter(
45+
(file): file is File => file instanceof File,
46+
);
47+
48+
const allowedTypes = [
49+
'image/png',
50+
'image/jpeg',
51+
'image/webp',
52+
'image/gif',
53+
];
54+
const allValid = files.every((file) =>
55+
allowedTypes.includes(file.type),
56+
);
57+
58+
if (!allValid) {
59+
toast('지원하지 않는 파일 형식입니다.', 'error');
60+
e.target.value = '';
61+
return;
62+
}
63+
64+
const total = images.length + files.length;
65+
if (total > 4) {
66+
toast('이미지는 최대 4장까지만 업로드할 수 있어요.', 'error');
67+
e.target.value = '';
68+
return;
69+
}
70+
71+
const newImages = [...images, ...files].slice(0, 4);
72+
setImages(newImages);
73+
onFileSelect?.(newImages);
74+
e.target.value = ''; // 같은 파일 다시 선택 가능하도록 초기화
75+
};
76+
77+
const handleFileRemove = (index: number) => {
78+
const newImages = [...images];
79+
newImages.splice(images.length - 1 - index, 1); // reverse된 index 고려
80+
81+
const removedUrl = previewUrls[index];
82+
if (removedUrl) {
83+
URL.revokeObjectURL(removedUrl);
84+
}
85+
86+
setImages(newImages);
87+
onFileSelect?.(newImages);
88+
};
89+
90+
return (
91+
<div className="flex flex-col items-start gap-2">
92+
{/* 이미지 추가 버튼 (항상 렌더링, 4장일 때는 disabled 스타일 + 클릭 방지) */}
93+
<div
94+
onClick={() => {
95+
if (images.length >= 4) return;
96+
handleImageClick();
97+
}}
98+
className={`w-20 h-20 rounded-[10px] flex items-center justify-center transition
99+
${
100+
images.length >= 4
101+
? 'bg-gray-100 cursor-not-allowed opacity-50'
102+
: 'bg-light-gray hover:bg-gray-200 cursor-pointer'
103+
}`}
104+
>
105+
<Plus className="w-6 h-6 text-neutral-200" />
106+
</div>
107+
108+
<input
109+
type="file"
110+
accept=".png, .jpg, .jpeg, .webp, .gif"
111+
onChange={handleFileChange}
112+
ref={fileInputRef}
113+
className="hidden"
114+
multiple
115+
/>
116+
117+
{/* 미리보기 이미지 영역 */}
118+
<div className="flex gap-2 flex-wrap justify-start items-start">
119+
{previewUrls
120+
.slice()
121+
.reverse()
122+
.map((url, idx) => (
123+
<div
124+
key={idx}
125+
className="w-20 h-20 rounded-[10px] relative"
126+
>
127+
<img
128+
src={url}
129+
alt={`미리보기 ${idx + 1}`}
130+
className="w-full h-full object-cover rounded-md"
131+
/>
132+
<button
133+
type="button"
134+
onClick={() => handleFileRemove(idx)}
135+
className="absolute -top-1 -right-1 bg-black bg-opacity-50 rounded-full p-1 text-white hover:bg-opacity-70 cursor-pointer"
136+
>
137+
<X size={12} />
138+
</button>
139+
</div>
140+
))}
141+
</div>
142+
</div>
143+
);
144+
}

0 commit comments

Comments
 (0)