feat: enhance file upload and attachment management with draft support

This commit is contained in:
Mo Tarbin
2026-07-06 18:10:43 -04:00
parent 4460d80b72
commit 9136ff3ea3
10 changed files with 352 additions and 39 deletions

View File

@@ -105,6 +105,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
const [projectId, setProjectId] = useState(getInitialProject())
const [attachments, setAttachments] = useState([])
const [draftId, setDraftId] = useState(() => crypto.randomUUID())
const [showScan, setShowScan] = useState(false)
const [pendingPhotoUrl, setPendingPhotoUrl] = useState(null)
const { isNativeScanner } = useDocumentScanner()
@@ -586,6 +587,8 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
setDueDateOnly(null)
setDueTime(null)
setUseCustomTime(false)
setAttachments([])
setDraftId(crypto.randomUUID())
}
const createChore = () => {
@@ -632,7 +635,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
notificationMetadata: {},
subTasks: subTasks?.length > 0 ? subTasks : null,
projectId: projectId === 'default' ? null : projectId,
attachments: attachments.length > 0 ? attachments : null,
draftId: draftId,
}
if (frequency) {
@@ -909,7 +912,8 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
onChange={setAttachments}
onClear={() => setAttachments([])}
emptyDisplay={pickerEmptyDisplay}
entityType='chore_attachment'
entityType='chore_attachment_draft'
draftId={draftId}
/>
<NotificationPickerField
value={notificationMetadata}

View File

@@ -19,11 +19,12 @@ const AttachmentPickerField = ({
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 })
const { uploadFile } = useFileUpload({ entityType, entityId, draftId })
useEffect(() => {
if (!isOpen) return

View File

@@ -2,6 +2,33 @@ import imageCompression from 'browser-image-compression'
import Quill from 'quill'
import 'quill/dist/quill.snow.css'
import QuillMarkdown from 'quilljs-markdown'
// Extend the built-in Image blot to preserve dt-data-path
const ImageBlot = Quill.import('formats/image')
class DtImageBlot extends ImageBlot {
static create(value) {
const node = super.create(typeof value === 'string' ? value : value.src)
if (value?.path) node.setAttribute('dt-data-path', value.path)
return node
}
static value(node) {
return { src: node.getAttribute('src'), path: node.getAttribute('dt-data-path') }
}
static formats(node) {
return { 'dt-data-path': node.getAttribute('dt-data-path') }
}
format(name, value) {
if (name === 'dt-data-path') {
if (value) this.domNode.setAttribute('dt-data-path', value)
else this.domNode.removeAttribute('dt-data-path')
} else {
super.format(name, value)
}
}
}
DtImageBlot.blotName = 'image'
DtImageBlot.tagName = 'img'
Quill.register(DtImageBlot, true)
import {
forwardRef,
useCallback,
@@ -12,7 +39,11 @@ import {
import { useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { apiClient } from '../../utils/ApiClient'
import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers'
import {
isPlusAccount,
refreshSignedUrlsInHtml,
resolvePhotoURL,
} from '../../utils/Helpers'
import './RichTextEditor.css'
const RichTextEditor = forwardRef(
@@ -141,11 +172,16 @@ const RichTextEditor = forwardRef(
return
}
const data = await response.json()
const url = resolvePhotoURL(data.url || data.sign)
// Insert image into Quill
// Prefer the backend-proxied path (data.sign) over the direct cloud
// signed URL (data.url) — the proxy re-signs on every request so the
// embedded src never expires.
const path = data.path
const url = resolvePhotoURL(data.sign || data.url)
// Insert image into Quill with dt-data-path tracked by the custom blot
const quill = editorRef.current
const range = quill.getSelection()
quill.insertEmbed(range ? range.index : 0, 'image', url)
const insertIndex = range ? range.index : 0
quill.insertEmbed(insertIndex, 'image', { src: url, path })
} catch (error) {
console.error('Error during image processing or upload:', error)
showError({
@@ -227,7 +263,7 @@ const RichTextEditor = forwardRef(
boxShadow:
'var(--joy-shadow-xs, 0px 1px 2px 0px rgba(16, 24, 40, 0.05))',
}}
dangerouslySetInnerHTML={{ __html: value }}
dangerouslySetInnerHTML={{ __html: refreshSignedUrlsInHtml(value) }}
/>
)
}

View File

@@ -21,7 +21,7 @@ import { useScanToTask } from './useScanToTask'
* Flow: capture → (auto) processing → done [calls onTaskExtracted + onClose]
* → error [retake or cancel]
*/
const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl }) => {
const ScanPanel = ({ open, onTaskExtracted, onClose }) => {
const {
isNativeScanner,
phase,
@@ -36,8 +36,6 @@ const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl }) => {
startCamera,
stopCamera,
capture,
processImage,
setCapturedImage,
handleFileSelect,
handleNativeScan,
retake,
@@ -48,23 +46,14 @@ const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl }) => {
// Start/stop based on open state
useEffect(() => {
if (open) {
if (initialImageUrl) {
setCapturedImage(initialImageUrl)
processImage(initialImageUrl, 'browser')
} else if (isNativeScanner) {
handleNativeScan().then(result => {
if (result?.cancelled) onClose()
})
} else {
activate()
}
activate()
} else {
reset()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
// Start/stop web camera based on capture phase
// Start camera when entering capture phase on web
useEffect(() => {
if (phase === 'capture' && !isNativeScanner) {
startCamera()

View File

@@ -189,15 +189,14 @@ export function useScanToTask() {
const handleNativeScan = useCallback(async () => {
const { image, cancelled, error } = await scanDocument()
if (cancelled) return { cancelled: true }
if (cancelled) return
if (error || !image) {
setErrorMsg(error ? `Scanner error: ${error}` : 'Scan failed.')
setPhase('error')
return { cancelled: false }
return
}
setCapturedImage(image)
processImage(image, 'native')
return { cancelled: false }
}, [scanDocument, processImage])
const retake = useCallback(() => {
@@ -229,7 +228,6 @@ export function useScanToTask() {
isNativeScanner,
phase,
capturedImage,
setCapturedImage,
ocrProgress,
taskResult,
errorMsg,
@@ -240,7 +238,6 @@ export function useScanToTask() {
startCamera,
stopCamera,
capture,
processImage,
handleFileSelect,
handleNativeScan,
retake,