feat: refactor NotificationTemplate and remove unused components for cleaner code
This commit is contained in:
@@ -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',
|
||||
|
||||
91
src/hooks/useFileUpload.js
Normal file
91
src/hooks/useFileUpload.js
Normal file
@@ -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) }
|
||||
}
|
||||
@@ -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 (
|
||||
<BaseOptionPicker
|
||||
items={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onClear={onClear}
|
||||
emptyDisplay={emptyDisplay}
|
||||
emptyLabel='Assignee'
|
||||
getItemValue={item => item.userId}
|
||||
getItemLabel={item => item.displayName}
|
||||
renderTriggerIcon={() => <Person sx={{ fontSize: '20px' }} />}
|
||||
renderItemStart={() => <Person sx={{ fontSize: '18px' }} />}
|
||||
getTriggerText={({ selectedItems, isEmpty }) =>
|
||||
isEmpty ? 'Assignee' : selectedItems[0].displayName
|
||||
}
|
||||
menuMinWidth={220}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default AssigneePickerPreview
|
||||
@@ -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 (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
size={size}
|
||||
variant={hasDueDate ? 'soft' : 'outlined'}
|
||||
color='neutral'
|
||||
onClick={() => setIsOpen(prev => !prev)}
|
||||
sx={{
|
||||
minHeight: 40,
|
||||
borderRadius: '128px',
|
||||
minWidth: 'min-content',
|
||||
px: shouldShowLabel ? 1.25 : 0.75,
|
||||
gap: shouldShowLabel ? 1 : 0,
|
||||
justifyContent: 'flex-start',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'all 0.25s ease-in-out',
|
||||
}}
|
||||
>
|
||||
<CalendarMonth sx={{ fontSize: '20px' }} />
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: shouldShowLabel ? 220 : 0,
|
||||
opacity: shouldShowLabel ? 1 : 0,
|
||||
transform: shouldShowLabel ? 'translateX(0)' : 'translateX(-4px)',
|
||||
transition:
|
||||
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
|
||||
}}
|
||||
>
|
||||
{dueDateLabel}
|
||||
</Typography>
|
||||
</Button>
|
||||
{hasDueDate && onClear && (
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onClear?.()
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
right: -16,
|
||||
zIndex: 10,
|
||||
maxHeight: 18,
|
||||
maxWidth: 18,
|
||||
borderRadius: '50%',
|
||||
'&:hover': {
|
||||
bgcolor: 'danger.softBg',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Close sx={{ fontSize: '18px' }} />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{isOpen && (
|
||||
<Popper
|
||||
open={isOpen}
|
||||
anchorEl={buttonRef.current}
|
||||
placement='top-start'
|
||||
modifiers={[
|
||||
{
|
||||
name: 'offset',
|
||||
options: {
|
||||
offset: [0, 8],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'flip',
|
||||
options: {
|
||||
fallbackPlacements: ['bottom-start', 'top-start'],
|
||||
},
|
||||
},
|
||||
]}
|
||||
sx={{ zIndex: Z_INDEX.MODAL_CLOSE_BUTTON + 1 }}
|
||||
>
|
||||
<ClickAwayListener onClickAway={() => setIsOpen(false)}>
|
||||
<Sheet
|
||||
variant='outlined'
|
||||
sx={{
|
||||
minWidth: 260,
|
||||
p: 1,
|
||||
borderRadius: 'md',
|
||||
boxShadow: 'lg',
|
||||
bgcolor: 'background.popup',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm' sx={{ mb: 0.5 }}>
|
||||
Due Date
|
||||
</Typography>
|
||||
<Input
|
||||
type='date'
|
||||
value={dueDateOnly || ''}
|
||||
onChange={onDueDateChange}
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ mb: 0.5, color: 'text.tertiary' }}
|
||||
>
|
||||
Due time (optional)
|
||||
</Typography>
|
||||
<Input
|
||||
type='time'
|
||||
value={dueTime || ''}
|
||||
disabled={!dueDateOnly}
|
||||
onChange={e => {
|
||||
if (!useCustomTime) {
|
||||
onUseCustomTimeChange?.(true)
|
||||
}
|
||||
onDueTimeChange?.(e)
|
||||
}}
|
||||
sx={{ maxWidth: 200, mb: 1 }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 0.75, mb: 0.5 }}>
|
||||
<Button
|
||||
size='sm'
|
||||
variant={!useCustomTime ? 'soft' : 'plain'}
|
||||
color='neutral'
|
||||
disabled={!dueDateOnly}
|
||||
onClick={() => onUseCustomTimeChange?.(false)}
|
||||
>
|
||||
Anytime
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant={useCustomTime ? 'soft' : 'plain'}
|
||||
color='neutral'
|
||||
disabled={!dueDateOnly}
|
||||
onClick={() => onUseCustomTimeChange?.(true)}
|
||||
>
|
||||
Specific time
|
||||
</Button>
|
||||
</Box>
|
||||
{hasDueDate && (
|
||||
<Button
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
onClick={() => {
|
||||
onClear?.()
|
||||
setIsOpen(false)
|
||||
}}
|
||||
>
|
||||
Clear due date
|
||||
</Button>
|
||||
)}
|
||||
</Sheet>
|
||||
</ClickAwayListener>
|
||||
</Popper>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default DueDatePickerPreview
|
||||
@@ -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 (
|
||||
<Box sx={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
size={size}
|
||||
variant={hasRepeat ? 'soft' : 'outlined'}
|
||||
color='neutral'
|
||||
onClick={() => setIsOpen(prev => !prev)}
|
||||
sx={{
|
||||
minHeight: 40,
|
||||
borderRadius: '128px',
|
||||
minWidth: 'min-content',
|
||||
px: shouldShowLabel ? 1.25 : 0.75,
|
||||
gap: shouldShowLabel ? 1 : 0,
|
||||
justifyContent: 'flex-start',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'all 0.25s ease-in-out',
|
||||
}}
|
||||
>
|
||||
<Repeat sx={{ fontSize: '20px' }} />
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: shouldShowLabel ? 220 : 0,
|
||||
opacity: shouldShowLabel ? 1 : 0,
|
||||
transform: shouldShowLabel ? 'translateX(0)' : 'translateX(-4px)',
|
||||
transition:
|
||||
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
|
||||
}}
|
||||
>
|
||||
{displayLabel}
|
||||
</Typography>
|
||||
</Button>
|
||||
|
||||
{hasRepeat && onClear && (
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onClear?.()
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
right: -16,
|
||||
zIndex: 10,
|
||||
maxHeight: 18,
|
||||
maxWidth: 18,
|
||||
borderRadius: '50%',
|
||||
'&:hover': { bgcolor: 'danger.softBg' },
|
||||
}}
|
||||
>
|
||||
<Close sx={{ fontSize: '18px' }} />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{isOpen && (
|
||||
<Popper
|
||||
open={isOpen}
|
||||
anchorEl={buttonRef.current}
|
||||
placement='top-start'
|
||||
modifiers={[
|
||||
{ name: 'offset', options: { offset: [0, 8] } },
|
||||
{
|
||||
name: 'flip',
|
||||
options: { fallbackPlacements: ['bottom-start', 'top-start'] },
|
||||
},
|
||||
]}
|
||||
sx={{ zIndex: Z_INDEX.MODAL_CLOSE_BUTTON + 1 }}
|
||||
>
|
||||
<ClickAwayListener onClickAway={() => setIsOpen(false)}>
|
||||
<Sheet
|
||||
variant='outlined'
|
||||
sx={{
|
||||
minWidth: 180,
|
||||
p: 0.75,
|
||||
borderRadius: 'md',
|
||||
boxShadow: 'lg',
|
||||
bgcolor: 'background.popup',
|
||||
}}
|
||||
>
|
||||
{REPEAT_PRESETS.map((preset, index) => {
|
||||
const isSelected = selectedPreset?.id === preset.id
|
||||
return (
|
||||
<Button
|
||||
key={preset.id}
|
||||
variant={isSelected ? 'soft' : 'plain'}
|
||||
color='neutral'
|
||||
onClick={() => {
|
||||
onChange({
|
||||
frequencyType: preset.frequencyType,
|
||||
frequency: preset.frequency,
|
||||
frequencyMetadata: preset.frequencyMetadata,
|
||||
})
|
||||
setIsOpen(false)
|
||||
}}
|
||||
sx={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
mb: index === REPEAT_PRESETS.length - 1 ? 0 : 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm'>
|
||||
{getRecurrentChipText(preset)}
|
||||
</Typography>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
{hasRepeat && (
|
||||
<Button
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
onClick={() => {
|
||||
onClear?.()
|
||||
setIsOpen(false)
|
||||
}}
|
||||
sx={{ width: '100%', mt: 0.5, justifyContent: 'flex-start' }}
|
||||
>
|
||||
No repeat
|
||||
</Button>
|
||||
)}
|
||||
</Sheet>
|
||||
</ClickAwayListener>
|
||||
</Popper>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default RepeatPickerPreview
|
||||
Reference in New Issue
Block a user