From db0c199674cfe65322d1dac0d45e9772e9e7c51b Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Thu, 2 Jul 2026 20:53:12 -0400 Subject: [PATCH] feat: refactor NotificationTemplate and remove unused components for cleaner code --- src/components/NotificationTemplate.jsx | 3 +- src/hooks/useFileUpload.js | 91 ++++++++ .../components/AssigneePickerPreview.jsx | 40 ---- src/views/components/DueDatePickerPreview.jsx | 221 ------------------ src/views/components/RepeatPickerPreview.jsx | 211 ----------------- 5 files changed, 93 insertions(+), 473 deletions(-) create mode 100644 src/hooks/useFileUpload.js delete mode 100644 src/views/components/AssigneePickerPreview.jsx delete mode 100644 src/views/components/DueDatePickerPreview.jsx delete mode 100644 src/views/components/RepeatPickerPreview.jsx diff --git a/src/components/NotificationTemplate.jsx b/src/components/NotificationTemplate.jsx index 734fd0b..9f468fa 100644 --- a/src/components/NotificationTemplate.jsx +++ b/src/components/NotificationTemplate.jsx @@ -12,7 +12,7 @@ import Input from '@mui/joy/Input' import Option from '@mui/joy/Option' import Select from '@mui/joy/Select' import Typography from '@mui/joy/Typography' -import { useCallback, useEffect, useState, useRef } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { NOTIFICATION_TYPE, TASK_COLOR } from '../utils/Colors' import { TIME_UNITS } from '../utils/DurationUtils' @@ -512,6 +512,7 @@ const NotificationTemplate = ({ '--Badge-fontSize': '0.7rem', '--Badge-paddingX': '5px', top: 10, + left: 10, '& .MuiBadge-badge': { background: colors.bgColor, color: 'white', diff --git a/src/hooks/useFileUpload.js b/src/hooks/useFileUpload.js new file mode 100644 index 0000000..1aba6f9 --- /dev/null +++ b/src/hooks/useFileUpload.js @@ -0,0 +1,91 @@ +import imageCompression from 'browser-image-compression' +import { useCallback } from 'react' +import { useUserProfile } from '../queries/UserQueries' +import { useNotification } from '../service/NotificationProvider' +import { apiClient } from '../utils/ApiClient' +import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers' + +export const useFileUpload = ({ entityType = 'chore_attachment', entityId } = {}) => { + const { showError } = useNotification() + const { data: userProfile } = useUserProfile() + + const uploadFile = useCallback( + async file => { + if (!isPlusAccount(userProfile)) { + showError({ + title: 'Plus Feature', + message: + 'Image uploads are not available in the Basic plan. Upgrade to Plus to add images to your content.', + }) + return null + } + + try { + const compressionOptions = { + maxSizeMB: entityType === 'profile' ? 0.5 : 1, + maxWidthOrHeight: entityType === 'profile' ? 320 : 1200, + useWebWorker: true, + fileType: 'image/jpeg', + } + + const compressedFile = await imageCompression(file, compressionOptions) + const compressedJpegFile = new File( + [compressedFile], + `${file.name.split('.')[0]}.jpg`, + { type: 'image/jpeg' }, + ) + + const formData = new FormData() + formData.append('file', compressedJpegFile) + formData.append('entityType', entityType) + if (entityId) formData.append('entityId', entityId) + + const response = await apiClient.upload('/assets/chore', formData) + + if (response.status === 507) { + showError({ + title: 'Storage Quota Exceeded', + message: 'You have exceeded your quota for uploading files.', + }) + return null + } else if (response.status === 413) { + showError({ + title: 'File Too Large', + message: 'The file you are trying to upload is too large.', + }) + return null + } else if (response.status === 403 && !isPlusAccount(userProfile)) { + showError({ + title: 'Upgrade Required', + message: 'Image uploads are only available for Plus accounts.', + }) + return null + } else if (response.status === 403) { + showError({ + title: 'Permission Denied', + message: 'You do not have permission to upload files.', + }) + return null + } else if (!response.ok) { + showError({ + title: 'Upload Failed', + message: 'Failed to upload image.', + }) + return null + } + + const data = await response.json() + return resolvePhotoURL(data.url || data.sign) + } catch { + showError({ + title: 'Upload Failed', + message: 'An error occurred while processing the image.', + }) + return null + } + }, + [entityType, entityId, showError, userProfile], + ) + + return { uploadFile, isPlus: isPlusAccount(userProfile) } +} diff --git a/src/views/components/AssigneePickerPreview.jsx b/src/views/components/AssigneePickerPreview.jsx deleted file mode 100644 index 82330e3..0000000 --- a/src/views/components/AssigneePickerPreview.jsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Person } from '@mui/icons-material' -import BaseOptionPicker from './BaseOptionPicker' - -const AssigneePickerPreview = ({ - value = null, - onChange, - onClear, - members = [], - includeAnyone = true, - emptyDisplay, -}) => { - const options = [ - ...(includeAnyone ? [{ userId: 'anyone', displayName: 'Anyone' }] : []), - ...members.map(member => ({ - userId: member.userId, - displayName: member.displayName || member.username || 'Unknown', - })), - ] - - return ( - item.userId} - getItemLabel={item => item.displayName} - renderTriggerIcon={() => } - renderItemStart={() => } - getTriggerText={({ selectedItems, isEmpty }) => - isEmpty ? 'Assignee' : selectedItems[0].displayName - } - menuMinWidth={220} - /> - ) -} - -export default AssigneePickerPreview diff --git a/src/views/components/DueDatePickerPreview.jsx b/src/views/components/DueDatePickerPreview.jsx deleted file mode 100644 index 989b59a..0000000 --- a/src/views/components/DueDatePickerPreview.jsx +++ /dev/null @@ -1,221 +0,0 @@ -import { CalendarMonth, Close } from '@mui/icons-material' -import { Box, Button, IconButton, Input, Sheet, Typography } from '@mui/joy' -import { ClickAwayListener, Popper } from '@mui/material' -import moment from 'moment' -import { useEffect, useMemo, useRef, useState } from 'react' -import { Z_INDEX } from '../../constants/zIndex' - -const DueDatePickerPreview = ({ - dueDateOnly, - dueTime, - useCustomTime, - onDueDateChange, - onDueTimeChange, - onUseCustomTimeChange, - onClear, - emptyDisplay = 'icon-text', - size = 'sm', -}) => { - const [isOpen, setIsOpen] = useState(false) - const buttonRef = useRef(null) - - useEffect(() => { - if (!isOpen) return - - const handleEscape = event => { - if (event.key === 'Escape') { - setIsOpen(false) - } - } - - document.addEventListener('keydown', handleEscape) - return () => { - document.removeEventListener('keydown', handleEscape) - } - }, [isOpen]) - - const hasDueDate = Boolean(dueDateOnly) - const shouldShowLabel = hasDueDate || emptyDisplay === 'icon-text' - - const dueDateLabel = useMemo(() => { - if (!dueDateOnly) { - return 'Due' - } - - const formattedDate = moment(dueDateOnly).format('MMM D') - if (useCustomTime && dueTime) { - return `${formattedDate}, ${dueTime}` - } - - return formattedDate - }, [dueDateOnly, dueTime, useCustomTime]) - - return ( - - - {hasDueDate && onClear && ( - { - e.stopPropagation() - onClear?.() - }} - sx={{ - position: 'absolute', - top: -12, - right: -16, - zIndex: 10, - maxHeight: 18, - maxWidth: 18, - borderRadius: '50%', - '&:hover': { - bgcolor: 'danger.softBg', - }, - }} - > - - - )} - - {isOpen && ( - - setIsOpen(false)}> - - - Due Date - - - - Due time (optional) - - { - if (!useCustomTime) { - onUseCustomTimeChange?.(true) - } - onDueTimeChange?.(e) - }} - sx={{ maxWidth: 200, mb: 1 }} - /> - - - - - {hasDueDate && ( - - )} - - - - )} - - ) -} - -export default DueDatePickerPreview diff --git a/src/views/components/RepeatPickerPreview.jsx b/src/views/components/RepeatPickerPreview.jsx deleted file mode 100644 index 5adb0bc..0000000 --- a/src/views/components/RepeatPickerPreview.jsx +++ /dev/null @@ -1,211 +0,0 @@ -import { Close, Repeat } from '@mui/icons-material' -import { Box, Button, IconButton, Sheet, Typography } from '@mui/joy' -import { ClickAwayListener, Popper } from '@mui/material' -import { useEffect, useRef, useState } from 'react' -import { Z_INDEX } from '../../constants/zIndex' -import { getRecurrentChipText } from '../../utils/ChoreCardHelpers' - -const REPEAT_PRESETS = [ - { - id: 'daily', - label: 'Daily', - frequencyType: 'interval', - frequency: 1, - frequencyMetadata: { unit: 'days' }, - }, - { - id: 'weekly', - label: 'Weekly', - frequencyType: 'interval', - frequency: 1, - frequencyMetadata: { unit: 'weeks' }, - }, - { - id: 'monthly', - label: 'Monthly', - frequencyType: 'interval', - frequency: 1, - frequencyMetadata: { unit: 'months' }, - }, - { - id: 'yearly', - label: 'Yearly', - frequencyType: 'interval', - frequency: 1, - frequencyMetadata: { unit: 'years' }, - }, -] - -const matchPreset = value => { - if (!value) return null - return ( - REPEAT_PRESETS.find( - p => - p.frequencyType === value.frequencyType && - p.frequency === value.frequency && - p.frequencyMetadata?.unit === value.frequencyMetadata?.unit, - ) || null - ) -} - -const RepeatPickerPreview = ({ - value, - onChange, - onClear, - emptyDisplay = 'icon-text', - size = 'sm', -}) => { - const [isOpen, setIsOpen] = useState(false) - const buttonRef = useRef(null) - - useEffect(() => { - if (!isOpen) return - const handleEscape = event => { - if (event.key === 'Escape') setIsOpen(false) - } - document.addEventListener('keydown', handleEscape) - return () => document.removeEventListener('keydown', handleEscape) - }, [isOpen]) - - const hasRepeat = Boolean(value) - const selectedPreset = matchPreset(value) - const shouldShowLabel = hasRepeat || emptyDisplay === 'icon-text' - const displayLabel = hasRepeat ? getRecurrentChipText(value) : 'Repeat' - - return ( - - - - {hasRepeat && onClear && ( - { - e.stopPropagation() - onClear?.() - }} - sx={{ - position: 'absolute', - top: -12, - right: -16, - zIndex: 10, - maxHeight: 18, - maxWidth: 18, - borderRadius: '50%', - '&:hover': { bgcolor: 'danger.softBg' }, - }} - > - - - )} - - {isOpen && ( - - setIsOpen(false)}> - - {REPEAT_PRESETS.map((preset, index) => { - const isSelected = selectedPreset?.id === preset.id - return ( - - ) - })} - {hasRepeat && ( - - )} - - - - )} - - ) -} - -export default RepeatPickerPreview