Enhance file upload functionality with document scanning support and improved error handling
This commit is contained in:
@@ -1,14 +1,15 @@
|
||||
import imageCompression from 'browser-image-compression'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { useUserProfile } from '../queries/UserQueries'
|
||||
import { useNotification } from '../service/NotificationProvider'
|
||||
import { apiClient } from '../utils/ApiClient'
|
||||
import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers'
|
||||
|
||||
export const useFileUpload = ({
|
||||
entityType = 'chore_attachment',
|
||||
entityId,
|
||||
draftId,
|
||||
entityId,
|
||||
entityType = 'chore_attachment',
|
||||
} = {}) => {
|
||||
const { showError } = useNotification()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
@@ -19,28 +20,36 @@ export const useFileUpload = ({
|
||||
showError({
|
||||
title: 'Plus Feature',
|
||||
message:
|
||||
'Image uploads are not available in the Basic plan. Upgrade to Plus to add images to your content.',
|
||||
'File uploads are not available in the Basic plan. Upgrade to Plus to add files to your content.',
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const compressionOptions = {
|
||||
maxSizeMB: entityType === 'profile' ? 0.5 : 1,
|
||||
maxWidthOrHeight: entityType === 'profile' ? 320 : 1200,
|
||||
useWebWorker: true,
|
||||
fileType: 'image/jpeg',
|
||||
// Only images go through compression — anything else (PDFs, docs)
|
||||
// would be destroyed by re-encoding it as a JPEG.
|
||||
let fileToUpload = file
|
||||
if (file.type?.startsWith('image/')) {
|
||||
const compressionOptions = {
|
||||
maxSizeMB: entityType === 'profile' ? 0.5 : 1,
|
||||
maxWidthOrHeight: entityType === 'profile' ? 320 : 1200,
|
||||
useWebWorker: true,
|
||||
fileType: 'image/jpeg',
|
||||
}
|
||||
|
||||
const compressedFile = await imageCompression(
|
||||
file,
|
||||
compressionOptions,
|
||||
)
|
||||
fileToUpload = new File(
|
||||
[compressedFile],
|
||||
`${file.name.split('.')[0]}.jpg`,
|
||||
{ type: 'image/jpeg' },
|
||||
)
|
||||
}
|
||||
|
||||
const compressedFile = await imageCompression(file, compressionOptions)
|
||||
const compressedJpegFile = new File(
|
||||
[compressedFile],
|
||||
`${file.name.split('.')[0]}.jpg`,
|
||||
{ type: 'image/jpeg' },
|
||||
)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', compressedJpegFile)
|
||||
formData.append('file', fileToUpload)
|
||||
formData.append('entityType', entityType)
|
||||
if (entityId) formData.append('entityId', String(entityId))
|
||||
if (draftId) formData.append('draftId', draftId)
|
||||
@@ -62,7 +71,7 @@ export const useFileUpload = ({
|
||||
} else if (response.status === 403 && !isPlusAccount(userProfile)) {
|
||||
showError({
|
||||
title: 'Upgrade Required',
|
||||
message: 'Image uploads are only available for Plus accounts.',
|
||||
message: 'File uploads are only available for Plus accounts.',
|
||||
})
|
||||
return null
|
||||
} else if (response.status === 403) {
|
||||
@@ -74,7 +83,7 @@ export const useFileUpload = ({
|
||||
} else if (!response.ok) {
|
||||
showError({
|
||||
title: 'Upload Failed',
|
||||
message: 'Failed to upload image.',
|
||||
message: 'Failed to upload file.',
|
||||
})
|
||||
return null
|
||||
}
|
||||
@@ -91,7 +100,7 @@ export const useFileUpload = ({
|
||||
} catch {
|
||||
showError({
|
||||
title: 'Upload Failed',
|
||||
message: 'An error occurred while processing the image.',
|
||||
message: 'An error occurred while processing the file.',
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
17
src/utils/FileConvert.js
Normal file
17
src/utils/FileConvert.js
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Turns an image source the scanners produce — a base64 data URI on iOS/web,
|
||||
* a Capacitor localhost URL on Android — into a File the upload endpoint
|
||||
* accepts. Both forms are fetchable, so one path covers them.
|
||||
*/
|
||||
export async function imageSourceToFile(source, fileName = 'scan.jpg') {
|
||||
if (!source) return null
|
||||
try {
|
||||
const response = await fetch(source)
|
||||
const blob = await response.blob()
|
||||
const type = blob.type && blob.type !== '' ? blob.type : 'image/jpeg'
|
||||
return new File([blob], fileName, { type })
|
||||
} catch (e) {
|
||||
console.error('[FileConvert] failed to convert image source:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ArrowDropDown,
|
||||
AttachFile,
|
||||
Delete,
|
||||
DocumentScanner,
|
||||
HorizontalRule,
|
||||
Save,
|
||||
UploadFile,
|
||||
@@ -41,6 +42,7 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import DurationInput from '../../components/common/DurationInput'
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import NotificationTemplate from '../../components/NotificationTemplate.jsx'
|
||||
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
|
||||
import {
|
||||
useArchiveChore,
|
||||
useChore,
|
||||
@@ -59,6 +61,7 @@ import {
|
||||
GetThings,
|
||||
UploadChoreAttachment,
|
||||
} from '../../utils/Fetcher'
|
||||
import { imageSourceToFile } from '../../utils/FileConvert'
|
||||
import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers'
|
||||
import { getImageSrc, removeCachedImage } from '../../utils/ImageCache'
|
||||
import Priorities from '../../utils/Priorities.jsx'
|
||||
@@ -173,6 +176,7 @@ const ChoreEdit = () => {
|
||||
const { data: membersData, isLoading: isMemberDataLoading } =
|
||||
useCircleMembers()
|
||||
const { showError, showSuccess } = useNotification()
|
||||
const { isNativeScanner, scanDocument } = useDocumentScanner()
|
||||
|
||||
const [userLabels, setUserLabels] = useState([])
|
||||
|
||||
@@ -671,6 +675,67 @@ const ChoreEdit = () => {
|
||||
}
|
||||
}, [assignableTo, name, frequencyMetadata, attemptToSave, dueDate])
|
||||
|
||||
const uploadAttachmentFile = async file => {
|
||||
if (!file) return
|
||||
setIsUploadingAttachment(true)
|
||||
try {
|
||||
const response = choreId
|
||||
? await UploadChoreAttachment(file, 'chore_attachment', {
|
||||
entityId: choreId,
|
||||
})
|
||||
: await UploadChoreAttachment(file, 'chore_attachment_draft', {
|
||||
draftId,
|
||||
})
|
||||
if (!response.ok) {
|
||||
showError({
|
||||
title: 'Upload Failed',
|
||||
message: 'Failed to upload attachment.',
|
||||
})
|
||||
return
|
||||
}
|
||||
const data = await response.json()
|
||||
setAttachments(prev => [
|
||||
...prev,
|
||||
{
|
||||
file_path: data.path,
|
||||
file_name: data.file_name,
|
||||
size_bytes: data.size_bytes,
|
||||
sign: data.sign,
|
||||
},
|
||||
])
|
||||
} catch {
|
||||
showError({
|
||||
title: 'Upload Failed',
|
||||
message: 'Failed to upload attachment.',
|
||||
})
|
||||
} finally {
|
||||
setIsUploadingAttachment(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Native only: the OS scanner returns a cropped, deskewed page which is a
|
||||
// better attachment than a raw camera shot of the same document.
|
||||
const handleScanAttachment = async () => {
|
||||
const { cancelled, error, image } = await scanDocument()
|
||||
if (cancelled) return
|
||||
if (error || !image) {
|
||||
showError({
|
||||
title: 'Scan Failed',
|
||||
message: error || 'Could not scan the document.',
|
||||
})
|
||||
return
|
||||
}
|
||||
const file = await imageSourceToFile(image, `scan-${Date.now()}.jpg`)
|
||||
if (!file) {
|
||||
showError({
|
||||
title: 'Scan Failed',
|
||||
message: 'Could not read the scanned image.',
|
||||
})
|
||||
return
|
||||
}
|
||||
await uploadAttachmentFile(file)
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
@@ -1109,62 +1174,39 @@ const ChoreEdit = () => {
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
<Button
|
||||
component='label'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
startDecorator={isUploadingAttachment ? null : <UploadFile />}
|
||||
loading={isUploadingAttachment}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
Upload File
|
||||
<input
|
||||
type='file'
|
||||
hidden
|
||||
onChange={async e => {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
setIsUploadingAttachment(true)
|
||||
try {
|
||||
const response = choreId
|
||||
? await UploadChoreAttachment(file, 'chore_attachment', {
|
||||
entityId: choreId,
|
||||
})
|
||||
: await UploadChoreAttachment(
|
||||
file,
|
||||
'chore_attachment_draft',
|
||||
{ draftId },
|
||||
)
|
||||
if (!response.ok) {
|
||||
showError({
|
||||
title: 'Upload Failed',
|
||||
message: 'Failed to upload attachment.',
|
||||
})
|
||||
return
|
||||
}
|
||||
const data = await response.json()
|
||||
setAttachments(prev => [
|
||||
...prev,
|
||||
{
|
||||
file_path: data.path,
|
||||
file_name: data.file_name,
|
||||
size_bytes: data.size_bytes,
|
||||
sign: data.sign,
|
||||
},
|
||||
])
|
||||
} catch {
|
||||
showError({
|
||||
title: 'Upload Failed',
|
||||
message: 'Failed to upload attachment.',
|
||||
})
|
||||
} finally {
|
||||
setIsUploadingAttachment(false)
|
||||
<Box sx={{ display: 'flex', gap: 1, alignSelf: 'flex-start' }}>
|
||||
<Button
|
||||
component='label'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
startDecorator={isUploadingAttachment ? null : <UploadFile />}
|
||||
loading={isUploadingAttachment}
|
||||
>
|
||||
Upload File
|
||||
<input
|
||||
type='file'
|
||||
hidden
|
||||
onChange={async e => {
|
||||
const file = e.target.files[0]
|
||||
e.target.value = ''
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
await uploadAttachmentFile(file)
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
{isNativeScanner && (
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
startDecorator={<DocumentScanner />}
|
||||
disabled={isUploadingAttachment}
|
||||
onClick={handleScanAttachment}
|
||||
>
|
||||
Scan
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
import { Add } from '@mui/icons-material'
|
||||
import { Box, Button, Typography } from '@mui/joy'
|
||||
import { useMediaQuery } from '@mui/material'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import * as chrono from 'chrono-node'
|
||||
import moment from 'moment'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
|
||||
import { useFileUpload } from '../../hooks/useFileUpload'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
import { useCreateChore } from '../../queries/ChoreQueries'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||
import { localAIService } from '../../service/LocalAIService'
|
||||
import { voiceInputService } from '../../service/VoiceInputService'
|
||||
import LABEL_COLORS, { TASK_COLOR } from '../../utils/Colors'
|
||||
import { CreateLabel } from '../../utils/Fetcher'
|
||||
import { imageSourceToFile } from '../../utils/FileConvert'
|
||||
import { isPlusAccount } from '../../utils/Helpers'
|
||||
import { generateUUID } from '../../utils/UUID'
|
||||
import { useLabels } from '../Labels/LabelQueries'
|
||||
import { useProjects } from '../Projects/ProjectQueries'
|
||||
import AdvancedOptionsSection, {
|
||||
AdvancedOptionsTrigger,
|
||||
} from './AdvancedOptionsSection'
|
||||
import AssigneePickerField from './AssigneePickerField'
|
||||
import AttachmentPickerField from './AttachmentPickerField'
|
||||
import {
|
||||
parseAssignees,
|
||||
parseDueDate,
|
||||
@@ -21,19 +35,6 @@ import {
|
||||
parsePriority,
|
||||
parseRepeatV2,
|
||||
} from './CustomParsers'
|
||||
import SmartTaskTitleInput from './SmartTaskTitleInput'
|
||||
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
|
||||
import { localAIService } from '../../service/LocalAIService'
|
||||
import { voiceInputService } from '../../service/VoiceInputService'
|
||||
import LABEL_COLORS, { TASK_COLOR } from '../../utils/Colors'
|
||||
import AdvancedOptionsSection, {
|
||||
AdvancedOptionsTrigger,
|
||||
} from './AdvancedOptionsSection'
|
||||
import AssigneePickerField from './AssigneePickerField'
|
||||
import AttachmentPickerField from './AttachmentPickerField'
|
||||
import DueDatePickerField from './DueDatePickerField'
|
||||
import LabelsPickerField from './LabelsPickerField'
|
||||
import LearnMoreButton from './LearnMore'
|
||||
@@ -42,6 +43,7 @@ import PriorityPickerField from './PriorityPickerField'
|
||||
import RepeatPickerField from './RepeatPickerField'
|
||||
import RichTextEditor from './RichTextEditor'
|
||||
import ScanPanel from './ScanToTask/ScanPanel'
|
||||
import SmartTaskTitleInput from './SmartTaskTitleInput'
|
||||
import SubTasks from './SubTask'
|
||||
import { buildChorePayload, parseVoiceTask } from './VoiceToTask/parseVoiceTask'
|
||||
import VoicePanel from './VoiceToTask/VoicePanel'
|
||||
@@ -106,7 +108,7 @@ const getDefaultNotification = () => {
|
||||
return DEFAULT_NOTIFICATION_TEMPLATES
|
||||
}
|
||||
|
||||
const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const isMobile = useMediaQuery(theme => theme.breakpoints.down('sm'))
|
||||
const pickerEmptyDisplay = isMobile ? 'icon' : 'icon-text'
|
||||
@@ -190,6 +192,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
const [showScan, setShowScan] = useState(false)
|
||||
const [scanAutoCapture, setScanAutoCapture] = useState(false)
|
||||
const [pendingPhotoUrl, setPendingPhotoUrl] = useState(null)
|
||||
const [isAttachingScan, setIsAttachingScan] = useState(false)
|
||||
const [llmAvailable, setLlmAvailable] = useState(false)
|
||||
const [showVoice, setShowVoice] = useState(false)
|
||||
const [voiceAvailable, setVoiceAvailable] = useState(false)
|
||||
@@ -213,6 +216,10 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
primaryAction: null,
|
||||
})
|
||||
const { isNativeScanner } = useDocumentScanner()
|
||||
const { uploadFile } = useFileUpload({
|
||||
entityType: 'chore_attachment_draft',
|
||||
draftId,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
localAIService.isAvailable().then(setLlmAvailable)
|
||||
@@ -274,11 +281,11 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = event => {
|
||||
const {
|
||||
isModalOpen,
|
||||
hasDescription,
|
||||
dueDate,
|
||||
createChore,
|
||||
dueDate,
|
||||
handleCloseModal,
|
||||
hasDescription,
|
||||
isModalOpen,
|
||||
} = latestRef.current
|
||||
const isHoldingCmd = event.ctrlKey || event.metaKey
|
||||
if (isHoldingCmd) {
|
||||
@@ -709,11 +716,38 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
createChore()
|
||||
}
|
||||
|
||||
// The scan keeps its source image when asked: upload it against the draft so
|
||||
// the server promotes it onto the chore the same way manual uploads are.
|
||||
const attachScannedImage = async imageSource => {
|
||||
// Creating the chore promotes whatever draft attachments exist at that
|
||||
// moment, so Create waits on this upload rather than orphaning it.
|
||||
setIsAttachingScan(true)
|
||||
try {
|
||||
const file = await imageSourceToFile(
|
||||
imageSource,
|
||||
`scan-${Date.now()}.jpg`,
|
||||
)
|
||||
if (!file) return
|
||||
const uploaded = await uploadFile(file)
|
||||
if (!uploaded) return
|
||||
setAttachments(prev => [
|
||||
...prev,
|
||||
{ url: uploaded.url, path: uploaded.path, name: uploaded.fileName },
|
||||
])
|
||||
} finally {
|
||||
setIsAttachingScan(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTaskExtracted = ({
|
||||
taskName,
|
||||
attachmentImage,
|
||||
description: extractedDesc,
|
||||
dueDate: extractedDue,
|
||||
taskName,
|
||||
}) => {
|
||||
if (attachmentImage) {
|
||||
attachScannedImage(attachmentImage)
|
||||
}
|
||||
if (taskName) {
|
||||
processText(taskName)
|
||||
}
|
||||
@@ -801,6 +835,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
setVoiceState({ segments: [], isListening: false })
|
||||
setScanState({ phase: 'idle', primaryAction: null })
|
||||
setCreatingVoiceTasks(false)
|
||||
setIsAttachingScan(false)
|
||||
setTaskText('')
|
||||
setTaskTitle('')
|
||||
setDueDate(null)
|
||||
@@ -829,6 +864,9 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
}
|
||||
|
||||
const createChore = () => {
|
||||
// A scanned attachment still uploading would be orphaned by the create
|
||||
if (isAttachingScan) return
|
||||
|
||||
// Handle different assignee scenarios
|
||||
let finalAssignees = assignees
|
||||
let finalAssignedTo = null
|
||||
@@ -988,7 +1026,8 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
disabled={!taskTitle.trim()}
|
||||
loading={isAttachingScan}
|
||||
disabled={!taskTitle.trim() || isAttachingScan}
|
||||
onClick={createChore}
|
||||
>
|
||||
Create
|
||||
@@ -1359,6 +1398,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
<ScanPanel
|
||||
open
|
||||
autoCapture={scanAutoCapture}
|
||||
canKeepImage={isPlusAccount(userProfile)}
|
||||
onTaskExtracted={handleTaskExtracted}
|
||||
initialImageUrl={pendingPhotoUrl}
|
||||
onStateChange={setScanState}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { AttachFile, Close, DeleteOutline, Image } from '@mui/icons-material'
|
||||
import {
|
||||
AttachFile,
|
||||
Close,
|
||||
DeleteOutline,
|
||||
DocumentScanner,
|
||||
Image,
|
||||
InsertDriveFile,
|
||||
PhotoCamera,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -9,23 +17,41 @@ import {
|
||||
} from '@mui/joy'
|
||||
import { ClickAwayListener, Popper } from '@mui/material'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
|
||||
import { useFileUpload } from '../../hooks/useFileUpload'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { DeleteDraftAttachment } from '../../utils/Fetcher'
|
||||
import { imageSourceToFile } from '../../utils/FileConvert'
|
||||
|
||||
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg']
|
||||
|
||||
const isImageAttachment = attachment => {
|
||||
const ext = (attachment?.name || '').split('.').pop()?.toLowerCase()
|
||||
return IMAGE_EXTENSIONS.includes(ext)
|
||||
}
|
||||
|
||||
const AttachmentPickerField = ({
|
||||
attachments = [],
|
||||
draftId,
|
||||
emptyDisplay = 'icon-text',
|
||||
entityId,
|
||||
entityType = 'chore_attachment',
|
||||
onChange,
|
||||
onClear,
|
||||
emptyDisplay = 'icon-text',
|
||||
entityType = 'chore_attachment',
|
||||
entityId,
|
||||
draftId,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const buttonRef = useRef(null)
|
||||
const { uploadFile } = useFileUpload({ entityType, entityId, draftId })
|
||||
const { isNativeScanner, scanDocument } = useDocumentScanner()
|
||||
const { showError } = useNotification()
|
||||
|
||||
// Without a native scanner, `capture` asks a phone for its camera directly.
|
||||
// Desktop browsers ignore it and fall back to the file picker, which would
|
||||
// duplicate "Image", so the button only appears on touch devices.
|
||||
const canTakePhoto = isNativeScanner || navigator.maxTouchPoints > 0
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
@@ -36,27 +62,60 @@ const AttachmentPickerField = ({
|
||||
return () => document.removeEventListener('keydown', handleEscape)
|
||||
}, [isOpen])
|
||||
|
||||
const handleAddFile = () => {
|
||||
const upload = async file => {
|
||||
setIsUploading(true)
|
||||
try {
|
||||
const uploaded = await uploadFile(file)
|
||||
if (uploaded) {
|
||||
onChange([
|
||||
...attachments,
|
||||
{ url: uploaded.url, path: uploaded.path, name: uploaded.fileName },
|
||||
])
|
||||
}
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePickFile = ({ accept, capture } = {}) => {
|
||||
const input = document.createElement('input')
|
||||
input.setAttribute('type', 'file')
|
||||
input.setAttribute('accept', 'image/*')
|
||||
input.click()
|
||||
input.onchange = async () => {
|
||||
if (accept) input.setAttribute('accept', accept)
|
||||
if (capture) input.setAttribute('capture', capture)
|
||||
input.onchange = () => {
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
setIsUploading(true)
|
||||
try {
|
||||
const uploaded = await uploadFile(file)
|
||||
if (uploaded) {
|
||||
onChange([
|
||||
...attachments,
|
||||
{ url: uploaded.url, path: uploaded.path, name: uploaded.fileName },
|
||||
])
|
||||
}
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
if (file) upload(file)
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
|
||||
// Native builds get the OS document scanner (edge detection + perspective
|
||||
// correction); everywhere else "take photo" is the camera roll shortcut.
|
||||
const handleScan = async () => {
|
||||
if (!isNativeScanner) {
|
||||
handlePickFile({ accept: 'image/*', capture: 'environment' })
|
||||
return
|
||||
}
|
||||
const { cancelled, error, image } = await scanDocument()
|
||||
if (cancelled) return
|
||||
if (error || !image) {
|
||||
showError({
|
||||
title: 'Scan Failed',
|
||||
message: error || 'Could not scan the document.',
|
||||
})
|
||||
return
|
||||
}
|
||||
setIsUploading(true)
|
||||
const file = await imageSourceToFile(image, `scan-${Date.now()}.jpg`)
|
||||
setIsUploading(false)
|
||||
if (!file) {
|
||||
showError({
|
||||
title: 'Scan Failed',
|
||||
message: 'Could not read the scanned image.',
|
||||
})
|
||||
return
|
||||
}
|
||||
await upload(file)
|
||||
}
|
||||
|
||||
const handleRemove = async index => {
|
||||
@@ -199,26 +258,30 @@ const AttachmentPickerField = ({
|
||||
'&:hover': { bgcolor: 'background.level1' },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component='img'
|
||||
src={attachment.url}
|
||||
alt={attachment.name}
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 'sm',
|
||||
flexShrink: 0,
|
||||
bgcolor: 'background.level2',
|
||||
}}
|
||||
onError={e => {
|
||||
e.target.style.display = 'none'
|
||||
e.target.nextSibling.style.display = 'flex'
|
||||
}}
|
||||
/>
|
||||
{isImageAttachment(attachment) && (
|
||||
<Box
|
||||
component='img'
|
||||
src={attachment.url}
|
||||
alt={attachment.name}
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 'sm',
|
||||
flexShrink: 0,
|
||||
bgcolor: 'background.level2',
|
||||
}}
|
||||
onError={e => {
|
||||
e.target.style.display = 'none'
|
||||
e.target.nextSibling.style.display = 'flex'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'none',
|
||||
display: isImageAttachment(attachment)
|
||||
? 'none'
|
||||
: 'flex',
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
@@ -228,7 +291,15 @@ const AttachmentPickerField = ({
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Image sx={{ fontSize: 20, color: 'text.tertiary' }} />
|
||||
{isImageAttachment(attachment) ? (
|
||||
<Image
|
||||
sx={{ fontSize: 20, color: 'text.tertiary' }}
|
||||
/>
|
||||
) : (
|
||||
<InsertDriveFile
|
||||
sx={{ fontSize: 20, color: 'text.tertiary' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
@@ -255,26 +326,64 @@ const AttachmentPickerField = ({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={
|
||||
isUploading ? (
|
||||
{isUploading ? (
|
||||
<Button
|
||||
fullWidth
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
disabled
|
||||
startDecorator={
|
||||
<CircularProgress
|
||||
size='sm'
|
||||
sx={{ '--CircularProgress-size': '14px' }}
|
||||
/>
|
||||
) : (
|
||||
<AttachFile sx={{ fontSize: 16 }} />
|
||||
)
|
||||
}
|
||||
onClick={handleAddFile}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? 'Uploading…' : 'Add image'}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
Uploading…
|
||||
</Button>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
{canTakePhoto && (
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
sx={{ flex: 1 }}
|
||||
startDecorator={
|
||||
isNativeScanner ? (
|
||||
<DocumentScanner sx={{ fontSize: 16 }} />
|
||||
) : (
|
||||
<PhotoCamera sx={{ fontSize: 16 }} />
|
||||
)
|
||||
}
|
||||
onClick={handleScan}
|
||||
>
|
||||
{isNativeScanner ? 'Scan' : 'Photo'}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
sx={{ flex: 1 }}
|
||||
startDecorator={<Image sx={{ fontSize: 16 }} />}
|
||||
onClick={() => handlePickFile({ accept: 'image/*' })}
|
||||
>
|
||||
Image
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
sx={{ flex: 1 }}
|
||||
startDecorator={<AttachFile sx={{ fontSize: 16 }} />}
|
||||
onClick={() => handlePickFile()}
|
||||
>
|
||||
File
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Sheet>
|
||||
</ClickAwayListener>
|
||||
</Popper>
|
||||
|
||||
@@ -8,11 +8,13 @@ import {
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
CircularProgress,
|
||||
LinearProgress,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useScanToTask } from './useScanToTask'
|
||||
|
||||
/**
|
||||
@@ -27,34 +29,39 @@ import { useScanToTask } from './useScanToTask'
|
||||
* belongs to the capture surface and drives a hidden input in this subtree.
|
||||
*/
|
||||
const ScanPanel = ({
|
||||
open,
|
||||
onTaskExtracted,
|
||||
autoCapture,
|
||||
canKeepImage = false,
|
||||
initialImageUrl,
|
||||
onClose,
|
||||
onStateChange,
|
||||
initialImageUrl,
|
||||
autoCapture,
|
||||
onTaskExtracted,
|
||||
open,
|
||||
}) => {
|
||||
const {
|
||||
isNativeScanner,
|
||||
phase,
|
||||
capturedImage,
|
||||
ocrProgress,
|
||||
taskResult,
|
||||
errorMsg,
|
||||
activate,
|
||||
cameraAvailable,
|
||||
videoRef,
|
||||
canvasRef,
|
||||
fileInputRef,
|
||||
startCamera,
|
||||
stopCamera,
|
||||
capture,
|
||||
capturedImage,
|
||||
errorMsg,
|
||||
fileInputRef,
|
||||
handleFileSelect,
|
||||
handleNativeScan,
|
||||
retake,
|
||||
activate,
|
||||
isNativeScanner,
|
||||
ocrProgress,
|
||||
phase,
|
||||
reset,
|
||||
retake,
|
||||
startCamera,
|
||||
stopCamera,
|
||||
taskResult,
|
||||
videoRef,
|
||||
} = useScanToTask()
|
||||
|
||||
// The scanned page is usually the task's source of truth (the bill, the
|
||||
// notice), so keeping it is the default — the OCR text alone loses it.
|
||||
const [keepImage, setKeepImage] = useState(false)
|
||||
|
||||
// Start/stop based on open state
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -82,7 +89,10 @@ const ScanPanel = ({
|
||||
// Auto-close and populate when done
|
||||
useEffect(() => {
|
||||
if (phase === 'done' && taskResult) {
|
||||
onTaskExtracted(taskResult)
|
||||
onTaskExtracted({
|
||||
...taskResult,
|
||||
attachmentImage: canKeepImage && keepImage ? capturedImage : null,
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -137,6 +147,18 @@ const ScanPanel = ({
|
||||
|
||||
const isProcessing = phase === 'processing'
|
||||
|
||||
// Attachments are a Plus feature; without it the upload would only ever
|
||||
// surface an upgrade error, so the choice isn't offered at all.
|
||||
const keepImageToggle = !canKeepImage ? null : (
|
||||
<Checkbox
|
||||
size='sm'
|
||||
checked={keepImage}
|
||||
onChange={e => setKeepImage(e.target.checked)}
|
||||
label='Keep photo as attachment'
|
||||
sx={{ '--Checkbox-size': '18px' }}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* ── Capture phase ── */}
|
||||
@@ -201,16 +223,18 @@ const ScanPanel = ({
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Hidden when Upload is already the footer's primary action */}
|
||||
{(isNativeScanner || cameraAvailable) && (
|
||||
<Box
|
||||
sx={{
|
||||
py: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
py: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{/* Hidden when Upload is already the footer's primary action */}
|
||||
{(isNativeScanner || cameraAvailable) && (
|
||||
<Button
|
||||
size='sm'
|
||||
variant='plain'
|
||||
@@ -220,8 +244,9 @@ const ScanPanel = ({
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
)}
|
||||
{keepImageToggle}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -285,6 +310,9 @@ const ScanPanel = ({
|
||||
sx={{ width: '100%' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Still editable here — the choice is only read once the task lands */}
|
||||
{keepImageToggle}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user