Part of #145. Twenty-three files across the task list zone: the list and card views, sorting and grouping, multi-select and its toolbar and help sheet, archived tasks, the assignee card, the chore action menu, the nudge/NFC/photo modals, the rich text editor, the scan panel, the notification templates and the keyboard-shortcut toasts. Extends the existing `chores` namespace, so `src/i18n/config.js` is untouched. English only — no translations, no behaviour change. Every t() value is checked against this branch's base: the string must appear character-for-character in the code it replaces (247 call sites). Two values are matched loosely and worth naming: archived.closeMultiSelect in both the archived view and the toolbar. The base builds that tooltip as `${size === 0 ? 'Close' : 'Clear'} multi-select (Esc)`, so only one branch of the ternary exists contiguously in the source. Both keys hold exactly what each branch renders; the tooltip is kept whole so a translator can reorder it. Rebased on current `develop` again after #215 and #216 landed — the dictionary conflict was theirs, not the code's. No code file in this PR was touched upstream in the meantime.
154 lines
4.9 KiB
JavaScript
154 lines
4.9 KiB
JavaScript
import { AttachFile, Image } from '@mui/icons-material'
|
|
import {
|
|
Box,
|
|
CircularProgress,
|
|
List,
|
|
ListItem,
|
|
ListItemButton,
|
|
Typography,
|
|
} from '@mui/joy'
|
|
import { useEffect, useState } from 'react'
|
|
import EmptyState from '../../../components/common/EmptyState'
|
|
import ModalActions from '../../../components/common/ModalActions'
|
|
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'
|
|
import { useTranslation } from 'react-i18next'
|
|
|
|
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 { t } = useTranslation('common')
|
|
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(async res => {
|
|
if (!res.ok) throw new Error(t('chores:dataError.attachmentsFailed'))
|
|
return res.json()
|
|
})
|
|
.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, t])
|
|
|
|
const handleClose = () => {
|
|
setAttachments([])
|
|
onClose?.()
|
|
}
|
|
|
|
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,
|
|
url,
|
|
fileName: attachment.file_name,
|
|
onClose: () => setViewerConfig({ isOpen: false }),
|
|
})
|
|
} else {
|
|
downloadFile(url, attachment.file_name)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<ResponsiveModal
|
|
open={!!isOpen}
|
|
onClose={handleClose}
|
|
title={t('attachments')}
|
|
footer={
|
|
<ModalActions primary={{ label: 'Done', onClick: handleClose }} />
|
|
}
|
|
>
|
|
{isLoading ? (
|
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
|
<CircularProgress size='md' />
|
|
</Box>
|
|
) : attachments.length === 0 ? (
|
|
<EmptyState
|
|
size='sm'
|
|
icon={<AttachFile />}
|
|
title='No attachments'
|
|
description='Photos and files added to this task will show up here.'
|
|
/>
|
|
) : (
|
|
<List sx={{ '--ListItem-paddingX': '0px' }}>
|
|
{attachments.map((attachment, index) => (
|
|
<ListItem
|
|
key={
|
|
attachment.id ||
|
|
attachment.file_path ||
|
|
attachment.sign ||
|
|
attachment.file_name ||
|
|
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 || t('fileNumbered', { index: 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
|