mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-28 19:58:11 +08:00
### What problem does this PR solve? Feat: Add chunk also supports uploading image. ### Type of change - [x] New Feature (non-breaking change which adds functionality)
432 lines
12 KiB
TypeScript
432 lines
12 KiB
TypeScript
// https://github.com/sadmann7/file-uploader
|
|
|
|
'use client';
|
|
|
|
import { FileText, FolderUp, LucideTrash2, Upload } from 'lucide-react';
|
|
import * as React from 'react';
|
|
import Dropzone, {
|
|
type DropzoneProps,
|
|
type FileRejection,
|
|
} from 'react-dropzone';
|
|
import { toast } from 'sonner';
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
import { Progress } from '@/components/ui/progress';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { useControllableState } from '@/hooks/use-controllable-state';
|
|
import { cn, formatBytes } from '@/lib/utils';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
|
|
|
function isFileWithPreview(file: File): file is File & { preview: string } {
|
|
return 'preview' in file && typeof file.preview === 'string';
|
|
}
|
|
|
|
interface FileCardProps {
|
|
file: File;
|
|
onRemove: () => void;
|
|
progress?: number;
|
|
}
|
|
|
|
interface FilePreviewProps {
|
|
file: File & { preview: string };
|
|
}
|
|
|
|
function FilePreview({ file }: FilePreviewProps) {
|
|
if (file.type.startsWith('image/')) {
|
|
return (
|
|
<img
|
|
src={file.preview}
|
|
alt={file.name}
|
|
width={48}
|
|
height={48}
|
|
loading="lazy"
|
|
className="size-full aspect-square shrink-0 rounded-md object-cover"
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<FileText className="size-10 text-muted-foreground" aria-hidden="true" />
|
|
);
|
|
}
|
|
|
|
function FileCard({ file, progress, onRemove }: FileCardProps) {
|
|
return (
|
|
<div className="relative flex items-center gap-2.5">
|
|
<div className="flex flex-1 gap-2.5 overflow-hidden">
|
|
<div className="w-8">
|
|
{isFileWithPreview(file) ? <FilePreview file={file} /> : null}
|
|
</div>
|
|
<div className="flex flex-col flex-1 gap-2 overflow-hidden">
|
|
<div className="flex flex-col gap-px">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<p className=" w-fit line-clamp-1 text-sm font-medium text-foreground/80 text-ellipsis truncate max-w-[370px]">
|
|
{file.name}
|
|
</p>
|
|
</TooltipTrigger>
|
|
<TooltipContent className="border border-border-button">
|
|
{file.name}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
<p className="text-xs text-text-secondary">
|
|
{formatBytes(file.size)}
|
|
</p>
|
|
</div>
|
|
{progress ? <Progress value={progress} /> : null}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="delete"
|
|
size="icon"
|
|
className="size-7"
|
|
onClick={onRemove}
|
|
>
|
|
<LucideTrash2 className="size-4" aria-hidden="true" />
|
|
<span className="sr-only">Remove file</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface FileUploaderProps extends Omit<
|
|
React.HTMLAttributes<HTMLDivElement>,
|
|
'title'
|
|
> {
|
|
/**
|
|
* Value of the uploader.
|
|
* @type File[]
|
|
* @default undefined
|
|
* @example value={files}
|
|
*/
|
|
value?: File[];
|
|
|
|
/**
|
|
* Function to be called when the value changes.
|
|
* @type (files: File[]) => void
|
|
* @default undefined
|
|
* @example onValueChange={(files) => setFiles(files)}
|
|
*/
|
|
onValueChange?: (files: File[]) => void;
|
|
|
|
/**
|
|
* Function to be called when files are uploaded.
|
|
* @type (files: File[]) => Promise<void>
|
|
* @default undefined
|
|
* @example onUpload={(files) => uploadFiles(files)}
|
|
*/
|
|
onUpload?: (files: File[]) => Promise<void>;
|
|
|
|
/**
|
|
* Progress of the uploaded files.
|
|
* @type Record<string, number> | undefined
|
|
* @default undefined
|
|
* @example progresses={{ "file1.png": 50 }}
|
|
*/
|
|
progresses?: Record<string, number>;
|
|
|
|
/**
|
|
* Accepted file types for the uploader.
|
|
* @type { [key: string]: string[]}
|
|
* @default
|
|
* ```ts
|
|
* { "image/*": [] }
|
|
* ```
|
|
* @example accept={["image/png", "image/jpeg"]}
|
|
*/
|
|
accept?: DropzoneProps['accept'];
|
|
|
|
/**
|
|
* Maximum file size for the uploader.
|
|
* @type number | undefined
|
|
* @default 1024 * 1024 * 2 // 2MB
|
|
* @example maxSize={1024 * 1024 * 2} // 2MB
|
|
*/
|
|
maxSize?: DropzoneProps['maxSize'];
|
|
|
|
/**
|
|
* Maximum number of files for the uploader.
|
|
* @type number | undefined
|
|
* @default 1
|
|
* @example maxFileCount={4}
|
|
*/
|
|
maxFileCount?: DropzoneProps['maxFiles'];
|
|
|
|
hideDropzoneOnMaxFileCount?: boolean;
|
|
|
|
/**
|
|
* Whether the uploader should accept multiple files.
|
|
* @type boolean
|
|
* @default false
|
|
* @example multiple
|
|
*/
|
|
multiple?: boolean;
|
|
|
|
/**
|
|
* Whether the uploader is disabled.
|
|
* @type boolean
|
|
* @default false
|
|
* @example disabled
|
|
*/
|
|
disabled?: boolean;
|
|
|
|
title?: React.ReactNode;
|
|
description?: React.ReactNode;
|
|
}
|
|
|
|
export function FileUploader(props: FileUploaderProps) {
|
|
const {
|
|
value: valueProp,
|
|
onValueChange,
|
|
onUpload,
|
|
progresses,
|
|
accept = {
|
|
'image/*': [],
|
|
},
|
|
maxSize = 1024 * 1024 * 10000000,
|
|
maxFileCount = 100000000000,
|
|
multiple = false,
|
|
disabled = false,
|
|
hideDropzoneOnMaxFileCount = false,
|
|
className,
|
|
title,
|
|
description,
|
|
...dropzoneProps
|
|
} = props;
|
|
const { t } = useTranslation();
|
|
const [files, setFiles] = useControllableState({
|
|
prop: valueProp,
|
|
onChange: onValueChange,
|
|
});
|
|
|
|
const folderInputRef = React.useRef<HTMLInputElement>(null);
|
|
|
|
const reachesMaxFileCount = (files?.length ?? 0) >= maxFileCount;
|
|
|
|
const processFiles = React.useCallback(
|
|
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
|
|
if (!multiple && maxFileCount === 1 && acceptedFiles.length > 1) {
|
|
toast.error('Cannot upload more than 1 file at a time');
|
|
return;
|
|
}
|
|
|
|
if ((files?.length ?? 0) + acceptedFiles.length > maxFileCount) {
|
|
toast.error(`Cannot upload more than ${maxFileCount} files`);
|
|
return;
|
|
}
|
|
|
|
const newFiles = acceptedFiles.map((file) => {
|
|
const enhancedFile = file as File & { preview?: string };
|
|
Object.defineProperty(enhancedFile, 'preview', {
|
|
value: URL.createObjectURL(file),
|
|
writable: true,
|
|
enumerable: true,
|
|
configurable: true,
|
|
});
|
|
return enhancedFile;
|
|
});
|
|
|
|
const updatedFiles = files ? [...files, ...newFiles] : newFiles;
|
|
|
|
setFiles(updatedFiles);
|
|
|
|
if (rejectedFiles.length > 0) {
|
|
rejectedFiles.forEach(({ file }) => {
|
|
toast.error(`File ${file.name} was rejected`);
|
|
});
|
|
}
|
|
|
|
if (
|
|
onUpload &&
|
|
updatedFiles.length > 0 &&
|
|
updatedFiles.length <= maxFileCount
|
|
) {
|
|
const target =
|
|
updatedFiles.length > 0 ? `${updatedFiles.length} files` : `file`;
|
|
|
|
toast.promise(onUpload(updatedFiles), {
|
|
loading: `Uploading ${target}...`,
|
|
success: () => {
|
|
setFiles([]);
|
|
return `${target} uploaded`;
|
|
},
|
|
error: `Failed to upload ${target}`,
|
|
});
|
|
}
|
|
},
|
|
[files, maxFileCount, multiple, onUpload, setFiles],
|
|
);
|
|
|
|
const onDrop = React.useCallback(
|
|
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
|
|
processFiles(acceptedFiles, rejectedFiles);
|
|
},
|
|
[processFiles],
|
|
);
|
|
|
|
const handleFolderSelect = React.useCallback(
|
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
if (!e.target.files) return;
|
|
const fileList = Array.from(e.target.files);
|
|
processFiles(fileList, []);
|
|
e.target.value = '';
|
|
},
|
|
[processFiles],
|
|
);
|
|
|
|
function onRemove(index: number) {
|
|
if (!files) return;
|
|
const newFiles = files.filter((_, i) => i !== index);
|
|
setFiles(newFiles);
|
|
onValueChange?.(newFiles);
|
|
}
|
|
|
|
// Revoke preview url when component unmounts
|
|
React.useEffect(() => {
|
|
return () => {
|
|
if (!files) return;
|
|
files.forEach((file) => {
|
|
if (isFileWithPreview(file)) {
|
|
URL.revokeObjectURL(file.preview);
|
|
}
|
|
});
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const isDisabled = disabled || (files?.length ?? 0) >= maxFileCount;
|
|
|
|
const renderDropzone = (isFolderMode: boolean = false) => {
|
|
const IconComponent = isFolderMode ? FolderUp : Upload;
|
|
|
|
return (
|
|
<Dropzone
|
|
onDrop={onDrop}
|
|
accept={isFolderMode ? undefined : accept}
|
|
maxSize={maxSize}
|
|
maxFiles={maxFileCount}
|
|
multiple={maxFileCount > 1 || multiple}
|
|
disabled={isDisabled}
|
|
noClick={isFolderMode}
|
|
noDrag={isFolderMode}
|
|
>
|
|
{({ getRootProps, getInputProps, isDragActive }) => (
|
|
<div
|
|
{...getRootProps()}
|
|
className={cn(
|
|
'group relative grid h-72 w-full cursor-pointer place-items-center rounded-lg border border-dashed border-border-default',
|
|
'px-5 py-2.5 text-center transition hover:bg-bg-card outline-none',
|
|
'focus-visible:border-accent-primary focus-visible:bg-bg-card',
|
|
isDragActive && 'border-border-button',
|
|
isDisabled && 'pointer-events-none opacity-60',
|
|
className,
|
|
)}
|
|
{...dropzoneProps}
|
|
>
|
|
{!isFolderMode && <input {...getInputProps()} />}
|
|
{isDragActive && !isFolderMode ? (
|
|
<div className="flex flex-col items-center justify-center gap-4 sm:px-5">
|
|
<div>
|
|
<Upload
|
|
className="size-7 text-text-secondary transition-colors group-hover:text-text-primary"
|
|
aria-hidden="true"
|
|
/>
|
|
</div>
|
|
<p className="font-medium text-text-secondary">
|
|
{t('fileManager.dropFilesHere', 'Drop the files here')}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div
|
|
className="flex flex-col items-center justify-center gap-4 sm:px-5"
|
|
onClick={() => {
|
|
if (isFolderMode && !isDisabled) {
|
|
folderInputRef.current?.click();
|
|
}
|
|
}}
|
|
>
|
|
<IconComponent
|
|
className="size-12 stroke-1 text-text-secondary transition-colors group-hover:text-text-primary"
|
|
aria-hidden="true"
|
|
/>
|
|
|
|
<p className="font-medium text-text-secondary">
|
|
{title ||
|
|
(isFolderMode
|
|
? t('fileManager.uploadFolderTitle', 'Upload Folder')
|
|
: t('knowledgeDetails.uploadTitle'))}
|
|
</p>
|
|
|
|
<p className="text-sm text-text-disabled">
|
|
{description ||
|
|
(isFolderMode
|
|
? t(
|
|
'knowledgeDetails.uploadDescription',
|
|
'Select a folder to upload all files inside',
|
|
)
|
|
: t('knowledgeDetails.uploadDescription'))}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</Dropzone>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="relative flex flex-col gap-4 overflow-hidden">
|
|
{!(hideDropzoneOnMaxFileCount && reachesMaxFileCount) && (
|
|
<Tabs defaultValue="file" className="w-full">
|
|
<TabsList className="w-fit justify-start">
|
|
<TabsTrigger value="file" className="gap-2">
|
|
<FileText className="size-4" />
|
|
{t('fileManager.files', 'Files')}
|
|
</TabsTrigger>
|
|
<TabsTrigger value="folder" className="gap-2">
|
|
<FolderUp className="size-4" />
|
|
{t('fileManager.folder', 'Folder')}
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
<TabsContent value="file" className="mt-1">
|
|
{renderDropzone(false)}
|
|
</TabsContent>
|
|
<TabsContent value="folder" className="mt-1">
|
|
{renderDropzone(true)}
|
|
<input
|
|
ref={folderInputRef}
|
|
type="file"
|
|
className="hidden"
|
|
multiple
|
|
onChange={handleFolderSelect}
|
|
{...{
|
|
webkitdirectory: '',
|
|
directory: '',
|
|
}}
|
|
/>
|
|
</TabsContent>
|
|
</Tabs>
|
|
)}
|
|
|
|
{files?.length ? (
|
|
<div className="h-fit w-full">
|
|
<div className="flex max-h-48 flex-col gap-4 overflow-auto scrollbar-auto">
|
|
{files?.map((file, index) => (
|
|
<FileCard
|
|
key={index}
|
|
file={file}
|
|
onRemove={() => onRemove(index)}
|
|
progress={progresses?.[file.name]}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|