Part of #145. Fourteen files whose user-facing strings are generic enough to belong in `common` — loading and empty states, the confirmation modal, the shared input modals (text/date/user/attachment viewer), the mobile nav bar, the autocomplete input, the error screen and the file-upload error paths. Extends the namespace that already exists, so `src/i18n/config.js` is untouched and this cannot collide with any other extraction PR over the `ns:` array. 28 keys added to `public/locales/en/common.json`. 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 (36 call sites). One value is matched loosely and worth naming: `errorScreen.hideDetails`. The base renders `{showDetails ? 'Hide' : 'Show'} error details`, so neither full phrase exists contiguously in the source — only one branch of the ternary can. Both keys hold exactly what each branch renders. The sentence is kept whole rather than split around the ternary, because a split sentence cannot be reordered by a translator.
107 lines
2.9 KiB
JavaScript
107 lines
2.9 KiB
JavaScript
import { Browser } from '@capacitor/browser'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { Capacitor } from '@capacitor/core'
|
|
import { Download } from '@mui/icons-material'
|
|
import { Box, CircularProgress, Typography } from '@mui/joy'
|
|
import { useState } from 'react'
|
|
import ModalActions from '../../../components/common/ModalActions'
|
|
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 { t } = useTranslation('common')
|
|
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={
|
|
<ModalActions
|
|
secondary={{ label: t('close'), onClick: handleClose }}
|
|
primary={{
|
|
label: 'Download',
|
|
startDecorator: <Download />,
|
|
onClick: () => downloadUrl(url, fileName),
|
|
disabled: !url,
|
|
}}
|
|
/>
|
|
}
|
|
>
|
|
<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' }}>
|
|
{t('imageLoadFailed')}
|
|
</Typography>
|
|
) : (
|
|
<Box
|
|
component='img'
|
|
src={url}
|
|
alt={fileName}
|
|
onClick={() => url && openUrl(url)}
|
|
onLoad={() => setImgLoaded(true)}
|
|
onError={() => {
|
|
setImgLoaded(true)
|
|
setImgError(true)
|
|
}}
|
|
sx={{
|
|
cursor: url ? 'zoom-in' : 'default',
|
|
maxWidth: '100%',
|
|
maxHeight: '65vh',
|
|
borderRadius: 'md',
|
|
objectFit: 'contain',
|
|
display: imgLoaded && !imgError ? 'block' : 'none',
|
|
}}
|
|
/>
|
|
)}
|
|
</Box>
|
|
</ResponsiveModal>
|
|
)
|
|
}
|
|
|
|
export default AttachmentViewerModal
|