Refactor Attchemnt Upload and fix bug with signing
This commit is contained in:
@@ -53,11 +53,13 @@ import { useNotification } from '../../service/NotificationProvider'
|
||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||
import {
|
||||
DeleteChoreAttachment,
|
||||
DeleteDraftAttachment,
|
||||
GetAllCircleMembers,
|
||||
GetThings,
|
||||
UploadChoreAttachment,
|
||||
} from '../../utils/Fetcher'
|
||||
import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers'
|
||||
import { getImageSrc, removeCachedImage } from '../../utils/ImageCache'
|
||||
import { generateUUID } from '../../utils/UUID'
|
||||
import Priorities from '../../utils/Priorities.jsx'
|
||||
import { getIconComponent } from '../../utils/ProjectIcons'
|
||||
@@ -725,6 +727,7 @@ const ChoreEdit = () => {
|
||||
onChange={setDescription}
|
||||
entityId={choreId}
|
||||
entityType={'chore_description'}
|
||||
draftId={draftId}
|
||||
/>
|
||||
<FormHelperText error>{errors.description}</FormHelperText>
|
||||
</FormControl>
|
||||
@@ -970,12 +973,23 @@ const ChoreEdit = () => {
|
||||
<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 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
mb: 1.5,
|
||||
}}
|
||||
>
|
||||
{attachments.map((att, idx) => (
|
||||
<Box
|
||||
key={att.file_path || idx}
|
||||
onClick={() => {
|
||||
const url = resolvePhotoURL(att.sign || att.file_path)
|
||||
onClick={async () => {
|
||||
const url = await getImageSrc(
|
||||
att.file_path,
|
||||
att.sign ? resolvePhotoURL(att.sign) : null,
|
||||
{ choreId, kind: 'attachment' },
|
||||
).catch(() => resolvePhotoURL(att.sign || att.file_path))
|
||||
const ext = (att.file_name || '')
|
||||
.split('.')
|
||||
.pop()
|
||||
@@ -1039,6 +1053,7 @@ const ChoreEdit = () => {
|
||||
event.stopPropagation()
|
||||
DeleteChoreAttachment(choreId, att.file_path)
|
||||
.then(() => {
|
||||
removeCachedImage(att.file_path)
|
||||
setAttachments(prev =>
|
||||
prev.filter(a => a.file_path !== att.file_path),
|
||||
)
|
||||
@@ -1061,9 +1076,20 @@ const ChoreEdit = () => {
|
||||
color='danger'
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
setAttachments(prev =>
|
||||
prev.filter((_, i) => i !== idx),
|
||||
)
|
||||
// Draft uploads live server-side too — delete there
|
||||
// so they are not promoted onto the chore on save.
|
||||
DeleteDraftAttachment(att.file_path)
|
||||
.then(() => {
|
||||
setAttachments(prev =>
|
||||
prev.filter((_, i) => i !== idx),
|
||||
)
|
||||
})
|
||||
.catch(() => {
|
||||
showError({
|
||||
title: 'Delete Failed',
|
||||
message: 'Failed to delete attachment.',
|
||||
})
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Delete sx={{ fontSize: 18 }} />
|
||||
@@ -1078,9 +1104,7 @@ const ChoreEdit = () => {
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
startDecorator={
|
||||
isUploadingAttachment ? null : <UploadFile />
|
||||
}
|
||||
startDecorator={isUploadingAttachment ? null : <UploadFile />}
|
||||
loading={isUploadingAttachment}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
|
||||
@@ -88,7 +88,7 @@ import RichTextEditor from '../components/RichTextEditor.jsx'
|
||||
import SubTasks from '../components/SubTask.jsx'
|
||||
import TimePassedCard from './TimePassedCard.jsx'
|
||||
import TimerSplitButton from './TimerSplitButton.jsx'
|
||||
import { refreshSignedUrlsInHtml } from '../../utils/Helpers.jsx'
|
||||
import { useDescriptionHtml } from '../../hooks/useDescriptionHtml'
|
||||
|
||||
const isNetworkError = err =>
|
||||
err instanceof TypeError && err.message === 'Failed to fetch'
|
||||
@@ -132,6 +132,12 @@ const ChoreView = () => {
|
||||
useCircleMembers()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const { impersonatedUser } = useImpersonateUser()
|
||||
const descriptionHtml = useDescriptionHtml(chore?.description || '', {
|
||||
choreId: chore?.id,
|
||||
})
|
||||
const notesHtml = useDescriptionHtml(chore?.notes || '', {
|
||||
choreId: chore?.id,
|
||||
})
|
||||
|
||||
const { data: choreData, isLoading: isChoreLoading } =
|
||||
useChoreDetails(choreId)
|
||||
@@ -935,7 +941,7 @@ const ChoreView = () => {
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: refreshSignedUrlsInHtml(raw) }}
|
||||
dangerouslySetInnerHTML={{ __html: descriptionHtml }}
|
||||
/>
|
||||
) : (
|
||||
<Typography
|
||||
@@ -1003,7 +1009,7 @@ const ChoreView = () => {
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: refreshSignedUrlsInHtml(raw) }}
|
||||
dangerouslySetInnerHTML={{ __html: notesHtml }}
|
||||
/>
|
||||
) : (
|
||||
<Typography
|
||||
@@ -1099,6 +1105,7 @@ const ChoreView = () => {
|
||||
<RichTextEditor
|
||||
value={note || ''}
|
||||
onChange={setNote}
|
||||
entityId={chore?.id}
|
||||
entityType={'chore_completion_note'}
|
||||
placeholder={t('choreView.notePlaceholder')}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { AttachFile, Close, Image } from '@mui/icons-material'
|
||||
import { Box, Button, CircularProgress, List, ListItem, ListItemButton, Typography } from '@mui/joy'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { GetChoreAttachments } from '../../../utils/Fetcher'
|
||||
import { resolvePhotoURL } from '../../../utils/Helpers'
|
||||
import { cacheChoreImages, getImageSrc } from '../../../utils/ImageCache'
|
||||
import AttachmentViewerModal from './AttachmentViewerModal'
|
||||
|
||||
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg']
|
||||
@@ -38,7 +47,12 @@ function AttachmentBrowserModal({ choreId, isOpen, onClose }) {
|
||||
if (!res.ok) throw new Error('Failed to fetch attachments')
|
||||
return res.json()
|
||||
})
|
||||
.then(data => setAttachments(Array.isArray(data) ? data : []))
|
||||
.then(data => {
|
||||
const list = Array.isArray(data) ? data : []
|
||||
setAttachments(list)
|
||||
// Fire-and-forget: store attachments so they open offline later
|
||||
cacheChoreImages({ id: choreId, attachments: list })
|
||||
})
|
||||
.catch(() => setAttachments([]))
|
||||
.finally(() => setIsLoading(false))
|
||||
}, [isOpen, choreId])
|
||||
@@ -48,8 +62,12 @@ function AttachmentBrowserModal({ choreId, isOpen, onClose }) {
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
const handleAttachmentClick = attachment => {
|
||||
const url = resolvePhotoURL(attachment.sign)
|
||||
const handleAttachmentClick = async attachment => {
|
||||
const url = await getImageSrc(
|
||||
attachment.file_path,
|
||||
attachment.sign ? resolvePhotoURL(attachment.sign) : null,
|
||||
{ choreId: String(choreId), kind: 'attachment' },
|
||||
).catch(() => resolvePhotoURL(attachment.sign))
|
||||
if (isImageFile(attachment.file_name)) {
|
||||
setViewerConfig({
|
||||
isOpen: true,
|
||||
@@ -119,7 +137,10 @@ function AttachmentBrowserModal({ choreId, isOpen, onClose }) {
|
||||
{attachment.file_name || `File ${index + 1}`}
|
||||
</Typography>
|
||||
{attachment.size_bytes > 0 && (
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'text.tertiary' }}
|
||||
>
|
||||
{(attachment.size_bytes / 1024).toFixed(1)} KB
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
@@ -1078,6 +1078,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
|
||||
onChange={setDescription}
|
||||
value={description || ''}
|
||||
entityType={'chore_description'}
|
||||
draftId={draftId}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ClickAwayListener, Popper } from '@mui/material'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
import { useFileUpload } from '../../hooks/useFileUpload'
|
||||
import { DeleteDraftAttachment } from '../../utils/Fetcher'
|
||||
|
||||
const AttachmentPickerField = ({
|
||||
attachments = [],
|
||||
@@ -45,9 +46,12 @@ const AttachmentPickerField = ({
|
||||
if (!file) return
|
||||
setIsUploading(true)
|
||||
try {
|
||||
const url = await uploadFile(file)
|
||||
if (url) {
|
||||
onChange([...attachments, { url, name: file.name }])
|
||||
const uploaded = await uploadFile(file)
|
||||
if (uploaded) {
|
||||
onChange([
|
||||
...attachments,
|
||||
{ url: uploaded.url, path: uploaded.path, name: uploaded.fileName },
|
||||
])
|
||||
}
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
@@ -55,7 +59,17 @@ const AttachmentPickerField = ({
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = index => {
|
||||
const handleRemove = async index => {
|
||||
const attachment = attachments[index]
|
||||
// Draft uploads exist server-side too — delete there so they are not
|
||||
// promoted onto the chore when it is created.
|
||||
if (attachment?.path) {
|
||||
try {
|
||||
await DeleteDraftAttachment(attachment.path)
|
||||
} catch {
|
||||
// file may already be gone; still drop it from the list
|
||||
}
|
||||
}
|
||||
const updated = attachments.filter((_, i) => i !== index)
|
||||
onChange(updated)
|
||||
if (updated.length === 0) setIsOpen(false)
|
||||
@@ -91,7 +105,10 @@ const AttachmentPickerField = ({
|
||||
}}
|
||||
>
|
||||
{isUploading ? (
|
||||
<CircularProgress size='sm' sx={{ '--CircularProgress-size': '16px' }} />
|
||||
<CircularProgress
|
||||
size='sm'
|
||||
sx={{ '--CircularProgress-size': '16px' }}
|
||||
/>
|
||||
) : (
|
||||
<AttachFile sx={{ fontSize: '20px' }} />
|
||||
)}
|
||||
@@ -162,7 +179,14 @@ const AttachmentPickerField = ({
|
||||
}}
|
||||
>
|
||||
{attachments.length > 0 && (
|
||||
<Box sx={{ mb: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
mb: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
{attachments.map((attachment, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
@@ -238,7 +262,10 @@ const AttachmentPickerField = ({
|
||||
color='neutral'
|
||||
startDecorator={
|
||||
isUploading ? (
|
||||
<CircularProgress size='sm' sx={{ '--CircularProgress-size': '14px' }} />
|
||||
<CircularProgress
|
||||
size='sm'
|
||||
sx={{ '--CircularProgress-size': '14px' }}
|
||||
/>
|
||||
) : (
|
||||
<AttachFile sx={{ fontSize: 16 }} />
|
||||
)
|
||||
|
||||
@@ -12,7 +12,10 @@ class DtImageBlot extends ImageBlot {
|
||||
return node
|
||||
}
|
||||
static value(node) {
|
||||
return { src: node.getAttribute('src'), path: node.getAttribute('dt-data-path') }
|
||||
return {
|
||||
src: node.getAttribute('src'),
|
||||
path: node.getAttribute('dt-data-path'),
|
||||
}
|
||||
}
|
||||
static formats(node) {
|
||||
return { 'dt-data-path': node.getAttribute('dt-data-path') }
|
||||
@@ -36,14 +39,12 @@ import {
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import { useDescriptionHtml } from '../../hooks/useDescriptionHtml'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
import {
|
||||
isPlusAccount,
|
||||
refreshSignedUrlsInHtml,
|
||||
resolvePhotoURL,
|
||||
} from '../../utils/Helpers'
|
||||
import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers'
|
||||
import { patchDescriptionHtml } from '../../utils/ImageCache'
|
||||
import './RichTextEditor.css'
|
||||
|
||||
const RichTextEditor = forwardRef(
|
||||
@@ -56,11 +57,14 @@ const RichTextEditor = forwardRef(
|
||||
variant = 'outlined',
|
||||
entityId,
|
||||
entityType,
|
||||
draftId,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { showError } = useNotification()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
// Display-only HTML with expired image srcs swapped for cached/re-signed ones
|
||||
const displayHtml = useDescriptionHtml(value)
|
||||
const quillRef = useRef(null)
|
||||
const editorRef = useRef(null)
|
||||
const initialContentSet = useRef(false)
|
||||
@@ -132,11 +136,20 @@ const RichTextEditor = forwardRef(
|
||||
`Compressed size: ${(compressedJpegFile.size / 1024 / 1024).toFixed(2)} MB`,
|
||||
)
|
||||
|
||||
// Upload compressed image to backend
|
||||
// Upload compressed image to backend. Without a saved entity yet,
|
||||
// upload as a draft tied to draftId — the backend promotes drafts
|
||||
// to the real entity when the chore is created.
|
||||
const formData = new FormData()
|
||||
formData.append('file', compressedJpegFile)
|
||||
formData.append('entityId', entityId)
|
||||
formData.append('entityType', entityType)
|
||||
if (entityId) {
|
||||
formData.append('entityId', String(entityId))
|
||||
formData.append('entityType', entityType)
|
||||
} else if (draftId) {
|
||||
formData.append('entityType', `${entityType}_draft`)
|
||||
formData.append('draftId', draftId)
|
||||
} else {
|
||||
formData.append('entityType', entityType)
|
||||
}
|
||||
|
||||
const response = await apiClient.upload('/assets/chore', formData)
|
||||
|
||||
@@ -152,7 +165,7 @@ const RichTextEditor = forwardRef(
|
||||
message: 'The file you are trying to upload is too large.',
|
||||
})
|
||||
return
|
||||
} else if (response.status === 403 && !isPlusAccount()) {
|
||||
} else if (response.status === 403 && !isPlusAccount(userProfile)) {
|
||||
showError({
|
||||
title: 'Upgrade Required',
|
||||
message:
|
||||
@@ -173,9 +186,9 @@ const RichTextEditor = forwardRef(
|
||||
return
|
||||
}
|
||||
const data = await response.json()
|
||||
// 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.
|
||||
// data.sign is a fetchable signed URL; data.path is the stable
|
||||
// storage key kept in dt-data-path so the src can be re-signed
|
||||
// after the URL 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
|
||||
@@ -191,7 +204,7 @@ const RichTextEditor = forwardRef(
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [entityId, entityType, showError, userProfile]) // Dependencies for useCallback
|
||||
}, [entityId, entityType, draftId, showError, userProfile]) // Dependencies for useCallback
|
||||
|
||||
useEffect(() => {
|
||||
if (!quillRef.current) return
|
||||
@@ -223,27 +236,25 @@ const RichTextEditor = forwardRef(
|
||||
}
|
||||
})
|
||||
}
|
||||
// If switching to read-only mode, disable Quill instance
|
||||
if (editorRef.current && !isEditable) {
|
||||
// editorRef.current.disable()
|
||||
editorRef.current.readOnly = true
|
||||
|
||||
// If switching back to editable, enable Quill
|
||||
if (editorRef.current && isEditable) {
|
||||
// editorRef.current.enable()
|
||||
editorRef.current.readOnly = false
|
||||
}
|
||||
// Keep Quill's editing state in sync with the isEditable prop
|
||||
if (editorRef.current) {
|
||||
editorRef.current.enable(isEditable)
|
||||
}
|
||||
}, [onChange, value, isEditable, variant, handleImageUpload, userProfile]) // Added handleImageUpload and userProfile to dependency array
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current && isEditable) {
|
||||
if (editorRef.current.root.innerHTML !== value) {
|
||||
const html = !initialContentSet.current
|
||||
? refreshSignedUrlsInHtml(value || '')
|
||||
: value || ''
|
||||
editorRef.current.root.innerHTML = value || ''
|
||||
// On first load, swap expired image srcs for cached/re-signed ones
|
||||
if (!initialContentSet.current && value) {
|
||||
patchDescriptionHtml(value).then(html => {
|
||||
if (editorRef.current && html !== value) {
|
||||
editorRef.current.root.innerHTML = html
|
||||
}
|
||||
})
|
||||
}
|
||||
initialContentSet.current = true
|
||||
editorRef.current.root.innerHTML = html
|
||||
}
|
||||
}
|
||||
}, [value, isEditable])
|
||||
@@ -268,7 +279,7 @@ const RichTextEditor = forwardRef(
|
||||
boxShadow:
|
||||
'var(--joy-shadow-xs, 0px 1px 2px 0px rgba(16, 24, 40, 0.05))',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: refreshSignedUrlsInHtml(value) }}
|
||||
dangerouslySetInnerHTML={{ __html: displayHtml }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user