feat: implement attachment browser and viewer modals, enhance chore attachment handling

This commit is contained in:
Mo Tarbin
2026-07-06 18:11:57 -04:00
parent 9136ff3ea3
commit 24508be4d4
11 changed files with 371 additions and 42 deletions

View File

@@ -1,4 +1,4 @@
import { Modal, ModalDialog, ModalOverflow, Typography } from '@mui/joy'
import { Modal, ModalClose, ModalDialog, ModalOverflow, Typography } from '@mui/joy'
import { Z_INDEX } from '../../constants/zIndex'
/**
@@ -78,6 +78,7 @@ const FadeModal = ({
},
}}
>
<ModalClose />
{title && (
<Typography level='title-lg' sx={{ fontWeight: 600, mb: 2 }}>
{title}

View File

@@ -8,6 +8,7 @@ import {
CreateChore,
DeleteChore,
DeleteChoreHistory,
GetChoreAttachments,
GetChoreByID,
GetChoreDetailById,
GetChoreHistory,
@@ -696,3 +697,19 @@ export const useRejectChore = () => {
},
})
}
export const useChoreAttachments = (choreId, hasAttachments = true) => {
return useQuery({
queryKey: ['choreAttachments', choreId],
queryFn: async () => {
const response = await GetChoreAttachments(choreId)
if (response && response.ok) {
return await response.json()
}
throw new Error('Failed to fetch attachments')
},
enabled: !!choreId && hasAttachments,
staleTime: 10 * 60 * 1000,
gcTime: 15 * 60 * 1000,
})
}

View File

@@ -17,14 +17,13 @@ const resolvePhotoURL = url => {
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
url.includes('X-Amz-Signature') ||
url.includes('X-Amz-Expires') ||
url.includes('X-Goog-Expires') ||
url.includes('expires')
)
} catch {
} catch(e) {
return false
}
}
@@ -86,32 +85,35 @@ const extractStorageKey = url => {
// 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 => {
console.debug('1. refreshSignedUrlsInHtml', { html })
if (!html) return html
if (
!html.includes('X-Amz-') &&
!html.includes('X-Goog-') &&
!html.includes('sig') &&
!html.includes('.blob.core.windows.net')
) {
return html
}
console.debug('2. refreshSignedUrlsInHtml: found potential signed URLs, parsing HTML...')
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
const imgs = doc.querySelectorAll('img[src]')
let changed = false
imgs.forEach(img => {
imgs.forEach(async img => {
if (!img.getAttribute('dt-data-path')) {
// not custom tag, skipping:
return
}
const src = img.getAttribute('src')
if (!isCloudSignedUrl(src)) return
const key = extractStorageKey(src)
if (!key) return
img.setAttribute('src', apiClient.getAssetURL(key))
img.setAttribute('src', resolvePhotoURL(src))
changed = true
})
return changed ? doc.body.innerHTML : html
}
export { extractStorageKey, isPlusAccount, refreshSignedUrlsInHtml, resolvePhotoURL }
export { isPlusAccount, refreshSignedUrlsInHtml, resolvePhotoURL }

View File

@@ -54,11 +54,10 @@ import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
import {
DeleteChoreAttachment,
GetAllCircleMembers,
GetChoreAttachments,
GetThings,
UploadChoreAttachment,
} from '../../utils/Fetcher'
import { isPlusAccount } from '../../utils/Helpers'
import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers'
import Priorities from '../../utils/Priorities.jsx'
import { getIconComponent } from '../../utils/ProjectIcons'
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
@@ -67,6 +66,7 @@ import LoadingComponent from '../components/Loading.jsx'
import RichTextEditor from '../components/RichTextEditor.jsx'
import SubTasks from '../components/SubTask.jsx'
import { useLabels } from '../Labels/LabelQueries'
import AttachmentViewerModal from '../Modals/Inputs/AttachmentViewerModal'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import LabelModal from '../Modals/Inputs/LabelModal'
import { useProjects } from '../Projects/ProjectQueries'
@@ -135,6 +135,9 @@ const ChoreEdit = () => {
const [attachments, setAttachments] = useState([])
const [isUploadingAttachment, setIsUploadingAttachment] = useState(false)
const [addLabelModalOpen, setAddLabelModalOpen] = useState(false)
const [attachmentViewerConfig, setAttachmentViewerConfig] = useState({
isOpen: false,
})
const [showSavePrivacyDefault, setShowSavePrivacyDefault] = useState(false)
const [privacySaved, setPrivacySaved] = useState(false)
const [showSaveNotificationDefault, setShowSaveNotificationDefault] =
@@ -423,14 +426,6 @@ 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) {
@@ -602,6 +597,7 @@ const ChoreEdit = () => {
setCreatedBy(data.res.createdBy)
setUpdatedBy(data.res.updatedBy)
setAttachments(data.res.attachments || [])
}
}, [choreData, isChoreLoading, searchParams])
@@ -977,6 +973,35 @@ const ChoreEdit = () => {
{attachments.map((att, idx) => (
<Box
key={att.file_path || idx}
onClick={() => {
const url = resolvePhotoURL(att.sign || att.file_path)
const ext = att.file_name?.split('.').pop().toLowerCase()
const isImage = [
'jpg',
'jpeg',
'png',
'gif',
'webp',
'bmp',
'svg',
].includes(ext)
if (isImage) {
setAttachmentViewerConfig({
isOpen: true,
url,
fileName: att.file_name,
onClose: () =>
setAttachmentViewerConfig({ isOpen: false }),
})
} else {
const a = document.createElement('a')
a.href = url
a.download = att.file_name || 'attachment'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
}}
sx={{
display: 'flex',
alignItems: 'center',
@@ -985,6 +1010,8 @@ const ChoreEdit = () => {
borderRadius: 'sm',
border: '1px solid',
borderColor: 'neutral.outlinedBorder',
cursor: 'pointer',
'&:hover': { bgcolor: 'neutral.softHoverBg' },
}}
>
<AttachFile sx={{ fontSize: 18, color: 'neutral.500' }} />
@@ -1891,6 +1918,7 @@ const ChoreEdit = () => {
)}
</Button>
</Sheet>
<AttachmentViewerModal config={attachmentViewerConfig} />
<ConfirmationModal config={confirmModelConfig} />
{addLabelModalOpen && (
<LabelModal

View File

@@ -1,5 +1,6 @@
import {
Archive,
AttachFile,
CalendarMonth,
Check,
Checklist,
@@ -78,6 +79,7 @@ import {
import { offlineDB } from '../../utils/OfflineDB'
import Priorities from '../../utils/Priorities'
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
import AttachmentBrowserModal from '../Modals/Inputs/AttachmentBrowserModal'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
import LoadingComponent from '../components/Loading.jsx'
@@ -86,6 +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'
const isNetworkError = err =>
err instanceof TypeError && err.message === 'Failed to fetch'
@@ -124,6 +127,7 @@ const ChoreView = () => {
const [chorePriority, setChorePriority] = useState(null)
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
const [timerActionConfig, setTimerActionConfig] = useState({ isOpen: false })
const [attachmentBrowserOpen, setAttachmentBrowserOpen] = useState(false)
const { data: circleMembersData, isLoading: isCircleMembersLoading } =
useCircleMembers()
const { data: userProfile } = useUserProfile()
@@ -132,6 +136,7 @@ const ChoreView = () => {
const { data: choreData, isLoading: isChoreLoading } =
useChoreDetails(choreId)
const { data: choreHistoryData } = useChoreHistory(choreId)
const { data: pendingCmds } = usePendingCommands(choreId)
const choreHistory = choreHistoryData?.res || []
@@ -626,7 +631,7 @@ const ChoreView = () => {
mb: 0.5,
}}
>
<Typography level='h3'>{chore.name}</Typography>
<Typography level='h3'>asde{chore.name}</Typography>
<PendingBadge commands={pendingCmds} />
</Box>
{chore.isActive === false && (
@@ -651,16 +656,14 @@ const ChoreView = () => {
justifyContent: 'center',
alignItems: 'center',
mb: 1,
flexWrap: 'wrap',
gap: 0.5,
}}
>
{chore?.labelsV2?.map((label, index) => (
<Chip
key={index}
sx={{
position: 'relative',
ml: index === 0 ? 0 : 0.5,
top: 2,
zIndex: 1,
backgroundColor: label?.color,
color: getTextColorFromBackgroundColor(label?.color),
}}
@@ -668,6 +671,20 @@ const ChoreView = () => {
{label?.name}
</Chip>
))}
{chore?.attachments?.length > 0 && (
<Chip
startDecorator={<AttachFile />}
size='md'
variant='soft'
color='neutral'
onClick={() => setAttachmentBrowserOpen(true)}
sx={{ cursor: 'pointer' }}
>
{chore.attachments.length}{' '}
{chore.attachments.length === 1 ? 'attachment' : 'attachments'}
</Chip>
)}
</Box>
</Box>
@@ -1324,6 +1341,11 @@ const ChoreView = () => {
<ConfirmationModal config={confirmModelConfig} />
<ConfirmationModal config={timerActionConfig} />
<NoteViewerModal config={noteViewerConfig} />
<AttachmentBrowserModal
choreId={choreId}
isOpen={attachmentBrowserOpen}
onClose={() => setAttachmentBrowserOpen(false)}
/>
</Card>
</Container>
)

View File

@@ -0,0 +1,125 @@
import { AttachFile, Close, Image } from '@mui/icons-material'
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 AttachmentViewerModal from './AttachmentViewerModal'
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg']
const isImageFile = fileName => {
if (!fileName) return false
const ext = fileName.split('.').pop().toLowerCase()
return IMAGE_EXTENSIONS.includes(ext)
}
const downloadFile = (url, fileName) => {
const a = document.createElement('a')
a.href = url
a.download = fileName || 'attachment'
a.rel = 'noopener'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
function AttachmentBrowserModal({ choreId, isOpen, onClose }) {
const { ResponsiveModal } = useResponsiveModal()
const [attachments, setAttachments] = useState([])
const [isLoading, setIsLoading] = useState(false)
const [viewerConfig, setViewerConfig] = useState({ isOpen: false })
useEffect(() => {
if (!isOpen || !choreId) return
setIsLoading(true)
GetChoreAttachments(choreId)
.then(res => res.json())
.then(data => setAttachments(Array.isArray(data) ? data : []))
.finally(() => setIsLoading(false))
}, [isOpen, choreId])
const handleClose = () => {
setAttachments([])
onClose?.()
}
const handleAttachmentClick = attachment => {
const url = resolvePhotoURL(attachment.sign)
if (isImageFile(attachment.file_name)) {
setViewerConfig({
isOpen: true,
url,
fileName: attachment.file_name,
onClose: () => setViewerConfig({ isOpen: false }),
})
} else {
downloadFile(url, attachment.file_name)
}
}
return (
<>
<ResponsiveModal
open={!!isOpen}
onClose={handleClose}
title='Attachments'
footer={
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant='plain'
color='neutral'
startDecorator={<Close />}
onClick={handleClose}
>
Close
</Button>
</Box>
}
>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size='md' />
</Box>
) : attachments.length === 0 ? (
<Typography
level='body-sm'
sx={{ color: 'text.secondary', py: 2, textAlign: 'center' }}
>
No attachments found.
</Typography>
) : (
<List sx={{ '--ListItem-paddingX': '0px' }}>
{attachments.map((attachment, index) => (
<ListItem key={index} sx={{ p: 0 }}>
<ListItemButton
onClick={() => handleAttachmentClick(attachment)}
sx={{ borderRadius: 'sm', gap: 1.5, py: 1 }}
>
{isImageFile(attachment.file_name) ? (
<Image fontSize='small' />
) : (
<AttachFile fontSize='small' />
)}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography level='body-sm' noWrap>
{attachment.file_name || `File ${index + 1}`}
</Typography>
{attachment.size_bytes > 0 && (
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
{(attachment.size_bytes / 1024).toFixed(1)} KB
</Typography>
)}
</Box>
</ListItemButton>
</ListItem>
))}
</List>
)}
</ResponsiveModal>
<AttachmentViewerModal config={viewerConfig} />
</>
)
}
export default AttachmentBrowserModal

View File

@@ -0,0 +1,114 @@
import { Browser } from '@capacitor/browser'
import { Capacitor } from '@capacitor/core'
import { Close, Download } from '@mui/icons-material'
import { Box, Button, CircularProgress, Typography } from '@mui/joy'
import { useState } from 'react'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
const openUrl = async url => {
if (Capacitor.isNativePlatform()) {
await Browser.open({ url })
} else {
window.open(url, '_blank', 'noopener,noreferrer')
}
}
const downloadUrl = (url, fileName) => {
if (Capacitor.isNativePlatform()) {
Browser.open({ url })
} else {
const a = document.createElement('a')
a.href = url
a.download = fileName || 'attachment'
a.rel = 'noopener'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
}
function AttachmentViewerModal({ config }) {
const { ResponsiveModal } = useResponsiveModal()
const [imgLoaded, setImgLoaded] = useState(false)
const [imgError, setImgError] = useState(false)
const { isOpen, url, fileName, onClose } = config || {}
const handleClose = () => {
setImgLoaded(false)
setImgError(false)
onClose?.()
}
return (
<ResponsiveModal
open={!!isOpen}
onClose={handleClose}
title={fileName || 'Attachment'}
maxHeight='92vh'
footer={
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button
variant='plain'
color='neutral'
startDecorator={<Close />}
onClick={handleClose}
>
Close
</Button>
<Button
variant='soft'
color='neutral'
startDecorator={<Download />}
onClick={() => downloadUrl(url, fileName)}
disabled={!url}
>
Download
</Button>
</Box>
}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: 200,
position: 'relative',
}}
>
{!imgLoaded && !imgError && (
<CircularProgress
sx={{ position: 'absolute' }}
size='md'
/>
)}
{imgError ? (
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
Failed to load image.
</Typography>
) : (
<Box
component='img'
src={url}
alt={fileName}
onLoad={() => setImgLoaded(true)}
onError={() => {
setImgLoaded(true)
setImgError(true)
}}
sx={{
maxWidth: '100%',
maxHeight: '65vh',
borderRadius: 'md',
objectFit: 'contain',
display: imgLoaded && !imgError ? 'block' : 'none',
}}
/>
)}
</Box>
</ResponsiveModal>
)
}
export default AttachmentViewerModal

View File

@@ -53,20 +53,33 @@
}
/* Material-UI style customizations for Quill toolbar */
.quill-root {
border-radius: 10px;
overflow: hidden;
background-color: var(--joy-palette-neutral-softBg, #f0f4f8);
transition: box-shadow 0.15s ease, background-color 0.15s ease;
}
.quill-root:focus-within {
box-shadow: 0 0 0 1px var(--joy-palette-primary-outlinedBorder, rgba(11, 107, 203, 0.15));
}
.quill-root:hover:not(:focus-within) {
background-color: var(--joy-palette-neutral-softHoverBg, #dde7ee);
}
.quill-root .ql-toolbar.ql-snow {
border: 1px solid var(--joy-palette-neutral-outlinedBorder, #dde7ee);
border-bottom: none;
border-radius: 8px 8px 0 0;
background: var(--joy-palette-background-surface, #fff);
box-shadow: var(--joy-shadow-xs, 0px 1px 2px 0px rgba(16, 24, 40, 0.05));
padding: 12px 16px;
border: none;
border-bottom: 1px solid var(--joy-palette-neutral-softActiveBg, rgba(99, 107, 116, 0.16));
border-radius: 0;
background: transparent;
box-shadow: none;
padding: 8px 12px;
}
.quill-root .ql-container.ql-snow {
border: 1px solid var(--joy-palette-neutral-outlinedBorder, #dde7ee);
border-top: none;
border-radius: 0 0 8px 8px;
background: var(--joy-palette-background-surface, #fff);
border: none;
background: transparent;
}
/* Style toolbar buttons with Material-UI look */

View File

@@ -63,6 +63,7 @@ const RichTextEditor = forwardRef(
const { data: userProfile } = useUserProfile()
const quillRef = useRef(null)
const editorRef = useRef(null)
const initialContentSet = useRef(false)
// Expose focus method to parent components
useImperativeHandle(
@@ -238,7 +239,11 @@ const RichTextEditor = forwardRef(
useEffect(() => {
if (editorRef.current && isEditable) {
if (editorRef.current.root.innerHTML !== value) {
editorRef.current.root.innerHTML = value || ''
const html = !initialContentSet.current
? refreshSignedUrlsInHtml(value || '')
: value || ''
initialContentSet.current = true
editorRef.current.root.innerHTML = html
}
}
}, [value, isEditable])

View File

@@ -69,7 +69,7 @@
}
.task-input:focus-within {
box-shadow: 0 0 0 1px var(--joy-palette-primary-outlinedBorder, rgba(11, 107, 203, 0.15));
box-shadow: 0 0 0 2px var(--joy-palette-primary-outlinedBorder, rgba(11, 107, 203, 0.15));
}
.task-input:hover:not(:focus-within) {

View File

@@ -235,6 +235,8 @@ const SmartTaskTitleInput = ({
caretColor: mode === 'dark' ? '#fff' : '#000',
border: 'none',
outline: 'none',
border: 'none',
outline: 'none',
}}
/>
<div