1- import React , { useReducer } from 'react' ;
2- import { MagnifyingGlassIcon } from '@heroicons/react/24/solid' ;
3- import { useCaseSearch } from '../../hooks' ;
1+ import React , { useReducer , useRef } from 'react' ;
2+ import { MagnifyingGlassIcon , DocumentArrowUpIcon } from '@heroicons/react/24/solid' ;
3+ import { useCaseSearch , useFileSearch } from '../../hooks' ;
44
55interface State {
66 caseNumber : string ;
@@ -41,6 +41,127 @@ interface SearchPanelProps {
4141const SearchPanel : React . FC < SearchPanelProps > = ( { onSearch } ) => {
4242 const [ localState , localDispatch ] = useReducer ( reducer , initialState ) ;
4343 const caseSearch = useCaseSearch ( ) ;
44+ const fileSearch = useFileSearch ( ) ;
45+ const fileInputRef = useRef < HTMLInputElement > ( null ) ;
46+
47+ const [ isDragging , setIsDragging ] = React . useState ( false ) ;
48+ const MAX_FILE_SIZE = 10 * 1024 * 1024 ; // 10MB
49+
50+ const handleDragEnter = ( e : React . DragEvent ) => {
51+ e . preventDefault ( ) ;
52+ e . stopPropagation ( ) ;
53+ setIsDragging ( true ) ;
54+ } ;
55+
56+ const handleDragLeave = ( e : React . DragEvent ) => {
57+ e . preventDefault ( ) ;
58+ e . stopPropagation ( ) ;
59+ if ( e . currentTarget === e . target ) {
60+ setIsDragging ( false ) ;
61+ }
62+ } ;
63+
64+ const handleDragOver = ( e : React . DragEvent ) => {
65+ e . preventDefault ( ) ;
66+ e . stopPropagation ( ) ;
67+ // Required to allow dropping
68+ } ;
69+
70+ const processSelectedFile = ( file : File ) => {
71+ if ( ! isSupportedFileType ( file ) ) {
72+ localDispatch ( {
73+ type : 'SET_ERROR' ,
74+ payload : 'Unsupported file type. Please upload a PDF, DOCX, TXT, CSV, XLSX, JPG, or PNG.' ,
75+ } ) ;
76+ return ;
77+ }
78+ if ( file . size > MAX_FILE_SIZE ) {
79+ localDispatch ( {
80+ type : 'SET_ERROR' ,
81+ payload : `File size exceeds 10MB limit (File size: ${ ( file . size / 1024 / 1024 ) . toFixed ( 2 ) } MB)` ,
82+ } ) ;
83+ return ;
84+ }
85+
86+ // Reset error and start processing
87+ localDispatch ( { type : 'SET_ERROR' , payload : null } ) ;
88+ localDispatch ( { type : 'SET_FEEDBACK' , payload : { message : 'Processing file...' , type : null } } ) ;
89+
90+ fileSearch . mutate ( file , {
91+ onSuccess : data => {
92+ localDispatch ( { type : 'SET_ERROR' , payload : null } ) ;
93+ const caseCount = Object . keys ( data ?. results || { } ) . length ;
94+
95+ if ( caseCount === 0 ) {
96+ localDispatch ( {
97+ type : 'SET_FEEDBACK' ,
98+ payload : { message : 'No case numbers found in file' , type : 'error' } ,
99+ } ) ;
100+ } else {
101+ localDispatch ( { type : 'SET_CASE_NUMBER' , payload : '' } ) ;
102+ const message = caseCount === 1 ? 'Found 1 case number' : `Found ${ caseCount } case numbers` ;
103+ localDispatch ( { type : 'SET_FEEDBACK' , payload : { message, type : 'success' } } ) ;
104+ }
105+ } ,
106+ onError : ( error : Error ) => {
107+ console . error ( 'File search error:' , error ) ;
108+ localDispatch ( { type : 'SET_ERROR' , payload : error . message } ) ;
109+ localDispatch ( { type : 'SET_FEEDBACK' , payload : { message : null , type : null } } ) ;
110+ } ,
111+ } ) ;
112+ } ;
113+
114+ const handleDrop = ( e : React . DragEvent ) => {
115+ e . preventDefault ( ) ;
116+ e . stopPropagation ( ) ;
117+ setIsDragging ( false ) ;
118+
119+ const file = e . dataTransfer . files ?. [ 0 ] ;
120+ if ( ! file ) return ;
121+
122+ processSelectedFile ( file ) ;
123+ } ;
124+
125+ const isSupportedFileType = ( file : File ) => {
126+ const extension = file . name . split ( '.' ) . pop ( ) ?. toLowerCase ( ) ;
127+ const allowedExtensions = new Set ( [ 'pdf' , 'txt' , 'csv' , 'xlsx' , 'xls' , 'docx' , 'jpg' , 'jpeg' , 'png' ] ) ;
128+
129+ if ( extension && allowedExtensions . has ( extension ) ) {
130+ return true ;
131+ }
132+
133+ const allowedMimeTypes = new Set ( [
134+ 'application/pdf' ,
135+ 'text/plain' ,
136+ 'text/csv' ,
137+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ,
138+ 'application/vnd.ms-excel' ,
139+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ,
140+ 'image/jpeg' ,
141+ 'image/png' ,
142+ ] ) ;
143+
144+ return allowedMimeTypes . has ( file . type ) ;
145+ } ;
146+
147+ const handlePaste = ( e : React . ClipboardEvent < HTMLTextAreaElement > ) => {
148+ const file = e . clipboardData ?. files ?. [ 0 ] ;
149+
150+ if ( ! file ) return ;
151+
152+ e . preventDefault ( ) ;
153+ processSelectedFile ( file ) ;
154+ } ;
155+
156+ const handleFileSelect = ( e : React . ChangeEvent < HTMLInputElement > ) => {
157+ const file = e . target . files ?. [ 0 ] ;
158+ if ( ! file ) return ;
159+
160+ processSelectedFile ( file ) ;
161+
162+ // Reset input value to allow selecting the same file again
163+ e . target . value = '' ;
164+ } ;
44165
45166 const submitSearch = ( e : React . FormEvent < HTMLFormElement > ) => {
46167 e . preventDefault ( ) ;
@@ -109,8 +230,24 @@ const SearchPanel: React.FC<SearchPanelProps> = ({ onSearch }) => {
109230 Standard (25CR123456-789) and LexisNexis (7892025CR 123456) case numbers are supported.
110231 </ p >
111232 </ div >
112- < form className = "mt-5 sm:flex sm:flex-col" onSubmit = { submitSearch } >
233+ < div className = "mt-2 text-xs text-gray-500" > Tip: drop or paste a document here to search for case numbers.</ div >
234+ < form
235+ className = "mt-5 sm:flex sm:flex-col"
236+ onSubmit = { submitSearch }
237+ onDragEnter = { handleDragEnter }
238+ onDragLeave = { handleDragLeave }
239+ onDragOver = { handleDragOver }
240+ onDrop = { handleDrop }
241+ >
113242 < div className = "w-full relative" >
243+ { isDragging && (
244+ < div className = "absolute inset-0 bg-blue-50/95 border-2 border-dashed border-[#336699] rounded-md flex items-center justify-center z-10 pointer-events-none" >
245+ < div className = "text-[#336699] font-semibold text-lg flex items-center" >
246+ < DocumentArrowUpIcon className = "h-8 w-8 mr-2" />
247+ Drop file to process
248+ </ div >
249+ </ div >
250+ ) }
114251 < textarea
115252 id = "case_number"
116253 name = "case_number"
@@ -121,7 +258,9 @@ const SearchPanel: React.FC<SearchPanelProps> = ({ onSearch }) => {
121258 placeholder:text-gray-400 resize-y
122259 focus:outline-2 focus:-outline-offset-2 focus:outline-[#336699]
123260 sm:text-sm/6
124- ${ caseSearch . isPending ? 'bg-gray-100 cursor-not-allowed' : 'bg-white' } ` }
261+ transition-colors duration-200
262+ ${ caseSearch . isPending || fileSearch . isPending ? 'bg-gray-100 cursor-not-allowed' : 'bg-white' } ` }
263+ placeholder = ""
125264 value = { localState . caseNumber }
126265 onChange = { e => {
127266 localDispatch ( {
@@ -152,15 +291,16 @@ const SearchPanel: React.FC<SearchPanelProps> = ({ onSearch }) => {
152291 }
153292 }
154293 } }
155- disabled = { caseSearch . isPending }
294+ onPaste = { handlePaste }
295+ disabled = { caseSearch . isPending || fileSearch . isPending }
156296 maxLength = { 50000 } // limit for text input
157297 />
158298 </ div >
159299 < div className = "mt-3 flex items-center justify-between" >
160300 < div className = "flex items-center" >
161301 < button
162302 type = "submit"
163- disabled = { caseSearch . isPending || ! localState . caseNumber . trim ( ) }
303+ disabled = { caseSearch . isPending || fileSearch . isPending || ! localState . caseNumber . trim ( ) }
164304 className = "inline-flex items-center justify-center rounded-md px-3 py-2 text-sm font-semibold text-white shadow-xs focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#1F7ABC]
165305 disabled:bg-gray-400 disabled:cursor-not-allowed
166306 bg-[#336699] enabled:hover:bg-[#4376a9]"
@@ -196,6 +336,52 @@ const SearchPanel: React.FC<SearchPanelProps> = ({ onSearch }) => {
196336 </ >
197337 ) }
198338 </ button >
339+
340+ < button
341+ type = "button"
342+ onClick = { ( ) => fileInputRef . current ?. click ( ) }
343+ disabled = { caseSearch . isPending || fileSearch . isPending }
344+ className = "ml-3 inline-flex items-center justify-center rounded-md px-3 py-2 text-sm font-semibold text-gray-700 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#336699] disabled:cursor-not-allowed disabled:opacity-50"
345+ >
346+ { fileSearch . isPending ? (
347+ < >
348+ < svg
349+ className = "animate-spin -ml-1 mr-2 h-5 w-5 text-gray-500"
350+ xmlns = "http://www.w3.org/2000/svg"
351+ fill = "none"
352+ viewBox = "0 0 24 24"
353+ >
354+ < circle
355+ className = "opacity-25"
356+ cx = "12"
357+ cy = "12"
358+ r = "10"
359+ stroke = "currentColor"
360+ strokeWidth = "4"
361+ > </ circle >
362+ < path
363+ className = "opacity-75"
364+ fill = "currentColor"
365+ 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"
366+ > </ path >
367+ </ svg >
368+ Processing...
369+ </ >
370+ ) : (
371+ < >
372+ < DocumentArrowUpIcon className = "h-5 w-5 mr-2 text-gray-500" aria-hidden = "true" />
373+ Upload File
374+ </ >
375+ ) }
376+ </ button >
377+ < input
378+ type = "file"
379+ ref = { fileInputRef }
380+ onChange = { handleFileSelect }
381+ className = "hidden"
382+ accept = ".pdf,application/pdf,.txt,text/plain,.csv,text/csv,.xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,.xls,application/vnd.ms-excel,.docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document,.jpg,.jpeg,image/jpeg,.png,image/png"
383+ />
384+
199385 < div className = "ml-3 text-xs text-gray-500" >
200386 < span className = "hidden sm:inline" > or press </ span >
201387 < kbd className = "px-1.5 py-0.5 text-xs font-semibold border border-gray-300 rounded-md bg-gray-50" >
0 commit comments