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

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