From 4dcd4483e9dcb660cc99a15daae06e19227f3398 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Thu, 21 Aug 2025 01:39:03 -0400 Subject: [PATCH] Refactor modals to use ResponsiveModal for improved responsiveness and consistency --- src/components/common/BottomSheetModal.jsx | 10 +- src/hooks/useConfirmationModal.js | 41 ++ src/hooks/useResponsiveModal.js | 18 + src/views/Modals/EditHistoryModal.jsx | 9 +- .../Modals/Inputs/BackupRestoreModal.jsx | 358 ++++++++++++++++++ src/views/Modals/Inputs/ConfirmationModal.jsx | 10 +- src/views/Modals/Inputs/CreateThingModal.jsx | 7 +- src/views/Modals/Inputs/DateModal.jsx | 8 +- src/views/Modals/Inputs/LabelModal.jsx | 8 +- .../Modals/Inputs/PasswordChangeModal.jsx | 8 +- src/views/Modals/Inputs/SelectModal.jsx | 8 +- src/views/Modals/Inputs/TextModal.jsx | 8 +- src/views/Modals/Inputs/TimerEditModal.jsx | 13 +- src/views/Modals/Inputs/UserDeletionModal.jsx | 9 +- src/views/Modals/Inputs/UserModal.jsx | 8 +- src/views/Modals/Inputs/WriteNFCModal.jsx | 8 +- src/views/Modals/RedeemPointsModal.jsx | 8 +- src/views/components/AddTaskModal.jsx | 64 ++-- 18 files changed, 529 insertions(+), 74 deletions(-) create mode 100644 src/hooks/useConfirmationModal.js create mode 100644 src/hooks/useResponsiveModal.js create mode 100644 src/views/Modals/Inputs/BackupRestoreModal.jsx diff --git a/src/components/common/BottomSheetModal.jsx b/src/components/common/BottomSheetModal.jsx index 2bd3884..e67ab4a 100644 --- a/src/components/common/BottomSheetModal.jsx +++ b/src/components/common/BottomSheetModal.jsx @@ -63,7 +63,10 @@ const BottomSheetModal = forwardRef( if (internalOpen) { document.addEventListener('keydown', handleEscape) // Prevent body scroll when modal is open - document.body.style.overflow = 'hidden' + // document.body.style.overflow = 'hidden' + } else { + // Restore scroll immediately when modal starts closing + // document.body.style.overflow = 'unset' } return () => { @@ -95,6 +98,7 @@ const BottomSheetModal = forwardRef( ref={ref} sx={{ zIndex: Z_INDEX.MODAL_CONTENT, + minHeight: '30%', width: '100%', height: currentHeight, maxHeight: isExpanded ? expandedHeight : maxHeight, @@ -185,7 +189,9 @@ const BottomSheetModal = forwardRef( style={{ display: 'flex', alignItems: 'center', - padding: showHandle ? '0 20px 16px 20px' : '16px 20px 16px 20px', + padding: showHandle + ? '0 20px 16px 20px' + : '16px 20px 16px 20px', paddingRight: showCloseButton ? '60px' : '20px', // Add space for close button minHeight: 24, }} diff --git a/src/hooks/useConfirmationModal.js b/src/hooks/useConfirmationModal.js new file mode 100644 index 0000000..674ef47 --- /dev/null +++ b/src/hooks/useConfirmationModal.js @@ -0,0 +1,41 @@ +import { useState } from 'react' + +const useConfirmationModal = () => { + const [confirmModalConfig, setConfirmModalConfig] = useState({}) + + const showConfirmation = ( + message, + title, + onConfirm, + confirmText = 'Confirm', + cancelText = 'Cancel', + color = 'primary', + ) => { + setConfirmModalConfig({ + isOpen: true, + message, + title, + confirmText, + cancelText, + color, + onClose: isConfirmed => { + if (isConfirmed) { + onConfirm() + } + setConfirmModalConfig({}) + }, + }) + } + + const hideConfirmation = () => { + setConfirmModalConfig({}) + } + + return { + confirmModalConfig, + showConfirmation, + hideConfirmation, + } +} + +export default useConfirmationModal \ No newline at end of file diff --git a/src/hooks/useResponsiveModal.js b/src/hooks/useResponsiveModal.js new file mode 100644 index 0000000..297f5fa --- /dev/null +++ b/src/hooks/useResponsiveModal.js @@ -0,0 +1,18 @@ +import BottomSheetModal from '../components/common/BottomSheetModal' +import FadeModal from '../components/common/FadeModal' +import useWindowWidth from './useWindowWidth' + +/** + * Hook that returns the appropriate modal component based on screen size + * @param {number} breakpoint - Screen width breakpoint to switch between modals (default: 768px) + * @returns {Object} - { Modal: Component, isMobile: boolean } + */ +export const useResponsiveModal = (breakpoint = 768) => { + const windowWidth = useWindowWidth() + const isMobile = windowWidth <= breakpoint + + return { + ResponsiveModal: isMobile ? BottomSheetModal : FadeModal, + isMobile, + } +} diff --git a/src/views/Modals/EditHistoryModal.jsx b/src/views/Modals/EditHistoryModal.jsx index 0795c99..c53d160 100644 --- a/src/views/Modals/EditHistoryModal.jsx +++ b/src/views/Modals/EditHistoryModal.jsx @@ -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 ( - + Edit History @@ -106,7 +109,7 @@ function EditHistoryModal({ config, historyRecord }) { cancelText: 'Cancel', }} /> - + ) } export default EditHistoryModal diff --git a/src/views/Modals/Inputs/BackupRestoreModal.jsx b/src/views/Modals/Inputs/BackupRestoreModal.jsx new file mode 100644 index 0000000..aa72b9f --- /dev/null +++ b/src/views/Modals/Inputs/BackupRestoreModal.jsx @@ -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 = () => ( + + + Create an encrypted backup of your data. This backup will include all + your chores, history, settings, and optionally your uploaded files. + + + + Encryption Key * + setEncryptionKey(e.target.value)} + placeholder='Enter a strong encryption key' + /> + + Keep this key safe - you'll need it to restore your backup + + + + + Backup Name (Optional) + setBackupName(e.target.value)} + placeholder='e.g., weekly-backup' + /> + + + + setIncludeAssets(e.target.checked)} + label='Include uploaded files and assets' + /> + + + {error && ( + + {error} + + )} + + + + + + + ) + + const renderRestoreTab = () => ( + + + Warning: Restoring a backup will replace all your + current data. This action cannot be undone. + + + + Backup File * + + {backupFile && ( + + Selected: {backupFile.name} + + )} + + + + Encryption Key * + setRestoreEncryptionKey(e.target.value)} + placeholder='Enter the encryption key used for this backup' + /> + + + {error && ( + + {error} + + )} + + + + + + + ) + + return ( + + {loading ? ( + + + + {activeTab === 0 ? 'Creating backup...' : 'Restoring backup...'} + + + ) : ( + <> + + 🔄 Backup & Restore + + + setActiveTab(newValue)} + > + + Create Backup + Restore Backup + + + {renderBackupTab()} + + {renderRestoreTab()} + + + )} + + ) +} + +export default BackupRestoreModal diff --git a/src/views/Modals/Inputs/ConfirmationModal.jsx b/src/views/Modals/Inputs/ConfirmationModal.jsx index 2e0b318..e5a8483 100644 --- a/src/views/Modals/Inputs/ConfirmationModal.jsx +++ b/src/views/Modals/Inputs/ConfirmationModal.jsx @@ -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 ( - {config?.title} - {config?.message} @@ -90,7 +90,7 @@ function ConfirmationModal({ config }) { handleAction(true) }} fullWidth - color={config.color ? config.color : 'primary'} + color={config?.color || 'primary'} endDecorator={ } @@ -110,7 +110,7 @@ function ConfirmationModal({ config }) { {config?.cancelText} - + ) } export default ConfirmationModal diff --git a/src/views/Modals/Inputs/CreateThingModal.jsx b/src/views/Modals/Inputs/CreateThingModal.jsx index a4863f7..c2a7329 100644 --- a/src/views/Modals/Inputs/CreateThingModal.jsx +++ b/src/views/Modals/Inputs/CreateThingModal.jsx @@ -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 ( - + {currentThing?.id ? 'Edit' : 'Create'} Thing @@ -131,7 +132,7 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) { {currentThing?.id ? 'Cancel' : 'Close'} - + ) } export default CreateThingModal diff --git a/src/views/Modals/Inputs/DateModal.jsx b/src/views/Modals/Inputs/DateModal.jsx index 27dbf6e..45db330 100644 --- a/src/views/Modals/Inputs/DateModal.jsx +++ b/src/views/Modals/Inputs/DateModal.jsx @@ -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 ( - + {title} - + ) } export default DateModal diff --git a/src/views/Modals/Inputs/LabelModal.jsx b/src/views/Modals/Inputs/LabelModal.jsx index df631dd..f6f675e 100644 --- a/src/views/Modals/Inputs/LabelModal.jsx +++ b/src/views/Modals/Inputs/LabelModal.jsx @@ -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 ( - + {label ? 'Edit Label' : 'Add Label'} @@ -159,7 +161,7 @@ function LabelModal({ isOpen, onClose, label }) { Cancel - + ) } diff --git a/src/views/Modals/Inputs/PasswordChangeModal.jsx b/src/views/Modals/Inputs/PasswordChangeModal.jsx index 793cbdd..106c073 100644 --- a/src/views/Modals/Inputs/PasswordChangeModal.jsx +++ b/src/views/Modals/Inputs/PasswordChangeModal.jsx @@ -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 ( - + Change Password @@ -108,7 +110,7 @@ function PassowrdChangeModal({ isOpen, onClose }) { Cancel - + ) } export default PassowrdChangeModal diff --git a/src/views/Modals/Inputs/SelectModal.jsx b/src/views/Modals/Inputs/SelectModal.jsx index 7f5936d..d595b9a 100644 --- a/src/views/Modals/Inputs/SelectModal.jsx +++ b/src/views/Modals/Inputs/SelectModal.jsx @@ -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 ( - + {title}