diff --git a/src/components/common/FadeModal.jsx b/src/components/common/FadeModal.jsx
index a87b44f..f6c6d9e 100644
--- a/src/components/common/FadeModal.jsx
+++ b/src/components/common/FadeModal.jsx
@@ -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 = ({
},
}}
>
+
{title && (
{title}
diff --git a/src/queries/ChoreQueries.jsx b/src/queries/ChoreQueries.jsx
index ec4883d..20186dc 100644
--- a/src/queries/ChoreQueries.jsx
+++ b/src/queries/ChoreQueries.jsx
@@ -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,
+ })
+}
diff --git a/src/utils/Helpers.jsx b/src/utils/Helpers.jsx
index 18a198f..5e33a12 100644
--- a/src/utils/Helpers.jsx
+++ b/src/utils/Helpers.jsx
@@ -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 }
diff --git a/src/views/ChoreEdit/ChoreEdit.jsx b/src/views/ChoreEdit/ChoreEdit.jsx
index e6fdeaa..a6d0a25 100644
--- a/src/views/ChoreEdit/ChoreEdit.jsx
+++ b/src/views/ChoreEdit/ChoreEdit.jsx
@@ -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) => (
{
+ 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' },
}}
>
@@ -1891,6 +1918,7 @@ const ChoreEdit = () => {
)}
+
{addLabelModalOpen && (
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,
}}
>
- {chore.name}
+ asde{chore.name}
{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) => (
{
{label?.name}
))}
+
+ {chore?.attachments?.length > 0 && (
+ }
+ size='md'
+ variant='soft'
+ color='neutral'
+ onClick={() => setAttachmentBrowserOpen(true)}
+ sx={{ cursor: 'pointer' }}
+ >
+ {chore.attachments.length}{' '}
+ {chore.attachments.length === 1 ? 'attachment' : 'attachments'}
+
+ )}
@@ -1324,6 +1341,11 @@ const ChoreView = () => {
+ setAttachmentBrowserOpen(false)}
+ />
)
diff --git a/src/views/Modals/Inputs/AttachmentBrowserModal.jsx b/src/views/Modals/Inputs/AttachmentBrowserModal.jsx
new file mode 100644
index 0000000..55b1f54
--- /dev/null
+++ b/src/views/Modals/Inputs/AttachmentBrowserModal.jsx
@@ -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 (
+ <>
+
+ }
+ onClick={handleClose}
+ >
+ Close
+
+
+ }
+ >
+ {isLoading ? (
+
+
+
+ ) : attachments.length === 0 ? (
+
+ No attachments found.
+
+ ) : (
+
+ {attachments.map((attachment, index) => (
+
+ handleAttachmentClick(attachment)}
+ sx={{ borderRadius: 'sm', gap: 1.5, py: 1 }}
+ >
+ {isImageFile(attachment.file_name) ? (
+
+ ) : (
+
+ )}
+
+
+ {attachment.file_name || `File ${index + 1}`}
+
+ {attachment.size_bytes > 0 && (
+
+ {(attachment.size_bytes / 1024).toFixed(1)} KB
+
+ )}
+
+
+
+ ))}
+
+ )}
+
+
+ >
+ )
+}
+
+export default AttachmentBrowserModal
diff --git a/src/views/Modals/Inputs/AttachmentViewerModal.jsx b/src/views/Modals/Inputs/AttachmentViewerModal.jsx
new file mode 100644
index 0000000..741a5ab
--- /dev/null
+++ b/src/views/Modals/Inputs/AttachmentViewerModal.jsx
@@ -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 (
+
+ }
+ onClick={handleClose}
+ >
+ Close
+
+ }
+ onClick={() => downloadUrl(url, fileName)}
+ disabled={!url}
+ >
+ Download
+
+
+ }
+ >
+
+ {!imgLoaded && !imgError && (
+
+ )}
+ {imgError ? (
+
+ Failed to load image.
+
+ ) : (
+ setImgLoaded(true)}
+ onError={() => {
+ setImgLoaded(true)
+ setImgError(true)
+ }}
+ sx={{
+ maxWidth: '100%',
+ maxHeight: '65vh',
+ borderRadius: 'md',
+ objectFit: 'contain',
+ display: imgLoaded && !imgError ? 'block' : 'none',
+ }}
+ />
+ )}
+
+
+ )
+}
+
+export default AttachmentViewerModal
diff --git a/src/views/components/RichTextEditor.css b/src/views/components/RichTextEditor.css
index 20147ea..042bf74 100644
--- a/src/views/components/RichTextEditor.css
+++ b/src/views/components/RichTextEditor.css
@@ -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 */
diff --git a/src/views/components/RichTextEditor.jsx b/src/views/components/RichTextEditor.jsx
index e776cd3..fe875eb 100644
--- a/src/views/components/RichTextEditor.jsx
+++ b/src/views/components/RichTextEditor.jsx
@@ -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])
diff --git a/src/views/components/SmartTaskTitleInput.css b/src/views/components/SmartTaskTitleInput.css
index 4e87af5..af7a6f2 100644
--- a/src/views/components/SmartTaskTitleInput.css
+++ b/src/views/components/SmartTaskTitleInput.css
@@ -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) {
diff --git a/src/views/components/SmartTaskTitleInput.jsx b/src/views/components/SmartTaskTitleInput.jsx
index 9a66b32..bd8a9ff 100644
--- a/src/views/components/SmartTaskTitleInput.jsx
+++ b/src/views/components/SmartTaskTitleInput.jsx
@@ -235,6 +235,8 @@ const SmartTaskTitleInput = ({
caretColor: mode === 'dark' ? '#fff' : '#000',
border: 'none',
outline: 'none',
+ border: 'none',
+ outline: 'none',
}}
/>