Refactor modals to use ResponsiveModal for improved responsiveness and consistency

This commit is contained in:
Mo Tarbin
2025-08-21 01:39:03 -04:00
parent 0102ce2144
commit 4dcd4483e9
18 changed files with 529 additions and 74 deletions

View File

@@ -1,10 +1,13 @@
import { Box, Button, FormLabel, Input, Typography } from '@mui/joy'
import moment from 'moment'
import { useEffect, useState } from 'react'
import FadeModal from '../../components/common/FadeModal'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import ConfirmationModal from './Inputs/ConfirmationModal'
function EditHistoryModal({ config, historyRecord }) {
const { ResponsiveModal } = useResponsiveModal()
useEffect(() => {
setCompletedDate(
moment(historyRecord.performedAt).format('YYYY-MM-DDTHH:mm'),
@@ -22,7 +25,7 @@ function EditHistoryModal({ config, historyRecord }) {
const [notes, setNotes] = useState(historyRecord.notes)
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
return (
<FadeModal open={config?.isOpen} onClose={config?.onClose}>
<ResponsiveModal open={config?.isOpen} onClose={config?.onClose}>
<Typography level='h4' mb={1}>
Edit History
</Typography>
@@ -106,7 +109,7 @@ function EditHistoryModal({ config, historyRecord }) {
cancelText: 'Cancel',
}}
/>
</FadeModal>
</ResponsiveModal>
)
}
export default EditHistoryModal

View File

@@ -0,0 +1,358 @@
import {
Box,
Button,
Checkbox,
CircularProgress,
FormControl,
FormLabel,
Input,
Tab,
TabList,
TabPanel,
Tabs,
Typography,
} from '@mui/joy'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { CreateBackup, RestoreBackup } from '../../../utils/Fetcher'
function BackupRestoreModal({ isOpen, onClose, showNotification }) {
const { ResponsiveModal } = useResponsiveModal()
const [activeTab, setActiveTab] = useState(0)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
// Backup state
const [encryptionKey, setEncryptionKey] = useState('')
const [backupName, setBackupName] = useState('')
const [includeAssets, setIncludeAssets] = useState(true)
// Restore state
const [restoreEncryptionKey, setRestoreEncryptionKey] = useState('')
const [backupFile, setBackupFile] = useState(null)
const fileInputRef = useRef(null)
const resetModal = useCallback(() => {
setActiveTab(0)
setEncryptionKey('')
setBackupName('')
setIncludeAssets(true)
setRestoreEncryptionKey('')
setBackupFile(null)
setError('')
setLoading(false)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}, [])
const handleClose = useCallback(() => {
resetModal()
onClose()
}, [onClose, resetModal])
const downloadFile = (data, filename) => {
const blob = new Blob([data], { type: 'application/octet-stream' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
}
const handleCreateBackup = async () => {
if (!encryptionKey.trim()) {
setError('Encryption key is required')
return
}
setLoading(true)
setError('')
try {
const response = await CreateBackup(
encryptionKey,
includeAssets,
backupName,
)
if (response.ok) {
const data = await response.json()
const timestamp = new Date()
.toISOString()
.slice(0, 19)
.replace(/:/g, '-')
const filename = backupName
? `${backupName}-${timestamp}.backup`
: `donetick-backup-${timestamp}.backup`
// Download the backup file
downloadFile(data.backup_data, filename)
showNotification({
type: 'success',
message: 'Backup created and downloaded successfully',
})
handleClose()
} else {
const errorData = await response.json()
setError(errorData.message || 'Failed to create backup')
}
} catch (err) {
setError('Failed to create backup')
} finally {
setLoading(false)
}
}
const handleFileUpload = event => {
const file = event.target.files[0]
if (file) {
setBackupFile(file)
setError('')
}
}
const handleRestore = async () => {
if (!restoreEncryptionKey.trim()) {
setError('Encryption key is required')
return
}
if (!backupFile) {
setError('Please select a backup file')
return
}
setLoading(true)
setError('')
try {
const reader = new FileReader()
reader.onload = async e => {
try {
const backupData = e.target.result
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.',
})
// Refresh the page after a short delay to allow user to see the message
setTimeout(() => {
window.location.reload()
}, 2000)
handleClose()
} else {
const errorData = await response.json()
setError(errorData.message || 'Failed to restore backup')
}
} catch (err) {
setError('Failed to restore backup')
} finally {
setLoading(false)
}
}
reader.onerror = () => {
setError('Failed to read backup file')
setLoading(false)
}
reader.readAsText(backupFile)
} catch (err) {
setError('Failed to restore backup')
setLoading(false)
}
}
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = event => {
if (!isOpen) return
if (event.key === 'Escape') {
event.preventDefault()
handleClose()
return
}
}
if (isOpen) {
document.addEventListener('keydown', handleKeyDown)
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [isOpen, handleClose])
const renderBackupTab = () => (
<Box>
<Typography level='body-md' mb={3}>
Create an encrypted backup of your data. This backup will include all
your chores, history, settings, and optionally your uploaded files.
</Typography>
<FormControl sx={{ mb: 2 }}>
<FormLabel>Encryption Key *</FormLabel>
<Input
type='password'
value={encryptionKey}
onChange={e => setEncryptionKey(e.target.value)}
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
</Typography>
</FormControl>
<FormControl sx={{ mb: 2 }}>
<FormLabel>Backup Name (Optional)</FormLabel>
<Input
value={backupName}
onChange={e => setBackupName(e.target.value)}
placeholder='e.g., weekly-backup'
/>
</FormControl>
<FormControl sx={{ mb: 3 }}>
<Checkbox
checked={includeAssets}
onChange={e => setIncludeAssets(e.target.checked)}
label='Include uploaded files and assets'
/>
</FormControl>
{error && (
<Typography level='body-sm' color='danger' mb={2}>
{error}
</Typography>
)}
<Box display='flex' justifyContent='space-between' gap={2}>
<Button variant='outlined' onClick={handleClose} fullWidth>
Cancel
</Button>
<Button
color='primary'
onClick={handleCreateBackup}
loading={loading}
disabled={!encryptionKey.trim()}
fullWidth
>
Create Backup
</Button>
</Box>
</Box>
)
const renderRestoreTab = () => (
<Box>
<Typography level='body-md' mb={3} color='warning'>
<strong>Warning:</strong> Restoring a backup will replace all your
current data. This action cannot be undone.
</Typography>
<FormControl sx={{ mb: 2 }}>
<FormLabel>Backup File *</FormLabel>
<Input
type='file'
accept='.backup'
onChange={handleFileUpload}
ref={fileInputRef}
/>
{backupFile && (
<Typography level='body-xs' sx={{ mt: 0.5 }}>
Selected: {backupFile.name}
</Typography>
)}
</FormControl>
<FormControl sx={{ mb: 3 }}>
<FormLabel>Encryption Key *</FormLabel>
<Input
type='password'
value={restoreEncryptionKey}
onChange={e => setRestoreEncryptionKey(e.target.value)}
placeholder='Enter the encryption key used for this backup'
/>
</FormControl>
{error && (
<Typography level='body-sm' color='danger' mb={2}>
{error}
</Typography>
)}
<Box display='flex' justifyContent='space-between' gap={2}>
<Button variant='outlined' onClick={handleClose} fullWidth>
Cancel
</Button>
<Button
color='warning'
onClick={handleRestore}
loading={loading}
disabled={!restoreEncryptionKey.trim() || !backupFile}
fullWidth
>
Restore Backup
</Button>
</Box>
</Box>
)
return (
<ResponsiveModal
open={isOpen}
onClose={handleClose}
size='md'
unmountDelay={250}
>
{loading ? (
<Box
display='flex'
justifyContent='center'
alignItems='center'
minHeight={200}
>
<CircularProgress />
<Typography level='body-md' sx={{ ml: 2 }}>
{activeTab === 0 ? 'Creating backup...' : 'Restoring backup...'}
</Typography>
</Box>
) : (
<>
<Typography level='h4' mb={3}>
🔄 Backup & Restore
</Typography>
<Tabs
value={activeTab}
onChange={(event, newValue) => setActiveTab(newValue)}
>
<TabList>
<Tab>Create Backup</Tab>
<Tab>Restore Backup</Tab>
</TabList>
<TabPanel value={0}>{renderBackupTab()}</TabPanel>
<TabPanel value={1}>{renderRestoreTab()}</TabPanel>
</Tabs>
</>
)}
</ResponsiveModal>
)
}
export default BackupRestoreModal

View File

@@ -1,9 +1,10 @@
import { Box, Button, Typography } from '@mui/joy'
import { useCallback, useEffect, useState } from 'react'
import FadeModal from '../../../components/common/FadeModal'
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
function ConfirmationModal({ config }) {
const { ResponsiveModal } = useResponsiveModal()
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
const handleAction = useCallback(
@@ -70,7 +71,7 @@ function ConfirmationModal({ config }) {
}, [config?.isOpen, handleAction])
return (
<FadeModal
<ResponsiveModal
open={config?.isOpen}
onClose={config?.onClose}
size='sm'
@@ -79,7 +80,6 @@ function ConfirmationModal({ config }) {
<Typography level='h4' mb={1}>
{config?.title}
</Typography>
<Typography level='body-md' gutterBottom>
{config?.message}
</Typography>
@@ -90,7 +90,7 @@ function ConfirmationModal({ config }) {
handleAction(true)
}}
fullWidth
color={config.color ? config.color : 'primary'}
color={config?.color || 'primary'}
endDecorator={
<KeyboardShortcutHint shortcut='Y' show={showKeyboardShortcuts} />
}
@@ -110,7 +110,7 @@ function ConfirmationModal({ config }) {
{config?.cancelText}
</Button>
</Box>
</FadeModal>
</ResponsiveModal>
)
}
export default ConfirmationModal

View File

@@ -10,9 +10,10 @@ import {
Typography,
} from '@mui/joy'
import { useEffect, useState } from 'react'
import FadeModal from '../../../components/common/FadeModal'
function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
const { ResponsiveModal } = useResponsiveModal()
const [name, setName] = useState(currentThing?.name || '')
const [type, setType] = useState(currentThing?.type || 'number')
const [state, setState] = useState(currentThing?.state || '')
@@ -58,7 +59,7 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
}
return (
<FadeModal open={isOpen} onClose={onClose}>
<ResponsiveModal open={isOpen} onClose={onClose}>
<Typography level='h4'>
{currentThing?.id ? 'Edit' : 'Create'} Thing
</Typography>
@@ -131,7 +132,7 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
{currentThing?.id ? 'Cancel' : 'Close'}
</Button>
</Box>
</FadeModal>
</ResponsiveModal>
)
}
export default CreateThingModal

View File

@@ -1,8 +1,10 @@
import { Box, Button, Input, Typography } from '@mui/joy'
import { useState } from 'react'
import FadeModal from '../../../components/common/FadeModal'
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] : null,
)
@@ -13,7 +15,7 @@ function DateModal({ isOpen, onClose, onSave, current, title }) {
}
return (
<FadeModal open={isOpen} onClose={onClose}>
<ResponsiveModal open={isOpen} onClose={onClose}>
<Typography variant='h4'>{title}</Typography>
<Input
sx={{ mt: 3 }}
@@ -29,7 +31,7 @@ function DateModal({ isOpen, onClose, onSave, current, title }) {
Cancel
</Button>
</Box>
</FadeModal>
</ResponsiveModal>
)
}
export default DateModal

