create file-uploader
This commit is contained in:
parent
7c8590c075
commit
84e9b2beb7
229
src/core/components/base/file-uploader.tsx
Normal file
229
src/core/components/base/file-uploader.tsx
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
import { cn } from "@/core/lib/utils";
|
||||
import { File, Upload, X } from "lucide-react";
|
||||
import { type ChangeEvent, useRef, useState } from "react";
|
||||
|
||||
type FileUploaderProps = {
|
||||
label?: string;
|
||||
onFileChange: (file: File | null) => void;
|
||||
onRemove?: () => void;
|
||||
className?: string;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
accept?: string;
|
||||
maxSize?: number; // in MB
|
||||
multiple?: boolean;
|
||||
disabled?: boolean;
|
||||
helperText?: string;
|
||||
showPreview?: boolean;
|
||||
};
|
||||
|
||||
export function FileUploader({
|
||||
label = "آپلود فایل",
|
||||
onFileChange,
|
||||
onRemove,
|
||||
className,
|
||||
error,
|
||||
required = false,
|
||||
accept,
|
||||
maxSize = 10,
|
||||
multiple = false,
|
||||
disabled = false,
|
||||
helperText,
|
||||
showPreview = true,
|
||||
}: FileUploaderProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [fileError, setFileError] = useState<string>("");
|
||||
|
||||
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
validateAndSetFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const validateAndSetFile = (file: File) => {
|
||||
setFileError("");
|
||||
|
||||
// Check file size
|
||||
if (file.size > maxSize * 1024 * 1024) {
|
||||
setFileError(`حجم فایل نباید بیشتر از ${maxSize} مگابایت باشد`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check file type if accept is specified
|
||||
if (accept) {
|
||||
const acceptedTypes = accept.split(",").map((type) => type.trim());
|
||||
const fileExtension = `.${file.name.split(".").pop()?.toLowerCase()}`;
|
||||
const fileMimeType = file.type;
|
||||
|
||||
const isAccepted = acceptedTypes.some((type) => {
|
||||
if (type.startsWith(".")) {
|
||||
return fileExtension === type.toLowerCase();
|
||||
}
|
||||
if (type.endsWith("/*")) {
|
||||
const category = type.split("/")[0];
|
||||
return fileMimeType.startsWith(category);
|
||||
}
|
||||
return fileMimeType === type;
|
||||
});
|
||||
|
||||
if (!isAccepted) {
|
||||
setFileError("نوع فایل انتخاب شده مجاز نیست");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedFile(file);
|
||||
onFileChange(file);
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
setSelectedFile(null);
|
||||
setFileError("");
|
||||
onFileChange(null);
|
||||
onRemove?.();
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (!disabled) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
if (disabled) return;
|
||||
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) {
|
||||
validateAndSetFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + " " + sizes[i];
|
||||
};
|
||||
|
||||
const getAcceptText = (): string => {
|
||||
if (!accept) return `تا ${maxSize} مگابایت`;
|
||||
|
||||
const types = accept.split(",").map((type) => type.trim());
|
||||
const extensions = types
|
||||
.filter((type) => type.startsWith("."))
|
||||
.map((type) => type.toUpperCase())
|
||||
.join(", ");
|
||||
|
||||
return extensions ? `${extensions} تا ${maxSize} مگابایت` : `تا ${maxSize} مگابایت`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
{label && (
|
||||
<label className="mb-2 flex items-center gap-1 text-sm font-medium text-foreground text-right">
|
||||
{label}
|
||||
{required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{selectedFile && showPreview ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg border-2 border-gray-300">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="flex-shrink-0">
|
||||
<File size={32} className="text-blue-500" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{selectedFile.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{formatFileSize(selectedFile.size)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemove}
|
||||
disabled={disabled}
|
||||
className="flex-shrink-0 mr-3 p-2 text-red-600 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
aria-label="حذف فایل"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemove}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-red-600 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors self-start"
|
||||
aria-label="حذف فایل"
|
||||
>
|
||||
<X size={16} />
|
||||
<span>حذف فایل</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<label
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-3 w-full h-32 rounded-lg border-2 border-dashed transition-all duration-200",
|
||||
disabled
|
||||
? "cursor-not-allowed opacity-50 bg-gray-100 border-gray-300"
|
||||
: "cursor-pointer",
|
||||
!disabled && isDragging
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: !disabled && "border-gray-300 bg-gray-50 hover:bg-gray-100 hover:border-gray-400"
|
||||
)}
|
||||
role="button"
|
||||
aria-label="آپلود فایل"
|
||||
>
|
||||
<Upload
|
||||
size={28}
|
||||
className={cn(
|
||||
isDragging && !disabled ? "text-blue-500" : "text-gray-500"
|
||||
)}
|
||||
/>
|
||||
<div className="text-center px-4">
|
||||
<p className="text-sm font-medium text-gray-700">
|
||||
{disabled ? "آپلود غیرفعال است" : "کلیک کنید یا فایل را بکشید"}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{helperText || getAcceptText()}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
disabled={disabled}
|
||||
multiple={multiple}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{(error || fileError) && (
|
||||
<p className="mt-2 text-sm text-red-600 flex items-center gap-1 text-end">
|
||||
{error || fileError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user