create file-uploader

This commit is contained in:
MehrdadAdabi 2025-11-28 09:12:09 +03:30
parent 7c8590c075
commit 84e9b2beb7

View 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>
);
}