refactor: 모달 Content 컴포넌트 분리 및 파일 입력 UI 공통화
- 모달 컴포넌트에서 Content 분리하여 재사용성 향상 - EstimateDocumentContent, DirectConstructionContent 등 - WorkLogContent, QuotePreviewContent, ReceivingReceiptContent - 파일 입력 공통 UI 컴포넌트 추가 - file-dropzone, file-input, file-list, image-upload - 폼 컴포넌트 코드 정리 및 중복 제거 (-4,056줄) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
226
src/components/ui/file-dropzone.tsx
Normal file
226
src/components/ui/file-dropzone.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* FileDropzone - 드래그 앤 드롭 파일 업로드 영역
|
||||
*
|
||||
* 도면, 첨부파일 등 다중 파일 업로드에 사용
|
||||
*
|
||||
* 사용 예시:
|
||||
* <FileDropzone
|
||||
* accept=".pdf,.dwg,.jpg"
|
||||
* multiple
|
||||
* maxSize={10}
|
||||
* onFilesSelect={(files) => setFiles(prev => [...prev, ...files])}
|
||||
* />
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { Upload, AlertCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface FileDropzoneProps {
|
||||
/** 파일 선택 시 콜백 */
|
||||
onFilesSelect: (files: File[]) => void;
|
||||
/** 허용 파일 타입 */
|
||||
accept?: string;
|
||||
/** 다중 파일 선택 허용 */
|
||||
multiple?: boolean;
|
||||
/** 최대 파일 크기 (MB) */
|
||||
maxSize?: number;
|
||||
/** 최대 파일 개수 */
|
||||
maxFiles?: number;
|
||||
/** 비활성화 */
|
||||
disabled?: boolean;
|
||||
/** 추가 클래스 */
|
||||
className?: string;
|
||||
/** 안내 텍스트 (메인) */
|
||||
title?: string;
|
||||
/** 안내 텍스트 (서브) */
|
||||
description?: string;
|
||||
/** 에러 상태 */
|
||||
error?: boolean;
|
||||
/** 에러 메시지 */
|
||||
errorMessage?: string;
|
||||
/** 컴팩트 모드 */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function FileDropzone({
|
||||
onFilesSelect,
|
||||
accept = '*/*',
|
||||
multiple = false,
|
||||
maxSize = 10,
|
||||
maxFiles,
|
||||
disabled = false,
|
||||
className,
|
||||
title,
|
||||
description,
|
||||
error = false,
|
||||
errorMessage,
|
||||
compact = false,
|
||||
}: FileDropzoneProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const displayError = errorMessage || validationError;
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (!disabled) {
|
||||
inputRef.current?.click();
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
const validateFiles = useCallback((files: File[]): File[] => {
|
||||
const validFiles: File[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
// 파일 크기 검증
|
||||
const fileSizeMB = file.size / (1024 * 1024);
|
||||
if (fileSizeMB > maxSize) {
|
||||
errors.push(`${file.name}: ${maxSize}MB 초과`);
|
||||
continue;
|
||||
}
|
||||
|
||||
validFiles.push(file);
|
||||
}
|
||||
|
||||
// 최대 파일 개수 검증
|
||||
if (maxFiles && validFiles.length > maxFiles) {
|
||||
setValidationError(`최대 ${maxFiles}개 파일만 선택할 수 있습니다`);
|
||||
return validFiles.slice(0, maxFiles);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
setValidationError(errors.join(', '));
|
||||
} else {
|
||||
setValidationError(null);
|
||||
}
|
||||
|
||||
return validFiles;
|
||||
}, [maxSize, maxFiles]);
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
const fileArray = Array.from(files);
|
||||
const validFiles = validateFiles(fileArray);
|
||||
if (validFiles.length > 0) {
|
||||
onFilesSelect(validFiles);
|
||||
}
|
||||
}
|
||||
// input 초기화
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = '';
|
||||
}
|
||||
}, [validateFiles, onFilesSelect]);
|
||||
|
||||
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 files = e.dataTransfer.files;
|
||||
if (files && files.length > 0) {
|
||||
const fileArray = multiple ? Array.from(files) : [files[0]];
|
||||
const validFiles = validateFiles(fileArray);
|
||||
if (validFiles.length > 0) {
|
||||
onFilesSelect(validFiles);
|
||||
}
|
||||
}
|
||||
}, [disabled, multiple, validateFiles, onFilesSelect]);
|
||||
|
||||
// 기본 텍스트
|
||||
const defaultTitle = isDragging
|
||||
? '파일을 여기에 놓으세요'
|
||||
: '클릭하거나 파일을 드래그하세요';
|
||||
const defaultDescription = `최대 ${maxSize}MB${accept !== '*/*' ? ` (${accept})` : ''}`;
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
<div
|
||||
onClick={handleClick}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
'border-2 border-dashed rounded-lg transition-colors',
|
||||
compact ? 'p-4' : 'p-8',
|
||||
error || displayError
|
||||
? 'border-red-500 bg-red-50'
|
||||
: isDragging
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-gray-300 hover:border-primary/50',
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
||||
)}
|
||||
>
|
||||
{/* 숨겨진 파일 input */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<Upload
|
||||
className={cn(
|
||||
'mb-2 transition-colors',
|
||||
compact ? 'w-6 h-6' : 'w-10 h-10',
|
||||
isDragging ? 'text-primary' : 'text-gray-400',
|
||||
)}
|
||||
/>
|
||||
<p className={cn(
|
||||
'font-medium',
|
||||
compact ? 'text-sm' : 'text-base',
|
||||
isDragging ? 'text-primary' : 'text-gray-700',
|
||||
)}>
|
||||
{title || defaultTitle}
|
||||
</p>
|
||||
<p className={cn(
|
||||
'text-muted-foreground mt-1',
|
||||
compact ? 'text-xs' : 'text-sm',
|
||||
)}>
|
||||
{description || defaultDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{displayError && (
|
||||
<div className="flex items-center gap-1 text-sm text-red-500">
|
||||
<AlertCircle className="w-3 h-3 shrink-0" />
|
||||
<span>{displayError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileDropzone;
|
||||
203
src/components/ui/file-input.tsx
Normal file
203
src/components/ui/file-input.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* FileInput - 기본 파일 선택 컴포넌트
|
||||
*
|
||||
* 사용 예시:
|
||||
* <FileInput
|
||||
* accept=".pdf,.doc"
|
||||
* maxSize={10}
|
||||
* onFileSelect={(file) => setFile(file)}
|
||||
* onFileRemove={() => setFile(null)}
|
||||
* />
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Upload, X, FileText, AlertCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface FileInputProps {
|
||||
/** 파일 선택 시 콜백 */
|
||||
onFileSelect: (file: File) => void;
|
||||
/** 파일 제거 시 콜백 */
|
||||
onFileRemove?: () => void;
|
||||
/** 허용 파일 타입 (예: ".pdf,.doc" 또는 "image/*") */
|
||||
accept?: string;
|
||||
/** 최대 파일 크기 (MB) */
|
||||
maxSize?: number;
|
||||
/** 비활성화 */
|
||||
disabled?: boolean;
|
||||
/** 버튼 텍스트 */
|
||||
buttonText?: string;
|
||||
/** 플레이스홀더 텍스트 */
|
||||
placeholder?: string;
|
||||
/** 현재 선택된 파일 (외부 제어용) */
|
||||
value?: File | null;
|
||||
/** 기존 파일 정보 (수정 모드용) */
|
||||
existingFile?: {
|
||||
name: string;
|
||||
url?: string;
|
||||
};
|
||||
/** 추가 클래스 */
|
||||
className?: string;
|
||||
/** 에러 상태 */
|
||||
error?: boolean;
|
||||
/** 에러 메시지 */
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export function FileInput({
|
||||
onFileSelect,
|
||||
onFileRemove,
|
||||
accept = '*/*',
|
||||
maxSize = 10,
|
||||
disabled = false,
|
||||
buttonText = '파일 선택',
|
||||
placeholder = '선택된 파일 없음',
|
||||
value,
|
||||
existingFile,
|
||||
className,
|
||||
error = false,
|
||||
errorMessage,
|
||||
}: FileInputProps) {
|
||||
const [internalFile, setInternalFile] = useState<File | null>(null);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 외부 제어 또는 내부 상태 사용
|
||||
const selectedFile = value !== undefined ? value : internalFile;
|
||||
const displayError = errorMessage || validationError;
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (!disabled) {
|
||||
inputRef.current?.click();
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
if (!file) return;
|
||||
|
||||
// 파일 크기 검증
|
||||
const fileSizeMB = file.size / (1024 * 1024);
|
||||
if (fileSizeMB > maxSize) {
|
||||
setValidationError(`파일 크기는 ${maxSize}MB 이하여야 합니다`);
|
||||
return;
|
||||
}
|
||||
|
||||
setValidationError(null);
|
||||
setInternalFile(file);
|
||||
onFileSelect(file);
|
||||
|
||||
// input 초기화 (같은 파일 재선택 가능하도록)
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = '';
|
||||
}
|
||||
}, [maxSize, onFileSelect]);
|
||||
|
||||
const handleRemove = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setInternalFile(null);
|
||||
setValidationError(null);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = '';
|
||||
}
|
||||
onFileRemove?.();
|
||||
}, [onFileRemove]);
|
||||
|
||||
// 표시할 파일명 결정
|
||||
const displayFileName = selectedFile?.name || existingFile?.name;
|
||||
const hasFile = !!selectedFile || !!existingFile;
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-1', className)}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 p-2 border rounded-md bg-background',
|
||||
error || displayError ? 'border-red-500' : 'border-input',
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:border-primary/50',
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* 숨겨진 파일 input */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* 파일 선택 버튼 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClick();
|
||||
}}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-1" />
|
||||
{buttonText}
|
||||
</Button>
|
||||
|
||||
{/* 파일명 표시 영역 */}
|
||||
<div className="flex-1 flex items-center gap-2 min-w-0">
|
||||
{hasFile ? (
|
||||
<>
|
||||
<FileText className="w-4 h-4 text-primary shrink-0" />
|
||||
<span className="text-sm truncate">
|
||||
{displayFileName}
|
||||
</span>
|
||||
{selectedFile && (
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
({(selectedFile.size / 1024).toFixed(1)} KB)
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{placeholder}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 파일 제거 버튼 */}
|
||||
{hasFile && !disabled && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRemove}
|
||||
className="shrink-0 h-7 w-7 p-0 hover:bg-destructive/10"
|
||||
>
|
||||
<X className="w-4 h-4 text-muted-foreground hover:text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</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 && accept !== '*/*' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
허용 형식: {accept}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileInput;
|
||||
273
src/components/ui/file-list.tsx
Normal file
273
src/components/ui/file-list.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* FileList - 업로드된 파일 목록 표시 컴포넌트
|
||||
*
|
||||
* 첨부파일 목록 표시, 다운로드, 삭제 기능
|
||||
*
|
||||
* 사용 예시:
|
||||
* <FileList
|
||||
* files={uploadedFiles}
|
||||
* onRemove={(index) => removeFile(index)}
|
||||
* onDownload={(file) => downloadFile(file)}
|
||||
* />
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FileText, Download, Trash2, ExternalLink, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** 새로 업로드된 파일 */
|
||||
export interface NewFile {
|
||||
file: File;
|
||||
/** 업로드 진행률 (0-100) */
|
||||
progress?: number;
|
||||
/** 업로드 중 여부 */
|
||||
uploading?: boolean;
|
||||
/** 에러 메시지 */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** 기존 파일 (서버에서 가져온) */
|
||||
export interface ExistingFile {
|
||||
id: string | number;
|
||||
name: string;
|
||||
url?: string;
|
||||
size?: number;
|
||||
/** 삭제 중 여부 */
|
||||
deleting?: boolean;
|
||||
}
|
||||
|
||||
export interface FileListProps {
|
||||
/** 새로 업로드된 파일 목록 */
|
||||
files?: NewFile[];
|
||||
/** 기존 파일 목록 */
|
||||
existingFiles?: ExistingFile[];
|
||||
/** 새 파일 제거 콜백 */
|
||||
onRemove?: (index: number) => void;
|
||||
/** 기존 파일 제거 콜백 */
|
||||
onRemoveExisting?: (id: string | number) => void;
|
||||
/** 기존 파일 다운로드 콜백 */
|
||||
onDownload?: (file: ExistingFile) => void;
|
||||
/** 읽기 전용 */
|
||||
readOnly?: boolean;
|
||||
/** 추가 클래스 */
|
||||
className?: string;
|
||||
/** 파일 없을 때 메시지 */
|
||||
emptyMessage?: string;
|
||||
/** 컴팩트 모드 */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
// 파일 크기 포맷
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
// 파일 확장자에 따른 아이콘 색상
|
||||
function getFileColor(fileName: string): string {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase();
|
||||
switch (ext) {
|
||||
case 'pdf':
|
||||
return 'text-red-500';
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
return 'text-blue-500';
|
||||
case 'xls':
|
||||
case 'xlsx':
|
||||
return 'text-green-500';
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
case 'png':
|
||||
case 'gif':
|
||||
case 'webp':
|
||||
return 'text-purple-500';
|
||||
case 'dwg':
|
||||
case 'dxf':
|
||||
return 'text-orange-500';
|
||||
default:
|
||||
return 'text-gray-500';
|
||||
}
|
||||
}
|
||||
|
||||
export function FileList({
|
||||
files = [],
|
||||
existingFiles = [],
|
||||
onRemove,
|
||||
onRemoveExisting,
|
||||
onDownload,
|
||||
readOnly = false,
|
||||
className,
|
||||
emptyMessage = '파일이 없습니다',
|
||||
compact = false,
|
||||
}: FileListProps) {
|
||||
const handleDownload = useCallback((file: ExistingFile) => {
|
||||
if (onDownload) {
|
||||
onDownload(file);
|
||||
} else if (file.url) {
|
||||
// 기본 다운로드 동작
|
||||
const link = document.createElement('a');
|
||||
link.href = file.url;
|
||||
link.download = file.name;
|
||||
link.click();
|
||||
}
|
||||
}, [onDownload]);
|
||||
|
||||
const totalFiles = files.length + existingFiles.length;
|
||||
|
||||
if (totalFiles === 0) {
|
||||
return (
|
||||
<div className={cn('text-sm text-muted-foreground text-center py-4', className)}>
|
||||
{emptyMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
{/* 기존 파일 목록 */}
|
||||
{existingFiles.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-2 rounded-md border bg-muted/30',
|
||||
compact ? 'p-2' : 'p-3',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<FileText className={cn('shrink-0', getFileColor(file.name), compact ? 'w-5 h-5' : 'w-6 h-6')} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={cn('font-medium truncate', compact ? 'text-xs' : 'text-sm')}>
|
||||
{file.name}
|
||||
</p>
|
||||
{file.size && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{/* 다운로드 버튼 */}
|
||||
{(file.url || onDownload) && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size={compact ? 'sm' : 'default'}
|
||||
onClick={() => handleDownload(file)}
|
||||
className={cn('text-blue-600 hover:text-blue-700 hover:bg-blue-50', compact && 'h-7 w-7 p-0')}
|
||||
title="다운로드"
|
||||
>
|
||||
<Download className={compact ? 'w-3.5 h-3.5' : 'w-4 h-4'} />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 새 탭에서 열기 */}
|
||||
{file.url && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size={compact ? 'sm' : 'default'}
|
||||
onClick={() => window.open(file.url, '_blank')}
|
||||
className={cn('text-gray-600 hover:text-gray-700', compact && 'h-7 w-7 p-0')}
|
||||
title="새 탭에서 열기"
|
||||
>
|
||||
<ExternalLink className={compact ? 'w-3.5 h-3.5' : 'w-4 h-4'} />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
{!readOnly && onRemoveExisting && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size={compact ? 'sm' : 'default'}
|
||||
onClick={() => onRemoveExisting(file.id)}
|
||||
disabled={file.deleting}
|
||||
className={cn('text-red-500 hover:text-red-700 hover:bg-red-50', compact && 'h-7 w-7 p-0')}
|
||||
title="삭제"
|
||||
>
|
||||
{file.deleting ? (
|
||||
<Loader2 className={cn('animate-spin', compact ? 'w-3.5 h-3.5' : 'w-4 h-4')} />
|
||||
) : (
|
||||
<Trash2 className={compact ? 'w-3.5 h-3.5' : 'w-4 h-4'} />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 새로 업로드된 파일 목록 */}
|
||||
{files.map((item, index) => (
|
||||
<div
|
||||
key={`new-${index}`}
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-2 rounded-md border',
|
||||
item.error ? 'border-red-300 bg-red-50' : 'border-blue-200 bg-blue-50',
|
||||
compact ? 'p-2' : 'p-3',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<FileText className={cn('shrink-0', getFileColor(item.file.name), compact ? 'w-5 h-5' : 'w-6 h-6')} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className={cn('font-medium truncate', compact ? 'text-xs' : 'text-sm')}>
|
||||
{item.file.name}
|
||||
</p>
|
||||
{!item.error && (
|
||||
<span className={cn('text-blue-600 shrink-0', compact ? 'text-[10px]' : 'text-xs')}>
|
||||
(새 파일)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatFileSize(item.file.size)}
|
||||
</p>
|
||||
{/* 에러 메시지 */}
|
||||
{item.error && (
|
||||
<p className="text-xs text-red-500 mt-1">{item.error}</p>
|
||||
)}
|
||||
{/* 업로드 진행률 */}
|
||||
{item.uploading && item.progress !== undefined && (
|
||||
<div className="w-full bg-gray-200 rounded-full h-1.5 mt-1">
|
||||
<div
|
||||
className="bg-blue-600 h-1.5 rounded-full transition-all"
|
||||
style={{ width: `${item.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{/* 삭제/취소 버튼 */}
|
||||
{!readOnly && onRemove && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size={compact ? 'sm' : 'default'}
|
||||
onClick={() => onRemove(index)}
|
||||
disabled={item.uploading}
|
||||
className={cn('text-red-500 hover:text-red-700 hover:bg-red-50', compact && 'h-7 w-7 p-0')}
|
||||
title={item.uploading ? '업로드 취소' : '삭제'}
|
||||
>
|
||||
{item.uploading ? (
|
||||
<Loader2 className={cn('animate-spin', compact ? 'w-3.5 h-3.5' : 'w-4 h-4')} />
|
||||
) : (
|
||||
<Trash2 className={compact ? 'w-3.5 h-3.5' : 'w-4 h-4'} />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileList;
|
||||
296
src/components/ui/image-upload.tsx
Normal file
296
src/components/ui/image-upload.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
'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 border-2 border-dashed flex flex-col items-center justify-center overflow-hidden transition-colors',
|
||||
rounded ? 'rounded-full' : 'rounded-lg',
|
||||
sizeClasses[size],
|
||||
aspectRatio !== 'square' && !rounded && aspectRatioClasses[aspectRatio],
|
||||
error || displayError
|
||||
? 'border-red-500 bg-red-50'
|
||||
: isDragging
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-gray-300 bg-gray-50 hover:border-primary/50',
|
||||
disabled ? '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;
|
||||
Reference in New Issue
Block a user