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

@@ -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) }

View File

@@ -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,

View File

@@ -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 }

View File

@@ -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 */}

View File

@@ -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

View File

@@ -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}

View File

@@ -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

View File

@@ -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) }}
/> />
) )
} }

View File

@@ -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()

View File

@@ -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,