Unify modals and buttons style and layout

This commit is contained in:
Mo Tarbin
2026-07-29 01:38:20 -04:00
parent b4d46caee4
commit 0132408eec
47 changed files with 1997 additions and 2215 deletions

View File

@@ -1,7 +1,8 @@
import { Box, Button, FormLabel, Input } from '@mui/joy'
import { FormLabel, Input } from '@mui/joy'
import moment from 'moment'
import { useEffect, useState } from 'react'
import ModalActions from '../../components/common/ModalActions'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import ConfirmationModal from './Inputs/ConfirmationModal'
@@ -41,31 +42,19 @@ function EditHistoryModal({ config, historyRecord }) {
// fullWidth={true}
title='Edit History'
footer={
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
<Button
size='lg'
onClick={() =>
<ModalActions
secondary={{ label: 'Cancel', onClick: config.onClose }}
primary={{
label: 'Save',
onClick: () =>
config.onSave({
id: historyRecord.id,
performedAt: moment(completedDate).toISOString(),
dueDate: moment(dueDate).toISOString(),
notes,
})
}
fullWidth
sx={{ mr: 1 }}
>
Save
</Button>
<Button
fullWidth
size='lg'
onClick={config.onClose}
variant='outlined'
>
Cancel
</Button>
</Box>
}),
}}
/>
}
>
<FormLabel>Due Date</FormLabel>
@@ -119,6 +108,7 @@ function EditHistoryModal({ config, historyRecord }) {
message: 'Are you sure you want to delete this history?',
confirmText: 'Delete',
cancelText: 'Cancel',
color: 'danger',
}}
/>
</ResponsiveModal>

View File