View File

@@ -8,15 +8,17 @@ import {
Typography,
} from '@mui/joy'
import { useEffect, useState } from 'react'
import FadeModal from '../../../components/common/FadeModal'
import { useQueryClient } from '@tanstack/react-query'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal.js'
import { useNotification } from '../../../service/NotificationProvider.jsx'
import LABEL_COLORS from '../../../utils/Colors.jsx'
import { CreateLabel, UpdateLabel } from '../../../utils/Fetcher'
import { useLabels } from '../../Labels/LabelQueries'
function LabelModal({ isOpen, onClose, label }) {
const { ResponsiveModal } = useResponsiveModal()
const [labelName, setLabelName] = useState('')
const [color, setColor] = useState('')
const [error, setError] = useState('')
@@ -89,7 +91,7 @@ function LabelModal({ isOpen, onClose, label }) {
}
return (
<FadeModal open={isOpen} onClose={onClose}>
<ResponsiveModal open={isOpen} onClose={onClose}>
<Typography level='title-md' mb={1}>
{label ? 'Edit Label' : 'Add Label'}
</Typography>
@@ -159,7 +161,7 @@ function LabelModal({ isOpen, onClose, label }) {
Cancel
</Button>
</Box>
</FadeModal>
</ResponsiveModal>
)
}

View File

@@ -7,9 +7,11 @@ import {
Typography,
} from '@mui/joy'
import React, { useEffect } from 'react'
import FadeModal from '../../../components/common/FadeModal'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
function PassowrdChangeModal({ isOpen, onClose }) {
const { ResponsiveModal } = useResponsiveModal()
const [password, setPassword] = React.useState('')
const [confirmPassword, setConfirmPassword] = React.useState('')
const [passwordError, setPasswordError] = React.useState(false)
@@ -39,7 +41,7 @@ function PassowrdChangeModal({ isOpen, onClose }) {
}
return (
<FadeModal open={isOpen} onClose={onClose}>
<ResponsiveModal open={isOpen} onClose={onClose}>
<Typography level='h4' mb={1}>
Change Password
</Typography>
@@ -108,7 +110,7 @@ function PassowrdChangeModal({ isOpen, onClose }) {
Cancel
</Button>
</Box>
</FadeModal>
</ResponsiveModal>
)
}
export default PassowrdChangeModal

View File

@@ -1,6 +1,6 @@
import { Box, Button, Option, Select, Typography } from '@mui/joy'
import React from 'react'
import FadeModal from '../../../components/common/FadeModal'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
function SelectModal({
isOpen,
@@ -11,6 +11,8 @@ function SelectModal({
displayKey,
placeholder,
}) {
const { ResponsiveModal } = useResponsiveModal()
const [selected, setSelected] = React.useState(null)
const handleSave = () => {
onSave(options.find(item => item.id === selected))
@@ -18,7 +20,7 @@ function SelectModal({
}
return (
<FadeModal open={isOpen} onClose={onClose}>
<ResponsiveModal open={isOpen} onClose={onClose}>
<Typography variant='h4'>{title}</Typography>
<Select placeholder={placeholder}>
{options.map((item, index) => (
@@ -42,7 +44,7 @@ function SelectModal({
Cancel
</Button>
</Box>
</FadeModal>
</ResponsiveModal>
)
}
export default SelectModal

View File

@@ -1,6 +1,6 @@
import { Box, Button, Textarea, Typography } from '@mui/joy'
import { useState } from 'react'
import FadeModal from '../../../components/common/FadeModal'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
function TextModal({
isOpen,
@@ -11,6 +11,8 @@ function TextModal({
okText,
cancelText,
}) {
const { ResponsiveModal } = useResponsiveModal()
const [text, setText] = useState(current)
const handleSave = () => {
@@ -19,7 +21,7 @@ function TextModal({
}
return (
<FadeModal open={isOpen} onClose={onClose}>
<ResponsiveModal open={isOpen} onClose={onClose}>
<Typography variant='h4'>{title}</Typography>
<Textarea
placeholder='Type in here…'
@@ -38,7 +40,7 @@ function TextModal({
{cancelText ? cancelText : 'Cancel'}
</Button>
</Box>
</FadeModal>
</ResponsiveModal>
)
}
export default TextModal

View File

@@ -12,7 +12,7 @@ import {
} from '@mui/joy'
import moment from 'moment'
import { useEffect, useState } from 'react'
import FadeModal from '../../../components/common/FadeModal'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { useNotification } from '../../../service/NotificationProvider'
import {
DeleteTimeSession,
@@ -22,6 +22,8 @@ import {
import ConfirmationModal from './ConfirmationModal'
const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
const { ResponsiveModal } = useResponsiveModal()
const [timerData, setTimerData] = useState(null)
const [loading, setLoading] = useState(false)
const [editingSessions, setEditingSessions] = useState({})
@@ -310,7 +312,12 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
return (
<>
<FadeModal open={isOpen} onClose={onClose} size='lg' fullWidth={true}>
<ResponsiveModal
open={isOpen}
onClose={onClose}
size='lg'
fullWidth={true}
>
<Typography level='h4'>Timer Details</Typography>
{loading && (
@@ -976,7 +983,7 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
)}
</Box>
</Box>
</FadeModal>
</ResponsiveModal>
<ConfirmationModal config={confirmDeleteConfig} />
</>

View File

@@ -13,10 +13,11 @@ import {
import { data } from 'autoprefixer'
import { useCallback, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import FadeModal from '../../../components/common/FadeModal'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { CheckUserDeletion, DeleteUser } from '../../../utils/Fetcher'
function UserDeletionModal({ isOpen, onClose, userProfile }) {
const { ResponsiveModal } = useResponsiveModal()
const Navigate = useNavigate()
const [step, setStep] = useState(1) // 1: Warning, 2: Transfer, 3: Confirm
const [password, setPassword] = useState('')
@@ -340,11 +341,11 @@ function UserDeletionModal({ isOpen, onClose, userProfile }) {
}
return (
<FadeModal
<ResponsiveModal
open={isOpen}
onClose={() => handleClose(false)}
size='md'
unmountDelay={250}
title='Delete Account'
>
{loading && step === 1 ? (
<Box
@@ -358,7 +359,7 @@ function UserDeletionModal({ isOpen, onClose, userProfile }) {
) : (
renderStep()
)}
</FadeModal>
</ResponsiveModal>
)
}

View File

@@ -1,9 +1,11 @@
import { Avatar, Box, Button, List, ListItem, Typography } from '@mui/joy'
import FadeModal from '../../../components/common/FadeModal'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
const UserModal = ({ isOpen, performers = [], onSelect, onClose }) => {
const { ResponsiveModal } = useResponsiveModal()
return (
<FadeModal open={isOpen} onClose={onClose} size='md' fullWidth>
<ResponsiveModal open={isOpen} onClose={onClose} size='md' fullWidth>
<Typography level='h4' sx={{ mb: 2 }}>
Select User
</Typography>
@@ -38,7 +40,7 @@ const UserModal = ({ isOpen, performers = [], onSelect, onClose }) => {
Cancel
</Button>
</Box>
</FadeModal>
</ResponsiveModal>
)
}

View File

@@ -1,9 +1,11 @@
import { CopyAll } from '@mui/icons-material'
import { Box, Button, Checkbox, Input, ListItem, Typography } from '@mui/joy'
import { useState } from 'react'
import FadeModal from '../../../components/common/FadeModal'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
function WriteNFCModal({ config }) {
const { ResponsiveModal } = useResponsiveModal()
const [nfcStatus, setNfcStatus] = useState('idle') // 'idle', 'writing', 'success', 'error'
const [errorMessage, setErrorMessage] = useState('')
const [isAutoCompleteWhenScan, setIsAutoCompleteWhenScan] = useState(false)
@@ -52,7 +54,7 @@ function WriteNFCModal({ config }) {
return url
}
return (
<FadeModal open={config?.isOpen} onClose={handleClose}>
<ResponsiveModal open={config?.isOpen} onClose={handleClose}>
<Typography level='h4' mb={1}>
{nfcStatus === 'success' ? 'Success!' : 'Write to NFC'}
</Typography>
@@ -106,7 +108,7 @@ function WriteNFCModal({ config }) {
</Box>
</>
)}
</FadeModal>
</ResponsiveModal>
)
}

View File

@@ -14,10 +14,12 @@ import {
Typography,
} from '@mui/joy'
import { useEffect, useState } from 'react'
import FadeModal from '../../components/common/FadeModal'
import { resolvePhotoURL } from '../../utils/Helpers.jsx'
function RedeemPointsModal({ config }) {
const { ResponsiveModal } = useResponsiveModal()
const [points, setPoints] = useState(0)
const predefinedPoints = [1, 5, 10, 25, 50]
@@ -50,7 +52,7 @@ function RedeemPointsModal({ config }) {
const canRedeem = points > 0 && points <= config.available
return (
<FadeModal open={config?.isOpen} onClose={config?.onClose} size='md'>
<ResponsiveModal open={config?.isOpen} onClose={config?.onClose} size='md'>
{/* Header Section */}
<Stack spacing={2}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
@@ -242,7 +244,7 @@ function RedeemPointsModal({ config }) {
</Button>
</Stack>
</Stack>
</FadeModal>
</ResponsiveModal>
)
}