Files
sam-react-prod/src/components/ui/image-upload.tsx
권혁성 9aa8983e72 [WEB] fix(ImageUpload): 뷰 모드에서 이미지 정상 표시
- disabled 상태에서 이미지 있으면 투명도 100% 유지
- 뷰 모드에서 테두리 제거하여 깔끔하게 표시
- 이미지 없을 때만 기존 흐림 효과 유지
2026-01-27 19:12:29 +09:00

309 lines
8.4 KiB
TypeScript

'use client';
/**
* ImageUpload - 이미지 업로드 컴포넌트 (미리보기 포함)
*
* 프로필 이미지, 로고, 썸네일 등에 사용
*
* 사용 예시:
* <ImageUpload
* value={profileImageUrl}
* onChange={(file) => handleUpload(file)}
* onRemove={() => setProfileImageUrl(null)}
* aspectRatio="square"
* maxSize={5}
* />
*/
import { useState, useRef, useCallback, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Camera, X, Upload, AlertCircle, Image as ImageIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface ImageUploadProps {
/** 현재 이미지 URL (기존 이미지 또는 미리보기) */
value?: string | null;
/** 이미지 선택 시 콜백 (File 객체 전달) */
onChange: (file: File) => void;
/** 이미지 제거 시 콜백 */
onRemove?: () => void;
/** 비활성화 */
disabled?: boolean;
/** 최대 파일 크기 (MB) */
maxSize?: number;
/** 허용 이미지 타입 */
accept?: string;
/** 가로세로 비율 */
aspectRatio?: 'square' | 'video' | 'wide' | 'portrait';
/** 컴포넌트 크기 */
size?: 'sm' | 'md' | 'lg';
/** 추가 클래스 */
className?: string;
/** 안내 텍스트 */
hint?: string;
/** 에러 상태 */
error?: boolean;
/** 에러 메시지 */
errorMessage?: string;
/** 원형 (프로필용) */
rounded?: boolean;
}
const sizeClasses = {
sm: 'w-24 h-24',
md: 'w-32 h-32',
lg: 'w-40 h-40',
};
const aspectRatioClasses = {
square: 'aspect-square',
video: 'aspect-video',
wide: 'aspect-[2/1]',
portrait: 'aspect-[3/4]',
};
export function ImageUpload({
value,
onChange,
onRemove,
disabled = false,
maxSize = 10,
accept = 'image/png,image/jpeg,image/gif,image/webp',
aspectRatio = 'square',
size = 'md',
className,
hint,
error = false,
errorMessage,
rounded = false,
}: ImageUploadProps) {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
// 외부 value 또는 내부 preview 사용
const displayUrl = value || previewUrl;
const displayError = errorMessage || validationError;
// value가 변경되면 previewUrl 초기화
useEffect(() => {
if (value) {
setPreviewUrl(null);
}
}, [value]);
const handleClick = useCallback(() => {
if (!disabled) {
inputRef.current?.click();
}
}, [disabled]);
const validateAndProcessFile = useCallback((file: File) => {
// 이미지 타입 검증
if (!file.type.startsWith('image/')) {
setValidationError('이미지 파일만 업로드할 수 있습니다');
return false;
}
// 파일 크기 검증
const fileSizeMB = file.size / (1024 * 1024);
if (fileSizeMB > maxSize) {
setValidationError(`파일 크기는 ${maxSize}MB 이하여야 합니다`);
return false;
}
setValidationError(null);
// 미리보기 URL 생성
const objectUrl = URL.createObjectURL(file);
setPreviewUrl(objectUrl);
onChange(file);
return true;
}, [maxSize, onChange]);
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
validateAndProcessFile(file);
}
// input 초기화
if (inputRef.current) {
inputRef.current.value = '';
}
}, [validateAndProcessFile]);
const handleRemove = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
setPreviewUrl(null);
setValidationError(null);
if (inputRef.current) {
inputRef.current.value = '';
}
onRemove?.();
}, [previewUrl, onRemove]);
// 드래그 앤 드롭 핸들러
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!disabled) {
setIsDragging(true);
}
}, [disabled]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (disabled) return;
const file = e.dataTransfer.files?.[0];
if (file) {
validateAndProcessFile(file);
}
}, [disabled, validateAndProcessFile]);
// 컴포넌트 언마운트 시 미리보기 URL 정리
useEffect(() => {
return () => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
};
}, [previewUrl]);
return (
<div className={cn('space-y-2', className)}>
{/* 업로드 영역 */}
<div
onClick={handleClick}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
className={cn(
'relative flex flex-col items-center justify-center overflow-hidden transition-colors',
rounded ? 'rounded-full' : 'rounded-lg',
sizeClasses[size],
aspectRatio !== 'square' && !rounded && aspectRatioClasses[aspectRatio],
// 테두리 스타일: 이미지 있고 비활성화면 테두리 없음
disabled && displayUrl
? 'border-0'
: 'border-2 border-dashed',
// 색상 스타일
error || displayError
? 'border-red-500 bg-red-50'
: isDragging
? 'border-primary bg-primary/5'
: disabled && displayUrl
? 'bg-transparent'
: 'border-gray-300 bg-gray-50 hover:border-primary/50',
// 커서 스타일
disabled
? displayUrl
? 'cursor-default'
: 'opacity-50 cursor-not-allowed'
: 'cursor-pointer',
)}
>
{/* 숨겨진 파일 input */}
<input
ref={inputRef}
type="file"
accept={accept}
onChange={handleChange}
disabled={disabled}
className="hidden"
/>
{displayUrl ? (
// 이미지 미리보기
<>
<img
src={displayUrl}
alt="미리보기"
className={cn(
'w-full h-full object-cover',
rounded && 'rounded-full',
)}
/>
{/* 호버 오버레이 */}
{!disabled && (
<div className={cn(
'absolute inset-0 bg-black/50 opacity-0 hover:opacity-100 transition-opacity flex items-center justify-center gap-2',
rounded && 'rounded-full',
)}>
<Button
type="button"
variant="secondary"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleClick();
}}
>
<Camera className="w-4 h-4" />
</Button>
<Button
type="button"
variant="destructive"
size="sm"
onClick={handleRemove}
>
<X className="w-4 h-4" />
</Button>
</div>
)}
</>
) : (
// 업로드 안내
<div className="flex flex-col items-center justify-center text-center p-2">
{isDragging ? (
<Upload className="w-8 h-8 text-primary mb-1" />
) : (
<ImageIcon className="w-8 h-8 text-gray-400 mb-1" />
)}
<span className="text-xs text-gray-500">
{isDragging ? '놓으세요' : '클릭 또는 드래그'}
</span>
</div>
)}
</div>
{/* 에러 메시지 */}
{displayError && (
<div className="flex items-center gap-1 text-sm text-red-500">
<AlertCircle className="w-3 h-3" />
<span>{displayError}</span>
</div>
)}
{/* 안내 텍스트 */}
{!displayError && hint && (
<p className="text-xs text-muted-foreground text-center">
{hint}
</p>
)}
</div>
);
}
export default ImageUpload;