diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6baa001..2247bc1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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: diff --git a/.github/workflows/manual-deploy.yml b/.github/workflows/manual-deploy.yml index bd218bb..7c53a4b 100644 --- a/.github/workflows/manual-deploy.yml +++ b/.github/workflows/manual-deploy.yml @@ -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: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3307b97..7584377 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -6309,27 +6309,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/@vitest/coverage-v8/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@vitest/coverage-v8/node_modules/istanbul-lib-source-maps": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", @@ -7856,6 +7835,27 @@ "node": ">= 0.4" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -7869,6 +7869,32 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/globals": { "version": "15.15.0", "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9770072..dd366d8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/components/app/SearchPanel.tsx b/frontend/src/components/app/SearchPanel.tsx index e13672b..ca7a2ee 100644 --- a/frontend/src/components/app/SearchPanel.tsx +++ b/frontend/src/components/app/SearchPanel.tsx @@ -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; @@ -41,6 +41,127 @@ interface SearchPanelProps { const SearchPanel: React.FC = ({ onSearch }) => { const [localState, localDispatch] = useReducer(reducer, initialState); const caseSearch = useCaseSearch(); + const fileSearch = useFileSearch(); + const fileInputRef = useRef(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) => { + const file = e.clipboardData?.files?.[0]; + + if (!file) return; + + e.preventDefault(); + processSelectedFile(file); + }; + + const handleFileSelect = (e: React.ChangeEvent) => { + 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) => { e.preventDefault(); @@ -109,8 +230,24 @@ const SearchPanel: React.FC = ({ onSearch }) => { Standard (25CR123456-789) and LexisNexis (7892025CR 123456) case numbers are supported.

-
+
Tip: drop or paste a document here to search for case numbers.
+
+ {isDragging && ( +
+
+ + Drop file to process +
+
+ )}