feat: enhance file upload and attachment management with draft support
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user