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,339 +1,416 @@
|
||||||
import { ArrowRight, Check, Funnel, Loader2, Plus } from "lucide-react";
|
import { ArrowRight, Check, Funnel, Loader2, Plus } from "lucide-react";
|
||||||
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
|
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
|
||||||
|
import { useDropzone } from "react-dropzone";
|
||||||
import TextareaAutosize from "react-textarea-autosize";
|
import TextareaAutosize from "react-textarea-autosize";
|
||||||
import type { FilterColor } from "@/components/filter-icon-popover";
|
import type { FilterColor } from "@/components/filter-icon-popover";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverAnchor,
|
PopoverAnchor,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
|
import { useFileDrag } from "@/hooks/use-file-drag";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import type { KnowledgeFilterData } from "../_types/types";
|
import type { KnowledgeFilterData } from "../_types/types";
|
||||||
import { FilePreview } from "./file-preview";
|
import { FilePreview } from "./file-preview";
|
||||||
import { SelectedKnowledgeFilter } from "./selected-knowledge-filter";
|
import { SelectedKnowledgeFilter } from "./selected-knowledge-filter";
|
||||||
|
|
||||||
export interface ChatInputHandle {
|
export interface ChatInputHandle {
|
||||||
focusInput: () => void;
|
focusInput: () => void;
|
||||||
clickFileInput: () => void;
|
clickFileInput: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
input: string;
|
input: string;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
isUploading: boolean;
|
isUploading: boolean;
|
||||||
selectedFilter: KnowledgeFilterData | null;
|
selectedFilter: KnowledgeFilterData | null;
|
||||||
isFilterDropdownOpen: boolean;
|
isFilterDropdownOpen: boolean;
|
||||||
availableFilters: KnowledgeFilterData[];
|
availableFilters: KnowledgeFilterData[];
|
||||||
filterSearchTerm: string;
|
filterSearchTerm: string;
|
||||||
selectedFilterIndex: number;
|
selectedFilterIndex: number;
|
||||||
anchorPosition: { x: number; y: number } | null;
|
anchorPosition: { x: number; y: number } | null;
|
||||||
parsedFilterData: { color?: FilterColor } | null;
|
parsedFilterData: { color?: FilterColor } | null;
|
||||||
uploadedFile: File | null;
|
uploadedFile: File | null;
|
||||||
onSubmit: (e: React.FormEvent) => void;
|
onSubmit: (e: React.FormEvent) => void;
|
||||||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||||
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||||
onFilterSelect: (filter: KnowledgeFilterData | null) => void;
|
onFilterSelect: (filter: KnowledgeFilterData | null) => void;
|
||||||
onAtClick: () => void;
|
onAtClick: () => void;
|
||||||
onFilePickerClick: () => void;
|
onFilePickerClick: () => void;
|
||||||
setSelectedFilter: (filter: KnowledgeFilterData | null) => void;
|
setSelectedFilter: (filter: KnowledgeFilterData | null) => void;
|
||||||
setIsFilterHighlighted: (highlighted: boolean) => void;
|
setIsFilterHighlighted: (highlighted: boolean) => void;
|
||||||
setIsFilterDropdownOpen: (open: boolean) => void;
|
setIsFilterDropdownOpen: (open: boolean) => void;
|
||||||
onFileSelected: (file: File | null) => void;
|
onFileSelected: (file: File | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
input,
|
input,
|
||||||
loading,
|
loading,
|
||||||
isUploading,
|
isUploading,
|
||||||
selectedFilter,
|
selectedFilter,
|
||||||
isFilterDropdownOpen,
|
isFilterDropdownOpen,
|
||||||
availableFilters,
|
availableFilters,
|
||||||
filterSearchTerm,
|
filterSearchTerm,
|
||||||
selectedFilterIndex,
|
selectedFilterIndex,
|
||||||
anchorPosition,
|
anchorPosition,
|
||||||
parsedFilterData,
|
parsedFilterData,
|
||||||
uploadedFile,
|
uploadedFile,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onChange,
|
onChange,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
onFilterSelect,
|
onFilterSelect,
|
||||||
onAtClick,
|
onAtClick,
|
||||||
onFilePickerClick,
|
onFilePickerClick,
|
||||||
setSelectedFilter,
|
setSelectedFilter,
|
||||||
setIsFilterHighlighted,
|
setIsFilterHighlighted,
|
||||||
setIsFilterDropdownOpen,
|
setIsFilterDropdownOpen,
|
||||||
onFileSelected,
|
onFileSelected,
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [textareaHeight, setTextareaHeight] = useState(0);
|
const [textareaHeight, setTextareaHeight] = useState(0);
|
||||||
|
const [fileUploadError, setFileUploadError] = useState<Error | null>(null);
|
||||||
|
const isDragging = useFileDrag();
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
const { getRootProps, getInputProps } = useDropzone({
|
||||||
focusInput: () => {
|
accept: {
|
||||||
inputRef.current?.focus();
|
"application/pdf": [".pdf"],
|
||||||
},
|
"application/msword": [".doc"],
|
||||||
clickFileInput: () => {
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
|
||||||
fileInputRef.current?.click();
|
[".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]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleFilePickerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
useImperativeHandle(ref, () => ({
|
||||||
const files = e.target.files;
|
focusInput: () => {
|
||||||
if (files && files.length > 0) {
|
inputRef.current?.focus();
|
||||||
onFileSelected(files[0]);
|
},
|
||||||
} else {
|
clickFileInput: () => {
|
||||||
onFileSelected(null);
|
fileInputRef.current?.click();
|
||||||
}
|
},
|
||||||
};
|
}));
|
||||||
|
|
||||||
return (
|
const handleFilePickerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
<div className="w-full">
|
setFileUploadError(null);
|
||||||
<form onSubmit={onSubmit} className="relative">
|
const files = e.target.files;
|
||||||
{/* Outer container - flex-col to stack file preview above input */}
|
if (files && files.length > 0) {
|
||||||
<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">
|
onFileSelected(files[0]);
|
||||||
{/* File Preview Section - Always above */}
|
} else {
|
||||||
{uploadedFile && (
|
onFileSelected(null);
|
||||||
<FilePreview
|
}
|
||||||
uploadedFile={uploadedFile}
|
};
|
||||||
onClear={() => {
|
|
||||||
onFileSelected(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Main Input Container - flex-row or flex-col based on textarea height */}
|
return (
|
||||||
<div
|
<div className="w-full">
|
||||||
className={`relative flex w-full gap-2 ${
|
<form onSubmit={onSubmit} className="relative">
|
||||||
textareaHeight > 40 ? "flex-col" : "flex-row items-center"
|
{/* Outer container - flex-col to stack file preview above input */}
|
||||||
}`}
|
<div
|
||||||
>
|
{...getRootProps()}
|
||||||
{/* Filter + Textarea Section */}
|
className={cn(
|
||||||
<div
|
"flex flex-col w-full p-2 rounded-xl border border-input transition-all",
|
||||||
className={`flex items-center gap-2 ${textareaHeight > 40 ? "w-full" : "flex-1"}`}
|
!isDragging &&
|
||||||
>
|
"hover:[&:not(:focus-within)]:border-muted-foreground focus-within:border-foreground",
|
||||||
{textareaHeight <= 40 &&
|
isDragging && "border-dashed",
|
||||||
(selectedFilter ? (
|
)}
|
||||||
<SelectedKnowledgeFilter
|
>
|
||||||
selectedFilter={selectedFilter}
|
<input {...getInputProps()} />
|
||||||
parsedFilterData={parsedFilterData}
|
{/* File Preview Section - Always above */}
|
||||||
onClear={() => {
|
<AnimatePresence>
|
||||||
setSelectedFilter(null);
|
{uploadedFile && (
|
||||||
setIsFilterHighlighted(false);
|
<motion.div
|
||||||
}}
|
initial={{ opacity: 0, height: 0, marginBottom: 0 }}
|
||||||
/>
|
animate={{ opacity: 1, height: "auto", marginBottom: 8 }}
|
||||||
) : (
|
exit={{ opacity: 0, height: 0, marginBottom: 0 }}
|
||||||
<Button
|
className="overflow-hidden"
|
||||||
type="button"
|
>
|
||||||
variant="ghost"
|
<FilePreview
|
||||||
size="iconSm"
|
uploadedFile={uploadedFile}
|
||||||
className="h-8 w-8 p-0 rounded-md hover:bg-muted/50"
|
onClear={() => {
|
||||||
onMouseDown={(e) => {
|
onFileSelected(null);
|
||||||
e.preventDefault();
|
}}
|
||||||
}}
|
/>
|
||||||
onClick={onAtClick}
|
</motion.div>
|
||||||
data-filter-button
|
)}
|
||||||
>
|
</AnimatePresence>
|
||||||
<Funnel className="h-4 w-4" />
|
<AnimatePresence>
|
||||||
</Button>
|
{fileUploadError && (
|
||||||
))}
|
<motion.p
|
||||||
<div
|
initial={{ opacity: 0, height: 0 }}
|
||||||
className="relative flex-1"
|
animate={{ opacity: 1, height: "auto" }}
|
||||||
style={{ height: `${textareaHeight}px` }}
|
exit={{ opacity: 0, height: 0 }}
|
||||||
>
|
className="text-sm text-destructive overflow-hidden"
|
||||||
<TextareaAutosize
|
>
|
||||||
ref={inputRef}
|
{fileUploadError.message}
|
||||||
value={input}
|
</motion.p>
|
||||||
onChange={onChange}
|
)}
|
||||||
onKeyDown={onKeyDown}
|
</AnimatePresence>
|
||||||
onHeightChange={(height) => setTextareaHeight(height)}
|
<AnimatePresence>
|
||||||
maxRows={7}
|
{isDragging && (
|
||||||
autoComplete="off"
|
<motion.div
|
||||||
minRows={1}
|
initial={{ opacity: 0, height: 0 }}
|
||||||
placeholder="Ask a question..."
|
animate={{ opacity: 1, height: 100 }}
|
||||||
disabled={loading}
|
exit={{ opacity: 0, height: 0 }}
|
||||||
className={`w-full text-sm bg-transparent focus-visible:outline-none resize-none`}
|
className="overflow-hidden w-full flex flex-col items-center justify-center gap-2"
|
||||||
rows={1}
|
>
|
||||||
/>
|
<p className="text-md font-medium text-primary">
|
||||||
</div>
|
Add files to conversation
|
||||||
</div>
|
</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 ${
|
||||||
|
textareaHeight > 40 ? "flex-col" : "flex-row items-center"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Filter + Textarea Section */}
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 ${textareaHeight > 40 ? "w-full" : "flex-1"}`}
|
||||||
|
>
|
||||||
|
{textareaHeight <= 40 &&
|
||||||
|
(selectedFilter ? (
|
||||||
|
<SelectedKnowledgeFilter
|
||||||
|
selectedFilter={selectedFilter}
|
||||||
|
parsedFilterData={parsedFilterData}
|
||||||
|
onClear={() => {
|
||||||
|
setSelectedFilter(null);
|
||||||
|
setIsFilterHighlighted(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="iconSm"
|
||||||
|
className="h-8 w-8 p-0 rounded-md hover:bg-muted/50"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
onClick={onAtClick}
|
||||||
|
data-filter-button
|
||||||
|
>
|
||||||
|
<Funnel className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
<div
|
||||||
|
className="relative flex-1"
|
||||||
|
style={{ height: `${textareaHeight}px` }}
|
||||||
|
>
|
||||||
|
<TextareaAutosize
|
||||||
|
ref={inputRef}
|
||||||
|
value={input}
|
||||||
|
onChange={onChange}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
onHeightChange={(height) => setTextareaHeight(height)}
|
||||||
|
maxRows={7}
|
||||||
|
autoComplete="off"
|
||||||
|
minRows={1}
|
||||||
|
placeholder="Ask a question..."
|
||||||
|
disabled={loading}
|
||||||
|
className={`w-full text-sm bg-transparent focus-visible:outline-none resize-none`}
|
||||||
|
rows={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons Section */}
|
{/* Action Buttons Section */}
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-2 ${textareaHeight > 40 ? "justify-between w-full" : ""}`}
|
className={`flex items-center gap-2 ${textareaHeight > 40 ? "justify-between w-full" : ""}`}
|
||||||
>
|
>
|
||||||
{textareaHeight > 40 &&
|
{textareaHeight > 40 &&
|
||||||
(selectedFilter ? (
|
(selectedFilter ? (
|
||||||
<SelectedKnowledgeFilter
|
<SelectedKnowledgeFilter
|
||||||
selectedFilter={selectedFilter}
|
selectedFilter={selectedFilter}
|
||||||
parsedFilterData={parsedFilterData}
|
parsedFilterData={parsedFilterData}
|
||||||
onClear={() => {
|
onClear={() => {
|
||||||
setSelectedFilter(null);
|
setSelectedFilter(null);
|
||||||
setIsFilterHighlighted(false);
|
setIsFilterHighlighted(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="iconSm"
|
size="iconSm"
|
||||||
className="h-8 w-8 p-0 rounded-md hover:bg-muted/50"
|
className="h-8 w-8 p-0 rounded-md hover:bg-muted/50"
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}}
|
}}
|
||||||
onClick={onAtClick}
|
onClick={onAtClick}
|
||||||
data-filter-button
|
data-filter-button
|
||||||
>
|
>
|
||||||
<Funnel className="h-4 w-4" />
|
<Funnel className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="iconSm"
|
size="iconSm"
|
||||||
onClick={onFilePickerClick}
|
onClick={onFilePickerClick}
|
||||||
disabled={isUploading}
|
disabled={isUploading}
|
||||||
className="h-8 w-8 p-0 !rounded-md hover:bg-muted/50"
|
className="h-8 w-8 p-0 !rounded-md hover:bg-muted/50"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
type="submit"
|
type="submit"
|
||||||
size="iconSm"
|
size="iconSm"
|
||||||
disabled={(!input.trim() && !uploadedFile) || loading}
|
disabled={(!input.trim() && !uploadedFile) || loading}
|
||||||
className="!rounded-md h-8 w-8 p-0"
|
className="!rounded-md h-8 w-8 p-0"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<ArrowRight className="h-4 w-4" />
|
<ArrowRight className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
onChange={handleFilePickerChange}
|
onChange={handleFilePickerChange}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt"
|
accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Popover
|
<Popover
|
||||||
open={isFilterDropdownOpen}
|
open={isFilterDropdownOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
setIsFilterDropdownOpen(open);
|
setIsFilterDropdownOpen(open);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{anchorPosition && (
|
{anchorPosition && (
|
||||||
<PopoverAnchor
|
<PopoverAnchor
|
||||||
asChild
|
asChild
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
left: anchorPosition.x,
|
left: anchorPosition.x,
|
||||||
top: anchorPosition.y,
|
top: anchorPosition.y,
|
||||||
width: 1,
|
width: 1,
|
||||||
height: 1,
|
height: 1,
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div />
|
<div />
|
||||||
</PopoverAnchor>
|
</PopoverAnchor>
|
||||||
)}
|
)}
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
className="w-64 p-2"
|
className="w-64 p-2"
|
||||||
side="top"
|
side="top"
|
||||||
align="start"
|
align="start"
|
||||||
sideOffset={6}
|
sideOffset={6}
|
||||||
alignOffset={-18}
|
alignOffset={-18}
|
||||||
onOpenAutoFocus={(e) => {
|
onOpenAutoFocus={(e) => {
|
||||||
// Prevent auto focus on the popover content
|
// Prevent auto focus on the popover content
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// Keep focus on the input
|
// Keep focus on the input
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{filterSearchTerm && (
|
{filterSearchTerm && (
|
||||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||||
Searching: @{filterSearchTerm}
|
Searching: @{filterSearchTerm}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{availableFilters.length === 0 ? (
|
{availableFilters.length === 0 ? (
|
||||||
<div className="px-2 py-3 text-sm text-muted-foreground">
|
<div className="px-2 py-3 text-sm text-muted-foreground">
|
||||||
No knowledge filters available
|
No knowledge filters available
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{!filterSearchTerm && (
|
{!filterSearchTerm && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onFilterSelect(null)}
|
onClick={() => onFilterSelect(null)}
|
||||||
className={`w-full text-left px-2 py-2 text-sm rounded hover:bg-muted/50 flex items-center justify-between ${
|
className={`w-full text-left px-2 py-2 text-sm rounded hover:bg-muted/50 flex items-center justify-between ${
|
||||||
selectedFilterIndex === -1 ? "bg-muted/50" : ""
|
selectedFilterIndex === -1 ? "bg-muted/50" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span>No knowledge filter</span>
|
<span>No knowledge filter</span>
|
||||||
{!selectedFilter && (
|
{!selectedFilter && (
|
||||||
<Check className="h-4 w-4 shrink-0" />
|
<Check className="h-4 w-4 shrink-0" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{availableFilters
|
{availableFilters
|
||||||
.filter((filter) =>
|
.filter((filter) =>
|
||||||
filter.name
|
filter.name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(filterSearchTerm.toLowerCase()),
|
.includes(filterSearchTerm.toLowerCase()),
|
||||||
)
|
)
|
||||||
.map((filter, index) => (
|
.map((filter, index) => (
|
||||||
<button
|
<button
|
||||||
key={filter.id}
|
key={filter.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onFilterSelect(filter)}
|
onClick={() => onFilterSelect(filter)}
|
||||||
className={`w-full overflow-hidden text-left px-2 py-2 gap-2 text-sm rounded hover:bg-muted/50 flex items-center justify-between ${
|
className={`w-full overflow-hidden text-left px-2 py-2 gap-2 text-sm rounded hover:bg-muted/50 flex items-center justify-between ${
|
||||||
index === selectedFilterIndex ? "bg-muted/50" : ""
|
index === selectedFilterIndex ? "bg-muted/50" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="overflow-hidden">
|
<div className="overflow-hidden">
|
||||||
<div className="font-medium truncate">
|
<div className="font-medium truncate">
|
||||||
{filter.name}
|
{filter.name}
|
||||||
</div>
|
</div>
|
||||||
{filter.description && (
|
{filter.description && (
|
||||||
<div className="text-xs text-muted-foreground truncate">
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
{filter.description}
|
{filter.description}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{selectedFilter?.id === filter.id && (
|
{selectedFilter?.id === filter.id && (
|
||||||
<Check className="h-4 w-4 shrink-0" />
|
<Check className="h-4 w-4 shrink-0" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{availableFilters.filter((filter) =>
|
{availableFilters.filter((filter) =>
|
||||||
filter.name
|
filter.name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(filterSearchTerm.toLowerCase()),
|
.includes(filterSearchTerm.toLowerCase()),
|
||||||
).length === 0 &&
|
).length === 0 &&
|
||||||
filterSearchTerm && (
|
filterSearchTerm && (
|
||||||
<div className="px-2 py-3 text-sm text-muted-foreground">
|
<div className="px-2 py-3 text-sm text-muted-foreground">
|
||||||
No filters match "{filterSearchTerm}"
|
No filters match "{filterSearchTerm}"
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
ChatInput.displayName = "ChatInput";
|
ChatInput.displayName = "ChatInput";
|
||||||
|
|
|
||||||
|
|
@ -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