Files
donetick/src/views/Modals/Inputs/AttachmentBrowserModal.jsx
everysingletear 7d36090e46 i18n: extract the chore list and its modals (chores namespace)
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.
2026-08-15 09:03:28 +08:00

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