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