diff --git a/src/components/NotificationTemplate.jsx b/src/components/NotificationTemplate.jsx index 734fd0b..9f468fa 100644 --- a/src/components/NotificationTemplate.jsx +++ b/src/components/NotificationTemplate.jsx @@ -12,7 +12,7 @@ import Input from '@mui/joy/Input' import Option from '@mui/joy/Option' import Select from '@mui/joy/Select' import Typography from '@mui/joy/Typography' -import { useCallback, useEffect, useState, useRef } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { NOTIFICATION_TYPE, TASK_COLOR } from '../utils/Colors' import { TIME_UNITS } from '../utils/DurationUtils' @@ -512,6 +512,7 @@ const NotificationTemplate = ({ '--Badge-fontSize': '0.7rem', '--Badge-paddingX': '5px', top: 10, + left: 10, '& .MuiBadge-badge': { background: colors.bgColor, color: 'white', diff --git a/src/hooks/useFileUpload.js b/src/hooks/useFileUpload.js new file mode 100644 index 0000000..1aba6f9 --- /dev/null +++ b/src/hooks/useFileUpload.js @@ -0,0 +1,91 @@ +import imageCompression from 'browser-image-compression' +import { useCallback } from 'react' +import { useUserProfile } from '../queries/UserQueries' +import { useNotification } from '../service/NotificationProvider' +import { apiClient } from '../utils/ApiClient' +import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers' + +export const useFileUpload = ({ entityType = 'chore_attachment', entityId } = {}) => { + const { showError } = useNotification() + const { data: userProfile } = useUserProfile() + + const uploadFile = useCallback( + async file => { + if (!isPlusAccount(userProfile)) { + showError({ + title: 'Plus Feature', + message: + 'Image uploads are not available in the Basic plan. Upgrade to Plus to add images to your content.', + }) + return null + } + + try { + const compressionOptions = { + maxSizeMB: entityType === 'profile' ? 0.5 : 1, + maxWidthOrHeight: entityType === 'profile' ? 320 : 1200, + useWebWorker: true, + fileType: 'image/jpeg', + } + + const compressedFile = await imageCompression(file, compressionOptions) + const compressedJpegFile = new File( + [compressedFile], + `${file.name.split('.')[0]}.jpg`, + { type: 'image/jpeg' }, + ) + + const formData = new FormData() + formData.append('file', compressedJpegFile) + formData.append('entityType', entityType) + if (entityId) formData.append('entityId', entityId) + + const response = await apiClient.upload('/assets/chore', formData) + + if (response.status === 507) { + showError({ + title: 'Storage Quota Exceeded', + message: 'You have exceeded your quota for uploading files.', + }) + return null + } else if (response.status === 413) { + showError({ + title: 'File Too Large', + message: 'The file you are trying to upload is too large.', + }) + return null + } else if (response.status === 403 && !isPlusAccount(userProfile)) { + showError({ + title: 'Upgrade Required', + message: 'Image uploads are only available for Plus accounts.', + }) + return null + } else if (response.status === 403) { + showError({ + title: 'Permission Denied', + message: 'You do not have permission to upload files.', + }) + return null + } else if (!response.ok) { + showError({ + title: 'Upload Failed', + message: 'Failed to upload image.', + }) + return null + } + + const data = await response.json() + return resolvePhotoURL(data.url || data.sign) + } catch { + showError({ + title: 'Upload Failed', + message: 'An error occurred while processing the image.', + }) + return null + } + }, + [entityType, entityId, showError, userProfile], + ) + + return { uploadFile, isPlus: isPlusAccount(userProfile) } +} diff --git a/src/views/ChoreEdit/RepeatSection.jsx b/src/views/ChoreEdit/RepeatSection.jsx index a7d3c44..4cb4fa9 100644 --- a/src/views/ChoreEdit/RepeatSection.jsx +++ b/src/views/ChoreEdit/RepeatSection.jsx @@ -108,7 +108,7 @@ const generateSchedulePreview = (metadata, formatTimeFn) => { return `Every ${dayNames} at ${timeStr}` } -const RepeatOnSections = ({ +export const RepeatOnSections = ({ frequencyType, frequency, onFrequencyUpdate, diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx index 806a897..cffb741 100644 --- a/src/views/components/AddTaskModal.jsx +++ b/src/views/components/AddTaskModal.jsx @@ -1,15 +1,6 @@ -import { Add, EditNotifications } from '@mui/icons-material' -import { - Box, - Button, - Checkbox, - FormHelperText, - Input, - Option, - Select, - Typography, -} from '@mui/joy' -import { FormControl } from '@mui/material' +import { Add } from '@mui/icons-material' +import { Box, Button, Typography } from '@mui/joy' +import { useMediaQuery } from '@mui/material' import * as chrono from 'chrono-node' import moment from 'moment' import { useCallback, useEffect, useRef, useState } from 'react' @@ -30,8 +21,15 @@ import { import SmartTaskTitleInput from './SmartTaskTitleInput' import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint' -import NotificationTemplate from '../../components/NotificationTemplate' +import { TASK_COLOR } from '../../utils/Colors' +import AssigneePickerField from './AssigneePickerField' +import AttachmentPickerField from './AttachmentPickerField' +import DueDatePickerField from './DueDatePickerField' +import LabelsPickerField from './LabelsPickerField' import LearnMoreButton from './LearnMore' +import NotificationPickerField from './NotificationPickerField' +import PriorityPickerField from './PriorityPickerField' +import RepeatPickerField from './RepeatPickerField' import RichTextEditor from './RichTextEditor' import SubTasks from './SubTask' const getDefaultNotification = () => { @@ -46,18 +44,20 @@ const getDefaultNotification = () => { ] localStorage.setItem( - 'defaultNotification', + 'defaultNotificationTemplate', JSON.stringify(defaultNotification), ) return defaultNotification } -const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { +const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { const { ResponsiveModal } = useResponsiveModal() + const isMobile = useMediaQuery(theme => theme.breakpoints.down('sm')) + const pickerEmptyDisplay = isMobile ? 'icon' : 'icon-text' const { data: userLabels, isLoading: userLabelsLoading } = useLabels() const { data: circleMembers, isLoading: isCircleMembersLoading } = useCircleMembers() - const { data: projects = [], isLoading: isProjectsLoading } = useProjects() + const { isLoading: isProjectsLoading } = useProjects() const createChoreMutation = useCreateChore() const { data: userProfile } = useUserProfile() @@ -80,9 +80,8 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { const [taskTitle, setTaskTitle] = useState('') const [renderedParts, setRenderedParts] = useState([]) - const textareaRef = useRef(null) - const mainInputRef = useRef(null) const richTextEditorRef = useRef(null) + const latestRef = useRef({}) const [priority, setPriority] = useState(0) const [dueDate, setDueDate] = useState(null) const [description, setDescription] = useState(null) @@ -92,20 +91,35 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { const [notificationMetadata, setNotificationMetadata] = useState({ templates: getDefaultNotification(), }) - const [frequencyHumanReadable, setFrequencyHumanReadable] = useState(null) const [subTasks, setSubTasks] = useState(null) const [points, setPoints] = useState(-1) const [isAnyoneTask, setIsAnyoneTask] = useState(false) const [hasDescription, setHasDescription] = useState(false) const [hasSubTasks, setHasSubTasks] = useState(false) - const [hasNotifications, setHasNotifications] = useState(false) - const [hasDeadline, setHasDeadline] = useState(false) const [deadlineOffset, setDeadlineOffset] = useState(-1) const [dueDateOnly, setDueDateOnly] = useState(null) const [dueTime, setDueTime] = useState(null) const [useCustomTime, setUseCustomTime] = useState(false) const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false) const [projectId, setProjectId] = useState(getInitialProject()) + const [attachments, setAttachments] = useState([]) + + // Priority colors + const priorityColors = { + 0: TASK_COLOR.NO_PRIORITY, + 1: TASK_COLOR.PRIORITY_1, + 2: TASK_COLOR.PRIORITY_2, + 3: TASK_COLOR.PRIORITY_3, + 4: TASK_COLOR.PRIORITY_4, + } + + const priorityLabels = { + 0: '--', + 1: 'P1', + 2: 'P2', + 3: 'P3', + 4: 'P4', + } // set showKeyboardShortcuts true as soon as the user hold ctrl or cmd key: useEffect(() => { @@ -117,12 +131,17 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { } }, [hasDescription]) - // set showKeyboardShortcuts true as soon as the user hold ctrl or cmd key: useEffect(() => { const handleKeyDown = event => { + const { + isModalOpen, + hasDescription, + dueDate, + createChore, + handleCloseModal, + } = latestRef.current const isHoldingCmd = event.ctrlKey || event.metaKey if (isHoldingCmd) { - // event.preventDefault() setShowKeyboardShortcuts(true) } if ( @@ -135,10 +154,8 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { setShowKeyboardShortcuts(false) } if (isHoldingCmd && event.key.toLowerCase() === 'j' && isModalOpen) { - // add subtask: setHasSubTasks(true) setShowKeyboardShortcuts(false) - // set focus on the first subtask input: } if ( isHoldingCmd && @@ -146,7 +163,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { isModalOpen && !dueDate ) { - // add due date: const tomorrow = moment().add(1, 'day') setDueDateOnly(tomorrow.format('YYYY-MM-DD')) setDueDate(tomorrow.endOf('day').format('YYYY-MM-DDTHH:mm:59')) @@ -154,7 +170,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { setDueTime(null) setShowKeyboardShortcuts(false) } - // Enter key to create task if ( event.key === 'Enter' && (event.ctrlKey || event.metaKey) && @@ -164,7 +179,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { createChore() return } - // Escape key to cancel/close modal if (event.key === 'Escape' && isModalOpen) { event.preventDefault() handleCloseModal() @@ -185,22 +199,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { } }, []) - useEffect(() => { - if (isModalOpen && textareaRef.current) { - textareaRef.current.focus() - textareaRef.current.selectionStart = textareaRef.current.value?.length - textareaRef.current.selectionEnd = textareaRef.current.value?.length - } - }, [isModalOpen]) - - useEffect(() => { - if (autoFocus > 0 && mainInputRef.current) { - mainInputRef.current.focus() - mainInputRef.current.selectionStart = mainInputRef.current.value?.length - mainInputRef.current.selectionEnd = mainInputRef.current.value?.length - } - }, [autoFocus]) - const renderHighlightedSentence = useCallback( ( sentence, @@ -262,20 +260,17 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { resolvedHighlights.push(current) } } else { - // No overlap, add the current highlight resolvedHighlights.push(current) } } for (const highlight of resolvedHighlights) { - // Add the text before the highlight if (highlight.start > lastIndex) { const textBefore = sentence.substring(lastIndex, highlight.start) parts.push(textBefore) plainText += textBefore } - // Determine the class name based on the highlight type let className = '' switch (highlight.type) { case 'repeat': @@ -300,7 +295,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { break } - // Add the highlighted span const highlightedText = sentence.substring( highlight.start, highlight.end, @@ -310,9 +304,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { key={highlight.start} className={className} style={{ - // text underline: textDecoration: 'underline', - // textDecorationColor: 'red', textDecorationThickness: '2px', textDecorationStyle: 'dashed', }} @@ -321,11 +313,9 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { , ) - // Update the last index to the end of the current highlight lastIndex = highlight.end } - // Add any remaining text after the last highlight if (lastIndex < sentence.length) { const remainingText = sentence.substring(lastIndex) parts.push(remainingText) @@ -342,12 +332,10 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { const processText = useCallback( sentence => { - // Parse everything from the original sentence to get correct highlight positions const priority = parsePriority(sentence) const pointsParsed = parsePoints(sentence) const labels = parseLabels(sentence, userLabels || []) - // Parse assignees using circle members const circleMembersList = circleMembers?.res || [] const assigneesForParsing = circleMembersList.map(member => ({ userId: member.userId, @@ -364,9 +352,15 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { const dueDateParsed = parseDueDate(sentence, chrono) // Set all the parsed values - if (priority.result) setPriority(priority.result) + if (priority.result) setPriority(parseInt(priority.result, 10)) if (pointsParsed.result) setPoints(pointsParsed.result) - if (labels.result) setLabelsV2(labels.result) + if (labels.result) { + // parseLabels returns array of label objects, extract their IDs + const labelIds = labels.result + .filter(label => label.id) // Only labels with IDs (existing labels) + .map(label => label.id) + setLabelsV2(labelIds) + } if (assigneesResult.isAnyone) { // @Anyone was used - set empty assignees (anyone can do the task) @@ -392,7 +386,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { if (repeat.result) { setFrequency(repeat.result) - setFrequencyHumanReadable(repeat.name) } const syncDueDateStates = parsedDate => { @@ -500,6 +493,8 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { moment(`${dateValue}T${dueTime}`).format('YYYY-MM-DDTHH:mm:00'), ) } else { + setUseCustomTime(false) + setDueTime(null) setDueDate(moment(dateValue).endOf('day').format('YYYY-MM-DDTHH:mm:ss')) } } @@ -508,9 +503,17 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { const timeValue = e.target.value setDueTime(timeValue) if (dueDateOnly) { - setDueDate( - moment(`${dueDateOnly}T${timeValue}`).format('YYYY-MM-DDTHH:mm:00'), - ) + if (timeValue) { + setUseCustomTime(true) + setDueDate( + moment(`${dueDateOnly}T${timeValue}`).format('YYYY-MM-DDTHH:mm:00'), + ) + } else { + setUseCustomTime(false) + setDueDate( + moment(dueDateOnly).endOf('day').format('YYYY-MM-DDTHH:mm:ss'), + ) + } } } @@ -518,13 +521,16 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { setUseCustomTime(checked) if (checked) { const defaultTime = dueTime || '18:00' - setDueTime(defaultTime) + if (!dueTime) { + setDueTime(defaultTime) + } if (dueDateOnly) { setDueDate( moment(`${dueDateOnly}T${defaultTime}`).format('YYYY-MM-DDTHH:mm:00'), ) } } else { + setDueTime(null) if (dueDateOnly) { setDueDate( moment(dueDateOnly).endOf('day').format('YYYY-MM-DDTHH:mm:ss'), @@ -543,7 +549,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { setTaskTitle('') setDueDate(null) setFrequency(null) - setFrequencyHumanReadable(null) setPriority(0) setPoints(-1) setIsAnyoneTask(false) @@ -554,7 +559,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { setLabelsV2([]) setAssignees([]) setProjectId(getInitialProject()) - setHasDeadline(false) setDeadlineOffset(-1) setDueDateOnly(null) setDueTime(null) @@ -605,6 +609,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { notificationMetadata: {}, subTasks: subTasks?.length > 0 ? subTasks : null, projectId: projectId === 'default' ? null : projectId, + attachments: attachments.length > 0 ? attachments : null, } if (frequency) { @@ -617,8 +622,8 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { } } if (!frequency && dueDate) { - // use dueDate converted to UTC: - chore.nextDueDate = new Date(dueDate).toUTCString() + // Use RFC3339/ISO-8601 format expected by backend. + chore.nextDueDate = new Date(dueDate).toISOString() chore.notificationMetadata = notificationMetadata } @@ -645,6 +650,15 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { }) handleCloseModal(false) } + + latestRef.current = { + isModalOpen, + hasDescription, + dueDate, + createChore, + handleCloseModal, + } + if (isCircleMembersLoading || isProjectsLoading) { return <> } @@ -685,6 +699,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { size='lg' variant='solid' color='primary' + disabled={!taskTitle.trim()} onClick={createChore} > Create @@ -748,7 +763,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { { setTaskText(text) }} @@ -793,21 +808,96 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { }} /> - {/* - Title: - setTaskTitle(e.target.value)} - sx={{ width: '100%', fontSize: '16px' }} - /> - */} + + // 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', + }} + > + { + setDueDate(null) + setDueDateOnly(null) + setDueTime(null) + setUseCustomTime(false) + }} + /> + setFrequency(null)} + /> + setPriority(0)} + emptyDisplay={pickerEmptyDisplay} + priorityColors={priorityColors} + priorityLabels={priorityLabels} + /> + { + if (!userId) { + setAssignees([]) + } else { + setAssignees([{ userId }]) + } + }} + onClear={() => setAssignees([])} + currentUserId={userProfile?.id} + members={circleMembers?.res || []} + /> + + setLabelsV2([])} + labels={userLabels || []} + /> + setAttachments([])} + emptyDisplay={pickerEmptyDisplay} + entityType='chore_attachment' + /> + setNotificationMetadata({ templates: [] })} + emptyDisplay={pickerEmptyDisplay} + /> + + + {!hasDescription && ( )} - {!dueDate && ( - - )} - {!hasNotifications && dueDate && ( - - )} - {/* {!hasDeadline && dueDate && ( - - )} */} {hasDescription && ( @@ -906,210 +951,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { /> )} - - - {priority > 0 && ( - - Priority - - - )} - {dueDate && ( - - Due Date - - handleUseCustomTimeChange(e.target.checked)} - label='Set a specific time' - sx={{ mt: 1 }} - /> - - {useCustomTime - ? 'Task will be due at the specified time' - : 'Task will be due at the end of the day (11:59 PM)'} - - {useCustomTime && ( - - )} - - )} - - {/* {projects.length >= 1 && ( - - Project - - - )} */} - - {/* - Assignees - - {assignees.length > 0 ? ( - assignees.map((assignee, index) => ( - - {assignee.displayName || assignee.username} - - )) - ) : ( - - {userProfile.displayName} - - )} - - */} - {/* {hasDeadline && dueDate && ( - - Deadline - - - after due date - - - )} */} - {hasNotifications && dueDate && ( - - Notification Schedule - - { - if ( - metadata.notifications !== notificationMetadata.templates - ) { - const newNotificationMetadata = { - ...notificationMetadata, - templates: metadata.notifications, - } - setNotificationMetadata(newNotificationMetadata) - } - }} - value={notificationMetadata} - showTimeline={false} - /> - - - )} - ) } diff --git a/src/views/components/AssigneePickerField.jsx b/src/views/components/AssigneePickerField.jsx new file mode 100644 index 0000000..b9444c7 --- /dev/null +++ b/src/views/components/AssigneePickerField.jsx @@ -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 ( + item.userId} + getItemLabel={item => item.displayName} + renderTriggerIcon={() => } + renderItemStart={() => } + getTriggerText={({ selectedItems, isEmpty }) => + isEmpty ? 'Assignee' : selectedItems[0].displayName + } + menuMinWidth={220} + /> + ) +} + +export default AssigneePickerField diff --git a/src/views/components/AttachmentPickerField.jsx b/src/views/components/AttachmentPickerField.jsx new file mode 100644 index 0000000..6f9c385 --- /dev/null +++ b/src/views/components/AttachmentPickerField.jsx @@ -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 ( + <> + + + {!isEmpty && onClear && ( + + + + )} + + + {isOpen && ( + + setIsOpen(false)}> + + {attachments.length > 0 && ( + + {attachments.map((attachment, index) => ( + + { + e.target.style.display = 'none' + e.target.nextSibling.style.display = 'flex' + }} + /> + + + + + {attachment.name} + + handleRemove(index)} + sx={{ flexShrink: 0 }} + > + + + + ))} + + )} + + + + + + )} + + ) +} + +export default AttachmentPickerField diff --git a/src/views/components/BaseOptionPicker.jsx b/src/views/components/BaseOptionPicker.jsx new file mode 100644 index 0000000..c176020 --- /dev/null +++ b/src/views/components/BaseOptionPicker.jsx @@ -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 ( + <> + + + {!isEmpty && onClear && ( + + + + )} + + + {isOpen && ( + + setIsOpen(false)}> + + {items.map((item, index) => { + const optionValue = getItemValue(item) + const selected = isSelected(item) + const itemColor = getItemColor ? getItemColor(item) : undefined + + return ( + + ) + })} + + + + )} + + ) +} + +export default BaseOptionPicker diff --git a/src/views/components/DueDatePickerField.jsx b/src/views/components/DueDatePickerField.jsx new file mode 100644 index 0000000..bb0685a --- /dev/null +++ b/src/views/components/DueDatePickerField.jsx @@ -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 ( + <> + + + {hasDueDate && onClear && ( + { + e.stopPropagation() + onClear?.() + }} + sx={{ + position: 'absolute', + top: -12, + right: -16, + zIndex: 10, + maxHeight: 18, + maxWidth: 18, + borderRadius: '50%', + '&:hover': { + bgcolor: 'danger.softBg', + }, + }} + > + + + )} + + + setIsOpen(false)} + title='Due Date' + footer={ + + {hasDueDate && ( + + )} + + + + } + > + + {/* Date shortcuts */} + + Quick date + + + {[ + { + key: 'today', + label: 'Today', + icon: , + }, + { + key: 'tomorrow', + label: 'Tomorrow', + icon: , + }, + { + key: 'weekend', + label: 'Weekend', + icon: , + }, + { + key: 'next-week', + label: 'Next week', + icon: , + }, + { + key: 'next-month', + label: 'Next month', + icon: , + }, + ].map(opt => { + const dateStr = getQuickScheduleDate(opt.key) + .toISOString() + .split('T')[0] + return ( + + handleQuickSchedule(opt.key)} + overlay + disableIcon + variant='soft' + label={ + + {opt.icon} + {opt.label} + + } + /> + + ) + })} + + + {/* Time shortcuts */} + + Quick time + + + {[ + { + time: '09:00', + label: 'Morning', + icon: , + }, + { + time: '12:00', + label: 'Noon', + icon: , + }, + { + time: '15:00', + label: 'Afternoon', + icon: , + }, + { + time: '18:00', + label: 'Evening', + icon: , + }, + { + time: '22:00', + label: 'Night', + icon: , + }, + ].map(opt => ( + + handleQuickTime(opt.time)} + overlay + disableIcon + variant='soft' + label={ + + {opt.icon} + {opt.label} + + } + /> + + ))} + + + + + ['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()] + } + /> + + + Custom time + + + + + + + + + + ) +} + +export default DueDatePickerField diff --git a/src/views/components/LabelsPickerField.jsx b/src/views/components/LabelsPickerField.jsx new file mode 100644 index 0000000..bf74602 --- /dev/null +++ b/src/views/components/LabelsPickerField.jsx @@ -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 ( + item.id} + getItemLabel={item => item.name} + getItemColor={item => item.color} + renderTriggerIcon={() =>