Files
calendar/packages/ui/components/file-uploader/FileUploader.tsx
T
devin-ai-integration[bot]GitHubpeer@cal.com <peer@cal.com>Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Amit SharmaPeer Richelsen
b9c49b4567 feat: Add contact form for free users in Plain support widget (#22311)
Co-authored-by: peer@cal.com <peer@cal.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-07-24 18:14:38 +00:00

258 lines
8.2 KiB
TypeScript

"use client";
import { useCallback, useState } from "react";
import { z } from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "../button";
import { Input, Label } from "../form";
import { Icon } from "../icon";
import { showToast } from "../toast";
export interface FileData {
file: File;
dataUrl: string;
id: string;
}
interface FileUploaderProps {
id: string;
buttonMsg?: string;
onFilesChange: (allFiles: FileData[], newFiles: FileData[], removedFiles: FileData[]) => void;
acceptedFileTypes?: TAcceptedFileTypes[];
showFilesList?: boolean;
maxFiles?: number;
maxFileSize?: number;
disabled?: boolean;
testId?: string;
multiple?: boolean;
}
const zAcceptedFileTypes = z.enum(["any", "images", "videos", "csv", "documents"]);
type TAcceptedFileTypes = z.infer<typeof zAcceptedFileTypes>;
const documentTypes = [
"application/pdf", // .pdf
"text/plain", // .txt
"application/msword", // .doc
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx
"application/vnd.ms-excel", // .xls
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // .xlsx
"text/csv", // .csv
];
const acceptFileTypes: Record<TAcceptedFileTypes, { types: string[]; extensions: string[] }> = {
any: { types: [], extensions: [] },
images: {
types: ["image/png", "image/jpeg"],
extensions: [".png", ".jpg", ".jpeg"],
},
csv: {
types: ["text/csv"],
extensions: [".csv"],
},
documents: {
types: documentTypes,
extensions: [".pdf", ".txt", ".doc", ".docx", ".xls", ".xlsx", ".csv"],
},
videos: {
types: ["video/mp4", "video/webm", "video/ogg"],
extensions: [".mp4", ".webm", ".ogg"],
},
};
export 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 `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
};
export default function FileUploader({
id,
buttonMsg,
onFilesChange,
acceptedFileTypes = ["any"],
maxFiles,
maxFileSize = 10 * 1024 * 1024,
disabled = false,
multiple = true,
showFilesList = true,
testId,
}: FileUploaderProps) {
const { t, isLocaleReady } = useLocale();
const [files, setFiles] = useState<FileData[]>([]);
const generateFileId = () => `file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const readFileAsDataURL = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
};
const defaultT = {
add_files: "Add Files",
file_size_limit_exceed: "File size exceeds limit",
invalid_file_type: "Invalid file type",
max_files_exceeded: "Maximum files exceeded",
files_uploaded_successfully: "Files uploaded successfully",
file_upload_instructions: "Upload images or videos",
};
const allowedTypes = acceptedFileTypes.flatMap((type) => acceptFileTypes[type].types);
const allowedExtensions = acceptedFileTypes.flatMap((type) => acceptFileTypes[type].extensions);
const validateFile = (file: File): string | null => {
if (file.size > maxFileSize) {
return isLocaleReady ? t("file_size_limit_exceed") : defaultT["file_size_limit_exceed"];
}
if (acceptedFileTypes.includes("any")) {
return null;
}
if (allowedTypes.length > 0 && !allowedTypes.includes(file.type)) {
const extensionsString = allowedExtensions.join(",");
return isLocaleReady
? t("invalid_file_type_with_extensions", { extensions: extensionsString })
: `Invalid file type. Allowed: ${extensionsString}`;
}
return null;
};
const handleFileSelect = useCallback(
async (selectedFiles: FileList) => {
const newFiles: FileData[] = [];
const errors: string[] = [];
if (maxFiles && files.length + selectedFiles.length > maxFiles) {
const maxFileText = isLocaleReady ? t("max_files_exceeded") : `${defaultT["max_files_exceeded"]}`;
showToast(maxFileText, "error");
return;
}
for (let i = 0; i < selectedFiles.length; i++) {
const file = selectedFiles[i];
const validationError = validateFile(file);
if (validationError) {
errors.push(`${file.name}: ${validationError}`);
continue;
}
try {
const dataUrl = await readFileAsDataURL(file);
newFiles.push({
file,
dataUrl,
id: generateFileId(),
});
} catch (error) {
errors.push(`${file.name}: Failed to read file`);
}
}
if (errors.length > 0) {
showToast(errors.join(", "), "error");
}
if (newFiles.length > 0) {
const updatedFiles = [...files, ...newFiles];
setFiles(updatedFiles);
onFilesChange(updatedFiles, newFiles, []);
}
},
[files, maxFiles, maxFileSize, acceptedFileTypes, onFilesChange, t]
);
const handleFileRemove = useCallback(
(fileId: string) => {
const updatedFiles = files.filter((file) => file.id !== fileId);
setFiles(updatedFiles);
onFilesChange(updatedFiles, [], [files.find((file) => file.id === fileId)!]);
},
[files, onFilesChange]
);
const getFileIcon = (fileType: string) => {
if (fileType.startsWith("image/")) return "file";
if (fileType.startsWith("video/")) return "video";
return "file-text";
};
const extensionsString = allowedExtensions.length > 0 ? allowedExtensions.join(",") : "any";
const fileInstructionsText = isLocaleReady
? t("file_upload_instructions", {
types: extensionsString,
maxSize: formatFileSize(maxFileSize),
})
: `Accepted types: ${extensionsString}; Max file size: ${formatFileSize(maxFileSize)}`;
const buttonText = buttonMsg ? buttonMsg : isLocaleReady ? t("add_files") : defaultT["add_files"];
return (
<div>
<div className="space-y-3">
<div className="flex items-center gap-2">
<Label
htmlFor={id}
className={`mb-0 inline-flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm font-medium shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
disabled ? "cursor-not-allowed opacity-50" : ""
}`}>
<Icon name="upload" className="h-4 w-4" />
{buttonText}
</Label>
<div className="flex items-center">
<Input
id={id}
type="file"
multiple={multiple}
accept={allowedTypes.join(",")}
onChange={(e) => e.target.files && handleFileSelect(e.target.files)}
disabled={disabled}
className="hidden"
data-testid={testId}
/>
{files.length > 0 && (
<span className="text-sm">
{files.length}/{maxFiles} files
</span>
)}
</div>
</div>
</div>
{showFilesList && files.length > 0 && (
<div className="mt-2 space-y-1 transition">
{files.map((fileData) => (
<div key={fileData.id} className="flex items-center justify-between rounded-md border">
<div className="flex min-w-0 items-center gap-2 pl-2">
<Icon name={getFileIcon(fileData.file.type)} className="h-5 w-5 flex-shrink-0" />
<div className="min-w-0 border-l py-2 pl-3">
<p className="text-emphasis truncate text-sm font-medium">{fileData.file.name}</p>
<p className="text-xs">{formatFileSize(fileData.file.size)}</p>
</div>
</div>
<Button
variant="icon"
color="destructive"
size="sm"
StartIcon="x"
onClick={() => handleFileRemove(fileData.id)}
className="mx-2 h-6 w-6 flex-shrink-0"
/>
</div>
))}
</div>
)}
<p className="mt-2 text-xs">{fileInstructionsText}</p>
</div>
);
}