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 Option from '@mui/joy/Option'
|
||||||
import Select from '@mui/joy/Select'
|
import Select from '@mui/joy/Select'
|
||||||
import Typography from '@mui/joy/Typography'
|
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 { NOTIFICATION_TYPE, TASK_COLOR } from '../utils/Colors'
|
||||||
import { TIME_UNITS } from '../utils/DurationUtils'
|
import { TIME_UNITS } from '../utils/DurationUtils'
|
||||||
|
|
||||||
@@ -512,6 +512,7 @@ const NotificationTemplate = ({
|
|||||||
'--Badge-fontSize': '0.7rem',
|
'--Badge-fontSize': '0.7rem',
|
||||||
'--Badge-paddingX': '5px',
|
'--Badge-paddingX': '5px',
|
||||||
top: 10,
|
top: 10,
|
||||||
|
left: 10,
|
||||||
'& .MuiBadge-badge': {
|
'& .MuiBadge-badge': {
|
||||||
background: colors.bgColor,
|
background: colors.bgColor,
|
||||||
color: 'white',
|
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}`
|
return `Every ${dayNames} at ${timeStr}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const RepeatOnSections = ({
|
export const RepeatOnSections = ({
|
||||||
frequencyType,
|
frequencyType,
|
||||||
frequency,
|
frequency,
|
||||||
onFrequencyUpdate,
|
onFrequencyUpdate,
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
import { Add, EditNotifications } from '@mui/icons-material'
|
import { Add } from '@mui/icons-material'
|
||||||
import {
|
import { Box, Button, Typography } from '@mui/joy'
|
||||||
Box,
|
import { useMediaQuery } from '@mui/material'
|
||||||
Button,
|
|
||||||
Checkbox,
|
|
||||||
FormHelperText,
|
|
||||||
Input,
|
|
||||||
Option,
|
|
||||||
Select,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/joy'
|
|
||||||
import { FormControl } from '@mui/material'
|
|
||||||
import * as chrono from 'chrono-node'
|
import * as chrono from 'chrono-node'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
@@ -30,8 +21,15 @@ import {
|
|||||||
import SmartTaskTitleInput from './SmartTaskTitleInput'
|
import SmartTaskTitleInput from './SmartTaskTitleInput'
|
||||||
|
|
||||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
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 LearnMoreButton from './LearnMore'
|
||||||
|
import NotificationPickerField from './NotificationPickerField'
|
||||||
|
import PriorityPickerField from './PriorityPickerField'
|
||||||
|
import RepeatPickerField from './RepeatPickerField'
|
||||||
import RichTextEditor from './RichTextEditor'
|
import RichTextEditor from './RichTextEditor'
|
||||||
import SubTasks from './SubTask'
|
import SubTasks from './SubTask'
|
||||||
const getDefaultNotification = () => {
|
const getDefaultNotification = () => {
|
||||||
@@ -46,18 +44,20 @@ const getDefaultNotification = () => {
|
|||||||
]
|
]
|
||||||
|
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
'defaultNotification',
|
'defaultNotificationTemplate',
|
||||||
JSON.stringify(defaultNotification),
|
JSON.stringify(defaultNotification),
|
||||||
)
|
)
|
||||||
return defaultNotification
|
return defaultNotification
|
||||||
}
|
}
|
||||||
|
|
||||||
const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
|
||||||
const { ResponsiveModal } = useResponsiveModal()
|
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: userLabels, isLoading: userLabelsLoading } = useLabels()
|
||||||
const { data: circleMembers, isLoading: isCircleMembersLoading } =
|
const { data: circleMembers, isLoading: isCircleMembersLoading } =
|
||||||
useCircleMembers()
|
useCircleMembers()
|
||||||
const { data: projects = [], isLoading: isProjectsLoading } = useProjects()
|
const { isLoading: isProjectsLoading } = useProjects()
|
||||||
const createChoreMutation = useCreateChore()
|
const createChoreMutation = useCreateChore()
|
||||||
|
|
||||||
const { data: userProfile } = useUserProfile()
|
const { data: userProfile } = useUserProfile()
|
||||||
@@ -80,9 +80,8 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
const [taskTitle, setTaskTitle] = useState('')
|
const [taskTitle, setTaskTitle] = useState('')
|
||||||
const [renderedParts, setRenderedParts] = useState([])
|
const [renderedParts, setRenderedParts] = useState([])
|
||||||
|
|
||||||
const textareaRef = useRef(null)
|
|
||||||
const mainInputRef = useRef(null)
|
|
||||||
const richTextEditorRef = useRef(null)
|
const richTextEditorRef = useRef(null)
|
||||||
|
const latestRef = useRef({})
|
||||||
const [priority, setPriority] = useState(0)
|
const [priority, setPriority] = useState(0)
|
||||||
const [dueDate, setDueDate] = useState(null)
|
const [dueDate, setDueDate] = useState(null)
|
||||||
const [description, setDescription] = useState(null)
|
const [description, setDescription] = useState(null)
|
||||||
@@ -92,20 +91,35 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
const [notificationMetadata, setNotificationMetadata] = useState({
|
const [notificationMetadata, setNotificationMetadata] = useState({
|
||||||
templates: getDefaultNotification(),
|
templates: getDefaultNotification(),
|
||||||
})
|
})
|
||||||
const [frequencyHumanReadable, setFrequencyHumanReadable] = useState(null)
|
|
||||||
const [subTasks, setSubTasks] = useState(null)
|
const [subTasks, setSubTasks] = useState(null)
|
||||||
const [points, setPoints] = useState(-1)
|
const [points, setPoints] = useState(-1)
|
||||||
const [isAnyoneTask, setIsAnyoneTask] = useState(false)
|
const [isAnyoneTask, setIsAnyoneTask] = useState(false)
|
||||||
const [hasDescription, setHasDescription] = useState(false)
|
const [hasDescription, setHasDescription] = useState(false)
|
||||||
const [hasSubTasks, setHasSubTasks] = useState(false)
|
const [hasSubTasks, setHasSubTasks] = useState(false)
|
||||||
const [hasNotifications, setHasNotifications] = useState(false)
|
|
||||||
const [hasDeadline, setHasDeadline] = useState(false)
|
|
||||||
const [deadlineOffset, setDeadlineOffset] = useState(-1)
|
const [deadlineOffset, setDeadlineOffset] = useState(-1)
|
||||||
const [dueDateOnly, setDueDateOnly] = useState(null)
|
const [dueDateOnly, setDueDateOnly] = useState(null)
|
||||||
const [dueTime, setDueTime] = useState(null)
|
const [dueTime, setDueTime] = useState(null)
|
||||||
const [useCustomTime, setUseCustomTime] = useState(false)
|
const [useCustomTime, setUseCustomTime] = useState(false)
|
||||||
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
|
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
|
||||||
const [projectId, setProjectId] = useState(getInitialProject())
|
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:
|
// set showKeyboardShortcuts true as soon as the user hold ctrl or cmd key:
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -117,12 +131,17 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
}
|
}
|
||||||
}, [hasDescription])
|
}, [hasDescription])
|
||||||
|
|
||||||
// set showKeyboardShortcuts true as soon as the user hold ctrl or cmd key:
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = event => {
|
const handleKeyDown = event => {
|
||||||
|
const {
|
||||||
|
isModalOpen,
|
||||||
|
hasDescription,
|
||||||
|
dueDate,
|
||||||
|
createChore,
|
||||||
|
handleCloseModal,
|
||||||
|
} = latestRef.current
|
||||||
const isHoldingCmd = event.ctrlKey || event.metaKey
|
const isHoldingCmd = event.ctrlKey || event.metaKey
|
||||||
if (isHoldingCmd) {
|
if (isHoldingCmd) {
|
||||||
// event.preventDefault()
|
|
||||||
setShowKeyboardShortcuts(true)
|
setShowKeyboardShortcuts(true)
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -135,10 +154,8 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
setShowKeyboardShortcuts(false)
|
setShowKeyboardShortcuts(false)
|
||||||
}
|
}
|
||||||
if (isHoldingCmd && event.key.toLowerCase() === 'j' && isModalOpen) {
|
if (isHoldingCmd && event.key.toLowerCase() === 'j' && isModalOpen) {
|
||||||
// add subtask:
|
|
||||||
setHasSubTasks(true)
|
setHasSubTasks(true)
|
||||||
setShowKeyboardShortcuts(false)
|
setShowKeyboardShortcuts(false)
|
||||||
// set focus on the first subtask input:
|
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
isHoldingCmd &&
|
isHoldingCmd &&
|
||||||
@@ -146,7 +163,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
isModalOpen &&
|
isModalOpen &&
|
||||||
!dueDate
|
!dueDate
|
||||||
) {
|
) {
|
||||||
// add due date:
|
|
||||||
const tomorrow = moment().add(1, 'day')
|
const tomorrow = moment().add(1, 'day')
|
||||||
setDueDateOnly(tomorrow.format('YYYY-MM-DD'))
|
setDueDateOnly(tomorrow.format('YYYY-MM-DD'))
|
||||||
setDueDate(tomorrow.endOf('day').format('YYYY-MM-DDTHH:mm:59'))
|
setDueDate(tomorrow.endOf('day').format('YYYY-MM-DDTHH:mm:59'))
|
||||||
@@ -154,7 +170,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
setDueTime(null)
|
setDueTime(null)
|
||||||
setShowKeyboardShortcuts(false)
|
setShowKeyboardShortcuts(false)
|
||||||
}
|
}
|
||||||
// Enter key to create task
|
|
||||||
if (
|
if (
|
||||||
event.key === 'Enter' &&
|
event.key === 'Enter' &&
|
||||||
(event.ctrlKey || event.metaKey) &&
|
(event.ctrlKey || event.metaKey) &&
|
||||||
@@ -164,7 +179,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
createChore()
|
createChore()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Escape key to cancel/close modal
|
|
||||||
if (event.key === 'Escape' && isModalOpen) {
|
if (event.key === 'Escape' && isModalOpen) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
handleCloseModal()
|
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(
|
const renderHighlightedSentence = useCallback(
|
||||||
(
|
(
|
||||||
sentence,
|
sentence,
|
||||||
@@ -262,20 +260,17 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
resolvedHighlights.push(current)
|
resolvedHighlights.push(current)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No overlap, add the current highlight
|
|
||||||
resolvedHighlights.push(current)
|
resolvedHighlights.push(current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const highlight of resolvedHighlights) {
|
for (const highlight of resolvedHighlights) {
|
||||||
// Add the text before the highlight
|
|
||||||
if (highlight.start > lastIndex) {
|
if (highlight.start > lastIndex) {
|
||||||
const textBefore = sentence.substring(lastIndex, highlight.start)
|
const textBefore = sentence.substring(lastIndex, highlight.start)
|
||||||
parts.push(textBefore)
|
parts.push(textBefore)
|
||||||
plainText += textBefore
|
plainText += textBefore
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine the class name based on the highlight type
|
|
||||||
let className = ''
|
let className = ''
|
||||||
switch (highlight.type) {
|
switch (highlight.type) {
|
||||||
case 'repeat':
|
case 'repeat':
|
||||||
@@ -300,7 +295,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the highlighted span
|
|
||||||
const highlightedText = sentence.substring(
|
const highlightedText = sentence.substring(
|
||||||
highlight.start,
|
highlight.start,
|
||||||
highlight.end,
|
highlight.end,
|
||||||
@@ -310,9 +304,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
key={highlight.start}
|
key={highlight.start}
|
||||||
className={className}
|
className={className}
|
||||||
style={{
|
style={{
|
||||||
// text underline:
|
|
||||||
textDecoration: 'underline',
|
textDecoration: 'underline',
|
||||||
// textDecorationColor: 'red',
|
|
||||||
textDecorationThickness: '2px',
|
textDecorationThickness: '2px',
|
||||||
textDecorationStyle: 'dashed',
|
textDecorationStyle: 'dashed',
|
||||||
}}
|
}}
|
||||||
@@ -321,11 +313,9 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
</span>,
|
</span>,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Update the last index to the end of the current highlight
|
|
||||||
lastIndex = highlight.end
|
lastIndex = highlight.end
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add any remaining text after the last highlight
|
|
||||||
if (lastIndex < sentence.length) {
|
if (lastIndex < sentence.length) {
|
||||||
const remainingText = sentence.substring(lastIndex)
|
const remainingText = sentence.substring(lastIndex)
|
||||||
parts.push(remainingText)
|
parts.push(remainingText)
|
||||||
@@ -342,12 +332,10 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
|
|
||||||
const processText = useCallback(
|
const processText = useCallback(
|
||||||
sentence => {
|
sentence => {
|
||||||
// Parse everything from the original sentence to get correct highlight positions
|
|
||||||
const priority = parsePriority(sentence)
|
const priority = parsePriority(sentence)
|
||||||
const pointsParsed = parsePoints(sentence)
|
const pointsParsed = parsePoints(sentence)
|
||||||
const labels = parseLabels(sentence, userLabels || [])
|
const labels = parseLabels(sentence, userLabels || [])
|
||||||
|
|
||||||
// Parse assignees using circle members
|
|
||||||
const circleMembersList = circleMembers?.res || []
|
const circleMembersList = circleMembers?.res || []
|
||||||
const assigneesForParsing = circleMembersList.map(member => ({
|
const assigneesForParsing = circleMembersList.map(member => ({
|
||||||
userId: member.userId,
|
userId: member.userId,
|
||||||
@@ -364,9 +352,15 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
const dueDateParsed = parseDueDate(sentence, chrono)
|
const dueDateParsed = parseDueDate(sentence, chrono)
|
||||||
|
|
||||||
// Set all the parsed values
|
// 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 (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) {
|
if (assigneesResult.isAnyone) {
|
||||||
// @Anyone was used - set empty assignees (anyone can do the task)
|
// @Anyone was used - set empty assignees (anyone can do the task)
|
||||||
@@ -392,7 +386,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
|
|
||||||
if (repeat.result) {
|
if (repeat.result) {
|
||||||
setFrequency(repeat.result)
|
setFrequency(repeat.result)
|
||||||
setFrequencyHumanReadable(repeat.name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncDueDateStates = parsedDate => {
|
const syncDueDateStates = parsedDate => {
|
||||||
@@ -500,6 +493,8 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
moment(`${dateValue}T${dueTime}`).format('YYYY-MM-DDTHH:mm:00'),
|
moment(`${dateValue}T${dueTime}`).format('YYYY-MM-DDTHH:mm:00'),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
setUseCustomTime(false)
|
||||||
|
setDueTime(null)
|
||||||
setDueDate(moment(dateValue).endOf('day').format('YYYY-MM-DDTHH:mm:ss'))
|
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
|
const timeValue = e.target.value
|
||||||
setDueTime(timeValue)
|
setDueTime(timeValue)
|
||||||
if (dueDateOnly) {
|
if (dueDateOnly) {
|
||||||
setDueDate(
|
if (timeValue) {
|
||||||
moment(`${dueDateOnly}T${timeValue}`).format('YYYY-MM-DDTHH:mm:00'),
|
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)
|
setUseCustomTime(checked)
|
||||||
if (checked) {
|
if (checked) {
|
||||||
const defaultTime = dueTime || '18:00'
|
const defaultTime = dueTime || '18:00'
|
||||||
setDueTime(defaultTime)
|
if (!dueTime) {
|
||||||
|
setDueTime(defaultTime)
|
||||||
|
}
|
||||||
if (dueDateOnly) {
|
if (dueDateOnly) {
|
||||||
setDueDate(
|
setDueDate(
|
||||||
moment(`${dueDateOnly}T${defaultTime}`).format('YYYY-MM-DDTHH:mm:00'),
|
moment(`${dueDateOnly}T${defaultTime}`).format('YYYY-MM-DDTHH:mm:00'),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
setDueTime(null)
|
||||||
if (dueDateOnly) {
|
if (dueDateOnly) {
|
||||||
setDueDate(
|
setDueDate(
|
||||||
moment(dueDateOnly).endOf('day').format('YYYY-MM-DDTHH:mm:ss'),
|
moment(dueDateOnly).endOf('day').format('YYYY-MM-DDTHH:mm:ss'),
|
||||||
@@ -543,7 +549,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
setTaskTitle('')
|
setTaskTitle('')
|
||||||
setDueDate(null)
|
setDueDate(null)
|
||||||
setFrequency(null)
|
setFrequency(null)
|
||||||
setFrequencyHumanReadable(null)
|
|
||||||
setPriority(0)
|
setPriority(0)
|
||||||
setPoints(-1)
|
setPoints(-1)
|
||||||
setIsAnyoneTask(false)
|
setIsAnyoneTask(false)
|
||||||
@@ -554,7 +559,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
setLabelsV2([])
|
setLabelsV2([])
|
||||||
setAssignees([])
|
setAssignees([])
|
||||||
setProjectId(getInitialProject())
|
setProjectId(getInitialProject())
|
||||||
setHasDeadline(false)
|
|
||||||
setDeadlineOffset(-1)
|
setDeadlineOffset(-1)
|
||||||
setDueDateOnly(null)
|
setDueDateOnly(null)
|
||||||
setDueTime(null)
|
setDueTime(null)
|
||||||
@@ -605,6 +609,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
notificationMetadata: {},
|
notificationMetadata: {},
|
||||||
subTasks: subTasks?.length > 0 ? subTasks : null,
|
subTasks: subTasks?.length > 0 ? subTasks : null,
|
||||||
projectId: projectId === 'default' ? null : projectId,
|
projectId: projectId === 'default' ? null : projectId,
|
||||||
|
attachments: attachments.length > 0 ? attachments : null,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (frequency) {
|
if (frequency) {
|
||||||
@@ -617,8 +622,8 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!frequency && dueDate) {
|
if (!frequency && dueDate) {
|
||||||
// use dueDate converted to UTC:
|
// Use RFC3339/ISO-8601 format expected by backend.
|
||||||
chore.nextDueDate = new Date(dueDate).toUTCString()
|
chore.nextDueDate = new Date(dueDate).toISOString()
|
||||||
chore.notificationMetadata = notificationMetadata
|
chore.notificationMetadata = notificationMetadata
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -645,6 +650,15 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
})
|
})
|
||||||
handleCloseModal(false)
|
handleCloseModal(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
latestRef.current = {
|
||||||
|
isModalOpen,
|
||||||
|
hasDescription,
|
||||||
|
dueDate,
|
||||||
|
createChore,
|
||||||
|
handleCloseModal,
|
||||||
|
}
|
||||||
|
|
||||||
if (isCircleMembersLoading || isProjectsLoading) {
|
if (isCircleMembersLoading || isProjectsLoading) {
|
||||||
return <></>
|
return <></>
|
||||||
}
|
}
|
||||||
@@ -685,6 +699,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
size='lg'
|
size='lg'
|
||||||
variant='solid'
|
variant='solid'
|
||||||
color='primary'
|
color='primary'
|
||||||
|
disabled={!taskTitle.trim()}
|
||||||
onClick={createChore}
|
onClick={createChore}
|
||||||
>
|
>
|
||||||
Create
|
Create
|
||||||
@@ -748,7 +763,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
<SmartTaskTitleInput
|
<SmartTaskTitleInput
|
||||||
autoFocus
|
autoFocus
|
||||||
value={taskText}
|
value={taskText}
|
||||||
placeholder='Type your full text here...'
|
placeholder='Type your task...'
|
||||||
onChange={text => {
|
onChange={text => {
|
||||||
setTaskText(text)
|
setTaskText(text)
|
||||||
}}
|
}}
|
||||||
@@ -793,21 +808,96 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
{/* <Box>
|
<Box
|
||||||
<Typography level='body-sm'>Title:</Typography>
|
sx={{
|
||||||
<Input
|
paddingTop: 2,
|
||||||
value={taskTitle}
|
paddingBottom: 1,
|
||||||
onChange={e => setTaskTitle(e.target.value)}
|
display: 'flex',
|
||||||
sx={{ width: '100%', fontSize: '16px' }}
|
flexDirection: 'row',
|
||||||
/>
|
gap: 1.5,
|
||||||
</Box> */}
|
|
||||||
|
|
||||||
<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 && (
|
{!hasDescription && (
|
||||||
<Button
|
<Button
|
||||||
startDecorator={<Add />}
|
startDecorator={<Add />}
|
||||||
variant='plain'
|
variant='outlined'
|
||||||
size='sm'
|
color='neutral'
|
||||||
|
size='md'
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setHasDescription(true)
|
setHasDescription(true)
|
||||||
// Focus will be handled by the useEffect hook
|
// Focus will be handled by the useEffect hook
|
||||||
@@ -823,8 +913,9 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
{!hasSubTasks && (
|
{!hasSubTasks && (
|
||||||
<Button
|
<Button
|
||||||
startDecorator={<Add />}
|
startDecorator={<Add />}
|
||||||
variant='plain'
|
variant='outlined'
|
||||||
size='sm'
|
color='neutral'
|
||||||
|
size='md'
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setHasSubTasks(true)
|
setHasSubTasks(true)
|
||||||
}}
|
}}
|
||||||
@@ -835,52 +926,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
Subtasks
|
Subtasks
|
||||||
</Button>
|
</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>
|
</Box>
|
||||||
|
|
||||||
{hasDescription && (
|
{hasDescription && (
|
||||||
@@ -906,210 +951,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</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>
|
</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 {
|
.smart-task-display {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -11,27 +26,36 @@
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.smart-task-common {
|
.smart-task-common {
|
||||||
font-size: 1.2em;
|
font-size: 1.2em;
|
||||||
line-height: 1.2em;
|
line-height: 1.2em;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
caret-color: #f08080;
|
caret-color: var(--highlight-date-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.highlight-date {
|
.highlight-date {
|
||||||
color: #f08080;
|
color: var(--highlight-date-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.highlight-repeat {
|
.highlight-repeat {
|
||||||
color: #90ee90;
|
color: var(--highlight-repeat-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.highlight-label {
|
.highlight-label {
|
||||||
color: #add8e6;
|
color: var(--highlight-label-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.highlight-priority {
|
.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 {
|
.task-input {
|
||||||
@@ -39,4 +63,6 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
border: 1px solid var(--joy-palette-neutral-outlinedBorder, #d0d5dd);
|
||||||
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,10 +183,7 @@ const SmartTaskTitleInput = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div className='task-input' style={{ minHeight: '2.4em' }}>
|
||||||
className='task-input overflow-auto rounded border'
|
|
||||||
style={{ minHeight: '2.4em' }}
|
|
||||||
>
|
|
||||||
<textarea
|
<textarea
|
||||||
ref={titleInputRef}
|
ref={titleInputRef}
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
|
|||||||
Reference in New Issue
Block a user