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 ( + <> + + + + } + > + {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 ( + + + + + } + > + + {!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', }} />