- disabled 상태에서 이미지 있으면 투명도 100% 유지 - 뷰 모드에서 테두리 제거하여 깔끔하게 표시 - 이미지 없을 때만 기존 흐림 효과 유지
309 lines
8.4 KiB
TypeScript
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;
|