Files
jaejadle/nextjs/components/FileUpload.tsx
Mayne0213 f78454c2a1
Some checks failed
Build Docker Image / build-and-push (push) Has been cancelled
CI / lint-and-build (push) Has been cancelled
CHORE(merge): merge from develop
- Initial setup and all features from develop branch
- Includes: auth, deploy, docker, style fixes
- K3S deployment configuration
2026-01-06 17:29:16 +09:00

187 lines
6.2 KiB
TypeScript

import React from "react";
import Image from "next/image";
import { X, Upload, Image as ImageIcon, FileText, File as FileIcon } from "lucide-react";
export interface PendingFile {
file: File;
preview?: string;
}
interface FileUploadProps {
files: PendingFile[];
onFilesChange: (files: PendingFile[]) => void;
accept?: string;
multiple?: boolean;
disabled?: boolean;
label?: string;
required?: boolean;
description?: string;
showImagePreview?: boolean;
maxSizeMB?: number;
}
export default function FileUpload({
files,
onFilesChange,
accept,
multiple = true,
disabled = false,
label = "파일 첨부",
required = false,
description = "이미지, PDF, 문서 등 모든 파일 형식 지원",
showImagePreview = false,
maxSizeMB,
}: FileUploadProps) {
const getFileIcon = (fileType: string) => {
if (fileType.startsWith("image/")) {
return ImageIcon;
} else if (fileType.includes("pdf")) {
return FileText;
} else {
return FileIcon;
}
};
const isImage = (fileType: string) => {
return fileType.startsWith("image/");
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = e.target.files;
if (!selectedFiles || selectedFiles.length === 0) return;
let newFiles = Array.from(selectedFiles);
// 파일 크기 검사
if (maxSizeMB) {
const maxSize = maxSizeMB * 1024 * 1024;
const oversizedFiles = newFiles.filter((f) => f.size > maxSize);
if (oversizedFiles.length > 0) {
alert(
`파일 크기는 ${maxSizeMB}MB를 초과할 수 없습니다: ${oversizedFiles.map((f) => f.name).join(", ")}`
);
newFiles = newFiles.filter((f) => f.size <= maxSize);
if (newFiles.length === 0) return;
}
}
const newPendingFiles: PendingFile[] = newFiles.map((file) => ({
file,
preview: file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined,
}));
onFilesChange([...files, ...newPendingFiles]);
// input 초기화
e.target.value = "";
};
const removeFile = (index: number) => {
const file = files[index];
if (file.preview) {
URL.revokeObjectURL(file.preview);
}
onFilesChange(files.filter((_, i) => i !== index));
};
return (
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
{label} {required && <span className="text-red-500">*</span>}
</label>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-blue-400 transition-colors">
<input
type="file"
accept={accept}
multiple={multiple}
onChange={handleFileChange}
disabled={disabled}
className="hidden"
id="file-upload"
/>
<label htmlFor="file-upload" className="cursor-pointer flex flex-col items-center">
<Upload className="w-12 h-12 text-gray-400 mb-3" />
<p className="text-gray-600 mb-1"> </p>
<p className="text-sm text-gray-500">{description}</p>
</label>
</div>
{/* 파일 목록 */}
{files.length > 0 && (
<div className="mt-4 space-y-2">
<p className="text-sm font-medium text-gray-700">
({files.length})
</p>
{/* 이미지 미리보기 그리드 */}
{showImagePreview ? (
<div className="grid grid-cols-3 gap-2">
{files.map((pf, index) => (
<div key={index} className="relative aspect-square">
{pf.preview ? (
<Image
src={pf.preview}
alt={`Preview ${index + 1}`}
fill
className="object-cover rounded-lg"
/>
) : (
<div className="w-full h-full bg-gray-100 rounded-lg flex items-center justify-center">
<FileIcon className="w-8 h-8 text-gray-400" />
</div>
)}
<button
type="button"
onClick={() => removeFile(index)}
className="absolute top-1 right-1 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm hover:bg-red-600"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
) : (
/* 파일 리스트 */
<div className="space-y-2">
{files.map((pf, index) => {
const FileIconComponent = getFileIcon(pf.file.type);
return (
<div
key={index}
className="relative group border rounded-lg p-3 hover:shadow-md transition-shadow flex items-center gap-3"
>
<div className="shrink-0">
<div className="w-12 h-12 bg-gray-100 rounded flex items-center justify-center">
{isImage(pf.file.type) ? (
<ImageIcon className="w-6 h-6 text-blue-500" />
) : (
<FileIconComponent className="w-6 h-6 text-gray-500" />
)}
</div>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-800 truncate">
{pf.file.name}
</p>
<p className="text-xs text-gray-500">
{(pf.file.size / 1024).toFixed(1)} KB
</p>
</div>
<button
type="button"
onClick={() => removeFile(index)}
className="shrink-0 bg-red-500 text-white rounded-full p-1.5 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-600"
>
<X className="w-4 h-4" />
</button>
</div>
);
})}
</div>
)}
</div>
)}
</div>
);
}