Merge pull request #125 from donetick/add-task-modal-v2
Add task modal v2
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) }
|
||||
}
|
||||
@@ -108,7 +108,7 @@ const generateSchedulePreview = (metadata, formatTimeFn) => {
|
||||
return `Every ${dayNames} at ${timeStr}`
|
||||
}
|
||||
|
||||
const RepeatOnSections = ({
|
||||
export const RepeatOnSections = ({
|
||||
frequencyType,
|
||||
frequency,
|
||||
onFrequencyUpdate,
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import { Add, EditNotifications } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
FormHelperText,
|
||||
Input,
|
||||
Option,
|
||||
Select,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { FormControl } from '@mui/material'
|
||||
import { Add } from '@mui/icons-material'
|
||||
import { Box, Button, Typography } from '@mui/joy'
|
||||
import { useMediaQuery } from '@mui/material'
|
||||
import * as chrono from 'chrono-node'
|
||||
import moment from 'moment'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
@@ -30,8 +21,15 @@ import {
|
||||
import SmartTaskTitleInput from './SmartTaskTitleInput'
|
||||
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import NotificationTemplate from '../../components/NotificationTemplate'
|
||||
import { TASK_COLOR } from '../../utils/Colors'
|
||||
import AssigneePickerField from './AssigneePickerField'
|
||||
import AttachmentPickerField from './AttachmentPickerField'
|
||||
import DueDatePickerField from './DueDatePickerField'
|
||||
import LabelsPickerField from './LabelsPickerField'
|
||||
import LearnMoreButton from './LearnMore'
|
||||
import NotificationPickerField from './NotificationPickerField'
|
||||
import PriorityPickerField from './PriorityPickerField'
|
||||
import RepeatPickerField from './RepeatPickerField'
|
||||
import RichTextEditor from './RichTextEditor'
|
||||
import SubTasks from './SubTask'
|
||||
const getDefaultNotification = () => {
|
||||
@@ -46,18 +44,20 @@ const getDefaultNotification = () => {
|
||||
]
|
||||
|
||||
localStorage.setItem(
|
||||
'defaultNotification',
|
||||
'defaultNotificationTemplate',
|
||||
JSON.stringify(defaultNotification),
|
||||
)
|
||||
return defaultNotification
|
||||
}
|
||||
|
||||
const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const isMobile = useMediaQuery(theme => theme.breakpoints.down('sm'))
|
||||
const pickerEmptyDisplay = isMobile ? 'icon' : 'icon-text'
|
||||
const { data: userLabels, isLoading: userLabelsLoading } = useLabels()
|
||||
const { data: circleMembers, isLoading: isCircleMembersLoading } =
|
||||
useCircleMembers()
|
||||
const { data: projects = [], isLoading: isProjectsLoading } = useProjects()
|
||||
const { isLoading: isProjectsLoading } = useProjects()
|
||||
const createChoreMutation = useCreateChore()
|
||||
|
||||
const { data: userProfile } = useUserProfile()
|
||||
@@ -80,9 +80,8 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
const [taskTitle, setTaskTitle] = useState('')
|
||||
const [renderedParts, setRenderedParts] = useState([])
|
||||
|
||||
const textareaRef = useRef(null)
|
||||
const mainInputRef = useRef(null)
|
||||
const richTextEditorRef = useRef(null)
|
||||
const latestRef = useRef({})
|
||||
const [priority, setPriority] = useState(0)
|
||||
const [dueDate, setDueDate] = useState(null)
|
||||
const [description, setDescription] = useState(null)
|
||||
@@ -92,20 +91,35 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
const [notificationMetadata, setNotificationMetadata] = useState({
|
||||
templates: getDefaultNotification(),
|
||||
})
|
||||
const [frequencyHumanReadable, setFrequencyHumanReadable] = useState(null)
|
||||
const [subTasks, setSubTasks] = useState(null)
|
||||
const [points, setPoints] = useState(-1)
|
||||
const [isAnyoneTask, setIsAnyoneTask] = useState(false)
|
||||
const [hasDescription, setHasDescription] = useState(false)
|
||||
const [hasSubTasks, setHasSubTasks] = useState(false)
|
||||
const [hasNotifications, setHasNotifications] = useState(false)
|
||||
const [hasDeadline, setHasDeadline] = useState(false)
|
||||
const [deadlineOffset, setDeadlineOffset] = useState(-1)
|
||||
const [dueDateOnly, setDueDateOnly] = useState(null)
|
||||
const [dueTime, setDueTime] = useState(null)
|
||||
const [useCustomTime, setUseCustomTime] = useState(false)
|
||||
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
|
||||
const [projectId, setProjectId] = useState(getInitialProject())
|
||||
const [attachments, setAttachments] = useState([])
|
||||
|
||||
// Priority colors
|
||||
const priorityColors = {
|
||||
0: TASK_COLOR.NO_PRIORITY,
|
||||
1: TASK_COLOR.PRIORITY_1,
|
||||
2: TASK_COLOR.PRIORITY_2,
|
||||
3: TASK_COLOR.PRIORITY_3,
|
||||
4: TASK_COLOR.PRIORITY_4,
|
||||
}
|
||||
|
||||
const priorityLabels = {
|
||||
0: '--',
|
||||
1: 'P1',
|
||||
2: 'P2',
|
||||
3: 'P3',
|
||||
4: 'P4',
|
||||
}
|
||||
|
||||
// set showKeyboardShortcuts true as soon as the user hold ctrl or cmd key:
|
||||
useEffect(() => {
|
||||
@@ -117,12 +131,17 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
}
|
||||
}, [hasDescription])
|
||||
|
||||
// set showKeyboardShortcuts true as soon as the user hold ctrl or cmd key:
|
||||
useEffect(() => {
|
||||
const handleKeyDown = event => {
|
||||
const {
|
||||
isModalOpen,
|
||||
hasDescription,
|
||||
dueDate,
|
||||
createChore,
|
||||
handleCloseModal,
|
||||
} = latestRef.current
|
||||
const isHoldingCmd = event.ctrlKey || event.metaKey
|
||||
if (isHoldingCmd) {
|
||||
// event.preventDefault()
|
||||
setShowKeyboardShortcuts(true)
|
||||
}
|
||||
if (
|
||||
@@ -135,10 +154,8 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
setShowKeyboardShortcuts(false)
|
||||
}
|
||||
if (isHoldingCmd && event.key.toLowerCase() === 'j' && isModalOpen) {
|
||||
// add subtask:
|
||||
setHasSubTasks(true)
|
||||
setShowKeyboardShortcuts(false)
|
||||
// set focus on the first subtask input:
|
||||
}
|
||||
if (
|
||||
isHoldingCmd &&
|
||||
@@ -146,7 +163,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
isModalOpen &&
|
||||
!dueDate
|
||||
) {
|
||||
// add due date:
|
||||
const tomorrow = moment().add(1, 'day')
|
||||
setDueDateOnly(tomorrow.format('YYYY-MM-DD'))
|
||||
setDueDate(tomorrow.endOf('day').format('YYYY-MM-DDTHH:mm:59'))
|
||||
@@ -154,7 +170,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
setDueTime(null)
|
||||
setShowKeyboardShortcuts(false)
|
||||
}
|
||||
// Enter key to create task
|
||||
if (
|
||||
event.key === 'Enter' &&
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
@@ -164,7 +179,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
createChore()
|
||||
return
|
||||
}
|
||||
// Escape key to cancel/close modal
|
||||
if (event.key === 'Escape' && isModalOpen) {
|
||||
event.preventDefault()
|
||||
handleCloseModal()
|
||||
@@ -185,22 +199,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isModalOpen && textareaRef.current) {
|
||||
textareaRef.current.focus()
|
||||
textareaRef.current.selectionStart = textareaRef.current.value?.length
|
||||
textareaRef.current.selectionEnd = textareaRef.current.value?.length
|
||||
}
|
||||
}, [isModalOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus > 0 && mainInputRef.current) {
|
||||
mainInputRef.current.focus()
|
||||
mainInputRef.current.selectionStart = mainInputRef.current.value?.length
|
||||
mainInputRef.current.selectionEnd = mainInputRef.current.value?.length
|
||||
}
|
||||
}, [autoFocus])
|
||||
|
||||
const renderHighlightedSentence = useCallback(
|
||||
(
|
||||
sentence,
|
||||
@@ -262,20 +260,17 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
resolvedHighlights.push(current)
|
||||
}
|
||||
} else {
|
||||
// No overlap, add the current highlight
|
||||
resolvedHighlights.push(current)
|
||||
}
|
||||
}
|
||||
|
||||
for (const highlight of resolvedHighlights) {
|
||||
// Add the text before the highlight
|
||||
if (highlight.start > lastIndex) {
|
||||
const textBefore = sentence.substring(lastIndex, highlight.start)
|
||||
parts.push(textBefore)
|
||||
plainText += textBefore
|
||||
}
|
||||
|
||||
// Determine the class name based on the highlight type
|
||||
let className = ''
|
||||
switch (highlight.type) {
|
||||
case 'repeat':
|
||||
@@ -300,7 +295,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
break
|
||||
}
|
||||
|
||||
// Add the highlighted span
|
||||
const highlightedText = sentence.substring(
|
||||
highlight.start,
|
||||
highlight.end,
|
||||
@@ -310,9 +304,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
key={highlight.start}
|
||||
className={className}
|
||||
style={{
|
||||
// text underline:
|
||||
textDecoration: 'underline',
|
||||
// textDecorationColor: 'red',
|
||||
textDecorationThickness: '2px',
|
||||
textDecorationStyle: 'dashed',
|
||||
}}
|
||||
@@ -321,11 +313,9 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
</span>,
|
||||
)
|
||||
|
||||
// Update the last index to the end of the current highlight
|
||||
lastIndex = highlight.end
|
||||
}
|
||||
|
||||
// Add any remaining text after the last highlight
|
||||
if (lastIndex < sentence.length) {
|
||||
const remainingText = sentence.substring(lastIndex)
|
||||
parts.push(remainingText)
|
||||
@@ -342,12 +332,10 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
|
||||
const processText = useCallback(
|
||||
sentence => {
|
||||
// Parse everything from the original sentence to get correct highlight positions
|
||||
const priority = parsePriority(sentence)
|
||||
const pointsParsed = parsePoints(sentence)
|
||||
const labels = parseLabels(sentence, userLabels || [])
|
||||
|
||||
// Parse assignees using circle members
|
||||
const circleMembersList = circleMembers?.res || []
|
||||
const assigneesForParsing = circleMembersList.map(member => ({
|
||||
userId: member.userId,
|
||||
@@ -364,9 +352,15 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
const dueDateParsed = parseDueDate(sentence, chrono)
|
||||
|
||||
// Set all the parsed values
|
||||
if (priority.result) setPriority(priority.result)
|
||||
if (priority.result) setPriority(parseInt(priority.result, 10))
|
||||
if (pointsParsed.result) setPoints(pointsParsed.result)
|
||||
if (labels.result) setLabelsV2(labels.result)
|
||||
if (labels.result) {
|
||||
// parseLabels returns array of label objects, extract their IDs
|
||||
const labelIds = labels.result
|
||||
.filter(label => label.id) // Only labels with IDs (existing labels)
|
||||
.map(label => label.id)
|
||||
setLabelsV2(labelIds)
|
||||
}
|
||||
|
||||
if (assigneesResult.isAnyone) {
|
||||
// @Anyone was used - set empty assignees (anyone can do the task)
|
||||
@@ -392,7 +386,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
|
||||
if (repeat.result) {
|
||||
setFrequency(repeat.result)
|
||||
setFrequencyHumanReadable(repeat.name)
|
||||
}
|
||||
|
||||
const syncDueDateStates = parsedDate => {
|
||||
@@ -500,6 +493,8 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
moment(`${dateValue}T${dueTime}`).format('YYYY-MM-DDTHH:mm:00'),
|
||||
)
|
||||
} else {
|
||||
setUseCustomTime(false)
|
||||
setDueTime(null)
|
||||
setDueDate(moment(dateValue).endOf('day').format('YYYY-MM-DDTHH:mm:ss'))
|
||||
}
|
||||
}
|
||||
@@ -508,9 +503,17 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
const timeValue = e.target.value
|
||||
setDueTime(timeValue)
|
||||
if (dueDateOnly) {
|
||||
if (timeValue) {
|
||||
setUseCustomTime(true)
|
||||
setDueDate(
|
||||
moment(`${dueDateOnly}T${timeValue}`).format('YYYY-MM-DDTHH:mm:00'),
|
||||
)
|
||||
} else {
|
||||
setUseCustomTime(false)
|
||||
setDueDate(
|
||||
moment(dueDateOnly).endOf('day').format('YYYY-MM-DDTHH:mm:ss'),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,13 +521,16 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
setUseCustomTime(checked)
|
||||
if (checked) {
|
||||
const defaultTime = dueTime || '18:00'
|
||||
if (!dueTime) {
|
||||
setDueTime(defaultTime)
|
||||
}
|
||||
if (dueDateOnly) {
|
||||
setDueDate(
|
||||
moment(`${dueDateOnly}T${defaultTime}`).format('YYYY-MM-DDTHH:mm:00'),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
setDueTime(null)
|
||||
if (dueDateOnly) {
|
||||
setDueDate(
|
||||
moment(dueDateOnly).endOf('day').format('YYYY-MM-DDTHH:mm:ss'),
|
||||
@@ -543,7 +549,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
setTaskTitle('')
|
||||
setDueDate(null)
|
||||
setFrequency(null)
|
||||
setFrequencyHumanReadable(null)
|
||||
setPriority(0)
|
||||
setPoints(-1)
|
||||
setIsAnyoneTask(false)
|
||||
@@ -554,7 +559,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
setLabelsV2([])
|
||||
setAssignees([])
|
||||
setProjectId(getInitialProject())
|
||||
setHasDeadline(false)
|
||||
setDeadlineOffset(-1)
|
||||
setDueDateOnly(null)
|
||||
setDueTime(null)
|
||||
@@ -605,6 +609,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
notificationMetadata: {},
|
||||
subTasks: subTasks?.length > 0 ? subTasks : null,
|
||||
projectId: projectId === 'default' ? null : projectId,
|
||||
attachments: attachments.length > 0 ? attachments : null,
|
||||
}
|
||||
|
||||
if (frequency) {
|
||||
@@ -617,8 +622,8 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
}
|
||||
}
|
||||
if (!frequency && dueDate) {
|
||||
// use dueDate converted to UTC:
|
||||
chore.nextDueDate = new Date(dueDate).toUTCString()
|
||||
// Use RFC3339/ISO-8601 format expected by backend.
|
||||
chore.nextDueDate = new Date(dueDate).toISOString()
|
||||
chore.notificationMetadata = notificationMetadata
|
||||
}
|
||||
|
||||
@@ -645,6 +650,15 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
})
|
||||
handleCloseModal(false)
|
||||
}
|
||||
|
||||
latestRef.current = {
|
||||
isModalOpen,
|
||||
hasDescription,
|
||||
dueDate,
|
||||
createChore,
|
||||
handleCloseModal,
|
||||
}
|
||||
|
||||
if (isCircleMembersLoading || isProjectsLoading) {
|
||||
return <></>
|
||||
}
|
||||
@@ -685,6 +699,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
size='lg'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
disabled={!taskTitle.trim()}
|
||||
onClick={createChore}
|
||||
>
|
||||
Create
|
||||
@@ -748,7 +763,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
<SmartTaskTitleInput
|
||||
autoFocus
|
||||
value={taskText}
|
||||
placeholder='Type your full text here...'
|
||||
placeholder='Type your task...'
|
||||
onChange={text => {
|
||||
setTaskText(text)
|
||||
}}
|
||||
@@ -793,21 +808,96 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{/* <Box>
|
||||
<Typography level='body-sm'>Title:</Typography>
|
||||
<Input
|
||||
value={taskTitle}
|
||||
onChange={e => setTaskTitle(e.target.value)}
|
||||
sx={{ width: '100%', fontSize: '16px' }}
|
||||
/>
|
||||
</Box> */}
|
||||
<Box
|
||||
sx={{
|
||||
paddingTop: 2,
|
||||
paddingBottom: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: 1.5,
|
||||
|
||||
<Box>
|
||||
// scrollable horizontally but hide the scrollbar:
|
||||
overflowX: 'auto',
|
||||
'&::-webkit-scrollbar': {
|
||||
display: 'none',
|
||||
},
|
||||
// if not mobile then go to next line if not enough space( show chip on next line):
|
||||
flexWrap: isMobile ? 'nowrap' : 'wrap',
|
||||
}}
|
||||
>
|
||||
<DueDatePickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
dueDateOnly={dueDateOnly}
|
||||
dueTime={dueTime}
|
||||
useCustomTime={useCustomTime}
|
||||
onDueDateChange={handleDueDateChange}
|
||||
onDueTimeChange={handleDueTimeChange}
|
||||
onUseCustomTimeChange={handleUseCustomTimeChange}
|
||||
onClear={() => {
|
||||
setDueDate(null)
|
||||
setDueDateOnly(null)
|
||||
setDueTime(null)
|
||||
setUseCustomTime(false)
|
||||
}}
|
||||
/>
|
||||
<RepeatPickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
value={frequency}
|
||||
onChange={setFrequency}
|
||||
onClear={() => setFrequency(null)}
|
||||
/>
|
||||
<PriorityPickerField
|
||||
value={priority}
|
||||
onChange={setPriority}
|
||||
onClear={() => setPriority(0)}
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
priorityColors={priorityColors}
|
||||
priorityLabels={priorityLabels}
|
||||
/>
|
||||
<AssigneePickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
value={assignees?.[0]?.userId || null}
|
||||
onChange={userId => {
|
||||
if (!userId) {
|
||||
setAssignees([])
|
||||
} else {
|
||||
setAssignees([{ userId }])
|
||||
}
|
||||
}}
|
||||
onClear={() => setAssignees([])}
|
||||
currentUserId={userProfile?.id}
|
||||
members={circleMembers?.res || []}
|
||||
/>
|
||||
|
||||
<LabelsPickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
values={labelsV2 || []}
|
||||
onChange={setLabelsV2}
|
||||
onClear={() => setLabelsV2([])}
|
||||
labels={userLabels || []}
|
||||
/>
|
||||
<AttachmentPickerField
|
||||
attachments={attachments}
|
||||
onChange={setAttachments}
|
||||
onClear={() => setAttachments([])}
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
entityType='chore_attachment'
|
||||
/>
|
||||
<NotificationPickerField
|
||||
value={notificationMetadata}
|
||||
onChange={setNotificationMetadata}
|
||||
onClear={() => setNotificationMetadata({ templates: [] })}
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box mt={2} sx={{ display: 'flex', flexDirection: 'row', gap: 1 }}>
|
||||
{!hasDescription && (
|
||||
<Button
|
||||
startDecorator={<Add />}
|
||||
variant='plain'
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='md'
|
||||
onClick={() => {
|
||||
setHasDescription(true)
|
||||
// Focus will be handled by the useEffect hook
|
||||
@@ -823,8 +913,9 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
{!hasSubTasks && (
|
||||
<Button
|
||||
startDecorator={<Add />}
|
||||
variant='plain'
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='md'
|
||||
onClick={() => {
|
||||
setHasSubTasks(true)
|
||||
}}
|
||||
@@ -835,52 +926,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
Subtasks
|
||||
</Button>
|
||||
)}
|
||||
{!dueDate && (
|
||||
<Button
|
||||
startDecorator={<Add />}
|
||||
variant='plain'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
const tomorrow = moment().add(1, 'day')
|
||||
setDueDateOnly(tomorrow.format('YYYY-MM-DD'))
|
||||
setDueDate(tomorrow.endOf('day').format('YYYY-MM-DDTHH:mm:ss'))
|
||||
setUseCustomTime(false)
|
||||
setDueTime(null)
|
||||
}}
|
||||
endDecorator={
|
||||
showKeyboardShortcuts && <KeyboardShortcutHint shortcut='B' />
|
||||
}
|
||||
>
|
||||
Due Date
|
||||
</Button>
|
||||
)}
|
||||
{!hasNotifications && dueDate && (
|
||||
<Button
|
||||
startDecorator={<EditNotifications />}
|
||||
variant='plain'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
setHasNotifications(true)
|
||||
setFrequencyHumanReadable('Once')
|
||||
setFrequency(null)
|
||||
}}
|
||||
>
|
||||
Edit Notifications
|
||||
</Button>
|
||||
)}
|
||||
{/* {!hasDeadline && dueDate && (
|
||||
<Button
|
||||
startDecorator={<Add />}
|
||||
variant='plain'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
setHasDeadline(true)
|
||||
setDeadlineOffset(86400)
|
||||
}}
|
||||
>
|
||||
Set Deadline
|
||||
</Button>
|
||||
)} */}
|
||||
</Box>
|
||||
|
||||
{hasDescription && (
|
||||
@@ -906,210 +951,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{priority > 0 && (
|
||||
<FormControl>
|
||||
<Typography level='body-sm'>Priority</Typography>
|
||||
<Select
|
||||
defaultValue={0}
|
||||
value={priority}
|
||||
onChange={(e, value) => setPriority(value)}
|
||||
>
|
||||
<Option value='0'>No Priority</Option>
|
||||
<Option value='1'>P1</Option>
|
||||
<Option value='2'>P2</Option>
|
||||
<Option value='3'>P3</Option>
|
||||
<Option value='4'>P4</Option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
{dueDate && (
|
||||
<FormControl>
|
||||
<Typography level='body-sm'>Due Date</Typography>
|
||||
<Input
|
||||
type='date'
|
||||
value={dueDateOnly || ''}
|
||||
onChange={handleDueDateChange}
|
||||
/>
|
||||
<Checkbox
|
||||
size='sm'
|
||||
checked={useCustomTime}
|
||||
onChange={e => handleUseCustomTimeChange(e.target.checked)}
|
||||
label='Set a specific time'
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
<FormHelperText>
|
||||
{useCustomTime
|
||||
? 'Task will be due at the specified time'
|
||||
: 'Task will be due at the end of the day (11:59 PM)'}
|
||||
</FormHelperText>
|
||||
{useCustomTime && (
|
||||
<Input
|
||||
type='time'
|
||||
value={dueTime || '18:00'}
|
||||
onChange={handleDueTimeChange}
|
||||
sx={{ maxWidth: 200, mt: 1 }}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
)}
|
||||
</Box>
|
||||
{/* {projects.length >= 1 && (
|
||||
<FormControl>
|
||||
<Typography level='body-sm'>Project</Typography>
|
||||
<Select
|
||||
value={projectId}
|
||||
onChange={(event, newValue) => setProjectId(newValue)}
|
||||
sx={{ minWidth: '15rem' }}
|
||||
>
|
||||
<Option key='default' value='default'>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Avatar
|
||||
size='sm'
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
bgcolor: '#1976d2',
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const IconComponent = getIconComponent('FolderOpen')
|
||||
return (
|
||||
<IconComponent
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
color: getTextColorFromBackgroundColor('#1976d2'),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</Avatar>
|
||||
Default Project
|
||||
</Box>
|
||||
</Option>
|
||||
{projects.map(project => (
|
||||
<Option key={project.id} value={project.id}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Avatar
|
||||
size='sm'
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
bgcolor: project.color || '#1976d2',
|
||||
}}
|
||||
>
|
||||
{project.icon ? (
|
||||
(() => {
|
||||
const IconComponent = getIconComponent(project.icon)
|
||||
return (
|
||||
<IconComponent
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
color: getTextColorFromBackgroundColor(
|
||||
project.color || '#1976d2',
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})()
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</Avatar>
|
||||
{project.name}
|
||||
</Box>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)} */}
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'start',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{/* <FormControl>
|
||||
<Typography level='body-sm'>Assignees</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{assignees.length > 0 ? (
|
||||
assignees.map((assignee, index) => (
|
||||
<Chip
|
||||
key={assignee.userId || index}
|
||||
variant='soft'
|
||||
size='lg'
|
||||
color='primary'
|
||||
>
|
||||
{assignee.displayName || assignee.username}
|
||||
</Chip>
|
||||
))
|
||||
) : (
|
||||
<Chip variant='soft' size='sm' color='neutral'>
|
||||
{userProfile.displayName}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</FormControl> */}
|
||||
{/* {hasDeadline && dueDate && (
|
||||
<Box
|
||||
sx={{
|
||||
flexDirection: 'column',
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm'>Deadline</Typography>
|
||||
<Box
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.5 }}
|
||||
>
|
||||
<DurationInput
|
||||
value={deadlineOffset}
|
||||
onChange={setDeadlineOffset}
|
||||
size='sm'
|
||||
minValue={0}
|
||||
/>
|
||||
<Typography level='body-sm'>after due date</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)} */}
|
||||
{hasNotifications && dueDate && (
|
||||
<Box
|
||||
sx={{
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm'>Notification Schedule</Typography>
|
||||
<Box sx={{ p: 0.5 }}>
|
||||
<NotificationTemplate
|
||||
onChange={metadata => {
|
||||
if (
|
||||
metadata.notifications !== notificationMetadata.templates
|
||||
) {
|
||||
const newNotificationMetadata = {
|
||||
...notificationMetadata,
|
||||
templates: metadata.notifications,
|
||||
}
|
||||
setNotificationMetadata(newNotificationMetadata)
|
||||
}
|
||||
}}
|
||||
value={notificationMetadata}
|
||||
showTimeline={false}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
|
||||
43
src/views/components/AssigneePickerField.jsx
Normal file
43
src/views/components/AssigneePickerField.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Person } from '@mui/icons-material'
|
||||
import BaseOptionPicker from './BaseOptionPicker'
|
||||
|
||||
const AssigneePickerField = ({
|
||||
value = null,
|
||||
onChange,
|
||||
onClear,
|
||||
members = [],
|
||||
includeAnyone = true,
|
||||
emptyDisplay,
|
||||
currentUserId = null,
|
||||
}) => {
|
||||
const options = [
|
||||
...(includeAnyone ? [{ userId: 'anyone', displayName: 'Anyone' }] : []),
|
||||
...members.map(member => ({
|
||||
userId: member.userId,
|
||||
displayName: member.displayName || member.username || 'Unknown',
|
||||
})),
|
||||
]
|
||||
|
||||
const displayValue = currentUserId && value === currentUserId ? null : value
|
||||
|
||||
return (
|
||||
<BaseOptionPicker
|
||||
items={options}
|
||||
value={displayValue}
|
||||
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 AssigneePickerField
|
||||
258
src/views/components/AttachmentPickerField.jsx
Normal file
258
src/views/components/AttachmentPickerField.jsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import { AttachFile, Close, DeleteOutline, Image } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
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 { useFileUpload } from '../../hooks/useFileUpload'
|
||||
|
||||
const AttachmentPickerField = ({
|
||||
attachments = [],
|
||||
onChange,
|
||||
onClear,
|
||||
emptyDisplay = 'icon-text',
|
||||
entityType = 'chore_attachment',
|
||||
entityId,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const buttonRef = useRef(null)
|
||||
const { uploadFile } = useFileUpload({ entityType, entityId })
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const handleEscape = e => {
|
||||
if (e.key === 'Escape') setIsOpen(false)
|
||||
}
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => document.removeEventListener('keydown', handleEscape)
|
||||
}, [isOpen])
|
||||
|
||||
const handleAddFile = () => {
|
||||
const input = document.createElement('input')
|
||||
input.setAttribute('type', 'file')
|
||||
input.setAttribute('accept', 'image/*')
|
||||
input.click()
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
setIsUploading(true)
|
||||
try {
|
||||
const url = await uploadFile(file)
|
||||
if (url) {
|
||||
onChange([...attachments, { url, name: file.name }])
|
||||
}
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = index => {
|
||||
const updated = attachments.filter((_, i) => i !== index)
|
||||
onChange(updated)
|
||||
if (updated.length === 0) setIsOpen(false)
|
||||
}
|
||||
|
||||
const handleClear = e => {
|
||||
e.stopPropagation()
|
||||
onClear?.()
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const isEmpty = attachments.length === 0
|
||||
const shouldShowLabel = !isEmpty || emptyDisplay === 'icon-text'
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box sx={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
size='sm'
|
||||
variant={isEmpty ? 'outlined' : 'soft'}
|
||||
color='neutral'
|
||||
onClick={() => setIsOpen(prev => !prev)}
|
||||
sx={{
|
||||
borderRadius: '128px',
|
||||
minHeight: 40,
|
||||
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',
|
||||
}}
|
||||
>
|
||||
{isUploading ? (
|
||||
<CircularProgress size='sm' sx={{ '--CircularProgress-size': '16px' }} />
|
||||
) : (
|
||||
<AttachFile sx={{ fontSize: '20px' }} />
|
||||
)}
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: shouldShowLabel ? 180 : 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',
|
||||
}}
|
||||
>
|
||||
{isEmpty
|
||||
? 'Attachments'
|
||||
: `${attachments.length} file${attachments.length !== 1 ? 's' : ''}`}
|
||||
</Typography>
|
||||
</Button>
|
||||
{!isEmpty && onClear && (
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={handleClear}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
right: -16,
|
||||
zIndex: 10,
|
||||
maxHeight: 18,
|
||||
maxWidth: 18,
|
||||
borderRadius: '50%',
|
||||
'&:hover': { bgcolor: 'danger.softBg' },
|
||||
}}
|
||||
>
|
||||
<Close sx={{ fontSize: '18px' }} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{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: 240,
|
||||
maxWidth: 320,
|
||||
p: 1,
|
||||
borderRadius: 'md',
|
||||
boxShadow: 'lg',
|
||||
bgcolor: 'background.popup',
|
||||
}}
|
||||
>
|
||||
{attachments.length > 0 && (
|
||||
<Box sx={{ mb: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{attachments.map((attachment, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
p: 0.5,
|
||||
borderRadius: 'sm',
|
||||
'&:hover': { bgcolor: 'background.level1' },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component='img'
|
||||
src={attachment.url}
|
||||
alt={attachment.name}
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 'sm',
|
||||
flexShrink: 0,
|
||||
bgcolor: 'background.level2',
|
||||
}}
|
||||
onError={e => {
|
||||
e.target.style.display = 'none'
|
||||
e.target.nextSibling.style.display = 'flex'
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'none',
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 'sm',
|
||||
bgcolor: 'background.level2',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Image sx={{ fontSize: 20, color: 'text.tertiary' }} />
|
||||
</Box>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{attachment.name}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='danger'
|
||||
onClick={() => handleRemove(index)}
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
<DeleteOutline sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={
|
||||
isUploading ? (
|
||||
<CircularProgress size='sm' sx={{ '--CircularProgress-size': '14px' }} />
|
||||
) : (
|
||||
<AttachFile sx={{ fontSize: 16 }} />
|
||||
)
|
||||
}
|
||||
onClick={handleAddFile}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? 'Uploading…' : 'Add image'}
|
||||
</Button>
|
||||
</Sheet>
|
||||
</ClickAwayListener>
|
||||
</Popper>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AttachmentPickerField
|
||||
253
src/views/components/BaseOptionPicker.jsx
Normal file
253
src/views/components/BaseOptionPicker.jsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import { Close } from '@mui/icons-material'
|
||||
import { Box, Button, IconButton, Sheet, Typography } from '@mui/joy'
|
||||
import { ClickAwayListener, Popper } from '@mui/material'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
|
||||
const BaseOptionPicker = ({
|
||||
items = [],
|
||||
value = null,
|
||||
values = [],
|
||||
multiple = false,
|
||||
onChange,
|
||||
onValuesChange,
|
||||
emptyDisplay = 'icon',
|
||||
emptyLabel = 'Select',
|
||||
placement = 'top-start',
|
||||
menuMinWidth = 180,
|
||||
menuMaxHeight = 280,
|
||||
getItemValue = item => item.id,
|
||||
getItemLabel = item => item.label,
|
||||
renderItemStart,
|
||||
renderTriggerIcon,
|
||||
getItemColor,
|
||||
getTriggerText,
|
||||
onClear,
|
||||
}) => {
|
||||
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 selectedItems = useMemo(() => {
|
||||
if (multiple) {
|
||||
const selectedSet = new Set(values)
|
||||
return items.filter(item => selectedSet.has(getItemValue(item)))
|
||||
}
|
||||
|
||||
if (value === null || value === undefined) return []
|
||||
return items.filter(item => getItemValue(item) === value)
|
||||
}, [items, multiple, value, values, getItemValue])
|
||||
|
||||
const isEmpty = selectedItems.length === 0
|
||||
const shouldShowLabel = !isEmpty || emptyDisplay === 'icon-text'
|
||||
|
||||
const triggerText = getTriggerText
|
||||
? getTriggerText({ selectedItems, isEmpty })
|
||||
: isEmpty
|
||||
? emptyLabel
|
||||
: getItemLabel(selectedItems[0])
|
||||
|
||||
const triggerColor = isEmpty
|
||||
? undefined
|
||||
: getItemColor
|
||||
? getItemColor(selectedItems[0])
|
||||
: undefined
|
||||
|
||||
const handleSelect = selectedValue => {
|
||||
if (multiple) {
|
||||
const selectedSet = new Set(values)
|
||||
if (selectedSet.has(selectedValue)) {
|
||||
selectedSet.delete(selectedValue)
|
||||
} else {
|
||||
selectedSet.add(selectedValue)
|
||||
}
|
||||
onValuesChange?.(Array.from(selectedSet))
|
||||
return
|
||||
}
|
||||
|
||||
onChange?.(selectedValue)
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const isSelected = item => {
|
||||
const optionValue = getItemValue(item)
|
||||
if (multiple) {
|
||||
return values.includes(optionValue)
|
||||
}
|
||||
return value === optionValue
|
||||
}
|
||||
|
||||
const handleClear = e => {
|
||||
e.stopPropagation()
|
||||
onClear?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
size={'sm'}
|
||||
variant={isEmpty ? 'outlined' : 'soft'}
|
||||
color='neutral'
|
||||
onClick={() => setIsOpen(prev => !prev)}
|
||||
sx={{
|
||||
borderRadius: '128px',
|
||||
minHeight: 40,
|
||||
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',
|
||||
backgroundColor: triggerColor ? `${triggerColor}20` : undefined,
|
||||
borderColor: triggerColor || undefined,
|
||||
color: triggerColor || undefined,
|
||||
'&:hover': {
|
||||
backgroundColor: triggerColor ? `${triggerColor}28` : undefined,
|
||||
borderColor: triggerColor || undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{renderTriggerIcon?.({ selectedItems, isEmpty })}
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: shouldShowLabel ? 180 : 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',
|
||||
}}
|
||||
>
|
||||
{triggerText}
|
||||
</Typography>
|
||||
</Button>
|
||||
{!isEmpty && onClear && (
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={handleClear}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
right: -16,
|
||||
zIndex: 10,
|
||||
maxHeight: 18,
|
||||
maxWidth: 18,
|
||||
borderRadius: '50%',
|
||||
'&:hover': {
|
||||
bgcolor: 'danger.softBg',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Close sx={{ fontSize: '18px' }} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{isOpen && (
|
||||
<Popper
|
||||
open={isOpen}
|
||||
anchorEl={buttonRef.current}
|
||||
placement={placement}
|
||||
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: menuMinWidth,
|
||||
maxHeight: menuMaxHeight,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
p: 0.75,
|
||||
borderRadius: 'md',
|
||||
boxShadow: 'lg',
|
||||
bgcolor: 'background.popup',
|
||||
}}
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
const optionValue = getItemValue(item)
|
||||
const selected = isSelected(item)
|
||||
const itemColor = getItemColor ? getItemColor(item) : undefined
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={optionValue ?? index}
|
||||
variant={selected ? 'soft' : 'plain'}
|
||||
color='neutral'
|
||||
onClick={() => handleSelect(optionValue)}
|
||||
sx={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
gap: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
mb: index === items.length - 1 ? 0 : 0.5,
|
||||
color: selected
|
||||
? itemColor || 'text.primary'
|
||||
: 'text.primary',
|
||||
}}
|
||||
>
|
||||
{renderItemStart?.({ item, selected })}
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{getItemLabel(item)}
|
||||
</Typography>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</Sheet>
|
||||
</ClickAwayListener>
|
||||
</Popper>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default BaseOptionPicker
|
||||
628
src/views/components/DueDatePickerField.jsx
Normal file
628
src/views/components/DueDatePickerField.jsx
Normal file
@@ -0,0 +1,628 @@
|
||||
import {
|
||||
Bedtime,
|
||||
CalendarMonth,
|
||||
Close,
|
||||
EventNote,
|
||||
LightMode,
|
||||
NextWeek,
|
||||
NightsStay,
|
||||
Today,
|
||||
WbSunny,
|
||||
WbTwilight,
|
||||
Weekend,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
IconButton,
|
||||
Input,
|
||||
List,
|
||||
ListItem,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Calendar from 'react-calendar'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
|
||||
const DueDatePickerField = ({
|
||||
dueDateOnly,
|
||||
dueTime,
|
||||
useCustomTime,
|
||||
onDueDateChange,
|
||||
onDueTimeChange,
|
||||
onUseCustomTimeChange,
|
||||
onClear,
|
||||
emptyDisplay = 'icon-text',
|
||||
size = 'sm',
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const { firstDayOfWeek } = useLocalization()
|
||||
|
||||
// Local buffered state — only committed on Apply
|
||||
const [localDueDateOnly, setLocalDueDateOnly] = useState(dueDateOnly)
|
||||
const [localDueTime, setLocalDueTime] = useState(dueTime)
|
||||
const [localUseCustomTime, setLocalUseCustomTime] = useState(useCustomTime)
|
||||
|
||||
// Sync local state from props whenever the modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setLocalDueDateOnly(dueDateOnly)
|
||||
setLocalDueTime(dueTime)
|
||||
setLocalUseCustomTime(useCustomTime)
|
||||
}
|
||||
}, [isOpen, dueDateOnly, dueTime, useCustomTime])
|
||||
|
||||
const calendarType =
|
||||
firstDayOfWeek === 1
|
||||
? 'iso8601'
|
||||
: firstDayOfWeek === 6
|
||||
? 'islamic'
|
||||
: 'gregory'
|
||||
|
||||
const pillListSx = {
|
||||
'--List-gap': '8px',
|
||||
'--ListItem-radius': '20px',
|
||||
}
|
||||
|
||||
const getQuickScheduleDate = option => {
|
||||
const now = new Date()
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
|
||||
switch (option) {
|
||||
case 'today':
|
||||
return today
|
||||
case 'tomorrow': {
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(today.getDate() + 1)
|
||||
return tomorrow
|
||||
}
|
||||
case 'weekend': {
|
||||
const weekend = new Date(today)
|
||||
const daysUntilSaturday = (6 - today.getDay() + 7) % 7 || 7
|
||||
weekend.setDate(today.getDate() + daysUntilSaturday)
|
||||
return weekend
|
||||
}
|
||||
case 'next-week': {
|
||||
const nextWeek = new Date(today)
|
||||
const daysUntilMonday = (1 - today.getDay() + 7) % 7 || 7
|
||||
nextWeek.setDate(today.getDate() + daysUntilMonday)
|
||||
return nextWeek
|
||||
}
|
||||
case 'next-month': {
|
||||
const nextMonth = new Date(today)
|
||||
nextMonth.setMonth(today.getMonth() + 1)
|
||||
return nextMonth
|
||||
}
|
||||
default:
|
||||
return today
|
||||
}
|
||||
}
|
||||
|
||||
const handleQuickSchedule = option => {
|
||||
const date = getQuickScheduleDate(option)
|
||||
setLocalDueDateOnly(date.toISOString().split('T')[0])
|
||||
}
|
||||
|
||||
const handleQuickTime = timeStr => {
|
||||
// Tap the active chip again to deselect it
|
||||
if (localUseCustomTime && localDueTime === timeStr) {
|
||||
setLocalUseCustomTime(false)
|
||||
setLocalDueTime(null)
|
||||
return
|
||||
}
|
||||
if (!localDueDateOnly) {
|
||||
setLocalDueDateOnly(new Date().toISOString().split('T')[0])
|
||||
}
|
||||
setLocalUseCustomTime(true)
|
||||
setLocalDueTime(timeStr)
|
||||
}
|
||||
|
||||
const handleCalendarChange = selected => {
|
||||
if (!selected || Array.isArray(selected)) return
|
||||
setLocalDueDateOnly(moment(selected).format('YYYY-MM-DD'))
|
||||
}
|
||||
|
||||
const handleLocalTimeInputChange = e => {
|
||||
setLocalUseCustomTime(true)
|
||||
setLocalDueTime(e.target.value)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
onDueDateChange?.({ target: { value: localDueDateOnly || '' } })
|
||||
onUseCustomTimeChange?.(localUseCustomTime)
|
||||
if (localUseCustomTime && localDueTime) {
|
||||
onDueTimeChange?.({ target: { value: localDueTime } })
|
||||
} else {
|
||||
onDueTimeChange?.({ target: { value: '' } })
|
||||
}
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
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
|
||||
size={size}
|
||||
variant={hasDueDate ? 'soft' : 'outlined'}
|
||||
color='neutral'
|
||||
onClick={() => setIsOpen(true)}
|
||||
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>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<ResponsiveModal
|
||||
open={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
title='Due Date'
|
||||
footer={
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
{hasDueDate && (
|
||||
<Button
|
||||
variant='plain'
|
||||
color='danger'
|
||||
size='lg'
|
||||
onClick={() => {
|
||||
onClear?.()
|
||||
setIsOpen(false)
|
||||
}}
|
||||
sx={{ mr: 'auto' }}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='lg'
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
size='lg'
|
||||
onClick={handleSave}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box sx={{ fontFamily: 'var(--joy-fontFamily-body)' }}>
|
||||
{/* Date shortcuts */}
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
mb: 0.75,
|
||||
color: 'text.tertiary',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
Quick date
|
||||
</Typography>
|
||||
<List orientation='horizontal' wrap sx={{ ...pillListSx, mb: 1.5 }}>
|
||||
{[
|
||||
{
|
||||
key: 'today',
|
||||
label: 'Today',
|
||||
icon: <Today sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
key: 'tomorrow',
|
||||
label: 'Tomorrow',
|
||||
icon: <WbSunny sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
key: 'weekend',
|
||||
label: 'Weekend',
|
||||
icon: <Weekend sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
key: 'next-week',
|
||||
label: 'Next week',
|
||||
icon: <NextWeek sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
key: 'next-month',
|
||||
label: 'Next month',
|
||||
icon: <EventNote sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
].map(opt => {
|
||||
const dateStr = getQuickScheduleDate(opt.key)
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
return (
|
||||
<ListItem key={opt.key}>
|
||||
<Checkbox
|
||||
checked={localDueDateOnly === dateStr}
|
||||
onClick={() => handleQuickSchedule(opt.key)}
|
||||
overlay
|
||||
disableIcon
|
||||
variant='soft'
|
||||
label={
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
{opt.icon}
|
||||
{opt.label}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
|
||||
{/* Time shortcuts */}
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
mb: 0.75,
|
||||
color: 'text.tertiary',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
Quick time
|
||||
</Typography>
|
||||
<List orientation='horizontal' wrap sx={{ ...pillListSx, mb: 1.5 }}>
|
||||
{[
|
||||
{
|
||||
time: '09:00',
|
||||
label: 'Morning',
|
||||
icon: <LightMode sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
time: '12:00',
|
||||
label: 'Noon',
|
||||
icon: <WbSunny sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
time: '15:00',
|
||||
label: 'Afternoon',
|
||||
icon: <WbTwilight sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
time: '18:00',
|
||||
label: 'Evening',
|
||||
icon: <NightsStay sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
time: '22:00',
|
||||
label: 'Night',
|
||||
icon: <Bedtime sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
].map(opt => (
|
||||
<ListItem key={opt.time}>
|
||||
<Checkbox
|
||||
checked={localUseCustomTime && localDueTime === opt.time}
|
||||
onClick={() => handleQuickTime(opt.time)}
|
||||
overlay
|
||||
disableIcon
|
||||
variant='soft'
|
||||
label={
|
||||
<Box
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}
|
||||
>
|
||||
{opt.icon}
|
||||
{opt.label}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
mb: 1.5,
|
||||
borderRadius: 'md',
|
||||
border: '1px solid',
|
||||
borderColor: 'neutral.outlinedBorder',
|
||||
bgcolor: 'background.surface',
|
||||
p: 1,
|
||||
// Fix the height so switching views (month/year/decade) doesn't
|
||||
// cause layout shift — month view with 6 rows is the tallest.
|
||||
minHeight: 300,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
'& .react-calendar': {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
'& .react-calendar__viewContainer': {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
'& .react-calendar__month-view, & .react-calendar__year-view, & .react-calendar__decade-view, & .react-calendar__century-view':
|
||||
{
|
||||
flex: 1,
|
||||
},
|
||||
// Navigation row
|
||||
'& .react-calendar__navigation': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
mb: 1,
|
||||
},
|
||||
// All nav buttons — large tap targets
|
||||
'& .react-calendar__navigation button': {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
color: 'var(--joy-palette-text-primary)',
|
||||
fontFamily: 'var(--joy-fontFamily-body)',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
minHeight: '40px',
|
||||
minWidth: '40px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0 8px',
|
||||
transition: 'background 0.15s',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--joy-palette-neutral-softBg)',
|
||||
},
|
||||
'&:disabled': {
|
||||
opacity: 0.35,
|
||||
cursor: 'default',
|
||||
},
|
||||
},
|
||||
// Label button (month/year text) takes remaining space
|
||||
'& .react-calendar__navigation__label': {
|
||||
flex: 1,
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.01em',
|
||||
},
|
||||
// Prev/next arrow buttons — slightly larger icon feel
|
||||
'& .react-calendar__navigation__prev-button, & .react-calendar__navigation__next-button':
|
||||
{
|
||||
fontSize: '1.75rem',
|
||||
},
|
||||
'& .react-calendar__navigation__prev2-button, & .react-calendar__navigation__next2-button':
|
||||
{
|
||||
fontSize: '1.4rem',
|
||||
},
|
||||
// Weekday headers
|
||||
'& .react-calendar__month-view__weekdays__weekday': {
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600,
|
||||
color: 'var(--joy-palette-text-tertiary)',
|
||||
textAlign: 'center',
|
||||
padding: '4px 0',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
},
|
||||
'& .react-calendar__month-view__weekdays__weekday abbr': {
|
||||
textDecoration: 'none',
|
||||
},
|
||||
// All tiles — shared base
|
||||
'& .react-calendar__tile': {
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
color: 'var(--joy-palette-text-primary)',
|
||||
fontFamily: 'var(--joy-fontFamily-body)',
|
||||
fontSize: '0.8rem',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'background 0.15s',
|
||||
'&:hover': {
|
||||
background: 'var(--joy-palette-neutral-softBg)',
|
||||
},
|
||||
},
|
||||
// Day tiles only — circular
|
||||
'& .react-calendar__month-view__days .react-calendar__tile': {
|
||||
aspectRatio: '1',
|
||||
borderRadius: '50%',
|
||||
},
|
||||
// Month tiles (year view) — pill shape, no huge circle
|
||||
'& .react-calendar__year-view .react-calendar__tile': {
|
||||
borderRadius: '8px',
|
||||
padding: '10px 4px',
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
// Year tiles (decade view) — pill shape
|
||||
'& .react-calendar__decade-view .react-calendar__tile': {
|
||||
borderRadius: '8px',
|
||||
padding: '10px 4px',
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
// Century tiles — pill shape
|
||||
'& .react-calendar__century-view .react-calendar__tile': {
|
||||
borderRadius: '8px',
|
||||
padding: '10px 4px',
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
'& .react-calendar__tile--now': {
|
||||
border:
|
||||
'1.5px solid var(--joy-palette-primary-solidBg) !important',
|
||||
color: 'var(--joy-palette-primary-solidBg) !important',
|
||||
fontWeight: 700,
|
||||
background: 'none !important',
|
||||
},
|
||||
'& .react-calendar__tile--active, & .react-calendar__tile--active:hover':
|
||||
{
|
||||
background: 'var(--joy-palette-primary-solidBg) !important',
|
||||
color: 'var(--joy-palette-primary-solidColor) !important',
|
||||
fontWeight: 700,
|
||||
},
|
||||
'& .react-calendar__month-view__days__day--neighboringMonth': {
|
||||
color: 'var(--joy-palette-text-tertiary)',
|
||||
},
|
||||
'& .react-calendar__month-view__days': {
|
||||
display: 'grid !important',
|
||||
gridTemplateColumns: 'repeat(7, 1fr) !important',
|
||||
},
|
||||
'& .react-calendar__month-view__weekdays': {
|
||||
display: 'grid !important',
|
||||
gridTemplateColumns: 'repeat(7, 1fr) !important',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Calendar
|
||||
value={
|
||||
localDueDateOnly
|
||||
? new Date(`${localDueDateOnly}T00:00:00`)
|
||||
: null
|
||||
}
|
||||
calendarType={calendarType}
|
||||
onChange={handleCalendarChange}
|
||||
formatShortWeekday={(locale, date) =>
|
||||
['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'][date.getDay()]
|
||||
}
|
||||
formatMonth={(locale, date) =>
|
||||
[
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
][date.getMonth()]
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
mb: 0.5,
|
||||
mt: 0.5,
|
||||
color: 'text.tertiary',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
Custom time
|
||||
</Typography>
|
||||
<Input
|
||||
type='time'
|
||||
size='sm'
|
||||
value={localUseCustomTime ? localDueTime || '' : ''}
|
||||
disabled={!localDueDateOnly}
|
||||
onChange={handleLocalTimeInputChange}
|
||||
sx={{ maxWidth: 200, mb: 1 }}
|
||||
slotProps={{ input: { style: { fontFamily: 'inherit' } } }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 0.75, mb: 0.5 }}>
|
||||
<Button
|
||||
size='sm'
|
||||
variant={!localUseCustomTime ? 'soft' : 'plain'}
|
||||
color='neutral'
|
||||
disabled={!localDueDateOnly}
|
||||
onClick={() => setLocalUseCustomTime(false)}
|
||||
>
|
||||
Anytime
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant={localUseCustomTime ? 'soft' : 'plain'}
|
||||
color='neutral'
|
||||
disabled={!localDueDateOnly}
|
||||
onClick={() => setLocalUseCustomTime(true)}
|
||||
>
|
||||
Specific time
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DueDatePickerField
|
||||
48
src/views/components/LabelsPickerField.jsx
Normal file
48
src/views/components/LabelsPickerField.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Label } from '@mui/icons-material'
|
||||
import BaseOptionPicker from './BaseOptionPicker'
|
||||
|
||||
const LabelsPickerField = ({
|
||||
values = [],
|
||||
onChange,
|
||||
onClear,
|
||||
labels = [],
|
||||
emptyDisplay = 'icon-text',
|
||||
}) => {
|
||||
const options = labels.map(label => ({
|
||||
id: label.id,
|
||||
name: label.name,
|
||||
color: label.color,
|
||||
}))
|
||||
|
||||
return (
|
||||
<BaseOptionPicker
|
||||
items={options}
|
||||
multiple
|
||||
values={values}
|
||||
onValuesChange={onChange}
|
||||
onClear={onClear}
|
||||
emptyDisplay={emptyDisplay}
|
||||
emptyLabel='Labels'
|
||||
getItemValue={item => item.id}
|
||||
getItemLabel={item => item.name}
|
||||
getItemColor={item => item.color}
|
||||
renderTriggerIcon={() => <Label sx={{ fontSize: '20px' }} />}
|
||||
renderItemStart={({ item }) => (
|
||||
<Label
|
||||
sx={{
|
||||
fontSize: '18px',
|
||||
color: item.color || 'text.secondary',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
getTriggerText={({ selectedItems, isEmpty }) => {
|
||||
if (isEmpty) return 'Labels'
|
||||
if (selectedItems.length === 1) return selectedItems[0].name
|
||||
return `${selectedItems.length} labels`
|
||||
}}
|
||||
menuMinWidth={220}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default LabelsPickerField
|
||||
160
src/views/components/NotificationPickerField.jsx
Normal file
160
src/views/components/NotificationPickerField.jsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { Close, NotificationsNone } from '@mui/icons-material'
|
||||
import { Box, Button, IconButton, Typography } from '@mui/joy'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import NotificationTemplate from '../../components/NotificationTemplate'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
|
||||
const getDisplayLabel = templates => {
|
||||
if (!templates || templates.length === 0) return 'Remind'
|
||||
const count = templates.length
|
||||
if (count === 1) {
|
||||
const n = templates[0]
|
||||
const numericValue = Number(n.value)
|
||||
if (numericValue === 0) return 'On due date'
|
||||
const unitName =
|
||||
n.unit === 'm' ? 'min' : n.unit === 'h' ? 'hr' : 'day'
|
||||
const absValue = Math.abs(numericValue)
|
||||
const plural = absValue !== 1 ? 's' : ''
|
||||
return `${absValue} ${unitName}${plural} ${numericValue < 0 ? 'before' : 'after'}`
|
||||
}
|
||||
return `${count} reminders`
|
||||
}
|
||||
|
||||
const NotificationPickerField = ({
|
||||
value,
|
||||
onChange,
|
||||
onClear,
|
||||
emptyDisplay = 'icon-text',
|
||||
size = 'sm',
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const latestTemplatesRef = useRef(value?.templates || [])
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
latestTemplatesRef.current = value?.templates || []
|
||||
}
|
||||
}, [isOpen, value])
|
||||
|
||||
const templates = value?.templates || []
|
||||
const hasNotifications = templates.length > 0
|
||||
const shouldShowLabel = hasNotifications || emptyDisplay === 'icon-text'
|
||||
const displayLabel = getDisplayLabel(templates)
|
||||
|
||||
const handleSave = () => {
|
||||
onChange({ ...value, templates: latestTemplatesRef.current })
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const footer = (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
{hasNotifications && (
|
||||
<Button
|
||||
variant='plain'
|
||||
color='danger'
|
||||
size='lg'
|
||||
onClick={() => {
|
||||
onClear?.()
|
||||
setIsOpen(false)
|
||||
}}
|
||||
sx={{ mr: 'auto' }}
|
||||
>
|
||||
Remove all
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='lg'
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='solid' color='primary' size='lg' onClick={handleSave}>
|
||||
Apply
|
||||
</Button>
|
||||
</Box>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box sx={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
|
||||
<Button
|
||||
size={size}
|
||||
variant={hasNotifications ? 'soft' : 'outlined'}
|
||||
color='neutral'
|
||||
onClick={() => setIsOpen(true)}
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<NotificationsNone 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>
|
||||
|
||||
{hasNotifications && 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>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<ResponsiveModal
|
||||
open={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
title='Reminders'
|
||||
footer={footer}
|
||||
>
|
||||
<NotificationTemplate
|
||||
value={value}
|
||||
onChange={({ notifications }) => {
|
||||
latestTemplatesRef.current = notifications
|
||||
}}
|
||||
showTimeline
|
||||
/>
|
||||
</ResponsiveModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotificationPickerField
|
||||
74
src/views/components/PriorityPickerField.jsx
Normal file
74
src/views/components/PriorityPickerField.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Flag } from '@mui/icons-material'
|
||||
import BaseOptionPicker from './BaseOptionPicker'
|
||||
|
||||
const defaultPriorityColors = {
|
||||
0: '#9CA3AF',
|
||||
1: '#EF4444',
|
||||
2: '#F97316',
|
||||
3: '#FBBF24',
|
||||
4: '#3B82F6',
|
||||
}
|
||||
|
||||
const defaultPriorityLabels = {
|
||||
0: 'No Priority',
|
||||
1: 'P1',
|
||||
2: 'P2',
|
||||
3: 'P3',
|
||||
4: 'P4',
|
||||
}
|
||||
|
||||
const PriorityPickerField = ({
|
||||
value = 0,
|
||||
onChange,
|
||||
onClear,
|
||||
emptyDisplay = 'icon-text',
|
||||
priorityColors = defaultPriorityColors,
|
||||
priorityLabels = defaultPriorityLabels,
|
||||
size = 'sm',
|
||||
}) => {
|
||||
const options = [1, 2, 3, 4].map(priorityOption => ({
|
||||
id: priorityOption,
|
||||
label: priorityLabels[priorityOption],
|
||||
color: priorityColors[priorityOption],
|
||||
}))
|
||||
|
||||
// Don't add the 0 option to the menu - priority 0 is the "empty" state (icon only)
|
||||
|
||||
return (
|
||||
<BaseOptionPicker
|
||||
items={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onClear={onClear}
|
||||
emptyDisplay={emptyDisplay}
|
||||
size={size}
|
||||
getItemValue={item => item.id}
|
||||
getItemLabel={item => item.label}
|
||||
getItemColor={item => item.color}
|
||||
getTriggerText={({ selectedItems, isEmpty }) => {
|
||||
// For priority 0 (no priority), show empty string (icon only)
|
||||
if (value === 0 || isEmpty) return 'Priority'
|
||||
return selectedItems[0]?.label || ''
|
||||
}}
|
||||
renderTriggerIcon={({ selectedItems, isEmpty }) => (
|
||||
<Flag
|
||||
sx={{
|
||||
color: isEmpty || value === 0 ? '' : selectedItems[0]?.color,
|
||||
fontSize: '20px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
renderItemStart={({ item }) => (
|
||||
<Flag
|
||||
sx={{
|
||||
color: item.color,
|
||||
fontSize: '18px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
menuMinWidth={180}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default PriorityPickerField
|
||||
48
src/views/components/ProjectPickerField.jsx
Normal file
48
src/views/components/ProjectPickerField.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { FolderOpen } from '@mui/icons-material'
|
||||
import BaseOptionPicker from './BaseOptionPicker'
|
||||
|
||||
const ProjectPickerField = ({
|
||||
value = 'default',
|
||||
onChange,
|
||||
onClear,
|
||||
projects = [],
|
||||
emptyDisplay = 'icon-text',
|
||||
}) => {
|
||||
const options = [
|
||||
{ id: 'default', name: 'Default Project', color: '#9CA3AF' },
|
||||
...projects.map(project => ({
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
color: project.color,
|
||||
})),
|
||||
]
|
||||
|
||||
return (
|
||||
<BaseOptionPicker
|
||||
items={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onClear={onClear}
|
||||
emptyDisplay={emptyDisplay}
|
||||
emptyLabel='Project'
|
||||
getItemValue={item => item.id}
|
||||
getItemLabel={item => item.name}
|
||||
getItemColor={item => item.color}
|
||||
renderTriggerIcon={() => <FolderOpen sx={{ fontSize: '20px' }} />}
|
||||
renderItemStart={({ item }) => (
|
||||
<FolderOpen
|
||||
sx={{
|
||||
fontSize: '18px',
|
||||
color: item.color || 'text.secondary',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
getTriggerText={({ selectedItems, isEmpty }) =>
|
||||
isEmpty ? 'Project' : selectedItems[0].name
|
||||
}
|
||||
menuMinWidth={240}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProjectPickerField
|
||||
639
src/views/components/RepeatPickerField.jsx
Normal file
639
src/views/components/RepeatPickerField.jsx
Normal file
@@ -0,0 +1,639 @@
|
||||
import { Close, Repeat } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
IconButton,
|
||||
Input,
|
||||
List,
|
||||
ListItem,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getRecurrentChipText } from '../../utils/ChoreCardHelpers'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
|
||||
const FREQUENCY_TYPES = [
|
||||
'daily',
|
||||
'weekly',
|
||||
'monthly',
|
||||
'yearly',
|
||||
'adaptive',
|
||||
'custom',
|
||||
]
|
||||
const REPEAT_ON_TYPE = ['interval', 'days_of_the_week', 'day_of_the_month']
|
||||
|
||||
const DAYS = [
|
||||
'monday',
|
||||
'tuesday',
|
||||
'wednesday',
|
||||
'thursday',
|
||||
'friday',
|
||||
'saturday',
|
||||
'sunday',
|
||||
]
|
||||
|
||||
const MONTHS = [
|
||||
'january',
|
||||
'february',
|
||||
'march',
|
||||
'april',
|
||||
'may',
|
||||
'june',
|
||||
'july',
|
||||
'august',
|
||||
'september',
|
||||
'october',
|
||||
'november',
|
||||
'december',
|
||||
]
|
||||
|
||||
const OCCURRENCE_OPTIONS = [
|
||||
{ value: 1, label: '1st' },
|
||||
{ value: 2, label: '2nd' },
|
||||
{ value: 3, label: '3rd' },
|
||||
{ value: 4, label: '4th' },
|
||||
{ value: -1, label: 'Last' },
|
||||
]
|
||||
|
||||
const defaultMetadata = () => ({
|
||||
unit: 'days',
|
||||
time: moment(moment(new Date()).format('YYYY-MM-DD') + 'T18:00').format(),
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
})
|
||||
|
||||
const initLocalState = value => {
|
||||
if (!value) {
|
||||
return {
|
||||
frequencyType: 'daily',
|
||||
frequency: 1,
|
||||
frequencyMetadata: defaultMetadata(),
|
||||
}
|
||||
}
|
||||
|
||||
let { frequencyType, frequency, frequencyMetadata } = value
|
||||
|
||||
// Normalize parser output: interval/1/days → daily, etc.
|
||||
if (frequencyType === 'interval' && frequency === 1) {
|
||||
const unitTypeMap = {
|
||||
days: 'daily',
|
||||
weeks: 'weekly',
|
||||
months: 'monthly',
|
||||
years: 'yearly',
|
||||
}
|
||||
frequencyType = unitTypeMap[frequencyMetadata?.unit] || frequencyType
|
||||
}
|
||||
|
||||
return {
|
||||
frequencyType,
|
||||
frequency: frequency ?? 1,
|
||||
frequencyMetadata: {
|
||||
...defaultMetadata(),
|
||||
...frequencyMetadata,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const getDisplayType = frequencyType =>
|
||||
REPEAT_ON_TYPE.includes(frequencyType) ? 'custom' : frequencyType
|
||||
|
||||
// Shared section label
|
||||
const SectionLabel = ({ children }) => (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
color: 'text.tertiary',
|
||||
mb: 0.75,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Typography>
|
||||
)
|
||||
|
||||
// Shared time-of-day picker
|
||||
const TimeRow = ({ metadata, onUpdate }) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mt: 2 }}>
|
||||
<SectionLabel>Time of day</SectionLabel>
|
||||
<Input
|
||||
type='time'
|
||||
size='sm'
|
||||
value={moment(metadata?.time).format('HH:mm')}
|
||||
onChange={e =>
|
||||
onUpdate({
|
||||
...metadata,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
time: moment(
|
||||
moment(new Date()).format('YYYY-MM-DD') + 'T' + e.target.value,
|
||||
).format(),
|
||||
})
|
||||
}
|
||||
sx={{ width: 120 }}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
|
||||
const pillListSx = {
|
||||
'--List-gap': '8px',
|
||||
'--ListItem-radius': '20px',
|
||||
}
|
||||
|
||||
// Interval section
|
||||
const IntervalSection = ({
|
||||
frequency,
|
||||
frequencyMetadata,
|
||||
onFrequencyUpdate,
|
||||
onFrequencyMetadataUpdate,
|
||||
}) => (
|
||||
<Box>
|
||||
<SectionLabel>Repeat every</SectionLabel>
|
||||
<Box
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}
|
||||
>
|
||||
<Input
|
||||
type='number'
|
||||
size='sm'
|
||||
value={frequency}
|
||||
onChange={e =>
|
||||
onFrequencyUpdate(Math.max(1, parseInt(e.target.value, 10) || 1))
|
||||
}
|
||||
sx={{ width: 72 }}
|
||||
slotProps={{ input: { min: 1, max: 999 } }}
|
||||
/>
|
||||
<List orientation='horizontal' wrap sx={pillListSx}>
|
||||
{['days', 'weeks', 'months', 'years'].map(unit => (
|
||||
<ListItem key={unit}>
|
||||
<Checkbox
|
||||
checked={frequencyMetadata?.unit === unit}
|
||||
onClick={() =>
|
||||
onFrequencyMetadataUpdate({ ...frequencyMetadata, unit })
|
||||
}
|
||||
overlay
|
||||
disableIcon
|
||||
variant='soft'
|
||||
label={unit.charAt(0).toUpperCase() + unit.slice(1)}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
<TimeRow
|
||||
metadata={frequencyMetadata}
|
||||
onUpdate={onFrequencyMetadataUpdate}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
|
||||
// Days of week section
|
||||
const DaysOfWeekSection = ({
|
||||
frequencyMetadata,
|
||||
onFrequencyMetadataUpdate,
|
||||
}) => {
|
||||
const selectedDays = frequencyMetadata?.days || []
|
||||
const weekPattern = frequencyMetadata?.weekPattern || 'every_week'
|
||||
const selectedOccurrences = frequencyMetadata?.occurrences || []
|
||||
|
||||
const toggleDay = day => {
|
||||
const next = selectedDays.includes(day)
|
||||
? selectedDays.filter(d => d !== day)
|
||||
: [...selectedDays, day]
|
||||
onFrequencyMetadataUpdate({ ...frequencyMetadata, days: next })
|
||||
}
|
||||
|
||||
const toggleOccurrence = val => {
|
||||
const next = selectedOccurrences.includes(val)
|
||||
? selectedOccurrences.filter(v => v !== val)
|
||||
: [...selectedOccurrences, val]
|
||||
onFrequencyMetadataUpdate({ ...frequencyMetadata, occurrences: next })
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<SectionLabel>Days</SectionLabel>
|
||||
<List orientation='horizontal' wrap sx={pillListSx}>
|
||||
{DAYS.map(day => (
|
||||
<ListItem key={day}>
|
||||
<Checkbox
|
||||
checked={selectedDays.includes(day)}
|
||||
onClick={() => toggleDay(day)}
|
||||
overlay
|
||||
disableIcon
|
||||
variant='soft'
|
||||
label={day.charAt(0).toUpperCase() + day.slice(1, 3)}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<SectionLabel>Pattern</SectionLabel>
|
||||
<RadioGroup
|
||||
orientation='horizontal'
|
||||
value={weekPattern}
|
||||
onChange={e =>
|
||||
onFrequencyMetadataUpdate({
|
||||
...frequencyMetadata,
|
||||
weekPattern: e.target.value,
|
||||
occurrences:
|
||||
e.target.value === 'every_week' ? [] : selectedOccurrences,
|
||||
})
|
||||
}
|
||||
sx={{
|
||||
padding: '3px',
|
||||
borderRadius: '10px',
|
||||
bgcolor: 'neutral.softBg',
|
||||
'--RadioGroup-gap': '3px',
|
||||
'--Radio-actionRadius': '7px',
|
||||
display: 'inline-flex',
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ value: 'every_week', label: 'Every week' },
|
||||
{ value: 'week_of_month', label: 'Specific weeks' },
|
||||
].map(opt => (
|
||||
<Radio
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
color='neutral'
|
||||
disableIcon
|
||||
label={opt.label}
|
||||
variant='plain'
|
||||
sx={{ px: 1.5, py: 0.5 }}
|
||||
slotProps={{
|
||||
action: ({ checked }) => ({
|
||||
sx: checked
|
||||
? {
|
||||
bgcolor: 'background.surface',
|
||||
boxShadow: 'sm',
|
||||
'&:hover': { bgcolor: 'background.surface' },
|
||||
}
|
||||
: {},
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</Box>
|
||||
|
||||
{weekPattern === 'week_of_month' && (
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<SectionLabel>Occurrences</SectionLabel>
|
||||
<List orientation='horizontal' wrap sx={pillListSx}>
|
||||
{OCCURRENCE_OPTIONS.map(opt => (
|
||||
<ListItem key={opt.value}>
|
||||
<Checkbox
|
||||
checked={selectedOccurrences.includes(opt.value)}
|
||||
onClick={() => toggleOccurrence(opt.value)}
|
||||
overlay
|
||||
disableIcon
|
||||
variant='soft'
|
||||
label={opt.label}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<TimeRow
|
||||
metadata={frequencyMetadata}
|
||||
onUpdate={onFrequencyMetadataUpdate}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Day of month section
|
||||
const DayOfMonthSection = ({
|
||||
frequency,
|
||||
frequencyMetadata,
|
||||
onFrequencyUpdate,
|
||||
onFrequencyMetadataUpdate,
|
||||
}) => {
|
||||
const selectedMonths = frequencyMetadata?.months || []
|
||||
|
||||
const toggleMonth = month => {
|
||||
const next = selectedMonths.includes(month)
|
||||
? selectedMonths.filter(m => m !== month)
|
||||
: [...selectedMonths, month]
|
||||
onFrequencyMetadataUpdate({ ...frequencyMetadata, months: next })
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<SectionLabel>Months</SectionLabel>
|
||||
<List orientation='horizontal' wrap sx={pillListSx}>
|
||||
{MONTHS.map(month => (
|
||||
<ListItem key={month}>
|
||||
<Checkbox
|
||||
checked={selectedMonths.includes(month)}
|
||||
onClick={() => toggleMonth(month)}
|
||||
overlay
|
||||
disableIcon
|
||||
variant='soft'
|
||||
label={month.charAt(0).toUpperCase() + month.slice(1, 3)}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mt: 2 }}>
|
||||
<SectionLabel>Day of month</SectionLabel>
|
||||
<Input
|
||||
type='number'
|
||||
size='sm'
|
||||
value={frequency}
|
||||
onChange={e => {
|
||||
const v = Math.min(
|
||||
31,
|
||||
Math.max(1, parseInt(e.target.value, 10) || 1),
|
||||
)
|
||||
onFrequencyUpdate(v)
|
||||
}}
|
||||
sx={{ width: 72 }}
|
||||
slotProps={{ input: { min: 1, max: 31 } }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<TimeRow
|
||||
metadata={frequencyMetadata}
|
||||
onUpdate={onFrequencyMetadataUpdate}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const RepeatPickerField = ({
|
||||
value,
|
||||
onChange,
|
||||
onClear,
|
||||
emptyDisplay = 'icon-text',
|
||||
size = 'sm',
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [localFrequencyType, setLocalFrequencyType] = useState('daily')
|
||||
const [localFrequency, setLocalFrequency] = useState(1)
|
||||
const [localFrequencyMetadata, setLocalFrequencyMetadata] =
|
||||
useState(defaultMetadata)
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const init = initLocalState(value)
|
||||
setLocalFrequencyType(init.frequencyType)
|
||||
setLocalFrequency(init.frequency)
|
||||
setLocalFrequencyMetadata(init.frequencyMetadata)
|
||||
}, [isOpen, value])
|
||||
|
||||
const hasRepeat = Boolean(value)
|
||||
const shouldShowLabel = hasRepeat || emptyDisplay === 'icon-text'
|
||||
const displayLabel = hasRepeat ? getRecurrentChipText(value) : 'Repeat'
|
||||
const displayType = getDisplayType(localFrequencyType)
|
||||
|
||||
const handleTypeSelect = type => {
|
||||
if (type === 'custom') {
|
||||
setLocalFrequencyType('interval')
|
||||
setLocalFrequency(1)
|
||||
setLocalFrequencyMetadata({ ...defaultMetadata(), unit: 'days' })
|
||||
} else {
|
||||
setLocalFrequencyType(type)
|
||||
setLocalFrequency(1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubTypeSelect = newType => {
|
||||
setLocalFrequencyType(newType)
|
||||
if (newType === 'interval') {
|
||||
setLocalFrequency(1)
|
||||
setLocalFrequencyMetadata(prev => ({ ...prev, unit: 'days' }))
|
||||
} else if (newType === 'days_of_the_week') {
|
||||
setLocalFrequencyMetadata(prev => ({
|
||||
...prev,
|
||||
days: [],
|
||||
weekPattern: 'every_week',
|
||||
occurrences: [],
|
||||
}))
|
||||
} else if (newType === 'day_of_the_month') {
|
||||
setLocalFrequency(1)
|
||||
setLocalFrequencyMetadata(prev => ({ ...prev, months: [] }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
onChange({
|
||||
frequencyType: localFrequencyType,
|
||||
frequency: localFrequency,
|
||||
frequencyMetadata: localFrequencyMetadata,
|
||||
})
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box sx={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
|
||||
<Button
|
||||
size={size}
|
||||
variant={hasRepeat ? 'soft' : 'outlined'}
|
||||
color='neutral'
|
||||
onClick={() => setIsOpen(true)}
|
||||
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>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<ResponsiveModal
|
||||
open={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
title='Repeat Schedule'
|
||||
footer={
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
{hasRepeat && (
|
||||
<Button
|
||||
variant='plain'
|
||||
color='danger'
|
||||
size='lg'
|
||||
onClick={() => {
|
||||
onClear?.()
|
||||
setIsOpen(false)
|
||||
}}
|
||||
sx={{ mr: 'auto' }}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='lg'
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
size='lg'
|
||||
onClick={handleSave}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{/* Frequency type selector */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
<Box>
|
||||
<SectionLabel>Frequency</SectionLabel>
|
||||
<List orientation='horizontal' wrap sx={pillListSx}>
|
||||
{FREQUENCY_TYPES.map(type => (
|
||||
<ListItem key={type}>
|
||||
<Checkbox
|
||||
checked={displayType === type}
|
||||
onClick={() => handleTypeSelect(type)}
|
||||
overlay
|
||||
disableIcon
|
||||
variant='soft'
|
||||
label={type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
{/* Custom sub-type + detail panel */}
|
||||
{displayType === 'custom' && (
|
||||
<>
|
||||
<Box>
|
||||
<SectionLabel>Schedule type</SectionLabel>
|
||||
<RadioGroup
|
||||
orientation='horizontal'
|
||||
value={localFrequencyType}
|
||||
onChange={e => handleSubTypeSelect(e.target.value)}
|
||||
sx={{
|
||||
padding: '3px',
|
||||
borderRadius: '10px',
|
||||
bgcolor: 'neutral.softBg',
|
||||
'--RadioGroup-gap': '3px',
|
||||
'--Radio-actionRadius': '7px',
|
||||
display: 'inline-flex',
|
||||
}}
|
||||
>
|
||||
{REPEAT_ON_TYPE.map(type => (
|
||||
<Radio
|
||||
key={type}
|
||||
value={type}
|
||||
color='neutral'
|
||||
disableIcon
|
||||
label={type
|
||||
.split('_')
|
||||
.map((w, i, arr) =>
|
||||
i === 0 || i === arr.length - 1
|
||||
? w.charAt(0).toUpperCase() + w.slice(1)
|
||||
: w,
|
||||
)
|
||||
.join(' ')}
|
||||
variant='plain'
|
||||
sx={{ px: 1.5, py: 0.5 }}
|
||||
slotProps={{
|
||||
action: ({ checked }) => ({
|
||||
sx: checked
|
||||
? {
|
||||
bgcolor: 'background.surface',
|
||||
boxShadow: 'sm',
|
||||
'&:hover': { bgcolor: 'background.surface' },
|
||||
}
|
||||
: {},
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{localFrequencyType === 'interval' && (
|
||||
<IntervalSection
|
||||
frequency={localFrequency}
|
||||
frequencyMetadata={localFrequencyMetadata}
|
||||
onFrequencyUpdate={setLocalFrequency}
|
||||
onFrequencyMetadataUpdate={setLocalFrequencyMetadata}
|
||||
/>
|
||||
)}
|
||||
{localFrequencyType === 'days_of_the_week' && (
|
||||
<DaysOfWeekSection
|
||||
frequencyMetadata={localFrequencyMetadata}
|
||||
onFrequencyMetadataUpdate={setLocalFrequencyMetadata}
|
||||
/>
|
||||
)}
|
||||
{localFrequencyType === 'day_of_the_month' && (
|
||||
<DayOfMonthSection
|
||||
frequency={localFrequency}
|
||||
frequencyMetadata={localFrequencyMetadata}
|
||||
onFrequencyUpdate={setLocalFrequency}
|
||||
onFrequencyMetadataUpdate={setLocalFrequencyMetadata}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default RepeatPickerField
|
||||
@@ -1,3 +1,18 @@
|
||||
:root,
|
||||
[data-joy-color-scheme='light'] {
|
||||
--highlight-date-color: #b45309;
|
||||
--highlight-repeat-color: #15803d;
|
||||
--highlight-label-color: #1d4ed8;
|
||||
--highlight-priority-color: #be123c;
|
||||
}
|
||||
|
||||
[data-joy-color-scheme='dark'] {
|
||||
--highlight-date-color: #fca5a5;
|
||||
--highlight-repeat-color: #86efac;
|
||||
--highlight-label-color: #93c5fd;
|
||||
--highlight-priority-color: #f9a8d4;
|
||||
}
|
||||
|
||||
.smart-task-display {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
@@ -11,27 +26,36 @@
|
||||
white-space: pre-wrap;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.smart-task-common {
|
||||
font-size: 1.2em;
|
||||
line-height: 1.2em;
|
||||
font-family: inherit;
|
||||
caret-color: #f08080;
|
||||
caret-color: var(--highlight-date-color);
|
||||
}
|
||||
|
||||
.highlight-date {
|
||||
color: #f08080;
|
||||
color: var(--highlight-date-color);
|
||||
}
|
||||
|
||||
.highlight-repeat {
|
||||
color: #90ee90;
|
||||
color: var(--highlight-repeat-color);
|
||||
}
|
||||
|
||||
.highlight-label {
|
||||
color: #add8e6;
|
||||
color: var(--highlight-label-color);
|
||||
}
|
||||
|
||||
.highlight-priority {
|
||||
color: #ffb6c1;
|
||||
color: var(--highlight-priority-color);
|
||||
}
|
||||
|
||||
.highlight-assignee {
|
||||
color: var(--highlight-repeat-color);
|
||||
}
|
||||
|
||||
.highlight-points {
|
||||
color: var(--highlight-label-color);
|
||||
}
|
||||
|
||||
.task-input {
|
||||
@@ -39,4 +63,6 @@
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--joy-palette-neutral-outlinedBorder, #d0d5dd);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -183,10 +183,7 @@ const SmartTaskTitleInput = ({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className='task-input overflow-auto rounded border'
|
||||
style={{ minHeight: '2.4em' }}
|
||||
>
|
||||
<div className='task-input' style={{ minHeight: '2.4em' }}>
|
||||
<textarea
|
||||
ref={titleInputRef}
|
||||
autoFocus={autoFocus}
|
||||
|
||||
Reference in New Issue
Block a user