@@ -15,28 +15,40 @@ import {
import { Avatar, Box, Button, Chip, Divider, Stack, Typography } from '@mui/joy'
import moment from 'moment'
import { useNavigate } from 'react-router-dom'
import ModalActions from '../../components/common/ModalActions'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import { TASK_COLOR } from '../../utils/Colors.jsx'
import RichTextEditor from '../components/RichTextEditor.jsx'
const STATUS_CONFIG = {
0: { label: 'In Progress', color: 'primary', icon: <AccessTime /> },
1: { label: 'Completed', color: 'success', icon: <Check /> },
2: { label: 'Skipped', color: 'warning', icon: <Redo /> },
0: { label: 'In Progress', color: 'primary', icon: <AccessTime /> },
1: { label: 'Completed', color: 'success', icon: <Check /> },
2: { label: 'Skipped', color: 'warning', icon: <Redo /> },
3: { label: 'Pending Approval', color: 'neutral', icon: <HourglassEmpty /> },
4: { label: 'Rejected', color: 'danger', icon: <ThumbDown /> },
5: { label: 'Missed', color: 'danger', icon: <RunningWithErrors /> },
6: { label: 'Rescheduled', color: 'warning', icon: <Schedule /> },
4: { label: 'Rejected', color: 'danger', icon: <ThumbDown /> },
5: { label: 'Missed', color: 'danger', icon: <RunningWithErrors /> },
6: { label: 'Rescheduled', color: 'warning', icon: <Schedule /> },
}
const DetailRow = ({ icon, label, value, children }) => (
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.5, py: 0.75 }}>
<Box sx={{ color: 'text.tertiary', mt: 0.25, flexShrink: 0, display: 'flex' }}>{icon}</Box>
<Box
sx={{ color: 'text.tertiary', mt: 0.25, flexShrink: 0, display: 'flex' }}
>
{icon}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography level='body-xs' sx={{ color: 'text.tertiary', mb: 0.15 }}>{label}</Typography>
<Typography level='body-xs' sx={{ color: 'text.tertiary', mb: 0.15 }}>
{label}
</Typography>
{children ?? (
<Typography level='body-sm' sx={{ color: 'text.primary', fontWeight: 'md' }}>{value}</Typography>
<Typography
level='body-sm'
sx={{ color: 'text.primary', fontWeight: 'md' }}
>
{value}
</Typography>
)}
</Box>
</Box>
@@ -52,15 +64,41 @@ const TimingBadge = ({ historyEntry }) => {
const gracePeriod = 6 * 60 * 60 * 1000
if (Math.abs(performedAt - dueDate) <= gracePeriod) {
return <Chip size='sm' variant='solid' sx={{ backgroundColor: TASK_COLOR.COMPLETED, color: 'white' }} startDecorator={<Check />}>On Time</Chip>
return (
<Chip
size='sm'
variant='solid'
sx={{ backgroundColor: TASK_COLOR.COMPLETED, color: 'white' }}
startDecorator={<Check />}
>
On Time
</Chip>
)
} else if (performedAt.isBefore(dueDate)) {
const abs = Math.abs(diffHours)
const label = abs >= 48 ? `${Math.floor(abs / 24)}d early` : `${abs}h early`
return <Chip size='sm' variant='soft' sx={{ backgroundColor: TASK_COLOR.SCHEDULED, color: 'white' }} startDecorator={<Check />}>{label}</Chip>
return (
<Chip
size='sm'
variant='soft'
sx={{ backgroundColor: TASK_COLOR.SCHEDULED, color: 'white' }}
startDecorator={<Check />}
>
{label}
</Chip>
)
} else {
const abs = Math.abs(diffHours)
const label = abs >= 48 ? `${Math.floor(abs / 24)}d late` : `${abs}h late`
return <Chip size='sm' variant='solid' sx={{ backgroundColor: TASK_COLOR.LATE, color: 'white' }}>{label}</Chip>
return (
<Chip
size='sm'
variant='solid'
sx={{ backgroundColor: TASK_COLOR.LATE, color: 'white' }}
>
{label}
</Chip>
)
}
}
@@ -79,7 +117,8 @@ function HistoryDetailModal({ config }) {
const statusLabel = isFirstSchedule ? 'Scheduled' : statusCfg.label
const performer = performers.find(p => p.userId === entry.completedBy)
const assignedTo = performers.find(p => p.userId === entry.assignedTo)
const isDifferentAssignee = entry.assignedTo && entry.completedBy !== entry.assignedTo
const isDifferentAssignee =
entry.assignedTo && entry.completedBy !== entry.assignedTo
// updatedAt is only meaningful if it differs from performedAt by more than a minute
const showUpdatedAt =
@@ -100,14 +139,50 @@ function HistoryDetailModal({ config }) {
open={config?.isOpen}
onClose={config?.onClose}
title='Activity Detail'
footer={
<ModalActions>
{entry.choreId && (
<Button
variant='outlined'
color='neutral'
startDecorator={<OpenInNew sx={{ fontSize: 16 }} />}
onClick={() => {
config?.onClose?.()
navigate(`/chores/${entry.choreId}`)
}}
>
Open Task
</Button>
)}
{config?.onEdit && (
<Button
startDecorator={<Edit sx={{ fontSize: 16 }} />}
onClick={() => config.onEdit(entry)}
>
Edit Entry
</Button>
)}
</ModalActions>
}
>
{/* Status header */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 1.5,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Avatar size='sm' color={statusCfg.color} variant='soft'>
{statusCfg.icon}
</Avatar>
<Typography level='title-md' fontWeight='lg' sx={{ color: `${statusCfg.color}.plainColor` }}>
<Typography
level='title-md'
fontWeight='lg'
sx={{ color: `${statusCfg.color}.plainColor` }}
>
{statusLabel}
</Typography>
</Box>
@@ -119,17 +194,31 @@ function HistoryDetailModal({ config }) {
<Stack spacing={0}>
{/* Who performed it */}
{performer && (
<DetailRow icon={<Check sx={{ fontSize: 16 }} />} label='Performed by'>
<DetailRow
icon={<Check sx={{ fontSize: 16 }} />}
label='Performed by'
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Avatar src={performer.image} alt={performer.displayName} size='sm' sx={{ width: 20, height: 20 }} />
<Typography level='body-sm' fontWeight='md'>{performer.displayName}</Typography>
<Avatar
src={performer.image}
alt={performer.displayName}
size='sm'
sx={{ width: 20, height: 20 }}
/>
<Typography level='body-sm' fontWeight='md'>
{performer.displayName}
</Typography>
</Box>
</DetailRow>
)}
{/* Assigned to (only if different) */}
{isDifferentAssignee && assignedTo && (
<DetailRow icon={<Person sx={{ fontSize: 16 }} />} label='Assigned to' value={assignedTo.displayName} />
<DetailRow
icon={<Person sx={{ fontSize: 16 }} />}
label='Assigned to'
value={assignedTo.displayName}
/>
)}
<Divider />
@@ -138,7 +227,15 @@ function HistoryDetailModal({ config }) {
{entry.performedAt && (
<DetailRow
icon={<AccessTime sx={{ fontSize: 16 }} />}
label={isFirstSchedule ? 'Scheduled on' : entry.status === 6 ? 'Rescheduled on' : entry.status === 2 ? 'Skipped on' : 'Completed on'}
label={
isFirstSchedule
? 'Scheduled on'
: entry.status === 6
? 'Rescheduled on'
: entry.status === 2
? 'Skipped on'
: 'Completed on'
}
value={fmt.dateTime(entry.performedAt)}
/>
)}
@@ -147,7 +244,13 @@ function HistoryDetailModal({ config }) {
{entry.dueDate && (
<DetailRow
icon={<CalendarMonth sx={{ fontSize: 16 }} />}
label={entry.status === 6 ? 'Previous due date' : entry.status === 5 ? 'Was due' : 'Due date'}
label={
entry.status === 6
? 'Previous due date'
: entry.status === 5
? 'Was due'
: 'Due date'
}
value={fmt.dateTime(entry.dueDate)}
/>
)}
@@ -184,45 +287,19 @@ function HistoryDetailModal({ config }) {
<>
<Divider />
<Box sx={{ pt: 1 }}>
<Typography level='body-xs' sx={{ color: 'text.tertiary', mb: 0.5 }}>
<Typography
level='body-xs'
sx={{ color: 'text.tertiary', mb: 0.5 }}
>
{entry.status === 2 || entry.status === 4 ? 'Reason' : 'Notes'}
</Typography>
<Box sx={{ overflowY: 'auto', maxHeight: '60vh' }}>
<RichTextEditor value={entry.notes || ''} isEditable={false} />
</Box>
<Box sx={{ overflowY: 'auto', maxHeight: '60vh' }}>
<RichTextEditor value={entry.notes || ''} isEditable={false} />
</Box>
</Box>
</>
)}
</Stack>
{/* Action buttons */}
<Box sx={{ display: 'flex', gap: 1, mt: 2, justifyContent: 'flex-end' }}>
{entry.choreId && (
<Button
variant='soft'
color='neutral'
size='sm'
startDecorator={<OpenInNew sx={{ fontSize: 16 }} />}
onClick={() => {
config?.onClose?.()
navigate(`/chores/${entry.choreId}`)
}}
>
Open Task
</Button>
)}
{config?.onEdit && (
<Button
variant='soft'
color='neutral'
size='md'
startDecorator={<Edit sx={{ fontSize: 16 }} />}
onClick={() => config.onEdit(entry)}
>
Edit Entry
</Button>
)}
</Box>
</ResponsiveModal>
)
}

View File

@@ -1,6 +1,7 @@
import { Box, Button, Typography } from '@mui/joy'
import { Typography } from '@mui/joy'
import { useCallback, useEffect, useState } from 'react'
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
function AcknowledgmentModal({ config }) {
@@ -11,42 +12,24 @@ function AcknowledgmentModal({ config }) {
config.onClose()
}, [config])
// Keyboard shortcuts for acknowledgment modal
useEffect(() => {
const handleKeyDown = event => {
if (!config?.isOpen) return
// Show keyboard shortcuts when Ctrl/Cmd is pressed
if (event.ctrlKey || event.metaKey) {
setShowKeyboardShortcuts(true)
}
if (event.ctrlKey || event.metaKey) setShowKeyboardShortcuts(true)
// Ctrl/Cmd + Y for acknowledge
if ((event.ctrlKey || event.metaKey) && event.key === 'y') {
if (
((event.ctrlKey || event.metaKey) && event.key === 'y') ||
event.key === 'Escape' ||
event.key === 'Enter'
) {
event.preventDefault()
handleAction()
return
}
// Escape key for acknowledge
if (event.key === 'Escape') {
event.preventDefault()
handleAction()
return
}
// Enter key for acknowledge
if (event.key === 'Enter') {
event.preventDefault()
handleAction()
return
}
}
const handleKeyUp = event => {
if (!event.ctrlKey && !event.metaKey) {
setShowKeyboardShortcuts(false)
}
if (!event.ctrlKey && !event.metaKey) setShowKeyboardShortcuts(false)
}
if (config?.isOpen) {
@@ -63,43 +46,33 @@ function AcknowledgmentModal({ config }) {
return (
<ResponsiveModal
open={config?.isOpen}
onClose={config?.onClose}
size='lg'
fullWidth={true}
unmountDelay={250}
onClose={handleAction}
size='sm'
title={config?.title}
>
<Box
sx={{ p: 2, minWidth: { xs: '100%', sm: '400px' }, maxWidth: '500px' }}
>
<Typography
level='body-md'
mb={3}
sx={{
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
showCloseButton={false}
footer={
<ModalActions
primary={{
label: config?.acknowledgeText,
color: config?.color || 'primary',
onClick: handleAction,
endDecorator: showKeyboardShortcuts ? (
<KeyboardShortcutHint shortcut='Y' />
) : undefined,
}}
>
{config?.message}
</Typography>
<Box display={'flex'} justifyContent={'center'} mt={2}>
<Button
size='lg'
onClick={handleAction}
color={config?.color || 'primary'}
fullWidth
endDecorator={
<KeyboardShortcutHint shortcut='Y' show={showKeyboardShortcuts} />
}
sx={{ minWidth: '120px' }}
>
{config?.acknowledgeText}
</Button>
</Box>
</Box>
/>
}
>
<Typography
level='body-md'
sx={{
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{config?.message}
</Typography>
</ResponsiveModal>
)
}

View File

@@ -1,14 +1,8 @@
import { Save } from '@mui/icons-material'
import {
Box,
Button,
Chip,
Divider,
Input,
Typography,
} from '@mui/joy'
import { Box, Button, Chip, Divider, Input, Typography } from '@mui/joy'
import { useEffect, useMemo, useState } from 'react'
import BottomSheetModal from '../../../components/common/BottomSheetModal'
import AppModal from '../../../components/common/AppModal'
import ModalActions from '../../../components/common/ModalActions'
import FilterBuilderContent, {
conditionsToSelections,
defaultSelections,
@@ -18,6 +12,8 @@ import { FILTER_COLORS } from '../../../utils/Colors'
import { applyFilter } from '../../../utils/FilterEngine'
import { useFilters } from '../../Filters/FilterQueries'
const EMPTY_FILTERS = []
const AdvancedFilterBuilder = ({
isOpen,
onClose,
@@ -33,7 +29,7 @@ const AdvancedFilterBuilder = ({
const [filterColor, setFilterColor] = useState(FILTER_COLORS[0].value)
const [selections, setSelections] = useState(defaultSelections())
const [error, setError] = useState('')
const { data: existedFilters = [] } = useFilters()
const { data: existedFilters = EMPTY_FILTERS } = useFilters()
const filterNameExists = (name, excludeId = null) =>
existedFilters.some(
@@ -55,9 +51,12 @@ const AdvancedFilterBuilder = ({
setSelections(defaultSelections())
}
setError('')
}, [editingFilter, isOpen])
}, [editingFilter, existedFilters, isOpen])
const conditions = useMemo(() => selectionsToConditions(selections), [selections])
const conditions = useMemo(
() => selectionsToConditions(selections),
[selections],
)
const previewChores = useMemo(() => {
if (conditions.length === 0) return []
@@ -100,8 +99,9 @@ const AdvancedFilterBuilder = ({
}
return (
<BottomSheetModal
<AppModal
open={isOpen}
isMobile
onClose={onClose}
maxHeight='92vh'
title={
@@ -109,7 +109,8 @@ const AdvancedFilterBuilder = ({
{editingFilter ? 'Edit Filter' : 'New Filter'}
{activeConditionCount > 0 && (
<Chip size='sm' variant='solid' color='primary'>
{activeConditionCount} condition{activeConditionCount !== 1 ? 's' : ''}
{activeConditionCount} condition
{activeConditionCount !== 1 ? 's' : ''}
</Chip>
)}
</Box>
@@ -144,20 +145,19 @@ const AdvancedFilterBuilder = ({
</Box>
{/* Actions */}
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant='plain' color='neutral' size='sm' onClick={onClose}>
<ModalActions>
<Button variant='outlined' color='neutral' onClick={onClose}>
Cancel
</Button>
<Button
variant='solid'
color='primary'
size='sm'
startDecorator={<Save sx={{ fontSize: 16 }} />}
onClick={handleSave}
>
Save Filter
</Button>
</Box>
</ModalActions>
</Box>
}
>
@@ -231,7 +231,7 @@ const AdvancedFilterBuilder = ({
projects={projects}
/>
</Box>
</BottomSheetModal>
</AppModal>
)
}

View File

@@ -1,7 +1,6 @@
import { AttachFile, Close, Image } from '@mui/icons-material'
import { AttachFile, Image } from '@mui/icons-material'
import {
Box,
Button,
CircularProgress,
List,
ListItem,
@@ -9,6 +8,7 @@ import {
Typography,
} from '@mui/joy'
import { useEffect, useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { GetChoreAttachments } from '../../../utils/Fetcher'
import { resolvePhotoURL } from '../../../utils/Helpers'
@@ -87,16 +87,7 @@ function AttachmentBrowserModal({ choreId, isOpen, onClose }) {
onClose={handleClose}
title='Attachments'
footer={
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant='plain'
color='neutral'
startDecorator={<Close />}
onClick={handleClose}
>
Close
</Button>
</Box>
<ModalActions primary={{ label: 'Done', onClick: handleClose }} />
}
>
{isLoading ? (

View File

@@ -1,8 +1,9 @@
import { Browser } from '@capacitor/browser'
import { Capacitor } from '@capacitor/core'
import { Close, Download } from '@mui/icons-material'
import { Box, Button, CircularProgress, Typography } from '@mui/joy'
import { Download } from '@mui/icons-material'
import { Box, CircularProgress, Typography } from '@mui/joy'
import { useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
const openUrl = async url => {
@@ -47,25 +48,15 @@ function AttachmentViewerModal({ config }) {
title={fileName || 'Attachment'}
maxHeight='92vh'
footer={
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button
variant='plain'
color='neutral'
startDecorator={<Close />}
onClick={handleClose}
>
Close
</Button>
<Button
variant='soft'
color='neutral'
startDecorator={<Download />}
onClick={() => downloadUrl(url, fileName)}
disabled={!url}
>
Download
</Button>
</Box>
<ModalActions
secondary={{ label: 'Close', onClick: handleClose }}
primary={{
label: 'Download',
startDecorator: <Download />,
onClick: () => downloadUrl(url, fileName),
disabled: !url,
}}
/>
}
>
<Box
@@ -78,10 +69,7 @@ function AttachmentViewerModal({ config }) {
}}
>
{!imgLoaded && !imgError && (
<CircularProgress
sx={{ position: 'absolute' }}
size='md'
/>
<CircularProgress sx={{ position: 'absolute' }} size='md' />
)}
{imgError ? (
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>

View File

@@ -1,6 +1,5 @@
import {
Box,
Button,
Checkbox,
CircularProgress,
FormControl,
@@ -13,6 +12,7 @@ import {
Typography,
} from '@mui/joy'
import { useCallback, useEffect, useRef, useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { CreateBackup, RestoreBackup } from '../../../utils/Fetcher'
@@ -140,7 +140,6 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
const response = await RestoreBackup(restoreEncryptionKey, backupData)
if (response.ok) {
const data = await response.json()
showNotification({
type: 'success',
message: 'Backup restored successfully. Please refresh the page.',
@@ -212,7 +211,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
placeholder='Enter a strong encryption key'
/>
<Typography level='body-xs' sx={{ mt: 0.5 }}>
Keep this key safe - you'll need it to restore your backup
Keep this key safeyou&apos;ll need it to restore your backup
</Typography>
</FormControl>
@@ -238,22 +237,6 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
{error}
</Typography>
)}
<Box display='flex' justifyContent='space-between' gap={2}>
<Button size='lg' variant='outlined' onClick={handleClose} fullWidth>
Cancel
</Button>
<Button
size='lg'
color='primary'
onClick={handleCreateBackup}
loading={loading}
disabled={!encryptionKey.trim()}
fullWidth
>
Create Backup
</Button>
</Box>
</Box>
)
@@ -294,22 +277,6 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
{error}
</Typography>
)}
<Box display='flex' justifyContent='space-between' gap={2}>
<Button size='lg' variant='outlined' onClick={handleClose} fullWidth>
Cancel
</Button>
<Button
size='lg'
color='warning'
onClick={handleRestore}
loading={loading}
disabled={!restoreEncryptionKey.trim() || !backupFile}
fullWidth
>
Restore Backup
</Button>
</Box>
</Box>
)
@@ -320,7 +287,28 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
size='lg'
fullWidth={true}
unmountDelay={250}
title='🔄 Backup & Restore'
title='Backup & Restore'
closeOnBackdrop={!loading}
closeOnEscape={!loading}
footer={
<ModalActions
secondary={{
label: 'Cancel',
onClick: handleClose,
disabled: loading,
}}
primary={{
label: activeTab === 0 ? 'Create Backup' : 'Restore Backup',
color: activeTab === 0 ? 'primary' : 'warning',
onClick: activeTab === 0 ? handleCreateBackup : handleRestore,
loading,
disabled:
activeTab === 0
? !encryptionKey.trim()
: !restoreEncryptionKey.trim() || !backupFile,
}}
/>
}
>
{loading ? (
<Box

View File

@@ -1,6 +1,7 @@
import { Box, Button, Typography } from '@mui/joy'
import { Typography } from '@mui/joy'
import { useCallback, useEffect, useState } from 'react'
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
function ConfirmationModal({ config }) {
@@ -14,49 +15,29 @@ function ConfirmationModal({ config }) {
[config],
)
// Keyboard shortcuts for confirmation modal
useEffect(() => {
const handleKeyDown = event => {
if (!config?.isOpen) return
// Show keyboard shortcuts when Ctrl/Cmd is pressed
if (event.ctrlKey || event.metaKey) {
setShowKeyboardShortcuts(true)
}
if (event.ctrlKey || event.metaKey) setShowKeyboardShortcuts(true)
// Ctrl/Cmd + Y for confirm
if ((event.ctrlKey || event.metaKey) && event.key === 'y') {
event.preventDefault()
handleAction(true)
return
}
// Ctrl/Cmd + X for cancel
if ((event.ctrlKey || event.metaKey) && event.key === 'x') {
} else if ((event.ctrlKey || event.metaKey) && event.key === 'x') {
event.preventDefault()
handleAction(false)
return
}
// Escape key for cancel
if (event.key === 'Escape') {
} else if (event.key === 'Escape') {
event.preventDefault()
handleAction(false)
return
}
// Enter key for confirm
if (event.key === 'Enter') {
} else if (event.key === 'Enter' && config?.color !== 'danger') {
event.preventDefault()
handleAction(true)
return
}
}
const handleKeyUp = event => {
if (!event.ctrlKey && !event.metaKey) {
setShowKeyboardShortcuts(false)
}
if (!event.ctrlKey && !event.metaKey) setShowKeyboardShortcuts(false)
}
if (config?.isOpen) {
@@ -68,51 +49,45 @@ function ConfirmationModal({ config }) {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('keyup', handleKeyUp)
}
}, [config?.isOpen, handleAction])
}, [config?.isOpen, config?.color, handleAction])
const isDestructive = config?.color === 'danger'
return (
<ResponsiveModal
open={config?.isOpen}
onClose={() => handleAction(false)}
size='sm'
unmountDelay={250}
role={isDestructive ? 'alertdialog' : 'dialog'}
title={config?.title}
showCloseButton={false}
closeOnBackdrop={!isDestructive}
footer={
<ModalActions
stackOnMobile
secondary={{
label: config?.cancelText,
onClick: () => handleAction(false),
endDecorator: showKeyboardShortcuts ? (
<KeyboardShortcutHint shortcut='X' />
) : undefined,
}}
primary={{
label: config?.confirmText,
color: config?.color || 'primary',
onClick: () => handleAction(true),
endDecorator: showKeyboardShortcuts ? (
<KeyboardShortcutHint shortcut='Y' />
) : undefined,
}}
/>
}
>
<Typography level='h4' mb={1}>
{config?.title}
</Typography>
<Typography level='body-md' gutterBottom>
<Typography level='body-md' sx={{ whiteSpace: 'pre-wrap' }}>
{config?.message}
</Typography>
<Box display={'flex'} justifyContent={'space-around'} mt={1} gap={1}>
<Button
size='lg'
onClick={() => {
handleAction(true)
}}
fullWidth
color={config?.color || 'primary'}
endDecorator={
<KeyboardShortcutHint shortcut='Y' show={showKeyboardShortcuts} />
}
>
{config?.confirmText}
</Button>
<Button
size='lg'
onClick={() => {
handleAction(false)
}}
variant='outlined'
endDecorator={
<KeyboardShortcutHint shortcut='X' show={showKeyboardShortcuts} />
}
>
{config?.cancelText}
</Button>
</Box>
</ResponsiveModal>
)
}
export default ConfirmationModal

View File

@@ -1,12 +1,6 @@
import {
Box,
Button,
FormControl,
FormHelperText,
Input,
Typography,
} from '@mui/joy'
import { FormControl, FormHelperText, Input, Typography } from '@mui/joy'
import { useEffect, useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
@@ -104,16 +98,30 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
password === confirmPassword
return (
<ResponsiveModal open={isOpen} onClose={handleClose}>
<Typography level='h4' mb={2}>
Create Sub Account
</Typography>
<Typography level='body-md' mb={3}>
Create a new sub account. The user will be able to log in using their
combined username and complete tasks assigned to them.
</Typography>
<ResponsiveModal
open={isOpen}
onClose={handleClose}
title='Create Sub Account'
description='Create a login that can complete tasks assigned to this account.'
size='md'
closeOnBackdrop={!isSubmitting}
closeOnEscape={!isSubmitting}
footer={
<ModalActions
secondary={{
label: 'Cancel',
onClick: handleClose,
disabled: isSubmitting,
}}
primary={{
label: 'Create Account',
onClick: handleSubmit,
disabled: !isValid || isSubmitting,
loading: isSubmitting,
}}
/>
}
>
<FormControl error={!!errors.childName} sx={{ mb: 2 }}>
<Typography level='body2' mb={1}>
Sub Account Name *
@@ -196,27 +204,6 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
<FormHelperText>{errors.confirmPassword}</FormHelperText>
)}
</FormControl>
<Box display='flex' justifyContent='space-between' gap={2}>
<Button
size='lg'
variant='outlined'
onClick={handleClose}
disabled={isSubmitting}
sx={{ flex: 1 }}
>
Cancel
</Button>
<Button
size='lg'
onClick={handleSubmit}
disabled={!isValid || isSubmitting}
loading={isSubmitting}
sx={{ flex: 1 }}
>
Create Account
</Button>
</Box>
</ResponsiveModal>
)
}

View File

@@ -1,15 +1,14 @@
import {
Box,
Button,
FormControl,
FormHelperText,
Input,
Option,
Select,
Textarea,
Typography,
FormControl,
FormHelperText,
Input,
Option,
Select,
Textarea,
Typography,
} from '@mui/joy'
import { useEffect, useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
@@ -29,7 +28,7 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
setState(0)
}
}
}, [type])
}, [type, state])
const isValid = () => {
const newErrors = {}
@@ -63,9 +62,20 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
<ResponsiveModal
open={isOpen}
onClose={onClose}
size='lg'
fullWidth={true}
size='md'
title={`${currentThing?.id ? 'Edit' : 'Create'} Thing`}
footer={
<ModalActions
secondary={{
label: 'Cancel',
onClick: onClose,
}}
primary={{
label: currentThing?.id ? 'Update' : 'Create',
onClick: handleSave,
}}
/>
}
>
<FormControl>
<Typography>Name</Typography>
@@ -79,9 +89,9 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
</FormControl>
<FormControl>
<Typography>Type</Typography>
<Select value={type} sx={{ minWidth: 300 }}>
<Select value={type} onChange={(_, value) => setType(value)}>
{['text', 'number', 'boolean'].map(type => (
<Option value={type} key={type} onClick={() => setType(type)}>
<Option value={type} key={type}>
{type.charAt(0).toUpperCase() + type.slice(1)}
</Option>
))}
@@ -118,24 +128,15 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
{type === 'boolean' && (
<FormControl>
<Typography>Value</Typography>
<Select sx={{ minWidth: 300 }} value={state}>
<Select value={state} onChange={(_, value) => setState(value)}>
{['true', 'false'].map(value => (
<Option value={value} key={value} onClick={() => setState(value)}>
<Option value={value} key={value}>
{value.charAt(0).toUpperCase() + value.slice(1)}
</Option>
))}
</Select>
</FormControl>
)}
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
<Button size='lg' onClick={handleSave} fullWidth sx={{ mr: 1 }}>
{currentThing?.id ? 'Update' : 'Create'}
</Button>
<Button size='lg' onClick={onClose} variant='outlined'>
{currentThing?.id ? 'Cancel' : 'Close'}
</Button>
</Box>
</ResponsiveModal>
)
}

View File

@@ -1,10 +1,10 @@
import { Box, Button, Input } from '@mui/joy'
import { Input } from '@mui/joy'
import { useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
function DateModal({ isOpen, onClose, onSave, current, title }) {
const { ResponsiveModal } = useResponsiveModal()
const [date, setDate] = useState(
current ? new Date(current).toISOString().split('T')[0] : '',
)
@@ -18,74 +18,23 @@ function DateModal({ isOpen, onClose, onSave, current, title }) {
<ResponsiveModal
open={isOpen}
onClose={onClose}
size='lg'
fullWidth={true}
size='sm'
title={title}
footer={
<ModalActions
secondary={{ label: 'Cancel', onClick: onClose }}
primary={{ label: 'Save', onClick: handleSave, disabled: !date }}
/>
}
>
<Input
sx={{ mt: 3 }}
autoFocus
type='date'
value={date}
onChange={e => setDate(e.target.value)}
onChange={event => setDate(event.target.value)}
/>
{/* <Box sx={{ mt: 3 }}>
<Typography level='body-sm' sx={{ mb: 1.5, fontWeight: 500 }}>
Quick select:
</Typography>
<Stack direction='row' spacing={1} flexWrap='wrap' useFlexGap>
<Chip
variant='soft'
color='primary'
startDecorator={<Today />}
size='lg'
onClick={() => handleQuickSchedule('today')}
sx={{ cursor: 'pointer' }}
>
Today
</Chip>
<Chip
variant='soft'
color='primary'
startDecorator={<WbSunny />}
size='lg'
onClick={() => handleQuickSchedule('tomorrow')}
sx={{ cursor: 'pointer' }}
>
Tomorrow
</Chip>
<Chip
variant='soft'
color='primary'
startDecorator={<Weekend />}
size='lg'
onClick={() => handleQuickSchedule('weekend')}
sx={{ cursor: 'pointer' }}
>
Weekend
</Chip>
<Chip
variant='soft'
color='primary'
startDecorator={<NextWeek />}
size='lg'
onClick={() => handleQuickSchedule('next-week')}
sx={{ cursor: 'pointer' }}
>
Next week
</Chip>
</Stack>
</Box> */}
<Box display={'flex'} justifyContent={'space-around'} mt={4}>
<Button size='lg' onClick={handleSave} fullWidth sx={{ mr: 1 }}>
Save
</Button>
<Button size='lg' onClick={onClose} variant='outlined'>
Cancel
</Button>
</Box>
</ResponsiveModal>
)
}
export default DateModal

View File

@@ -1,12 +1,6 @@
import {
Box,
Button,
FormControl,
FormHelperText,
Input,
Typography,
} from '@mui/joy'
import { FormControl, FormHelperText, Input, Typography } from '@mui/joy'
import { useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) {
@@ -31,7 +25,7 @@ function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) {
return
}
onSave({
name,
name: currentThing?.name,
type: currentThing?.type,
id: currentThing?.id,
state: state || null,
@@ -43,9 +37,14 @@ function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) {
<ResponsiveModal
open={isOpen}
onClose={onClose}
size='lg'
fullWidth={true}
size='sm'
title='Update state'
footer={
<ModalActions
secondary={{ label: 'Cancel', onClick: onClose }}
primary={{ label: 'Update', onClick: handleSave }}
/>
}
>
<FormControl>
<Typography>Value</Typography>
@@ -57,15 +56,6 @@ function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) {
/>
<FormHelperText color='danger'>{errors.state}</FormHelperText>
</FormControl>
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
<Button size='lg' onClick={handleSave} fullWidth sx={{ mr: 1 }}>
{currentThing?.id ? 'Update' : 'Create'}
</Button>
<Button size='lg' onClick={onClose} variant='outlined'>
{currentThing?.id ? 'Cancel' : 'Close'}
</Button>
</Box>
</ResponsiveModal>
)
}

View File

@@ -1,12 +1,5 @@
import {
Avatar,
Box,
Button,
FormControl,
FormLabel,
Grid,
Typography,
} from '@mui/joy'
import { Avatar, Box, FormControl, FormLabel, Grid, Typography } from '@mui/joy'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { getTextColorFromBackgroundColor } from '../../../utils/Colors'
import PROJECT_ICONS from '../../../utils/ProjectIcons'
@@ -33,8 +26,10 @@ const IconPickerModal = ({
fullWidth={true}
unmountDelay={250}
title='Choose Project Icon'
footer={
<ModalActions secondary={{ label: 'Cancel', onClick: onClose }} />
}
>
<FormControl>
<FormLabel>Available Icons</FormLabel>
<Grid
@@ -58,7 +53,9 @@ const IconPickerModal = ({
border: '2px solid',
borderColor: isCurrentIcon ? 'primary.500' : 'transparent',
'&:hover': {
borderColor: isCurrentIcon ? 'primary.600' : 'neutral.300',
borderColor: isCurrentIcon
? 'primary.600'
: 'neutral.300',
},
transition: 'border-color 0.2s',
}}
@@ -96,12 +93,6 @@ const IconPickerModal = ({
})}
</Grid>
</FormControl>
<Box display='flex' justifyContent='center' mt={3}>
<Button variant='outlined' onClick={onClose} fullWidth size='lg'>
Cancel
</Button>
</Box>
</ResponsiveModal>
)
}

View File

@@ -1,7 +1,8 @@
import { Box, Button, FormControl, Input, Typography } from '@mui/joy'
import { Box, FormControl, Input, Typography } from '@mui/joy'
import { useEffect, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal.js'
import { useNotification } from '../../../service/NotificationProvider.jsx'
import LABEL_COLORS from '../../../utils/Colors.jsx'
@@ -90,14 +91,13 @@ function LabelModal({ isOpen, onClose, label }) {
fullWidth={true}
title={label ? 'Edit Label' : 'Add Label'}
footer={
<Box display='flex' justifyContent='space-around' mt={1}>
<Button size='lg' onClick={handleSave} fullWidth sx={{ mr: 1 }}>
{label ? 'Save Changes' : 'Add Label'}
</Button>
<Button size='lg' onClick={onClose} variant='outlined'>
Cancel
</Button>
</Box>
<ModalActions
secondary={{ label: 'Cancel', onClick: onClose }}
primary={{
label: label ? 'Save Changes' : 'Add Label',
onClick: handleSave,
}}
/>
}
>
<Box>
@@ -120,12 +120,18 @@ function LabelModal({ isOpen, onClose, label }) {
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{LABEL_COLORS.map(colorOption => (
<Box
component='button'
type='button'
key={colorOption.value}
aria-label={`Select ${colorOption.name}`}
aria-pressed={color === colorOption.value}
title={colorOption.name}
onClick={() => setColor(colorOption.value)}
sx={{
width: 26,
height: 26,
width: 40,
height: 40,
border: 0,
p: 0,
borderRadius: '50%',
background: colorOption.value,
cursor: 'pointer',

View File

@@ -1,15 +1,33 @@
import { Box, Button, Typography } from '@mui/joy'
import { Box, Typography } from '@mui/joy'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
const NativeCancelSubscriptionModal = ({ isOpen, onClose }) => {
const { ResponsiveModal } = useResponsiveModal()
return (
<ResponsiveModal open={isOpen} onClose={onClose} size='md' fullWidth>
<Typography level='h4' sx={{ mb: 2 }}>
Cancel Subscription
</Typography>
<Box sx={{ p: 2 }}>
<ResponsiveModal
open={isOpen}
onClose={onClose}
size='lg'
title='Cancel Subscription'
footer={
<ModalActions
stackOnMobile
tertiary={{ label: 'Dismiss', onClick: onClose }}
secondary={{
label: "I'll cancel from my app store",
onClick: onClose,
}}
primary={{
label: 'Cancel desktop subscription',
color: 'danger',
onClick: () => onClose('desktop'),
}}
/>
}
>
<Box>
<Typography level='body-md' mb={3}>
To cancel your subscription, please follow the instructions for your
platform (you should cancel through the same platform you used to
@@ -84,8 +102,8 @@ const NativeCancelSubscriptionModal = ({ isOpen, onClose }) => {
<strong>Important:</strong> You must cancel your subscription
through the same platform where you originally subscribed. If you
subscribed through the iOS App Store or Google Play Store (even if
you're now using the web/desktop version), you must cancel through
that original platform using the instructions above.
you&apos;re now using the web/desktop version), you must cancel
through that original platform using the instructions above.
</Typography>
</Box>
@@ -93,24 +111,6 @@ const NativeCancelSubscriptionModal = ({ isOpen, onClose }) => {
Your subscription will remain active until the end of your current
billing period.
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Button size='lg' onClick={onClose} variant='outlined' fullWidth>
I'll cancel from my app store
</Button>
<Button
size='lg'
onClick={() => onClose('desktop')}
variant='solid'
color='danger'
fullWidth
>
I subscribed via desktop - Cancel now
</Button>
<Button size='lg' onClick={onClose} fullWidth>
Dismiss
</Button>
</Box>
</Box>
</ResponsiveModal>
)

View File

@@ -1,15 +1,15 @@
import {
Alert,
Box,
Button,
FormControl,
FormLabel,
Switch,
Textarea,
Typography,
Alert,
Box,
FormControl,
FormLabel,
Switch,
Textarea,
Typography,
} from '@mui/joy'
import { useCallback, useEffect, useState } from 'react'
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { isOfficialDonetickInstanceSync } from '../../../utils/FeatureToggle'
@@ -108,19 +108,34 @@ function NudgeModal({ config }) {
fullWidth={true}
unmountDelay={250}
title='Send Nudge'
description='Send a gentle reminder to the people assigned to this task.'
footer={
<ModalActions
secondary={{
label: 'Cancel',
onClick: () => handleAction(false),
endDecorator: showKeyboardShortcuts ? (
<KeyboardShortcutHint shortcut='X' />
) : undefined,
}}
primary={{
label: 'Send Nudge',
onClick: () => handleAction(true),
disabled: !isOfficialInstance,
endDecorator: showKeyboardShortcuts ? (
<KeyboardShortcutHint shortcut='Y' />
) : undefined,
}}
/>
}
>
<Typography level='body-md' mb={2}>
Send a gentle reminder to the assignee about this task. You can
customize the message and choose who gets notified.
</Typography>
{!isOfficialInstance && (
<Alert color='warning' sx={{ mb: 2 }}>
<Typography level='body-sm'>
<strong>Heads up!</strong>This feature avaiable on Donetick Cloud!
Since you're using a self-hosted instance, nudges will requires you
to setup Google cloud account and Firebase Cloud Messaging (FCM).
and build the Android or the iOS app by yourself.
Since you&apos;re using a self-hosted instance, nudges will requires
you to setup Google cloud account and Firebase Cloud Messaging
(FCM). and build the Android or the iOS app by yourself.
<br />
Will update if we come up with a solution to make this easier for to
configure. for selfhosters
@@ -152,33 +167,6 @@ function NudgeModal({ config }) {
onChange={e => setNotifyAllAssignees(e.target.checked)}
/>
</FormControl>
<Box display={'flex'} justifyContent={'space-around'} gap={1}>
<Button
size='lg'
onClick={() => handleAction(true)}
disabled={!isOfficialInstance}
fullWidth
color='primary'
endDecorator={
<KeyboardShortcutHint shortcut='Y' show={showKeyboardShortcuts} />
}
>
Send Nudge
</Button>
<Button
size='lg'
onClick={() => handleAction(false)}
variant='outlined'
fullWidth
endDecorator={
<KeyboardShortcutHint shortcut='X' show={showKeyboardShortcuts} />
}
>
Cancel
</Button>
</Box>
</ResponsiveModal>
)
}

View File

@@ -1,27 +1,20 @@
import {
Box,
Button,
FormControl,
FormHelperText,
Input,
Typography,
} from '@mui/joy'
import React, { useEffect } from 'react'
import { FormControl, FormHelperText, Input, Typography } from '@mui/joy'
import { useEffect, useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
function PassowrdChangeModal({ isOpen, onClose }) {
function PasswordChangeModal({ isOpen, onClose }) {
const { ResponsiveModal } = useResponsiveModal()
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [passwordError, setPasswordError] = useState(null)
const [passwordTouched, setPasswordTouched] = useState(false)
const [confirmPasswordTouched, setConfirmPasswordTouched] = useState(false)
const [password, setPassword] = React.useState('')
const [confirmPassword, setConfirmPassword] = React.useState('')
const [passwordError, setPasswordError] = React.useState(false)
const [passwordTouched, setPasswordTouched] = React.useState(false)
const [confirmPasswordTouched, setConfirmPasswordTouched] =
React.useState(false)
useEffect(() => {
if (!passwordTouched || !confirmPasswordTouched) {
return
} else if (password !== confirmPassword) {
if (!passwordTouched || !confirmPasswordTouched) return
if (password !== confirmPassword) {
setPasswordError('Passwords do not match')
} else if (password.length < 8) {
setPasswordError('Password must be at least 8 characters')
@@ -32,90 +25,66 @@ function PassowrdChangeModal({ isOpen, onClose }) {
}
}, [password, confirmPassword, passwordTouched, confirmPasswordTouched])
const handleAction = isConfirmed => {
if (!isConfirmed) {
onClose(null)
return
}
onClose(password)
}
const handleAction = isConfirmed => onClose(isConfirmed ? password : null)
const canSubmit =
passwordTouched &&
confirmPasswordTouched &&
password.length >= 8 &&
password === confirmPassword &&
passwordError == null
return (
<ResponsiveModal
open={isOpen}
onClose={onClose}
size='lg'
fullWidth={true}
onClose={() => handleAction(false)}
size='sm'
title='Change Password'
description='Choose a password between 8 and 64 characters.'
footer={
<ModalActions
secondary={{ label: 'Cancel', onClick: () => handleAction(false) }}
primary={{
label: 'Change Password',
disabled: !canSubmit,
onClick: () => handleAction(true),
}}
/>
}
>
<Typography level='body-md' gutterBottom>
Please enter your new password.
</Typography>
<FormControl>
<Typography level='body2' alignSelf={'start'}>
New Password
</Typography>
<FormControl sx={{ mb: 2 }}>
<Typography level='body-sm'>New password</Typography>
<Input
margin='normal'
required
fullWidth
name='password'
label='Password'
type='password'
id='password'
placeholder='Enter password (8-64 characters)'
autoComplete='new-password'
placeholder='Enter password'
value={password}
onChange={e => {
onChange={event => {
setPasswordTouched(true)
setPassword(e.target.value)
setPassword(event.target.value)
}}
/>
</FormControl>
<FormControl>
<Typography level='body2' alignSelf={'start'}>
Confirm Password
</Typography>
<FormControl error={Boolean(passwordError)}>
<Typography level='body-sm'>Confirm password</Typography>
<Input
margin='normal'
required
fullWidth
name='confirmPassword'
label='confirmPassword'
type='password'
id='confirmPassword'
autoComplete='new-password'
placeholder='Repeat password'
value={confirmPassword}
onChange={e => {
onChange={event => {
setConfirmPasswordTouched(true)
setConfirmPassword(e.target.value)
setConfirmPassword(event.target.value)
}}
/>
<FormHelperText>{passwordError}</FormHelperText>
{passwordError && <FormHelperText>{passwordError}</FormHelperText>}
</FormControl>
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
<Button
size='lg'
disabled={passwordError != null}
onClick={() => {
handleAction(true)
}}
fullWidth
sx={{ mr: 1 }}
>
Change Password
</Button>
<Button
size='lg'
onClick={() => {
handleAction(false)
}}
variant='outlined'
>
Cancel
</Button>
</Box>
</ResponsiveModal>
)
}
export default PassowrdChangeModal
export default PasswordChangeModal

View File

@@ -10,6 +10,7 @@ import {
Typography,
} from '@mui/joy'
import { useEffect, useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import PROJECT_COLORS, {
getTextColorFromBackgroundColor,
@@ -124,28 +125,23 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
unmountDelay={250}
fullWidth={true}
title={project ? 'Edit Project' : 'Create New Project'}
closeOnBackdrop={!isSubmitting}
closeOnEscape={!isSubmitting}
footer={
<Box display='flex' justifyContent='space-around' gap={1}>
<Button
type='submit'
form='project-form'
loading={isSubmitting}
disabled={!projectName.trim() || isSubmitting}
fullWidth
size='lg'
>
{project ? 'Update' : 'Create'}
</Button>
<Button
variant='outlined'
onClick={handleClose}
disabled={isSubmitting}
fullWidth
size='lg'
>
Cancel
</Button>
</Box>
<ModalActions
secondary={{
label: 'Cancel',
onClick: handleClose,
disabled: isSubmitting,
}}
primary={{
label: project ? 'Update' : 'Create',
type: 'submit',
form: 'project-form',
loading: isSubmitting,
disabled: !projectName.trim() || isSubmitting,
}}
/>
}
>
<form onSubmit={handleSubmit} id='project-form'>

View File

@@ -1,5 +1,6 @@
import { Box, Button, Option, Select } from '@mui/joy'
import React from 'react'
import { Option, Select } from '@mui/joy'
import { useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
function SelectModal({
@@ -12,8 +13,8 @@ function SelectModal({
placeholder,
}) {
const { ResponsiveModal } = useResponsiveModal()
const [selected, setSelected] = useState(null)
const [selected, setSelected] = React.useState(null)
const handleSave = () => {
onSave(options.find(item => item.id === selected))
onClose()
@@ -23,33 +24,33 @@ function SelectModal({
<ResponsiveModal
open={isOpen}
onClose={onClose}
size='lg'
fullWidth={true}
size='sm'
title={title}
footer={
<ModalActions
secondary={{ label: 'Cancel', onClick: onClose }}
primary={{
label: 'Save',
onClick: handleSave,
disabled: selected == null,
}}
/>
}
>
<Select placeholder={placeholder}>
{options.map((item, index) => (
<Option
value={item.id}
key={item[displayKey]}
onClick={() => {
setSelected(item.id)
}}
>
<Select
autoFocus
placeholder={placeholder}
value={selected}
onChange={(_, value) => setSelected(value)}
>
{options.map(item => (
<Option value={item.id} key={item[displayKey]}>
{item[displayKey]}
</Option>
))}
</Select>
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
<Button size='lg' onClick={handleSave} fullWidth sx={{ mr: 1 }}>
Save
</Button>
<Button size='lg' onClick={onClose} variant='outlined'>
Cancel
</Button>
</Box>
</ResponsiveModal>
)
}
export default SelectModal

View File

@@ -1,5 +1,6 @@
import { Box, Button, Textarea } from '@mui/joy'
import { Textarea } from '@mui/joy'
import { useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
function TextModal({
@@ -12,7 +13,6 @@ function TextModal({
cancelText,
}) {
const { ResponsiveModal } = useResponsiveModal()
const [text, setText] = useState(current)
const handleSave = () => {
@@ -24,28 +24,25 @@ function TextModal({
<ResponsiveModal
open={isOpen}
onClose={onClose}
size='lg'
fullWidth={true}
size='md'
title={title}
footer={
<ModalActions
secondary={{ label: cancelText || 'Cancel', onClick: onClose }}
primary={{ label: okText || 'Save', onClick: handleSave }}
/>
}
>
<Textarea
autoFocus
placeholder='Type in here…'
value={text}
onChange={e => setText(e.target.value)}
minRows={2}
maxRows={4}
sx={{ minWidth: 300 }}
onChange={event => setText(event.target.value)}
minRows={3}
maxRows={8}
/>
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
<Button size='lg' onClick={handleSave} fullWidth sx={{ mr: 1 }}>
{okText ? okText : 'Save'}
</Button>
<Button size='lg' onClick={onClose} variant='outlined'>
{cancelText ? cancelText : 'Cancel'}
</Button>
</Box>
</ResponsiveModal>
)
}
export default TextModal

View File

@@ -13,6 +13,7 @@ import {
import moment from 'moment'
import { useEffect, useState } from 'react'
import { useLocalization } from '../../../contexts/LocalizationContext'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { useNotification } from '../../../service/NotificationProvider'
import {
@@ -59,7 +60,6 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
}
}, [isOpen, timerData])
const formatTime = seconds => {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
@@ -304,10 +304,37 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
open={isOpen}
onClose={onClose}
size='lg'
fullWidth={true}
title='Timer Details'
footer={
<ModalActions
tertiary={
!loading && timerData && !editingSessions[timerData.id]
? {
label: 'Delete',
color: 'danger',
onClick: () => confirmDeleteSession(timerData.id),
}
: undefined
}
secondary={{ label: 'Close', onClick: handleClose }}
primary={
!loading && timerData
? editingSessions[timerData.id]
? {
label: 'Save',
onClick: () => saveSession(timerData.id),
loading,
}
: {
label: 'Edit',
startDecorator: <Edit />,
onClick: () => startEditingSession(),
}
: undefined
}
/>
}
>
<Typography level='h4'>Timer Details</Typography>
{loading && (
<Alert color='neutral' sx={{ mb: 2 }}>
Loading timer data...
@@ -919,56 +946,6 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
)}
</Box>
)}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 1,
}}
>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant='outlined' onClick={handleClose} color='neutral'>
Cancel
</Button>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
{/* Action buttons on the right */}
{!loading && timerData && !editingSessions[timerData.id] && (
<>
<Button
size='sm'
variant='outlined'
color='danger'
onClick={() => confirmDeleteSession(timerData.id)}
>
Delete
</Button>
<Button
variant='outlined'
startDecorator={<Edit />}
onClick={() => startEditingSession()}
>
Edit
</Button>
</>
)}
{/* Save button when editing */}
{!loading && timerData && editingSessions[timerData.id] && (
<Button
variant='solid'
color='primary'
onClick={() => saveSession(timerData.id)}
loading={loading}
>
Save
</Button>
)}
</Box>
</Box>
</ResponsiveModal>
<ConfirmationModal config={confirmDeleteConfig} />

View File

@@ -1,6 +1,5 @@
import {
Box,
Button,
Card,
CircularProgress,
FormControl,
@@ -10,13 +9,13 @@ import {
Select,
Typography,
} from '@mui/joy'
import { data } from 'autoprefixer'
import { useCallback, useEffect, useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useNavigate } from 'react-router-dom'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { CheckUserDeletion, DeleteUser } from '../../../utils/Fetcher'
function UserDeletionModal({ isOpen, onClose, userProfile }) {
function UserDeletionModal({ isOpen, onClose }) {
const { ResponsiveModal } = useResponsiveModal()
const Navigate = useNavigate()
const [step, setStep] = useState(1) // 1: Warning, 2: Transfer, 3: Confirm
@@ -70,7 +69,8 @@ function UserDeletionModal({ isOpen, onClose, userProfile }) {
setError(data.error || 'Failed to check deletion requirements')
}
} catch (err) {
setError(data.error || 'Failed to check deletion requirements')
console.error('Failed to check deletion requirements:', err)
setError('Failed to check deletion requirements')
} finally {
setLoading(false)
}
@@ -119,6 +119,7 @@ function UserDeletionModal({ isOpen, onClose, userProfile }) {
setError(data.message || 'Failed to delete account')
}
} catch (err) {
console.error('Failed to delete account:', err)
setError('Failed to delete account')
} finally {
setLoading(false)
@@ -148,10 +149,6 @@ function UserDeletionModal({ isOpen, onClose, userProfile }) {
const renderWarningStep = () => (
<>
<Typography level='h4' mb={2} color='danger'>
Delete Account
</Typography>
<Typography level='body-md' mb={2}>
<strong>This action cannot be undone.</strong> Deleting your account
will permanently remove:
@@ -193,30 +190,11 @@ function UserDeletionModal({ isOpen, onClose, userProfile }) {
{error}
</Typography>
)}
<Box display='flex' justifyContent='space-between' mt={3} gap={2}>
<Button variant='outlined' onClick={() => handleClose(false)} fullWidth>
Cancel
</Button>
<Button
color='danger'
onClick={checkDeletionRequirements}
loading={loading}
disabled={!password}
fullWidth
>
Continue
</Button>
</Box>
</>
)
const renderTransferStep = () => (
<>
<Typography level='h4' mb={2} color='warning'>
Circle Ownership Transfer Required
</Typography>
<Typography level='body-md' mb={3}>
You own circles that require ownership transfer before deletion. Please
select new owners:
@@ -253,29 +231,11 @@ function UserDeletionModal({ isOpen, onClose, userProfile }) {
</FormControl>
</Card>
))}
<Box display='flex' justifyContent='space-between' mt={3} gap={2}>
<Button variant='outlined' onClick={() => handleClose(false)} fullWidth>
Cancel
</Button>
<Button
color='primary'
onClick={proceedToConfirmation}
disabled={circlesRequiringTransfer.length !== transferOptions.length}
fullWidth
>
Continue
</Button>
</Box>
</>
)
const renderConfirmationStep = () => (
<>
<Typography level='h4' mb={2} color='danger'>
Final Confirmation
</Typography>
<Typography level='body-md' mb={3}>
Please enter your password and type <strong>DELETE</strong> to confirm
account deletion.
@@ -296,7 +256,7 @@ function UserDeletionModal({ isOpen, onClose, userProfile }) {
</FormControl>
<FormControl sx={{ mb: 3 }}>
<FormLabel>Type "DELETE" to confirm</FormLabel>
<FormLabel>Type &quot;DELETE&quot; to confirm</FormLabel>
<Input
value={confirmation}
onChange={e => setConfirmation(e.target.value)}
@@ -309,21 +269,6 @@ function UserDeletionModal({ isOpen, onClose, userProfile }) {
{error}
</Typography>
)}
<Box display='flex' justifyContent='space-between' gap={2}>
<Button variant='outlined' onClick={() => handleClose(false)} fullWidth>
Cancel
</Button>
<Button
color='danger'
onClick={executeUserDeletion}
loading={loading}
disabled={!password || confirmation !== 'DELETE'}
fullWidth
>
Delete Account
</Button>
</Box>
</>
)
@@ -345,8 +290,42 @@ function UserDeletionModal({ isOpen, onClose, userProfile }) {
open={isOpen}
onClose={() => handleClose(false)}
size='lg'
fullWidth={true}
title='Delete Account'
title={
step === 1
? 'Delete Account'
: step === 2
? 'Transfer Circle Ownership'
: 'Final Confirmation'
}
role={step === 3 ? 'alertdialog' : 'dialog'}
closeOnBackdrop={false}
footer={
!loading && (
<ModalActions
stackOnMobile
secondary={{
label: 'Cancel',
onClick: () => handleClose(false),
}}
primary={{
label: step === 3 ? 'Delete Account' : 'Continue',
color: step === 3 ? 'danger' : 'primary',
onClick:
step === 1
? checkDeletionRequirements
: step === 2
? proceedToConfirmation
: executeUserDeletion,
disabled:
step === 1
? !password
: step === 2
? circlesRequiringTransfer.length !== transferOptions.length
: !password || confirmation !== 'DELETE',
}}
/>
)
}
>
{loading && step === 1 ? (
<Box

View File

@@ -1,4 +1,5 @@
import { Avatar, Box, Button, List, ListItem, Typography } from '@mui/joy'
import { Avatar, Box, List, ListItem, Typography } from '@mui/joy'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
const UserModal = ({ isOpen, performers = [], onSelect, onClose }) => {
@@ -11,6 +12,9 @@ const UserModal = ({ isOpen, performers = [], onSelect, onClose }) => {
size='lg'
fullWidth={true}
title='Select User'
footer={
<ModalActions secondary={{ label: 'Cancel', onClick: onClose }} />
}
>
<List sx={{ mb: 2 }}>
{performers.map(user => (
@@ -38,11 +42,6 @@ const UserModal = ({ isOpen, performers = [], onSelect, onClose }) => {
</ListItem>
))}
</List>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Button size='lg' variant='outlined' color='neutral' onClick={onClose}>
Cancel
</Button>
</Box>
</ResponsiveModal>
)
}

View File

@@ -5,16 +5,9 @@ import {
ErrorOutline,
Nfc,
} from '@mui/icons-material'
import {
Box,
Button,
CircularProgress,
IconButton,
Input,
Switch,
Typography,
} from '@mui/joy'
import { Box, IconButton, Input, Switch, Typography } from '@mui/joy'
import { useRef, useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { startNativeNFCWrite } from '../../../service/NFCWriter'
@@ -29,9 +22,6 @@ const pulseKeyframes = `
70% { transform: scale(2.1); opacity: 0; }
100% { transform: scale(2.1); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.nfc-pulse-ring { animation: none !important; }
}
`
function NFCIcon({ status }) {
@@ -55,7 +45,6 @@ function NFCIcon({ status }) {
{isWaiting && (
<>
<Box
className='nfc-pulse-ring'
sx={{
position: 'absolute',
inset: 0,
@@ -63,10 +52,10 @@ function NFCIcon({ status }) {
border: '2px solid',
borderColor: 'primary.400',
animation: 'nfc-pulse 1.8s ease-out infinite',
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
}}
/>
<Box
className='nfc-pulse-ring'
sx={{
position: 'absolute',
inset: 0,
@@ -74,6 +63,7 @@ function NFCIcon({ status }) {
border: '2px solid',
borderColor: 'primary.300',
animation: 'nfc-pulse-2 1.8s ease-out infinite 0.4s',
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
}}
/>
</>
@@ -215,27 +205,37 @@ function WriteNFCModal({ config }) {
return (
<>
<style>{pulseKeyframes}</style>
<ResponsiveModal open={config?.isOpen} onClose={handleClose}>
<ResponsiveModal
open={config?.isOpen}
onClose={handleClose}
title={title}
description={subtitle}
closeOnBackdrop={!isWaiting}
closeOnEscape={!isWaiting}
footer={
isSuccess ? (
<ModalActions primary={{ label: 'Done', onClick: handleClose }} />
) : isWaiting ? (
<ModalActions
secondary={{ label: 'Cancel', onClick: handleCancel }}
/>
) : (
<ModalActions
secondary={{ label: 'Cancel', onClick: handleClose }}
primary={{
label: nfcStatus === 'writing' ? 'Starting…' : 'Write tag',
onClick: writeToNFC,
disabled: nfcStatus === 'writing',
startDecorator: <Nfc />,
}}
/>
)
}
>
<Box sx={{ px: 0.5, pb: 1 }}>
{/* Icon */}
<NFCIcon status={nfcStatus} />
{/* Heading */}
<Typography
level='title-lg'
textAlign='center'
sx={{ mb: 0.75, fontWeight: 600 }}
>
{title}
</Typography>
<Typography
level='body-sm'
textAlign='center'
sx={{ color: 'text.secondary', mb: 3, px: 2 }}
>
{subtitle}
</Typography>
{/* Idle / Error: URL + toggle + CTA */}
{!isWaiting && !isSuccess && (
<>
@@ -264,6 +264,7 @@ function WriteNFCModal({ config }) {
}}
endDecorator={
<IconButton
aria-label='Copy tag URL'
size='sm'
variant='plain'
color={copied ? 'success' : 'neutral'}
@@ -310,55 +311,8 @@ function WriteNFCModal({ config }) {
size='sm'
/>
</Box>
<Box sx={{ display: 'flex', gap: 1.5 }}>
<Button
size='lg'
variant='outlined'
color='neutral'
sx={{ flex: 1 }}
onClick={isError ? handleClose : handleClose}
>
Cancel
</Button>
<Button
size='lg'
sx={{ flex: 1 }}
onClick={writeToNFC}
disabled={nfcStatus === 'writing'}
startDecorator={
nfcStatus === 'writing' ? (
<CircularProgress size='sm' />
) : (
<Nfc />
)
}
>
{nfcStatus === 'writing' ? 'Starting…' : 'Write tag'}
</Button>
</Box>
</>
)}
{/* Waiting state */}
{isWaiting && (
<Button
size='lg'
variant='outlined'
color='neutral'
fullWidth
onClick={handleCancel}
>
Cancel
</Button>
)}
{/* Success state */}
{isSuccess && (
<Button size='lg' fullWidth onClick={handleClose}>
Done
</Button>
)}
</Box>
</ResponsiveModal>
</>

View File

@@ -2,10 +2,8 @@ import { CreditCard, Person, Toll } from '@mui/icons-material'
import {
Avatar,
Box,
Button,
Card,
Chip,
Divider,
FormControl,
FormLabel,
IconButton,
@@ -15,6 +13,7 @@ import {
} from '@mui/joy'
import { useEffect, useState } from 'react'
import ModalActions from '../../components/common/ModalActions.jsx'
import { useResponsiveModal } from '../../hooks/useResponsiveModal.js'
import { resolvePhotoURL } from '../../utils/Helpers.jsx'
@@ -53,22 +52,28 @@ function RedeemPointsModal({ config }) {
const canRedeem = points > 0 && points <= config.available
return (
<ResponsiveModal open={config?.isOpen} onClose={config?.onClose} size='md'>
{/* Header Section */}
<ResponsiveModal
open={config?.isOpen}
onClose={config?.onClose}
size='md'
title='Redeem Points'
footer={
<ModalActions
secondary={{ label: 'Cancel', onClick: config?.onClose }}
primary={{
label: 'Redeem',
startDecorator: <CreditCard />,
disabled: !canRedeem,
onClick: () =>
config?.onSave({
points: Number(points),
userId: config?.user?.userId,
}),
}}
/>
}
>
<Stack spacing={2}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<CreditCard
sx={{
fontSize: '1.5rem',
}}
/>
<Typography level='h4' sx={{ fontWeight: 600 }}>
Redeem Points
</Typography>
</Box>
<Divider />
{/* User Info Card */}
<Card
variant='soft'
@@ -155,6 +160,7 @@ function RedeemPointsModal({ config }) {
{predefinedPoints.map(point => (
<IconButton
key={point}
aria-label={`Add ${point} points`}
variant='outlined'
disabled={points + point > config?.available}
onClick={() => addPredefinedPoints(point)}
@@ -209,43 +215,6 @@ function RedeemPointsModal({ config }) {
</Typography>
</Card>
)}
<Divider />
{/* Action Buttons */}
<Stack direction='row' spacing={2}>
<Button
size='lg'
onClick={config?.onClose}
variant='outlined'
color='neutral'
fullWidth
sx={{
'&:hover': {
backgroundColor: 'neutral.50',
},
}}
>
Cancel
</Button>
<Button
size='lg'
onClick={() =>
config?.onSave({
points: Number(points),
userId: config?.user?.userId,
})
}
disabled={!canRedeem}
fullWidth
startDecorator={<CreditCard />}
sx={{
transition: 'all 0.2s ease',
}}
>
Redeem
</Button>
</Stack>
</Stack>
</ResponsiveModal>
)