mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-08 06:41:45 -05:00
feat: picture selection question (#1388)
Co-authored-by: Olasunkanmi Balogun <olasunkanmiibalogun@gmail.com> Co-authored-by: Johannes <johannes@formbricks.com> Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com> Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
+325
-170
@@ -1,31 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { PhotoIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TAllowedFileExtensions } from "@formbricks/types/common";
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { ArrowUpTrayIcon } from "@heroicons/react/24/solid";
|
||||
import { FileIcon } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { uploadFile } from "./lib/fileUpload";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { Button } from "../Button";
|
||||
|
||||
const allowedFileTypesForPreview = ["png", "jpeg", "jpg", "webp"];
|
||||
const isImage = (name: string) => {
|
||||
return allowedFileTypesForPreview.includes(name.split(".").pop() as TAllowedFileExtensions);
|
||||
};
|
||||
interface FileInputProps {
|
||||
allowedFileExtensions: string[];
|
||||
id: string;
|
||||
allowedFileExtensions: TAllowedFileExtensions[];
|
||||
environmentId: string | undefined;
|
||||
onFileUpload: (uploadedUrl: string | undefined) => void;
|
||||
fileUrl: string | undefined;
|
||||
onFileUpload: (uploadedUrl: string[] | undefined) => void;
|
||||
fileUrl?: string | string[];
|
||||
multiple?: boolean;
|
||||
}
|
||||
|
||||
interface SelectedFile {
|
||||
url: string;
|
||||
name: string;
|
||||
uploaded: Boolean;
|
||||
}
|
||||
|
||||
const FileInput: React.FC<FileInputProps> = ({
|
||||
id,
|
||||
allowedFileExtensions,
|
||||
environmentId,
|
||||
onFileUpload,
|
||||
fileUrl,
|
||||
multiple = false,
|
||||
}) => {
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [isUploaded, setIsUploaded] = useState<boolean>(!!fileUrl);
|
||||
const [isError, setIsError] = useState<boolean>(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [selectedFiles, setSelectedFiles] = useState<SelectedFile[]>([]);
|
||||
|
||||
const handleUpload = async (files: File[]) => {
|
||||
if (!multiple && files.length > 1) {
|
||||
files = [files[0]];
|
||||
toast.error("Only one file is allowed");
|
||||
}
|
||||
|
||||
const allowedFiles = files.filter(
|
||||
(file) =>
|
||||
file &&
|
||||
file.type &&
|
||||
allowedFileExtensions.includes(file.name.split(".").pop() as TAllowedFileExtensions)
|
||||
);
|
||||
|
||||
if (allowedFiles.length < files.length) {
|
||||
if (allowedFiles.length === 0) {
|
||||
toast.error("No files are supported");
|
||||
return;
|
||||
}
|
||||
toast.error("Some files are not supported");
|
||||
}
|
||||
|
||||
setSelectedFiles(
|
||||
allowedFiles.map((file) => ({ url: URL.createObjectURL(file), name: file.name, uploaded: false }))
|
||||
);
|
||||
|
||||
const uploadedFiles = await Promise.allSettled(
|
||||
allowedFiles.map((file) => uploadFile(file, allowedFileExtensions, environmentId))
|
||||
);
|
||||
|
||||
if (
|
||||
uploadedFiles.length < allowedFiles.length ||
|
||||
uploadedFiles.some((file) => file.status === "rejected")
|
||||
) {
|
||||
if (uploadedFiles.length === 0) {
|
||||
toast.error("No files were uploaded");
|
||||
} else {
|
||||
toast.error("Some files failed to upload");
|
||||
}
|
||||
}
|
||||
|
||||
const uploadedUrls: string[] = [];
|
||||
uploadedFiles.forEach((file) => {
|
||||
if (file.status === "fulfilled") {
|
||||
uploadedUrls.push(file.value.url);
|
||||
}
|
||||
});
|
||||
|
||||
if (uploadedUrls.length === 0) {
|
||||
setSelectedFiles([]);
|
||||
return;
|
||||
}
|
||||
|
||||
onFileUpload(uploadedUrls);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
|
||||
e.preventDefault();
|
||||
@@ -33,181 +99,270 @@ const FileInput: React.FC<FileInputProps> = ({
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
};
|
||||
|
||||
const handleFileChange = async (file: File) => {
|
||||
if (
|
||||
file &&
|
||||
file.type &&
|
||||
allowedFileExtensions.includes(file.type.substring(file.type.lastIndexOf("/") + 1))
|
||||
) {
|
||||
setIsUploaded(false);
|
||||
setSelectedFile(file);
|
||||
const response = await uploadFile(file, allowedFileExtensions, environmentId);
|
||||
if (response.uploaded) {
|
||||
setIsUploaded(true);
|
||||
onFileUpload(response.url);
|
||||
}
|
||||
} else {
|
||||
toast.error("File not supported");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = async (e: React.DragEvent<HTMLLabelElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const file = e.dataTransfer.files[0];
|
||||
await handleFileChange(file);
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
handleUpload(files);
|
||||
};
|
||||
|
||||
const handleFileUpload = async (params: {
|
||||
file: File;
|
||||
allowedFileExtensions: string[];
|
||||
environmentId: string | undefined;
|
||||
}) => {
|
||||
setIsUploaded(false);
|
||||
setIsError(false);
|
||||
setSelectedFile(params.file);
|
||||
const handleRemove = async (idx: number) => {
|
||||
const newFileUrl = selectedFiles.filter((_, i) => i !== idx).map((file) => file.url);
|
||||
onFileUpload(newFileUrl);
|
||||
};
|
||||
|
||||
try {
|
||||
let response = await uploadFile(params.file, params.allowedFileExtensions, params.environmentId);
|
||||
setIsUploaded(true);
|
||||
onFileUpload(response.url);
|
||||
} catch (error: any) {
|
||||
setIsUploaded(false);
|
||||
setSelectedFile(null);
|
||||
setIsError(true);
|
||||
toast.error("Something went wrong.");
|
||||
const handleUploadMoreDrop = async (e: React.DragEvent<HTMLLabelElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
handleUploadMore(files);
|
||||
};
|
||||
|
||||
const handleUploadMore = async (files: File[]) => {
|
||||
let filesToUpload: File[] = files;
|
||||
|
||||
const allowedFiles = filesToUpload.filter(
|
||||
(file) =>
|
||||
file &&
|
||||
file.type &&
|
||||
allowedFileExtensions.includes(file.name.split(".").pop() as TAllowedFileExtensions)
|
||||
);
|
||||
|
||||
if (allowedFiles.length < filesToUpload.length) {
|
||||
if (allowedFiles.length === 0) {
|
||||
toast.error("No files are supported");
|
||||
return;
|
||||
}
|
||||
toast.error("Some files are not supported");
|
||||
}
|
||||
|
||||
setSelectedFiles((prevFiles) => [
|
||||
...prevFiles,
|
||||
...allowedFiles.map((file) => ({ url: URL.createObjectURL(file), name: file.name, uploaded: false })),
|
||||
]);
|
||||
|
||||
const uploadedFiles = await Promise.allSettled(
|
||||
allowedFiles.map((file) => uploadFile(file, allowedFileExtensions, environmentId))
|
||||
);
|
||||
|
||||
if (
|
||||
uploadedFiles.length < allowedFiles.length ||
|
||||
uploadedFiles.some((file) => file.status === "rejected")
|
||||
) {
|
||||
if (uploadedFiles.length === 0) {
|
||||
toast.error("No files were uploaded");
|
||||
} else {
|
||||
toast.error("Some files failed to upload");
|
||||
}
|
||||
}
|
||||
|
||||
const uploadedUrls: string[] = [];
|
||||
uploadedFiles.forEach((file) => {
|
||||
if (file.status === "fulfilled") {
|
||||
uploadedUrls.push(file.value.url);
|
||||
}
|
||||
});
|
||||
|
||||
const prevUrls = Array.isArray(fileUrl) ? fileUrl : fileUrl ? [fileUrl] : [];
|
||||
onFileUpload([...prevUrls, ...uploadedUrls]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const getSelectedFiles = () => {
|
||||
if (fileUrl && typeof fileUrl === "string") {
|
||||
return [{ url: fileUrl, name: fileUrl.split("/").pop() || "", uploaded: true }];
|
||||
} else if (fileUrl && Array.isArray(fileUrl)) {
|
||||
return fileUrl.map((url) => ({ url, name: url.split("/").pop() || "", uploaded: true }));
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
setSelectedFiles(getSelectedFiles());
|
||||
}, [fileUrl]);
|
||||
|
||||
return (
|
||||
<label
|
||||
htmlFor="selectedFile"
|
||||
className={cn(
|
||||
"relative flex h-52 w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-700 dark:hover:border-slate-500 dark:hover:bg-slate-600 dark:hover:bg-slate-800",
|
||||
isError && "border-red-500"
|
||||
)}
|
||||
onDragOver={(e) => handleDragOver(e)}
|
||||
onDrop={(e) => handleDrop(e)}>
|
||||
{isUploaded && fileUrl ? (
|
||||
<>
|
||||
<div className="absolute inset-0 mr-4 mt-2 flex items-start justify-end gap-4">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-300 bg-opacity-50 text-slate-800 hover:bg-slate-200/50 hover:text-slate-900">
|
||||
<label htmlFor="modifyFile">
|
||||
<PhotoIcon className="h-5 cursor-pointer text-slate-700 hover:text-slate-900" />
|
||||
<div className="w-full cursor-default">
|
||||
{selectedFiles.length > 0 ? (
|
||||
multiple ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedFiles.map((file, idx) => (
|
||||
<>
|
||||
{isImage(file.name) ? (
|
||||
<div className="relative h-24 w-40 overflow-hidden rounded-lg">
|
||||
<Image
|
||||
src={file.url}
|
||||
alt={file.name}
|
||||
fill
|
||||
style={{ objectFit: "cover" }}
|
||||
quality={100}
|
||||
className={!file.uploaded ? "opacity-50" : ""}
|
||||
/>
|
||||
{file.uploaded ? (
|
||||
<div
|
||||
className="absolute right-2 top-2 flex cursor-pointer items-center justify-center rounded-md bg-slate-100 p-1 hover:bg-slate-200 hover:bg-white/90"
|
||||
onClick={() => handleRemove(idx)}>
|
||||
<XMarkIcon className="h-5 text-slate-700 hover:text-slate-900" />
|
||||
</div>
|
||||
) : (
|
||||
<Loader />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative flex h-24 w-40 flex-col items-center justify-center rounded-lg border border-slate-300 px-2 py-3">
|
||||
<FileIcon className="h-6 text-slate-500" />
|
||||
<p className="mt-2 w-full truncate text-center text-sm text-slate-500" title={file.name}>
|
||||
<span className="font-semibold">{file.name}</span>
|
||||
</p>
|
||||
{file.uploaded ? (
|
||||
<div
|
||||
className="absolute right-2 top-2 flex cursor-pointer items-center justify-center rounded-md bg-slate-100 p-1 hover:bg-slate-200 hover:bg-white/90"
|
||||
onClick={() => handleRemove(idx)}>
|
||||
<XMarkIcon className="h-5 text-slate-700 hover:text-slate-900" />
|
||||
</div>
|
||||
) : (
|
||||
<Loader />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
|
||||
<input
|
||||
type="file"
|
||||
id="modifyFile"
|
||||
name="modifyFile"
|
||||
accept={allowedFileExtensions.map((ext) => `.${ext}`).join(",")}
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const file = e.target?.files?.[0];
|
||||
if (file) {
|
||||
await handleFileUpload({
|
||||
file,
|
||||
allowedFileExtensions,
|
||||
environmentId,
|
||||
});
|
||||
}
|
||||
}}
|
||||
<Uploader
|
||||
id={id}
|
||||
name="uploadMore"
|
||||
handleDragOver={handleDragOver}
|
||||
uploaderClassName="h-24 w-40"
|
||||
handleDrop={handleUploadMoreDrop}
|
||||
allowedFileExtensions={allowedFileExtensions}
|
||||
multiple={multiple}
|
||||
handleUpload={handleUploadMore}
|
||||
uploadMore={true}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-52">
|
||||
{isImage(selectedFiles[0].name) ? (
|
||||
<div className="relative mx-auto h-full w-full overflow-hidden rounded-lg">
|
||||
<Image
|
||||
src={selectedFiles[0].url}
|
||||
alt={selectedFiles[0].name}
|
||||
fill
|
||||
style={{ objectFit: "cover" }}
|
||||
quality={100}
|
||||
className={!selectedFiles[0].uploaded ? "opacity-50" : ""}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-300 bg-opacity-50 hover:bg-slate-200/50">
|
||||
<TrashIcon
|
||||
className="h-5 text-slate-700 hover:text-slate-900"
|
||||
onClick={() => onFileUpload(undefined)}
|
||||
/>
|
||||
</div>
|
||||
{selectedFiles[0].uploaded ? (
|
||||
<div
|
||||
className="absolute right-2 top-2 flex cursor-pointer items-center justify-center rounded-md bg-slate-100 p-1 hover:bg-slate-200 hover:bg-white/90"
|
||||
onClick={() => handleRemove(0)}>
|
||||
<XMarkIcon className="h-5 text-slate-700 hover:text-slate-900" />
|
||||
</div>
|
||||
) : (
|
||||
<Loader />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative flex h-full w-full flex-col items-center justify-center border border-slate-300">
|
||||
<FileIcon className="h-6 text-slate-500" />
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
<span className="font-semibold">{selectedFiles[0].name}</span>
|
||||
</p>
|
||||
{selectedFiles[0].uploaded ? (
|
||||
<div
|
||||
className="absolute right-2 top-2 flex cursor-pointer items-center justify-center rounded-md bg-slate-100 p-1 hover:bg-slate-200 hover:bg-white/90"
|
||||
onClick={() => handleRemove(0)}>
|
||||
<XMarkIcon className="h-5 text-slate-700 hover:text-slate-900" />
|
||||
</div>
|
||||
) : (
|
||||
<Loader />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{fileUrl.endsWith("jpg") || fileUrl.endsWith("jpeg") || fileUrl.endsWith("png") ? (
|
||||
<img
|
||||
src={fileUrl}
|
||||
alt="Company Logo"
|
||||
className="max-h-full max-w-full rounded-lg object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center pb-6 pt-5">
|
||||
<FileIcon className="h-6 text-slate-500" />
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
<span className="font-semibold">{fileUrl.split("/").pop()}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : !isUploaded && selectedFile ? (
|
||||
<>
|
||||
{selectedFile.type.startsWith("image/") ? (
|
||||
<img
|
||||
src={URL.createObjectURL(selectedFile)}
|
||||
alt="Company Logo"
|
||||
className="max-h-full max-w-full rounded-lg object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center pb-6 pt-5">
|
||||
<FileIcon className="h-6 text-slate-500" />
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
<span className="font-semibold">{selectedFile.name}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="hover.bg-opacity-60 absolute inset-0 flex items-center justify-center bg-black bg-opacity-20 transition-opacity duration-300">
|
||||
<label htmlFor="selectedFile" className="cursor-pointer text-sm font-semibold text-white">
|
||||
Uploading
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center pb-6 pt-5">
|
||||
{!isError && <ArrowUpTrayIcon className="h-6 text-slate-500" />}
|
||||
<p className={cn("mt-2 text-sm text-slate-500", isError && "text-red-500")}>
|
||||
<span className="font-semibold">
|
||||
{isError ? "Failed to upload file! Please try again." : "Click or drag to upload files."}
|
||||
</span>
|
||||
</p>
|
||||
{isError && (
|
||||
<Button
|
||||
variant="warn"
|
||||
className="mt-2"
|
||||
onClick={() => {
|
||||
setIsError(false);
|
||||
setIsUploaded(false);
|
||||
setSelectedFile(null);
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
}}
|
||||
type="button">
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
id="selectedFile"
|
||||
ref={fileInputRef}
|
||||
name="selectedFile"
|
||||
accept={allowedFileExtensions.map((ext) => `.${ext}`).join(",")}
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const file = e.target?.files?.[0];
|
||||
if (file) {
|
||||
await handleFileUpload({
|
||||
file,
|
||||
allowedFileExtensions,
|
||||
environmentId,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Uploader
|
||||
id={id}
|
||||
name="selected-file"
|
||||
handleDragOver={handleDragOver}
|
||||
handleDrop={handleDrop}
|
||||
uploaderClassName="h-52 w-full"
|
||||
allowedFileExtensions={allowedFileExtensions}
|
||||
multiple={multiple}
|
||||
handleUpload={handleUpload}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileInput;
|
||||
|
||||
const Uploader = ({
|
||||
id,
|
||||
name,
|
||||
handleDragOver,
|
||||
uploaderClassName,
|
||||
handleDrop,
|
||||
allowedFileExtensions,
|
||||
multiple,
|
||||
handleUpload,
|
||||
uploadMore = false,
|
||||
}: {
|
||||
id: string;
|
||||
name: string;
|
||||
handleDragOver: (e: React.DragEvent<HTMLLabelElement>) => void;
|
||||
uploaderClassName: string;
|
||||
handleDrop: (e: React.DragEvent<HTMLLabelElement>) => void;
|
||||
allowedFileExtensions: TAllowedFileExtensions[];
|
||||
multiple: boolean;
|
||||
handleUpload: (files: File[]) => void;
|
||||
uploadMore?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<label
|
||||
htmlFor={`${id}-${name}`}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-700 dark:hover:border-slate-500 dark:hover:bg-slate-800",
|
||||
uploaderClassName
|
||||
)}
|
||||
onDragOver={(e) => handleDragOver(e)}
|
||||
onDrop={(e) => handleDrop(e)}>
|
||||
<div className="flex flex-col items-center justify-center pb-6 pt-5">
|
||||
<ArrowUpTrayIcon className="h-6 text-slate-500" />
|
||||
<p className={cn("mt-2 text-center text-sm text-slate-500", uploadMore && "text-xs")}>
|
||||
<span className="font-semibold">Click or drag to upload files.</span>
|
||||
</p>
|
||||
<input
|
||||
type="file"
|
||||
id={`${id}-${name}`}
|
||||
name={`${id}-${name}`}
|
||||
accept={allowedFileExtensions.map((ext) => `.${ext}`).join(",")}
|
||||
className="hidden"
|
||||
multiple={multiple}
|
||||
onChange={async (e) => {
|
||||
let selectedFiles = Array.from(e.target?.files || []);
|
||||
handleUpload(selectedFiles);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
const Loader = () => {
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<svg className="h-7 w-7 animate-spin text-slate-900" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user