Skip to content

Commit b35256e

Browse files
committed
#91 add frontend code for file upload
1 parent ffbe356 commit b35256e

5 files changed

Lines changed: 574 additions & 10 deletions

File tree

frontend/src/components/app/SearchPanel.tsx

Lines changed: 193 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
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

55
interface State {
66
caseNumber: string;
@@ -41,6 +41,127 @@ interface SearchPanelProps {
4141
const 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

Comments
 (0)