feat: enhance file upload and attachment management with draft support
This commit is contained in:
@@ -5,7 +5,7 @@ import { useNotification } from '../service/NotificationProvider'
|
|||||||
import { apiClient } from '../utils/ApiClient'
|
import { apiClient } from '../utils/ApiClient'
|
||||||
import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers'
|
import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers'
|
||||||
|
|
||||||
export const useFileUpload = ({ entityType = 'chore_attachment', entityId } = {}) => {
|
export const useFileUpload = ({ entityType = 'chore_attachment', entityId, draftId } = {}) => {
|
||||||
const { showError } = useNotification()
|
const { showError } = useNotification()
|
||||||
const { data: userProfile } = useUserProfile()
|
const { data: userProfile } = useUserProfile()
|
||||||
|
|
||||||
@@ -38,7 +38,8 @@ export const useFileUpload = ({ entityType = 'chore_attachment', entityId } = {}
|
|||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', compressedJpegFile)
|
formData.append('file', compressedJpegFile)
|
||||||
formData.append('entityType', entityType)
|
formData.append('entityType', entityType)
|
||||||
if (entityId) formData.append('entityId', entityId)
|
if (entityId) formData.append('entityId', String(entityId))
|
||||||
|
if (draftId) formData.append('draftId', draftId)
|
||||||
|
|
||||||
const response = await apiClient.upload('/assets/chore', formData)
|
const response = await apiClient.upload('/assets/chore', formData)
|
||||||
|
|
||||||
@@ -84,7 +85,7 @@ export const useFileUpload = ({ entityType = 'chore_attachment', entityId } = {}
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[entityType, entityId, showError, userProfile],
|
[entityType, entityId, draftId, showError, userProfile],
|
||||||
)
|
)
|
||||||
|
|
||||||
return { uploadFile, isPlus: isPlusAccount(userProfile) }
|
return { uploadFile, isPlus: isPlusAccount(userProfile) }
|
||||||
|
|||||||
@@ -728,6 +728,30 @@ const DeleteUser = (password, confirmation, transferOptions = []) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const UploadChoreAttachment = (file, entityType, { entityId, draftId } = {}) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
formData.append('entityType', entityType)
|
||||||
|
if (entityId != null) formData.append('entityId', String(entityId))
|
||||||
|
if (draftId != null) formData.append('draftId', draftId)
|
||||||
|
return apiClient.upload('/assets/chore', formData)
|
||||||
|
}
|
||||||
|
|
||||||
|
const GetChoreAttachments = choreId => {
|
||||||
|
return Fetch(`/chores/${choreId}/attachments`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: HEADERS(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeleteChoreAttachment = (choreId, filePath) => {
|
||||||
|
return Fetch(`/chores/${choreId}/attachments`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: HEADERS(),
|
||||||
|
body: JSON.stringify({ file_path: filePath }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const CreateBackup = (encryptionKey, includeAssets = true, backupName = '') => {
|
const CreateBackup = (encryptionKey, includeAssets = true, backupName = '') => {
|
||||||
return Fetch(`/backup/create`, {
|
return Fetch(`/backup/create`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -933,6 +957,9 @@ const TrackFilterUsage = id => {
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
AcceptCircleMemberRequest,
|
AcceptCircleMemberRequest,
|
||||||
|
DeleteChoreAttachment,
|
||||||
|
GetChoreAttachments,
|
||||||
|
UploadChoreAttachment,
|
||||||
ApproveChore,
|
ApproveChore,
|
||||||
ArchiveChore,
|
ArchiveChore,
|
||||||
CancelSubscription,
|
CancelSubscription,
|
||||||
|
|||||||
@@ -10,9 +10,108 @@ const resolvePhotoURL = url => {
|
|||||||
if (url.startsWith('http') || url.startsWith('https')) {
|
if (url.startsWith('http') || url.startsWith('https')) {
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
if (url.startsWith('assets')) {
|
|
||||||
return apiClient.getAssetURL(url)
|
return apiClient.getAssetURL(url)
|
||||||
}
|
}
|
||||||
return url
|
|
||||||
|
// Detect cloud storage pre-signed URLs (S3, GCS, Azure) that carry expiry params.
|
||||||
|
const isCloudSignedUrl = url => {
|
||||||
|
if (!url) return false
|
||||||
|
try {
|
||||||
|
const u = new URL(url)
|
||||||
|
return (
|
||||||
|
u.searchParams.has('X-Amz-Signature') ||
|
||||||
|
u.searchParams.has('X-Amz-Expires') ||
|
||||||
|
u.searchParams.has('X-Goog-Signature') ||
|
||||||
|
u.searchParams.has('sig') // Azure Blob SAS
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
export { isPlusAccount, resolvePhotoURL }
|
}
|
||||||
|
|
||||||
|
// Extract the storage key from a cloud signed URL so we can route it through
|
||||||
|
// the backend proxy (which re-signs on every request and never expires).
|
||||||
|
//
|
||||||
|
// Handles:
|
||||||
|
// Virtual-hosted S3: https://{bucket}.s3[.region].amazonaws.com/{key}?...
|
||||||
|
// Path-style S3: https://s3[.region].amazonaws.com/{bucket}/{key}?...
|
||||||
|
// Cloudflare R2: https://{bucket}.{accountid}.r2.cloudflarestorage.com/{key}?...
|
||||||
|
// GCS: https://storage.googleapis.com/{bucket}/{key}?...
|
||||||
|
// Azure Blob: https://{account}.blob.core.windows.net/{container}/{blob}?...
|
||||||
|
//
|
||||||
|
// The app stores files under an "assets/" prefix in the bucket but the backend
|
||||||
|
// proxy already mounts at /assets/, so we strip that leading segment when present.
|
||||||
|
const extractStorageKey = url => {
|
||||||
|
try {
|
||||||
|
const u = new URL(url)
|
||||||
|
const host = u.hostname
|
||||||
|
const rawPath = u.pathname.replace(/^\//, '')
|
||||||
|
|
||||||
|
let key
|
||||||
|
|
||||||
|
if (host.endsWith('.r2.cloudflarestorage.com')) {
|
||||||
|
// Virtual-hosted R2: bucket is in the host, key is the full path
|
||||||
|
key = rawPath
|
||||||
|
} else if (host.endsWith('.amazonaws.com')) {
|
||||||
|
if (host.startsWith('s3') || host.includes('.s3.')) {
|
||||||
|
// Path-style S3: first segment is the bucket — strip it
|
||||||
|
key = rawPath.split('/').slice(1).join('/')
|
||||||
|
} else {
|
||||||
|
// Virtual-hosted S3: bucket is in the host, path is the key
|
||||||
|
key = rawPath
|
||||||
|
}
|
||||||
|
} else if (host === 'storage.googleapis.com') {
|
||||||
|
// First segment is the bucket
|
||||||
|
key = rawPath.split('/').slice(1).join('/')
|
||||||
|
} else if (host.endsWith('.blob.core.windows.net')) {
|
||||||
|
// First segment is the container name
|
||||||
|
key = rawPath.split('/').slice(1).join('/')
|
||||||
|
} else {
|
||||||
|
key = rawPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bucket stores files under an "assets/" prefix; the backend /assets/
|
||||||
|
// endpoint already adds that prefix, so strip it to avoid duplication.
|
||||||
|
if (key.startsWith('assets/')) {
|
||||||
|
key = key.slice('assets/'.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
return key || null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan an HTML string for <img> tags whose src is a cloud signed URL and
|
||||||
|
// replace them with backend proxy URLs (which generate fresh signed URLs on
|
||||||
|
// each request). Returns the patched HTML, or the original if nothing changed.
|
||||||
|
const refreshSignedUrlsInHtml = html => {
|
||||||
|
if (!html) return html
|
||||||
|
if (
|
||||||
|
!html.includes('X-Amz-') &&
|
||||||
|
!html.includes('X-Goog-') &&
|
||||||
|
!html.includes('.blob.core.windows.net')
|
||||||
|
) {
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
const parser = new DOMParser()
|
||||||
|
const doc = parser.parseFromString(html, 'text/html')
|
||||||
|
const imgs = doc.querySelectorAll('img[src]')
|
||||||
|
let changed = false
|
||||||
|
|
||||||
|
imgs.forEach(img => {
|
||||||
|
const src = img.getAttribute('src')
|
||||||
|
if (!isCloudSignedUrl(src)) return
|
||||||
|
|
||||||
|
const key = extractStorageKey(src)
|
||||||
|
if (!key) return
|
||||||
|
|
||||||
|
img.setAttribute('src', apiClient.getAssetURL(key))
|
||||||
|
changed = true
|
||||||
|
})
|
||||||
|
|
||||||
|
return changed ? doc.body.innerHTML : html
|
||||||
|
}
|
||||||
|
|
||||||
|
export { extractStorageKey, isPlusAccount, refreshSignedUrlsInHtml, resolvePhotoURL }
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { Add, ArrowDropDown, HorizontalRule, Save } from '@mui/icons-material'
|
import {
|
||||||
|
Add,
|
||||||
|
ArrowDropDown,
|
||||||
|
AttachFile,
|
||||||
|
Delete,
|
||||||
|
HorizontalRule,
|
||||||
|
Save,
|
||||||
|
UploadFile,
|
||||||
|
} from '@mui/icons-material'
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
@@ -43,7 +51,13 @@ import {
|
|||||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
|
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
|
||||||
import { useNotification } from '../../service/NotificationProvider'
|
import { useNotification } from '../../service/NotificationProvider'
|
||||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||||
import { GetAllCircleMembers, GetThings } from '../../utils/Fetcher'
|
import {
|
||||||
|
DeleteChoreAttachment,
|
||||||
|
GetAllCircleMembers,
|
||||||
|
GetChoreAttachments,
|
||||||
|
GetThings,
|
||||||
|
UploadChoreAttachment,
|
||||||
|
} from '../../utils/Fetcher'
|
||||||
import { isPlusAccount } from '../../utils/Helpers'
|
import { isPlusAccount } from '../../utils/Helpers'
|
||||||
import Priorities from '../../utils/Priorities.jsx'
|
import Priorities from '../../utils/Priorities.jsx'
|
||||||
import { getIconComponent } from '../../utils/ProjectIcons'
|
import { getIconComponent } from '../../utils/ProjectIcons'
|
||||||
@@ -117,6 +131,9 @@ const ChoreEdit = () => {
|
|||||||
const [createdBy, setCreatedBy] = useState(0)
|
const [createdBy, setCreatedBy] = useState(0)
|
||||||
const [errors, setErrors] = useState({})
|
const [errors, setErrors] = useState({})
|
||||||
const [attemptToSave, setAttemptToSave] = useState(false)
|
const [attemptToSave, setAttemptToSave] = useState(false)
|
||||||
|
const [draftId] = useState(() => crypto.randomUUID())
|
||||||
|
const [attachments, setAttachments] = useState([])
|
||||||
|
const [isUploadingAttachment, setIsUploadingAttachment] = useState(false)
|
||||||
const [addLabelModalOpen, setAddLabelModalOpen] = useState(false)
|
const [addLabelModalOpen, setAddLabelModalOpen] = useState(false)
|
||||||
const [showSavePrivacyDefault, setShowSavePrivacyDefault] = useState(false)
|
const [showSavePrivacyDefault, setShowSavePrivacyDefault] = useState(false)
|
||||||
const [privacySaved, setPrivacySaved] = useState(false)
|
const [privacySaved, setPrivacySaved] = useState(false)
|
||||||
@@ -362,6 +379,7 @@ const ChoreEdit = () => {
|
|||||||
deadlineOffset: deadlineOffset < 0 ? null : deadlineOffset,
|
deadlineOffset: deadlineOffset < 0 ? null : deadlineOffset,
|
||||||
priority: priority,
|
priority: priority,
|
||||||
projectId: projectId === 'default' ? null : projectId,
|
projectId: projectId === 'default' ? null : projectId,
|
||||||
|
draftId: newChoreId > 0 ? undefined : draftId,
|
||||||
}
|
}
|
||||||
let SaveFunction = createChoreMutation.mutateAsync
|
let SaveFunction = createChoreMutation.mutateAsync
|
||||||
if (newChoreId > 0) {
|
if (newChoreId > 0) {
|
||||||
@@ -405,6 +423,14 @@ const ChoreEdit = () => {
|
|||||||
setAllUserThings(data.res)
|
setAllUserThings(data.res)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
if (choreId) {
|
||||||
|
GetChoreAttachments(choreId)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.res) setAttachments(data.res)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
// Load default privacy setting for new chores
|
// Load default privacy setting for new chores
|
||||||
if (!choreId) {
|
if (!choreId) {
|
||||||
@@ -941,6 +967,139 @@ const ChoreEdit = () => {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<Box mt={3}>
|
||||||
|
<Typography level='h4'>Attachments</Typography>
|
||||||
|
<Typography level='body-md'>Files attached to this task</Typography>
|
||||||
|
<Card variant='outlined' sx={{ mt: 2, p: 1.5 }}>
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mb: 1.5 }}>
|
||||||
|
{attachments.map((att, idx) => (
|
||||||
|
<Box
|
||||||
|
key={att.file_path || idx}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
p: 1,
|
||||||
|
borderRadius: 'sm',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'neutral.outlinedBorder',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AttachFile sx={{ fontSize: 18, color: 'neutral.500' }} />
|
||||||
|
<Typography
|
||||||
|
level='body-sm'
|
||||||
|
sx={{ flex: 1, wordBreak: 'break-all' }}
|
||||||
|
>
|
||||||
|
{att.file_name}
|
||||||
|
</Typography>
|
||||||
|
{att.size_bytes && (
|
||||||
|
<Typography level='body-xs' color='neutral'>
|
||||||
|
{(att.size_bytes / 1024).toFixed(1)} KB
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{choreId && (
|
||||||
|
<IconButton
|
||||||
|
size='sm'
|
||||||
|
variant='plain'
|
||||||
|
color='danger'
|
||||||
|
onClick={() => {
|
||||||
|
DeleteChoreAttachment(choreId, att.file_path)
|
||||||
|
.then(() => {
|
||||||
|
setAttachments(prev =>
|
||||||
|
prev.filter(a => a.file_path !== att.file_path),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
showError({
|
||||||
|
title: 'Delete Failed',
|
||||||
|
message: 'Failed to delete attachment.',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Delete sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
{!choreId && (
|
||||||
|
<IconButton
|
||||||
|
size='sm'
|
||||||
|
variant='plain'
|
||||||
|
color='danger'
|
||||||
|
onClick={() => {
|
||||||
|
setAttachments(prev =>
|
||||||
|
prev.filter((_, i) => i !== idx),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Delete sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</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)
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Section 2: Assignment & Responsibility */}
|
{/* Section 2: Assignment & Responsibility */}
|
||||||
|
|||||||
@@ -918,7 +918,7 @@ const ChoreView = () => {
|
|||||||
whiteSpace: 'pre-wrap',
|
whiteSpace: 'pre-wrap',
|
||||||
wordBreak: 'break-word',
|
wordBreak: 'break-word',
|
||||||
}}
|
}}
|
||||||
dangerouslySetInnerHTML={{ __html: raw }}
|
dangerouslySetInnerHTML={{ __html: refreshSignedUrlsInHtml(raw) }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Typography
|
<Typography
|
||||||
@@ -986,7 +986,7 @@ const ChoreView = () => {
|
|||||||
whiteSpace: 'pre-wrap',
|
whiteSpace: 'pre-wrap',
|
||||||
wordBreak: 'break-word',
|
wordBreak: 'break-word',
|
||||||
}}
|
}}
|
||||||
dangerouslySetInnerHTML={{ __html: raw }}
|
dangerouslySetInnerHTML={{ __html: refreshSignedUrlsInHtml(raw) }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Typography
|
<Typography
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
|
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
|
||||||
const [projectId, setProjectId] = useState(getInitialProject())
|
const [projectId, setProjectId] = useState(getInitialProject())
|
||||||
const [attachments, setAttachments] = useState([])
|
const [attachments, setAttachments] = useState([])
|
||||||
|
const [draftId, setDraftId] = useState(() => crypto.randomUUID())
|
||||||
const [showScan, setShowScan] = useState(false)
|
const [showScan, setShowScan] = useState(false)
|
||||||
const [pendingPhotoUrl, setPendingPhotoUrl] = useState(null)
|
const [pendingPhotoUrl, setPendingPhotoUrl] = useState(null)
|
||||||
const { isNativeScanner } = useDocumentScanner()
|
const { isNativeScanner } = useDocumentScanner()
|
||||||
@@ -586,6 +587,8 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
setDueDateOnly(null)
|
setDueDateOnly(null)
|
||||||
setDueTime(null)
|
setDueTime(null)
|
||||||
setUseCustomTime(false)
|
setUseCustomTime(false)
|
||||||
|
setAttachments([])
|
||||||
|
setDraftId(crypto.randomUUID())
|
||||||
}
|
}
|
||||||
|
|
||||||
const createChore = () => {
|
const createChore = () => {
|
||||||
@@ -632,7 +635,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
notificationMetadata: {},
|
notificationMetadata: {},
|
||||||
subTasks: subTasks?.length > 0 ? subTasks : null,
|
subTasks: subTasks?.length > 0 ? subTasks : null,
|
||||||
projectId: projectId === 'default' ? null : projectId,
|
projectId: projectId === 'default' ? null : projectId,
|
||||||
attachments: attachments.length > 0 ? attachments : null,
|
draftId: draftId,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (frequency) {
|
if (frequency) {
|
||||||
@@ -909,7 +912,8 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
onChange={setAttachments}
|
onChange={setAttachments}
|
||||||
onClear={() => setAttachments([])}
|
onClear={() => setAttachments([])}
|
||||||
emptyDisplay={pickerEmptyDisplay}
|
emptyDisplay={pickerEmptyDisplay}
|
||||||
entityType='chore_attachment'
|
entityType='chore_attachment_draft'
|
||||||
|
draftId={draftId}
|
||||||
/>
|
/>
|
||||||
<NotificationPickerField
|
<NotificationPickerField
|
||||||
value={notificationMetadata}
|
value={notificationMetadata}
|
||||||
|
|||||||
@@ -19,11 +19,12 @@ const AttachmentPickerField = ({
|
|||||||
emptyDisplay = 'icon-text',
|
emptyDisplay = 'icon-text',
|
||||||
entityType = 'chore_attachment',
|
entityType = 'chore_attachment',
|
||||||
entityId,
|
entityId,
|
||||||
|
draftId,
|
||||||
}) => {
|
}) => {
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const [isUploading, setIsUploading] = useState(false)
|
const [isUploading, setIsUploading] = useState(false)
|
||||||
const buttonRef = useRef(null)
|
const buttonRef = useRef(null)
|
||||||
const { uploadFile } = useFileUpload({ entityType, entityId })
|
const { uploadFile } = useFileUpload({ entityType, entityId, draftId })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return
|
if (!isOpen) return
|
||||||
|
|||||||
@@ -2,6 +2,33 @@ import imageCompression from 'browser-image-compression'
|
|||||||
import Quill from 'quill'
|
import Quill from 'quill'
|
||||||
import 'quill/dist/quill.snow.css'
|
import 'quill/dist/quill.snow.css'
|
||||||
import QuillMarkdown from 'quilljs-markdown'
|
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 {
|
import {
|
||||||
forwardRef,
|
forwardRef,
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -12,7 +39,11 @@ import {
|
|||||||
import { useUserProfile } from '../../queries/UserQueries'
|
import { useUserProfile } from '../../queries/UserQueries'
|
||||||
import { useNotification } from '../../service/NotificationProvider'
|
import { useNotification } from '../../service/NotificationProvider'
|
||||||
import { apiClient } from '../../utils/ApiClient'
|
import { apiClient } from '../../utils/ApiClient'
|
||||||
import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers'
|
import {
|
||||||
|
isPlusAccount,
|
||||||
|
refreshSignedUrlsInHtml,
|
||||||
|
resolvePhotoURL,
|
||||||
|
} from '../../utils/Helpers'
|
||||||
import './RichTextEditor.css'
|
import './RichTextEditor.css'
|
||||||
|
|
||||||
const RichTextEditor = forwardRef(
|
const RichTextEditor = forwardRef(
|
||||||
@@ -141,11 +172,16 @@ const RichTextEditor = forwardRef(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
const url = resolvePhotoURL(data.url || data.sign)
|
// Prefer the backend-proxied path (data.sign) over the direct cloud
|
||||||
// Insert image into Quill
|
// 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 quill = editorRef.current
|
||||||
const range = quill.getSelection()
|
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) {
|
} catch (error) {
|
||||||
console.error('Error during image processing or upload:', error)
|
console.error('Error during image processing or upload:', error)
|
||||||
showError({
|
showError({
|
||||||
@@ -227,7 +263,7 @@ const RichTextEditor = forwardRef(
|
|||||||
boxShadow:
|
boxShadow:
|
||||||
'var(--joy-shadow-xs, 0px 1px 2px 0px rgba(16, 24, 40, 0.05))',
|
'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]
|
* Flow: capture → (auto) processing → done [calls onTaskExtracted + onClose]
|
||||||
* → error [retake or cancel]
|
* → error [retake or cancel]
|
||||||
*/
|
*/
|
||||||
const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl }) => {
|
const ScanPanel = ({ open, onTaskExtracted, onClose }) => {
|
||||||
const {
|
const {
|
||||||
isNativeScanner,
|
isNativeScanner,
|
||||||
phase,
|
phase,
|
||||||
@@ -36,8 +36,6 @@ const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl }) => {
|
|||||||
startCamera,
|
startCamera,
|
||||||
stopCamera,
|
stopCamera,
|
||||||
capture,
|
capture,
|
||||||
processImage,
|
|
||||||
setCapturedImage,
|
|
||||||
handleFileSelect,
|
handleFileSelect,
|
||||||
handleNativeScan,
|
handleNativeScan,
|
||||||
retake,
|
retake,
|
||||||
@@ -48,23 +46,14 @@ const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl }) => {
|
|||||||
// Start/stop based on open state
|
// Start/stop based on open state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
if (initialImageUrl) {
|
|
||||||
setCapturedImage(initialImageUrl)
|
|
||||||
processImage(initialImageUrl, 'browser')
|
|
||||||
} else if (isNativeScanner) {
|
|
||||||
handleNativeScan().then(result => {
|
|
||||||
if (result?.cancelled) onClose()
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
activate()
|
activate()
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
reset()
|
reset()
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
// Start/stop web camera based on capture phase
|
// Start camera when entering capture phase on web
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (phase === 'capture' && !isNativeScanner) {
|
if (phase === 'capture' && !isNativeScanner) {
|
||||||
startCamera()
|
startCamera()
|
||||||
|
|||||||
@@ -189,15 +189,14 @@ export function useScanToTask() {
|
|||||||
|
|
||||||
const handleNativeScan = useCallback(async () => {
|
const handleNativeScan = useCallback(async () => {
|
||||||
const { image, cancelled, error } = await scanDocument()
|
const { image, cancelled, error } = await scanDocument()
|
||||||
if (cancelled) return { cancelled: true }
|
if (cancelled) return
|
||||||
if (error || !image) {
|
if (error || !image) {
|
||||||
setErrorMsg(error ? `Scanner error: ${error}` : 'Scan failed.')
|
setErrorMsg(error ? `Scanner error: ${error}` : 'Scan failed.')
|
||||||
setPhase('error')
|
setPhase('error')
|
||||||
return { cancelled: false }
|
return
|
||||||
}
|
}
|
||||||
setCapturedImage(image)
|
setCapturedImage(image)
|
||||||
processImage(image, 'native')
|
processImage(image, 'native')
|
||||||
return { cancelled: false }
|
|
||||||
}, [scanDocument, processImage])
|
}, [scanDocument, processImage])
|
||||||
|
|
||||||
const retake = useCallback(() => {
|
const retake = useCallback(() => {
|
||||||
@@ -229,7 +228,6 @@ export function useScanToTask() {
|
|||||||
isNativeScanner,
|
isNativeScanner,
|
||||||
phase,
|
phase,
|
||||||
capturedImage,
|
capturedImage,
|
||||||
setCapturedImage,
|
|
||||||
ocrProgress,
|
ocrProgress,
|
||||||
taskResult,
|
taskResult,
|
||||||
errorMsg,
|
errorMsg,
|
||||||
@@ -240,7 +238,6 @@ export function useScanToTask() {
|
|||||||
startCamera,
|
startCamera,
|
||||||
stopCamera,
|
stopCamera,
|
||||||
capture,
|
capture,
|
||||||
processImage,
|
|
||||||
handleFileSelect,
|
handleFileSelect,
|
||||||
handleNativeScan,
|
handleNativeScan,
|
||||||
retake,
|
retake,
|
||||||
|
|||||||
Reference in New Issue
Block a user