200 lines
6.6 KiB
TypeScript
200 lines
6.6 KiB
TypeScript
|
|
/**
|
||
|
|
* File 필드 컴포넌트
|
||
|
|
* 파일/이미지 첨부 (드래그 앤 드롭 + 파일 선택)
|
||
|
|
*
|
||
|
|
* API 연동 전: File 객체를 로컬 상태로 관리, URL.createObjectURL로 미리보기
|
||
|
|
* API 연동 시: POST /v1/files/upload (multipart)
|
||
|
|
*/
|
||
|
|
|
||
|
|
'use client';
|
||
|
|
|
||
|
|
import { useState, useRef, useCallback } from 'react';
|
||
|
|
import { Upload, X, FileIcon, ImageIcon } from 'lucide-react';
|
||
|
|
import { Label } from '@/components/ui/label';
|
||
|
|
import { Button } from '@/components/ui/button';
|
||
|
|
import type { DynamicFieldRendererProps, FileConfig } from '../types';
|
||
|
|
|
||
|
|
interface LocalFile {
|
||
|
|
file: File;
|
||
|
|
previewUrl?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
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 isImageFile(file: File): boolean {
|
||
|
|
return file.type.startsWith('image/');
|
||
|
|
}
|
||
|
|
|
||
|
|
export function FileField({
|
||
|
|
field,
|
||
|
|
value,
|
||
|
|
onChange,
|
||
|
|
error,
|
||
|
|
disabled,
|
||
|
|
}: DynamicFieldRendererProps) {
|
||
|
|
const fieldKey = field.field_key || `field_${field.id}`;
|
||
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
|
const [localFiles, setLocalFiles] = useState<LocalFile[]>([]);
|
||
|
|
const [isDragOver, setIsDragOver] = useState(false);
|
||
|
|
|
||
|
|
const config = (field.properties || {}) as FileConfig;
|
||
|
|
const accept = config.accept || '*';
|
||
|
|
const maxSize = config.maxSize || 10485760; // 10MB
|
||
|
|
const maxFiles = config.maxFiles || 1;
|
||
|
|
const showPreview = config.preview !== false;
|
||
|
|
|
||
|
|
// 파일 추가
|
||
|
|
const addFiles = useCallback((newFiles: FileList | File[]) => {
|
||
|
|
const files = Array.from(newFiles);
|
||
|
|
const validFiles: LocalFile[] = [];
|
||
|
|
|
||
|
|
for (const file of files) {
|
||
|
|
if (localFiles.length + validFiles.length >= maxFiles) break;
|
||
|
|
if (file.size > maxSize) continue;
|
||
|
|
|
||
|
|
const previewUrl = showPreview && isImageFile(file)
|
||
|
|
? URL.createObjectURL(file)
|
||
|
|
: undefined;
|
||
|
|
validFiles.push({ file, previewUrl });
|
||
|
|
}
|
||
|
|
|
||
|
|
if (validFiles.length === 0) return;
|
||
|
|
|
||
|
|
const updated = [...localFiles, ...validFiles];
|
||
|
|
setLocalFiles(updated);
|
||
|
|
// 단일 파일이면 File 객체, 복수면 배열 이름을 저장
|
||
|
|
onChange(maxFiles === 1
|
||
|
|
? updated[0]?.file.name || null
|
||
|
|
: updated.map(f => f.file.name)
|
||
|
|
);
|
||
|
|
}, [localFiles, maxFiles, maxSize, showPreview, onChange]);
|
||
|
|
|
||
|
|
// 파일 제거
|
||
|
|
const removeFile = (index: number) => {
|
||
|
|
const file = localFiles[index];
|
||
|
|
if (file.previewUrl) URL.revokeObjectURL(file.previewUrl);
|
||
|
|
const updated = localFiles.filter((_, i) => i !== index);
|
||
|
|
setLocalFiles(updated);
|
||
|
|
onChange(updated.length === 0 ? null : (
|
||
|
|
maxFiles === 1 ? updated[0]?.file.name : updated.map(f => f.file.name)
|
||
|
|
));
|
||
|
|
};
|
||
|
|
|
||
|
|
// 드래그 핸들러
|
||
|
|
const handleDragOver = (e: React.DragEvent) => {
|
||
|
|
e.preventDefault();
|
||
|
|
if (!disabled) setIsDragOver(true);
|
||
|
|
};
|
||
|
|
const handleDragLeave = () => setIsDragOver(false);
|
||
|
|
const handleDrop = (e: React.DragEvent) => {
|
||
|
|
e.preventDefault();
|
||
|
|
setIsDragOver(false);
|
||
|
|
if (!disabled && e.dataTransfer.files.length > 0) {
|
||
|
|
addFiles(e.dataTransfer.files);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const canAddMore = localFiles.length < maxFiles;
|
||
|
|
// value가 이미 있으면 (서버에서 받은 파일명) 기존 파일 표시
|
||
|
|
const existingFileName = typeof value === 'string' && value && localFiles.length === 0 ? value : null;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div>
|
||
|
|
<Label htmlFor={fieldKey}>
|
||
|
|
{field.field_name}
|
||
|
|
{field.is_required && <span className="text-red-500"> *</span>}
|
||
|
|
</Label>
|
||
|
|
|
||
|
|
{/* 드래그 앤 드롭 영역 */}
|
||
|
|
{canAddMore && !disabled && (
|
||
|
|
<div
|
||
|
|
onDragOver={handleDragOver}
|
||
|
|
onDragLeave={handleDragLeave}
|
||
|
|
onDrop={handleDrop}
|
||
|
|
className={`mt-1 flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors cursor-pointer ${
|
||
|
|
isDragOver ? 'border-primary bg-primary/5' : 'border-muted-foreground/25 hover:border-muted-foreground/50'
|
||
|
|
} ${error ? 'border-red-500' : ''}`}
|
||
|
|
onClick={() => fileInputRef.current?.click()}
|
||
|
|
>
|
||
|
|
<Upload className="h-8 w-8 text-muted-foreground mb-2" />
|
||
|
|
<p className="text-sm text-muted-foreground">
|
||
|
|
파일을 드래그하거나 클릭하여 선택
|
||
|
|
</p>
|
||
|
|
<p className="text-xs text-muted-foreground mt-1">
|
||
|
|
최대 {formatFileSize(maxSize)}
|
||
|
|
{accept !== '*' && ` (${accept})`}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<input
|
||
|
|
ref={fileInputRef}
|
||
|
|
type="file"
|
||
|
|
id={fieldKey}
|
||
|
|
accept={accept}
|
||
|
|
multiple={maxFiles > 1}
|
||
|
|
className="hidden"
|
||
|
|
onChange={(e) => e.target.files && addFiles(e.target.files)}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{/* 기존 파일 (서버에서 받은) */}
|
||
|
|
{existingFileName && (
|
||
|
|
<div className="mt-2 flex items-center gap-2 rounded-md border p-2">
|
||
|
|
<FileIcon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||
|
|
<span className="text-sm truncate flex-1">{existingFileName}</span>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* 선택된 파일 목록 */}
|
||
|
|
{localFiles.length > 0 && (
|
||
|
|
<div className="mt-2 space-y-2">
|
||
|
|
{localFiles.map((lf, index) => (
|
||
|
|
<div key={index} className="flex items-center gap-2 rounded-md border p-2">
|
||
|
|
{/* 이미지 미리보기 */}
|
||
|
|
{lf.previewUrl ? (
|
||
|
|
<img
|
||
|
|
src={lf.previewUrl}
|
||
|
|
alt={lf.file.name}
|
||
|
|
className="h-10 w-10 rounded object-cover shrink-0"
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
isImageFile(lf.file)
|
||
|
|
? <ImageIcon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||
|
|
: <FileIcon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||
|
|
)}
|
||
|
|
<div className="flex-1 min-w-0">
|
||
|
|
<p className="text-sm truncate">{lf.file.name}</p>
|
||
|
|
<p className="text-xs text-muted-foreground">{formatFileSize(lf.file.size)}</p>
|
||
|
|
</div>
|
||
|
|
{!disabled && (
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="ghost"
|
||
|
|
size="icon"
|
||
|
|
className="h-6 w-6 shrink-0"
|
||
|
|
onClick={() => removeFile(index)}
|
||
|
|
>
|
||
|
|
<X className="h-3 w-3" />
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{error && (
|
||
|
|
<p className="text-xs text-red-500 mt-1">{error}</p>
|
||
|
|
)}
|
||
|
|
{!error && field.description && (
|
||
|
|
<p className="text-xs text-muted-foreground mt-1">
|
||
|
|
* {field.description}
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|