Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: us-east-2
DOMAIN: ${{ vars.DOMAIN }}

jobs:
determine-environment:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/manual-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: us-east-2
DOMAIN: ${{ vars.DOMAIN }}

jobs:
verify-ssm-parameters:
Expand Down
68 changes: 47 additions & 21 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
"test:coverage": "vitest run --coverage --config ./config/vitest.config.ts",
"deploy:dev": "npm run build && aws s3 sync dist/ s3://zipcase-frontend-dev && aws cloudfront create-invalidation --distribution-id $(terraform -chdir=../infra/terraform/dev output -raw cloudfront_distribution_id) --paths \"/*\""
},
"overrides": {
"glob": "^10.5.0"
},
"dependencies": {
"@aws-amplify/ui-react": "^6.9.5",
"@headlessui/react": "^2.2.0",
Expand Down
200 changes: 193 additions & 7 deletions frontend/src/components/app/SearchPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useReducer } from 'react';
import { MagnifyingGlassIcon } from '@heroicons/react/24/solid';
import { useCaseSearch } from '../../hooks';
import React, { useReducer, useRef } from 'react';
import { MagnifyingGlassIcon, DocumentArrowUpIcon } from '@heroicons/react/24/solid';
import { useCaseSearch, useFileSearch } from '../../hooks';

interface State {
caseNumber: string;
Expand Down Expand Up @@ -41,6 +41,127 @@ interface SearchPanelProps {
const SearchPanel: React.FC<SearchPanelProps> = ({ onSearch }) => {
const [localState, localDispatch] = useReducer(reducer, initialState);
const caseSearch = useCaseSearch();
const fileSearch = useFileSearch();
const fileInputRef = useRef<HTMLInputElement>(null);

const [isDragging, setIsDragging] = React.useState(false);
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB

const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};

const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.currentTarget === e.target) {
setIsDragging(false);
}
};

const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Required to allow dropping
};

const processSelectedFile = (file: File) => {
if (!isSupportedFileType(file)) {
localDispatch({
type: 'SET_ERROR',
payload: 'Unsupported file type. Please upload a PDF, DOCX, TXT, CSV, XLSX, JPG, or PNG.',
});
return;
}
if (file.size > MAX_FILE_SIZE) {
localDispatch({
type: 'SET_ERROR',
payload: `File size exceeds 10MB limit (File size: ${(file.size / 1024 / 1024).toFixed(2)}MB)`,
});
return;
}

// Reset error and start processing
localDispatch({ type: 'SET_ERROR', payload: null });
localDispatch({ type: 'SET_FEEDBACK', payload: { message: 'Processing file...', type: null } });

fileSearch.mutate(file, {
onSuccess: data => {
localDispatch({ type: 'SET_ERROR', payload: null });
const caseCount = Object.keys(data?.results || {}).length;

if (caseCount === 0) {
localDispatch({
type: 'SET_FEEDBACK',
payload: { message: 'No case numbers found in file', type: 'error' },
});
} else {
localDispatch({ type: 'SET_CASE_NUMBER', payload: '' });
const message = caseCount === 1 ? 'Found 1 case number' : `Found ${caseCount} case numbers`;
localDispatch({ type: 'SET_FEEDBACK', payload: { message, type: 'success' } });
}
},
onError: (error: Error) => {
console.error('File search error:', error);
localDispatch({ type: 'SET_ERROR', payload: error.message });
localDispatch({ type: 'SET_FEEDBACK', payload: { message: null, type: null } });
},
});
};

const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);

const file = e.dataTransfer.files?.[0];
if (!file) return;

processSelectedFile(file);
};

const isSupportedFileType = (file: File) => {
const extension = file.name.split('.').pop()?.toLowerCase();
const allowedExtensions = new Set(['pdf', 'txt', 'csv', 'xlsx', 'xls', 'docx', 'jpg', 'jpeg', 'png']);

if (extension && allowedExtensions.has(extension)) {
return true;
}

const allowedMimeTypes = new Set([
'application/pdf',
'text/plain',
'text/csv',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'image/jpeg',
'image/png',
]);

return allowedMimeTypes.has(file.type);
};

const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const file = e.clipboardData?.files?.[0];

if (!file) return;

e.preventDefault();
processSelectedFile(file);
};

const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;

processSelectedFile(file);

// Reset input value to allow selecting the same file again
e.target.value = '';
};

const submitSearch = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
Expand Down Expand Up @@ -109,8 +230,24 @@ const SearchPanel: React.FC<SearchPanelProps> = ({ onSearch }) => {
Standard (25CR123456-789) and LexisNexis (7892025CR 123456) case numbers are supported.
</p>
</div>
<form className="mt-5 sm:flex sm:flex-col" onSubmit={submitSearch}>
<div className="mt-2 text-xs text-gray-500">Tip: drop or paste a document here to search for case numbers.</div>
<form
className="mt-5 sm:flex sm:flex-col"
onSubmit={submitSearch}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<div className="w-full relative">
{isDragging && (
<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">
<div className="text-[#336699] font-semibold text-lg flex items-center">
<DocumentArrowUpIcon className="h-8 w-8 mr-2" />
Drop file to process
</div>
</div>
)}
<textarea
id="case_number"
name="case_number"
Expand All @@ -121,7 +258,9 @@ const SearchPanel: React.FC<SearchPanelProps> = ({ onSearch }) => {
placeholder:text-gray-400 resize-y
focus:outline-2 focus:-outline-offset-2 focus:outline-[#336699]
sm:text-sm/6
${caseSearch.isPending ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'}`}
transition-colors duration-200
${caseSearch.isPending || fileSearch.isPending ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'}`}
placeholder=""
value={localState.caseNumber}
onChange={e => {
localDispatch({
Expand Down Expand Up @@ -152,15 +291,16 @@ const SearchPanel: React.FC<SearchPanelProps> = ({ onSearch }) => {
}
}
}}
disabled={caseSearch.isPending}
onPaste={handlePaste}
disabled={caseSearch.isPending || fileSearch.isPending}
maxLength={50000} // limit for text input
/>
</div>
<div className="mt-3 flex items-center justify-between">
<div className="flex items-center">
<button
type="submit"
disabled={caseSearch.isPending || !localState.caseNumber.trim()}
disabled={caseSearch.isPending || fileSearch.isPending || !localState.caseNumber.trim()}
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]
disabled:bg-gray-400 disabled:cursor-not-allowed
bg-[#336699] enabled:hover:bg-[#4376a9]"
Expand Down Expand Up @@ -196,6 +336,52 @@ const SearchPanel: React.FC<SearchPanelProps> = ({ onSearch }) => {
</>
)}
</button>

<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={caseSearch.isPending || fileSearch.isPending}
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"
>
{fileSearch.isPending ? (
<>
<svg
className="animate-spin -ml-1 mr-2 h-5 w-5 text-gray-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
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"
></path>
</svg>
Processing...
</>
) : (
<>
<DocumentArrowUpIcon className="h-5 w-5 mr-2 text-gray-500" aria-hidden="true" />
Upload File
</>
)}
</button>
<input
type="file"
ref={fileInputRef}
onChange={handleFileSelect}
className="hidden"
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"
/>

<div className="ml-3 text-xs text-gray-500">
<span className="hidden sm:inline">or press </span>
<kbd className="px-1.5 py-0.5 text-xs font-semibold border border-gray-300 rounded-md bg-gray-50">
Expand Down
Loading