feat: implement attachment browser and viewer modals, enhance chore attachment handling
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
125
src/views/Modals/Inputs/AttachmentBrowserModal.jsx
Normal file
125
src/views/Modals/Inputs/AttachmentBrowserModal.jsx
Normal 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
|
||||
114
src/views/Modals/Inputs/AttachmentViewerModal.jsx
Normal file
114
src/views/Modals/Inputs/AttachmentViewerModal.jsx
Normal 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
|
||||
@@ -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 */
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -235,6 +235,8 @@ const SmartTaskTitleInput = ({
|
||||
caretColor: mode === 'dark' ? '#fff' : '#000',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user