use react dropzone on chat input
This commit is contained in:
parent
98cb6c5198
commit
3ad90f7293
3 changed files with 442 additions and 514 deletions
|
|
@ -1,5 +1,7 @@
|
|||
import { ArrowRight, Check, Funnel, Loader2, Plus } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import TextareaAutosize from "react-textarea-autosize";
|
||||
import type { FilterColor } from "@/components/filter-icon-popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -8,6 +10,8 @@ import {
|
|||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
} from "@/components/ui/popover";
|
||||
import { useFileDrag } from "@/hooks/use-file-drag";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { KnowledgeFilterData } from "../_types/types";
|
||||
import { FilePreview } from "./file-preview";
|
||||
import { SelectedKnowledgeFilter } from "./selected-knowledge-filter";
|
||||
|
|
@ -71,6 +75,30 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [textareaHeight, setTextareaHeight] = useState(0);
|
||||
const [fileUploadError, setFileUploadError] = useState<Error | null>(null);
|
||||
const isDragging = useFileDrag();
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
accept: {
|
||||
"application/pdf": [".pdf"],
|
||||
"application/msword": [".doc"],
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
|
||||
[".docx"],
|
||||
"text/markdown": [".md"],
|
||||
},
|
||||
maxFiles: 1,
|
||||
disabled: !isDragging,
|
||||
onDrop: (acceptedFiles, fileRejections) => {
|
||||
setFileUploadError(null);
|
||||
if (fileRejections.length > 0) {
|
||||
console.log(fileRejections);
|
||||
const message = fileRejections.at(0)?.errors.at(0)?.message;
|
||||
setFileUploadError(new Error(message));
|
||||
return;
|
||||
}
|
||||
onFileSelected(acceptedFiles[0]);
|
||||
},
|
||||
});
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focusInput: () => {
|
||||
|
|
@ -82,6 +110,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|||
}));
|
||||
|
||||
const handleFilePickerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFileUploadError(null);
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
onFileSelected(files[0]);
|
||||
|
|
@ -94,17 +123,65 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|||
<div className="w-full">
|
||||
<form onSubmit={onSubmit} className="relative">
|
||||
{/* Outer container - flex-col to stack file preview above input */}
|
||||
<div className="flex flex-col w-full gap-2 rounded-xl border border-input hover:[&:not(:focus-within)]:border-muted-foreground focus-within:border-foreground p-2 transition-colors">
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={cn(
|
||||
"flex flex-col w-full p-2 rounded-xl border border-input transition-all",
|
||||
!isDragging &&
|
||||
"hover:[&:not(:focus-within)]:border-muted-foreground focus-within:border-foreground",
|
||||
isDragging && "border-dashed",
|
||||
)}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{/* File Preview Section - Always above */}
|
||||
<AnimatePresence>
|
||||
{uploadedFile && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0, marginBottom: 0 }}
|
||||
animate={{ opacity: 1, height: "auto", marginBottom: 8 }}
|
||||
exit={{ opacity: 0, height: 0, marginBottom: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<FilePreview
|
||||
uploadedFile={uploadedFile}
|
||||
onClear={() => {
|
||||
onFileSelected(null);
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
</AnimatePresence>
|
||||
<AnimatePresence>
|
||||
{fileUploadError && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="text-sm text-destructive overflow-hidden"
|
||||
>
|
||||
{fileUploadError.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence>
|
||||
{isDragging && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 100 }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="overflow-hidden w-full flex flex-col items-center justify-center gap-2"
|
||||
>
|
||||
<p className="text-md font-medium text-primary">
|
||||
Add files to conversation
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Text formats and image files.{" "}
|
||||
<span className="font-semibold">10</span> files per chat,{" "}
|
||||
<span className="font-semibold">150 MB</span> each.
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{/* Main Input Container - flex-row or flex-col based on textarea height */}
|
||||
<div
|
||||
className={`relative flex w-full gap-2 ${
|
||||
|
|
|
|||
|
|
@ -1,202 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { UploadIcon } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { DropEvent, DropzoneOptions, FileRejection } from 'react-dropzone';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type DropzoneContextType = {
|
||||
src?: File[];
|
||||
accept?: DropzoneOptions['accept'];
|
||||
maxSize?: DropzoneOptions['maxSize'];
|
||||
minSize?: DropzoneOptions['minSize'];
|
||||
maxFiles?: DropzoneOptions['maxFiles'];
|
||||
};
|
||||
|
||||
const renderBytes = (bytes: number) => {
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(2)}${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
const DropzoneContext = createContext<DropzoneContextType | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
export type DropzoneProps = Omit<DropzoneOptions, 'onDrop'> & {
|
||||
src?: File[];
|
||||
className?: string;
|
||||
onDrop?: (
|
||||
acceptedFiles: File[],
|
||||
fileRejections: FileRejection[],
|
||||
event: DropEvent
|
||||
) => void;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const Dropzone = ({
|
||||
accept,
|
||||
maxFiles = 1,
|
||||
maxSize,
|
||||
minSize,
|
||||
onDrop,
|
||||
onError,
|
||||
disabled,
|
||||
src,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: DropzoneProps) => {
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
accept,
|
||||
maxFiles,
|
||||
maxSize,
|
||||
minSize,
|
||||
onError,
|
||||
disabled,
|
||||
onDrop: (acceptedFiles, fileRejections, event) => {
|
||||
if (fileRejections.length > 0) {
|
||||
const message = fileRejections.at(0)?.errors.at(0)?.message;
|
||||
onError?.(new Error(message));
|
||||
return;
|
||||
}
|
||||
|
||||
onDrop?.(acceptedFiles, fileRejections, event);
|
||||
},
|
||||
...props,
|
||||
});
|
||||
|
||||
return (
|
||||
<DropzoneContext.Provider
|
||||
key={JSON.stringify(src)}
|
||||
value={{ src, accept, maxSize, minSize, maxFiles }}
|
||||
>
|
||||
<Button
|
||||
className={cn(
|
||||
'relative h-auto w-full flex-col overflow-hidden p-8',
|
||||
isDragActive && 'outline-none ring-1 ring-ring',
|
||||
className
|
||||
)}
|
||||
disabled={disabled}
|
||||
type="button"
|
||||
variant="outline"
|
||||
{...getRootProps()}
|
||||
>
|
||||
<input {...getInputProps()} disabled={disabled} />
|
||||
{children}
|
||||
</Button>
|
||||
</DropzoneContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useDropzoneContext = () => {
|
||||
const context = useContext(DropzoneContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useDropzoneContext must be used within a Dropzone');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type DropzoneContentProps = {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const maxLabelItems = 3;
|
||||
|
||||
export const DropzoneContent = ({
|
||||
children,
|
||||
className,
|
||||
}: DropzoneContentProps) => {
|
||||
const { src } = useDropzoneContext();
|
||||
|
||||
if (!src) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center', className)}>
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||
<UploadIcon size={16} />
|
||||
</div>
|
||||
<p className="my-2 w-full truncate font-medium text-sm">
|
||||
{src.length > maxLabelItems
|
||||
? `${new Intl.ListFormat('en').format(
|
||||
src.slice(0, maxLabelItems).map((file) => file.name)
|
||||
)} and ${src.length - maxLabelItems} more`
|
||||
: new Intl.ListFormat('en').format(src.map((file) => file.name))}
|
||||
</p>
|
||||
<p className="w-full text-wrap text-muted-foreground text-xs">
|
||||
Drag and drop or click to replace
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type DropzoneEmptyStateProps = {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const DropzoneEmptyState = ({
|
||||
children,
|
||||
className,
|
||||
}: DropzoneEmptyStateProps) => {
|
||||
const { src, accept, maxSize, minSize, maxFiles } = useDropzoneContext();
|
||||
|
||||
if (src) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
let caption = '';
|
||||
|
||||
if (accept) {
|
||||
caption += 'Accepts ';
|
||||
caption += new Intl.ListFormat('en').format(Object.keys(accept));
|
||||
}
|
||||
|
||||
if (minSize && maxSize) {
|
||||
caption += ` between ${renderBytes(minSize)} and ${renderBytes(maxSize)}`;
|
||||
} else if (minSize) {
|
||||
caption += ` at least ${renderBytes(minSize)}`;
|
||||
} else if (maxSize) {
|
||||
caption += ` less than ${renderBytes(maxSize)}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center', className)}>
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||
<UploadIcon size={16} />
|
||||
</div>
|
||||
<p className="my-2 w-full truncate text-wrap font-medium text-sm">
|
||||
Upload {maxFiles === 1 ? 'a file' : 'files'}
|
||||
</p>
|
||||
<p className="w-full truncate text-wrap text-muted-foreground text-xs">
|
||||
Drag and drop or click to upload
|
||||
</p>
|
||||
{caption && (
|
||||
<p className="text-wrap text-muted-foreground text-xs">{caption}.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
53
frontend/hooks/use-file-drag.ts
Normal file
53
frontend/hooks/use-file-drag.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Hook to detect when files are being dragged into the browser window
|
||||
* @returns isDragging - true when files are being dragged over the window
|
||||
*/
|
||||
export function useFileDrag() {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let dragCounter = 0;
|
||||
|
||||
const handleDragEnter = (e: DragEvent) => {
|
||||
// Only detect file drags
|
||||
if (e.dataTransfer?.types.includes("Files")) {
|
||||
dragCounter++;
|
||||
if (dragCounter === 1) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
dragCounter--;
|
||||
if (dragCounter === 0) {
|
||||
setIsDragging(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleDrop = () => {
|
||||
dragCounter = 0;
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
window.addEventListener("dragenter", handleDragEnter);
|
||||
window.addEventListener("dragleave", handleDragLeave);
|
||||
window.addEventListener("dragover", handleDragOver);
|
||||
window.addEventListener("drop", handleDrop);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("dragenter", handleDragEnter);
|
||||
window.removeEventListener("dragleave", handleDragLeave);
|
||||
window.removeEventListener("dragover", handleDragOver);
|
||||
window.removeEventListener("drop", handleDrop);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isDragging;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue