From 9136ff3ea30ddc85f4f5802a1ecdac6a119417f2 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Mon, 6 Jul 2026 18:10:43 -0400 Subject: [PATCH] feat: enhance file upload and attachment management with draft support --- src/hooks/useFileUpload.js | 7 +- src/utils/Fetcher.jsx | 27 +++ src/utils/Helpers.jsx | 109 +++++++++++- src/views/ChoreEdit/ChoreEdit.jsx | 163 +++++++++++++++++- src/views/ChoreEdit/ChoreView.jsx | 4 +- src/views/components/AddTaskModal.jsx | 8 +- .../components/AttachmentPickerField.jsx | 3 +- src/views/components/RichTextEditor.jsx | 46 ++++- src/views/components/ScanToTask/ScanPanel.jsx | 17 +- .../components/ScanToTask/useScanToTask.js | 7 +- 10 files changed, 352 insertions(+), 39 deletions(-) diff --git a/src/hooks/useFileUpload.js b/src/hooks/useFileUpload.js index 1aba6f9..e44329e 100644 --- a/src/hooks/useFileUpload.js +++ b/src/hooks/useFileUpload.js @@ -5,7 +5,7 @@ import { useNotification } from '../service/NotificationProvider' import { apiClient } from '../utils/ApiClient' 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 { data: userProfile } = useUserProfile() @@ -38,7 +38,8 @@ export const useFileUpload = ({ entityType = 'chore_attachment', entityId } = {} const formData = new FormData() formData.append('file', compressedJpegFile) 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) @@ -84,7 +85,7 @@ export const useFileUpload = ({ entityType = 'chore_attachment', entityId } = {} return null } }, - [entityType, entityId, showError, userProfile], + [entityType, entityId, draftId, showError, userProfile], ) return { uploadFile, isPlus: isPlusAccount(userProfile) } diff --git a/src/utils/Fetcher.jsx b/src/utils/Fetcher.jsx index 167394a..0e3caae 100644 --- a/src/utils/Fetcher.jsx +++ b/src/utils/Fetcher.jsx @@ -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 = '') => { return Fetch(`/backup/create`, { method: 'POST', @@ -933,6 +957,9 @@ const TrackFilterUsage = id => { export { AcceptCircleMemberRequest, + DeleteChoreAttachment, + GetChoreAttachments, + UploadChoreAttachment, ApproveChore, ArchiveChore, CancelSubscription, diff --git a/src/utils/Helpers.jsx b/src/utils/Helpers.jsx index fb1ef4c..18a198f 100644 --- a/src/utils/Helpers.jsx +++ b/src/utils/Helpers.jsx @@ -10,9 +10,108 @@ const resolvePhotoURL = url => { if (url.startsWith('http') || url.startsWith('https')) { return url } - if (url.startsWith('assets')) { - return apiClient.getAssetURL(url) - } - return url + return apiClient.getAssetURL(url) } -export { isPlusAccount, resolvePhotoURL } + +// 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 + } +} + +// 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 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 } diff --git a/src/views/ChoreEdit/ChoreEdit.jsx b/src/views/ChoreEdit/ChoreEdit.jsx index 4d5c78e..e6fdeaa 100644 --- a/src/views/ChoreEdit/ChoreEdit.jsx +++ b/src/views/ChoreEdit/ChoreEdit.jsx @@ -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 { Avatar, Box, @@ -43,7 +51,13 @@ import { import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx' import { useNotification } from '../../service/NotificationProvider' 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 Priorities from '../../utils/Priorities.jsx' import { getIconComponent } from '../../utils/ProjectIcons' @@ -117,6 +131,9 @@ const ChoreEdit = () => { const [createdBy, setCreatedBy] = useState(0) const [errors, setErrors] = useState({}) 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 [showSavePrivacyDefault, setShowSavePrivacyDefault] = useState(false) const [privacySaved, setPrivacySaved] = useState(false) @@ -362,6 +379,7 @@ const ChoreEdit = () => { deadlineOffset: deadlineOffset < 0 ? null : deadlineOffset, priority: priority, projectId: projectId === 'default' ? null : projectId, + draftId: newChoreId > 0 ? undefined : draftId, } let SaveFunction = createChoreMutation.mutateAsync if (newChoreId > 0) { @@ -405,6 +423,14 @@ const ChoreEdit = () => { 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 if (!choreId) { @@ -941,6 +967,139 @@ const ChoreEdit = () => { /> + + + Attachments + Files attached to this task + + {attachments.length > 0 && ( + + {attachments.map((att, idx) => ( + + + + {att.file_name} + + {att.size_bytes && ( + + {(att.size_bytes / 1024).toFixed(1)} KB + + )} + {choreId && ( + { + 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.', + }) + }) + }} + > + + + )} + {!choreId && ( + { + setAttachments(prev => + prev.filter((_, i) => i !== idx), + ) + }} + > + + + )} + + ))} + + )} + + + {/* Section 2: Assignment & Responsibility */} diff --git a/src/views/ChoreEdit/ChoreView.jsx b/src/views/ChoreEdit/ChoreView.jsx index e2629d5..c28eb5b 100644 --- a/src/views/ChoreEdit/ChoreView.jsx +++ b/src/views/ChoreEdit/ChoreView.jsx @@ -918,7 +918,7 @@ const ChoreView = () => { whiteSpace: 'pre-wrap', wordBreak: 'break-word', }} - dangerouslySetInnerHTML={{ __html: raw }} + dangerouslySetInnerHTML={{ __html: refreshSignedUrlsInHtml(raw) }} /> ) : ( { whiteSpace: 'pre-wrap', wordBreak: 'break-word', }} - dangerouslySetInnerHTML={{ __html: raw }} + dangerouslySetInnerHTML={{ __html: refreshSignedUrlsInHtml(raw) }} /> ) : ( { 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} /> { 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 diff --git a/src/views/components/RichTextEditor.jsx b/src/views/components/RichTextEditor.jsx index adfb070..e776cd3 100644 --- a/src/views/components/RichTextEditor.jsx +++ b/src/views/components/RichTextEditor.jsx @@ -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) }} /> ) } diff --git a/src/views/components/ScanToTask/ScanPanel.jsx b/src/views/components/ScanToTask/ScanPanel.jsx index 3ef1c1d..88bf541 100644 --- a/src/views/components/ScanToTask/ScanPanel.jsx +++ b/src/views/components/ScanToTask/ScanPanel.jsx @@ -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() diff --git a/src/views/components/ScanToTask/useScanToTask.js b/src/views/components/ScanToTask/useScanToTask.js index a2178bb..2b5eea4 100644 --- a/src/views/components/ScanToTask/useScanToTask.js +++ b/src/views/components/ScanToTask/useScanToTask.js @@ -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,