@@ -1,16 +1,8 @@
|
||||
import { Check, Star } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Chip,
|
||||
Divider,
|
||||
Modal,
|
||||
ModalDialog,
|
||||
Radio,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { Box, Card, Chip, Divider, Radio, Typography } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import AppModal from './common/AppModal'
|
||||
import ModalActions from './common/ModalActions'
|
||||
import { useNotification } from '../service/NotificationProvider'
|
||||
import { GetSubscriptionSession } from '../utils/Fetcher'
|
||||
|
||||
@@ -76,25 +68,26 @@ const SubscriptionModal = ({ open, onClose }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<ModalDialog
|
||||
layout='center'
|
||||
sx={{
|
||||
width: 600,
|
||||
maxWidth: '95vw',
|
||||
maxHeight: '95vh',
|
||||
overflow: 'auto',
|
||||
p: 0,
|
||||
<AppModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title='Upgrade to Plus'
|
||||
description='Unlock reminders, rich task details, and advanced automation.'
|
||||
size='lg'
|
||||
closeOnBackdrop={!isLoading}
|
||||
closeOnEscape={!isLoading}
|
||||
footer={
|
||||
<ModalActions
|
||||
stackOnMobile
|
||||
secondary={{ label: 'Cancel', onClick: onClose, disabled: isLoading }}
|
||||
primary={{
|
||||
label: 'Subscribe',
|
||||
onClick: handleSubscribe,
|
||||
loading: isLoading,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Box sx={{ p: 4 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ textAlign: 'center', mb: 4 }}>
|
||||
<Typography level='h3' sx={{ mb: 1 }}>
|
||||
Upgrade to Plus
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Features List */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography level='title-lg' sx={{ mb: 2 }}>
|
||||
@@ -115,11 +108,11 @@ const SubscriptionModal = ({ open, onClose }) => {
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
{/* Plan Selection */}
|
||||
<Box
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 1.2, mb: 4 }}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.2, mb: 4 }}>
|
||||
{Object.entries(plans).map(([key, plan]) => (
|
||||
<Card
|
||||
component='label'
|
||||
htmlFor={`subscription-plan-${key}`}
|
||||
key={key}
|
||||
color={selectedPlan === key ? 'primary' : 'neutral'}
|
||||
onClick={() => setSelectedPlan(key)}
|
||||
@@ -149,6 +142,7 @@ const SubscriptionModal = ({ open, onClose }) => {
|
||||
}}
|
||||
>
|
||||
<Radio
|
||||
id={`subscription-plan-${key}`}
|
||||
checked={selectedPlan === key}
|
||||
onChange={() => setSelectedPlan(key)}
|
||||
value={key}
|
||||
@@ -217,31 +211,6 @@ const SubscriptionModal = ({ open, onClose }) => {
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Box
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 1.2, mt: 2 }}
|
||||
>
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
onClick={handleSubscribe}
|
||||
loading={isLoading}
|
||||
fullWidth
|
||||
size='lg'
|
||||
sx={{ mb: 1 }}
|
||||
>
|
||||
Subscribe
|
||||
</Button>
|
||||
<Button
|
||||
variant='plain'
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
fullWidth
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Footer */}
|
||||
<Typography
|
||||
level='body-xs'
|
||||
@@ -250,9 +219,7 @@ const SubscriptionModal = ({ open, onClose }) => {
|
||||
>
|
||||
Cancel anytime. No hidden fees. Secure payment powered by Stripe.
|
||||
</Typography>
|
||||
</Box>
|
||||
</ModalDialog>
|
||||
</Modal>
|
||||
</AppModal>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
230
src/components/common/AppModal.jsx
Normal file
230
src/components/common/AppModal.jsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { Close } from '@mui/icons-material'
|
||||
import { Box, Divider, IconButton, Modal, Sheet, Typography } from '@mui/joy'
|
||||
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||
import { forwardRef, useId } from 'react'
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
|
||||
const WIDTH_BY_SIZE = {
|
||||
sm: 400,
|
||||
md: 520,
|
||||
lg: 680,
|
||||
xl: 840,
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's modal primitive. It owns modal layout, spacing, accessibility,
|
||||
* responsive presentation, and motion so feature components only own content.
|
||||
*/
|
||||
const AppModal = forwardRef(
|
||||
(
|
||||
{
|
||||
open,
|
||||
onClose,
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
footer,
|
||||
size = 'md',
|
||||
fullWidth = true,
|
||||
isMobile: isMobileProp,
|
||||
mobilePresentation = 'sheet',
|
||||
role = 'dialog',
|
||||
showCloseButton = true,
|
||||
showHandle = false,
|
||||
closeOnBackdrop = true,
|
||||
closeOnEscape = true,
|
||||
backdropBlur = true,
|
||||
maxHeight = '90dvh',
|
||||
contentSx,
|
||||
footerSx,
|
||||
sx,
|
||||
unmountDelay = 180,
|
||||
...modalProps
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const generatedId = useId()
|
||||
const detectedMobile = useMediaQuery('(max-width:768px)')
|
||||
const isMobile = isMobileProp ?? detectedMobile
|
||||
const titleId = title ? `${generatedId}-title` : undefined
|
||||
const descriptionId = description ? `${generatedId}-description` : undefined
|
||||
const isSheet = isMobile && mobilePresentation === 'sheet'
|
||||
const isFullscreen = isMobile && mobilePresentation === 'fullscreen'
|
||||
|
||||
const handleClose = (event, reason) => {
|
||||
if (reason === 'backdropClick' && !closeOnBackdrop) return
|
||||
if (reason === 'escapeKeyDown' && !closeOnEscape) return
|
||||
onClose?.(event, reason)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={descriptionId}
|
||||
keepMounted
|
||||
sx={{
|
||||
zIndex: Z_INDEX.MODAL_BACKDROP,
|
||||
display: 'flex',
|
||||
alignItems: isSheet ? 'flex-end' : 'center',
|
||||
justifyContent: 'center',
|
||||
p: isMobile ? 0 : 2,
|
||||
'& .MuiModal-backdrop': {
|
||||
backgroundColor: 'rgba(8, 15, 24, 0.52)',
|
||||
backdropFilter: backdropBlur ? 'blur(4px)' : 'none',
|
||||
},
|
||||
}}
|
||||
{...modalProps}
|
||||
>
|
||||
<Sheet
|
||||
ref={ref}
|
||||
role={role}
|
||||
aria-modal='true'
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={descriptionId}
|
||||
variant='outlined'
|
||||
sx={{
|
||||
zIndex: Z_INDEX.MODAL_CONTENT,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: isFullscreen
|
||||
? '100%'
|
||||
: isSheet
|
||||
? '100%'
|
||||
: fullWidth
|
||||
? `min(calc(100vw - 32px), ${WIDTH_BY_SIZE[size] || WIDTH_BY_SIZE.md}px)`
|
||||
: 'auto',
|
||||
maxWidth: isMobile
|
||||
? 'none'
|
||||
: WIDTH_BY_SIZE[size] || WIDTH_BY_SIZE.md,
|
||||
height: isFullscreen ? '100dvh' : 'auto',
|
||||
maxHeight: isFullscreen ? '100dvh' : maxHeight,
|
||||
overflow: 'hidden',
|
||||
borderRadius: isFullscreen ? 0 : isSheet ? '16px 16px 0 0' : '16px',
|
||||
borderBottom: isSheet ? 0 : undefined,
|
||||
boxShadow: 'lg',
|
||||
outline: 0,
|
||||
pb: isMobile ? 'env(safe-area-inset-bottom)' : 0,
|
||||
animation: open
|
||||
? `${isSheet ? 'appSheetEnter' : 'appModalEnter'} ${Math.min(unmountDelay, 300)}ms cubic-bezier(0.2, 0.8, 0.2, 1)`
|
||||
: undefined,
|
||||
'@keyframes appModalEnter': {
|
||||
from: { opacity: 0, transform: 'translateY(8px) scale(0.99)' },
|
||||
to: { opacity: 1, transform: 'translateY(0) scale(1)' },
|
||||
},
|
||||
'@keyframes appSheetEnter': {
|
||||
from: { transform: 'translateY(24px)' },
|
||||
to: { transform: 'translateY(0)' },
|
||||
},
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
animation: 'none',
|
||||
},
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{isSheet && showHandle && (
|
||||
<Box
|
||||
aria-hidden='true'
|
||||
sx={{ display: 'flex', justifyContent: 'center', pt: 1.25 }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 4,
|
||||
borderRadius: 999,
|
||||
bgcolor: 'neutral.300',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{(title || description || showCloseButton) && (
|
||||
<Box
|
||||
component='header'
|
||||
sx={{
|
||||
position: 'relative',
|
||||
flexShrink: 0,
|
||||
px: { xs: 2, sm: 3 },
|
||||
pt: isSheet && showHandle ? 1.25 : { xs: 2, sm: 2.5 },
|
||||
pb: description ? 1.5 : 2,
|
||||
pr: showCloseButton ? { xs: 7, sm: 8 } : { xs: 2, sm: 3 },
|
||||
}}
|
||||
>
|
||||
{title && (
|
||||
<Typography
|
||||
id={titleId}
|
||||
level='title-lg'
|
||||
sx={{ fontWeight: 650 }}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
)}
|
||||
{description && (
|
||||
<Typography
|
||||
id={descriptionId}
|
||||
level='body-sm'
|
||||
sx={{ color: 'text.tertiary', mt: 0.5 }}
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
{showCloseButton && (
|
||||
<IconButton
|
||||
aria-label='Close dialog'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
onClick={handleClose}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: isSheet && showHandle ? 6 : 12,
|
||||
right: { xs: 10, sm: 16 },
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
overscrollBehavior: 'contain',
|
||||
px: { xs: 2, sm: 3 },
|
||||
pb: { xs: 2.5, sm: 3 },
|
||||
...contentSx,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
|
||||
{footer && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box
|
||||
component='footer'
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
px: { xs: 2, sm: 3 },
|
||||
py: 2,
|
||||
bgcolor: 'background.surface',
|
||||
...footerSx,
|
||||
}}
|
||||
>
|
||||
{footer}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Sheet>
|
||||
</Modal>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
AppModal.displayName = 'AppModal'
|
||||
|
||||
export default AppModal
|
||||
@@ -1,254 +0,0 @@
|
||||
import { Close } from '@mui/icons-material'
|
||||
import { Divider, IconButton, Modal, Sheet, Typography } from '@mui/joy'
|
||||
import { forwardRef, useEffect, useState } from 'react'
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
|
||||
const BottomSheetModal = forwardRef(
|
||||
(
|
||||
{
|
||||
open,
|
||||
onClose,
|
||||
children,
|
||||
title,
|
||||
footer,
|
||||
height = 'auto',
|
||||
maxHeight = '90vh',
|
||||
expandedHeight = '95vh',
|
||||
backdropBlur = true,
|
||||
showHandle = true,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [isClosing, setIsClosing] = useState(false)
|
||||
const [internalOpen, setInternalOpen] = useState(open)
|
||||
|
||||
// Handle opening
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setInternalOpen(true)
|
||||
setIsClosing(false)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Handle closing with animation
|
||||
useEffect(() => {
|
||||
if (!open && internalOpen) {
|
||||
setIsClosing(true)
|
||||
// Wait for animation to complete before hiding modal
|
||||
const timer = setTimeout(() => {
|
||||
setInternalOpen(false)
|
||||
setIsClosing(false)
|
||||
setIsExpanded(false)
|
||||
}, 250) // Match transition duration
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [open, internalOpen])
|
||||
|
||||
// Handle toggle expansion
|
||||
const handleToggleExpansion = () => {
|
||||
setIsExpanded(prev => !prev)
|
||||
}
|
||||
|
||||
// Close on escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = event => {
|
||||
if (event.key === 'Escape' && internalOpen) {
|
||||
onClose?.()
|
||||
}
|
||||
}
|
||||
|
||||
if (internalOpen) {
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
// Prevent body scroll when modal is open
|
||||
// document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
// Restore scroll immediately when modal starts closing
|
||||
// document.body.style.overflow = 'unset'
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
document.body.style.overflow = 'unset'
|
||||
}
|
||||
}, [internalOpen, onClose])
|
||||
|
||||
// Calculate current height
|
||||
const currentHeight = isExpanded ? expandedHeight : height
|
||||
|
||||
// Filter out DOM props that shouldn't be passed to Modal
|
||||
const {
|
||||
fullWidth: _fullWidth,
|
||||
unmountDelay: _unmountDelay,
|
||||
...modalProps
|
||||
} = props
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={internalOpen}
|
||||
onClose={onClose}
|
||||
sx={{
|
||||
'& .MuiModal-backdrop': {
|
||||
backdropFilter: backdropBlur ? 'blur(3px)' : 'none',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||
},
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
keepMounted
|
||||
{...modalProps}
|
||||
>
|
||||
<Sheet
|
||||
ref={ref}
|
||||
sx={{
|
||||
zIndex: Z_INDEX.MODAL_CONTENT,
|
||||
minHeight: '20%',
|
||||
width: '100%',
|
||||
height: currentHeight,
|
||||
maxHeight: isExpanded ? expandedHeight : maxHeight,
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
p: 0,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition:
|
||||
'height 0.3s cubic-bezier(0.32, 0.72, 0, 1), max-height 0.3s cubic-bezier(0.32, 0.72, 0, 1), transform 0.3s cubic-bezier(0.32, 0.72, 0, 1)',
|
||||
transform:
|
||||
open && !isClosing ? 'translateY(0)' : 'translateY(100%)',
|
||||
// Handle safe area on mobile devices
|
||||
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||
}}
|
||||
>
|
||||
{/* Header Section with drag handle, title, and close button */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: 'inherit',
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Close button positioned absolutely in top-right */}
|
||||
{showCloseButton && (
|
||||
<IconButton
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
onClick={onClose}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 16,
|
||||
zIndex: 1,
|
||||
borderRadius: '50%',
|
||||
width: 32,
|
||||
height: 32,
|
||||
backgroundColor: 'neutral.softBg',
|
||||
color: 'neutral.softColor',
|
||||
'&:hover': {
|
||||
backgroundColor: 'neutral.softHoverBg',
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
transition: 'all 0.2s ease',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Close fontSize='small' />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{/* Drag Handle */}
|
||||
{showHandle && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
padding: '12px 0 8px 0',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
onClick={handleToggleExpansion}
|
||||
title={isExpanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 30,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: 'var(--joy-palette-neutral-300)',
|
||||
transition: 'background-color 0.2s ease',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title Row */}
|
||||
{title && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: showHandle
|
||||
? '0 20px 16px 20px'
|
||||
: '16px 20px 16px 20px',
|
||||
paddingRight: showCloseButton ? '60px' : '20px', // Add space for close button
|
||||
minHeight: 24,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
level='title-lg'
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Content area */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
padding: '0 20px 20px 20px',
|
||||
minHeight: 0, // Important for flex child with overflow
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{footer && (
|
||||
<>
|
||||
<Divider />
|
||||
<footer
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
// borderTop: '1px solid var(--joy-palette-divider)',
|
||||
padding: '16px 20px',
|
||||
}}
|
||||
>
|
||||
{footer}
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
</Sheet>
|
||||
</Modal>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
BottomSheetModal.displayName = 'BottomSheetModal'
|
||||
|
||||
export default BottomSheetModal
|
||||
@@ -1,95 +0,0 @@
|
||||
import { Modal, ModalClose, ModalDialog, ModalOverflow, Typography } from '@mui/joy'
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
|
||||
/**
|
||||
* FadeModal component with consistent fade-in/out animations
|
||||
* Can be used as a drop-in replacement for Joy UI's Modal component
|
||||
*/
|
||||
const FadeModal = ({
|
||||
open,
|
||||
onClose,
|
||||
children,
|
||||
size = 'md',
|
||||
fullWidth = true,
|
||||
backdropBlur = true,
|
||||
title,
|
||||
footer,
|
||||
...props
|
||||
}) => {
|
||||
// Filter out props that shouldn't be passed to Modal
|
||||
const { unmountDelay: _unmountDelay, ...modalProps } = props
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
sx={{
|
||||
'& .MuiModal-backdrop': {
|
||||
backdropFilter: backdropBlur ? 'blur(3px)' : 'none',
|
||||
},
|
||||
}}
|
||||
keepMounted
|
||||
// These transition properties create a smooth fade + slide effect
|
||||
transition={{
|
||||
mount: { opacity: 1, transform: 'translateY(0px)' },
|
||||
unmount: { opacity: 0, transform: 'translateY(20px)' },
|
||||
duration: 250, // Animation duration in ms
|
||||
easing: {
|
||||
enter: 'cubic-bezier(0.34, 1.56, 0.64, 1)', // Slight overshoot for natural feel
|
||||
exit: 'cubic-bezier(0.4, 0, 0.2, 1)', // Standard ease out
|
||||
},
|
||||
}}
|
||||
{...modalProps}
|
||||
>
|
||||
<ModalOverflow>
|
||||
<ModalDialog
|
||||
size={size}
|
||||
sx={{
|
||||
zIndex: Z_INDEX.MODAL_CONTENT,
|
||||
minWidth: fullWidth ? '90%' : 'auto',
|
||||
animation: open
|
||||
? 'modalFadeIn 0.35s forwards'
|
||||
: 'modalFadeOut 0.25s forwards',
|
||||
'@keyframes modalFadeIn': {
|
||||
from: { opacity: 0, transform: 'translateY(8px)' },
|
||||
to: { opacity: 1, transform: 'translateY(0)' },
|
||||
},
|
||||
'@keyframes modalFadeOut': {
|
||||
from: { opacity: 1, transform: 'translateY(0)' },
|
||||
to: { opacity: 0, transform: 'translateY(8px)' },
|
||||
},
|
||||
// Add staggered animation for child elements
|
||||
'& > *': {
|
||||
opacity: 0,
|
||||
animation: open
|
||||
? 'contentFadeIn 0.35s forwards'
|
||||
: 'contentFadeOut 0.2s forwards',
|
||||
},
|
||||
// Stagger child animations
|
||||
'& > *:nth-of-type(1)': { animationDelay: '0.05s' },
|
||||
'& > *:nth-of-type(2)': { animationDelay: '0.1s' },
|
||||
'& > *:nth-of-type(3)': { animationDelay: '0.15s' },
|
||||
'& > *:nth-of-type(4)': { animationDelay: '0.2s' },
|
||||
'& > *:nth-of-type(5)': { animationDelay: '0.25s' },
|
||||
'@keyframes contentFadeIn': {
|
||||
to: { opacity: 1 },
|
||||
},
|
||||
'@keyframes contentFadeOut': {
|
||||
to: { opacity: 0 },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ModalClose />
|
||||
{title && (
|
||||
<Typography level='title-lg' sx={{ fontWeight: 600, mb: 2 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
)}
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>{children}</div>
|
||||
{footer && <div style={{ marginTop: 16 }}>{footer}</div>}
|
||||
</ModalDialog>
|
||||
</ModalOverflow>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default FadeModal
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import BottomSheetModal from './BottomSheetModal'
|
||||
import AppModal from './AppModal'
|
||||
import ModalActions from './ModalActions'
|
||||
import ActiveFilterChips from './filter/ActiveFilterChips'
|
||||
|
||||
/**
|
||||
@@ -41,7 +42,10 @@ const DATE_RANGE_PRESETS = [
|
||||
label: 'Today',
|
||||
getRange: () => {
|
||||
const t = d(new Date())
|
||||
return { from: t.toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
|
||||
return {
|
||||
from: t.toISOString(),
|
||||
to: d(new Date(), 23, 59, 59, 999).toISOString(),
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -49,8 +53,12 @@ const DATE_RANGE_PRESETS = [
|
||||
label: 'Yesterday',
|
||||
getRange: () => {
|
||||
const t = d(new Date())
|
||||
const y = new Date(t); y.setDate(t.getDate() - 1)
|
||||
return { from: d(y).toISOString(), to: d(y, 23, 59, 59, 999).toISOString() }
|
||||
const y = new Date(t)
|
||||
y.setDate(t.getDate() - 1)
|
||||
return {
|
||||
from: d(y).toISOString(),
|
||||
to: d(y, 23, 59, 59, 999).toISOString(),
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -58,9 +66,14 @@ const DATE_RANGE_PRESETS = [
|
||||
label: 'This Week',
|
||||
getRange: () => {
|
||||
const t = d(new Date())
|
||||
const start = new Date(t); start.setDate(t.getDate() - t.getDay())
|
||||
const end = new Date(start); end.setDate(start.getDate() + 6)
|
||||
return { from: d(start).toISOString(), to: d(end, 23, 59, 59, 999).toISOString() }
|
||||
const start = new Date(t)
|
||||
start.setDate(t.getDate() - t.getDay())
|
||||
const end = new Date(start)
|
||||
end.setDate(start.getDate() + 6)
|
||||
return {
|
||||
from: d(start).toISOString(),
|
||||
to: d(end, 23, 59, 59, 999).toISOString(),
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -68,8 +81,12 @@ const DATE_RANGE_PRESETS = [
|
||||
label: 'Last 7 Days',
|
||||
getRange: () => {
|
||||
const t = d(new Date())
|
||||
const start = new Date(t); start.setDate(t.getDate() - 6)
|
||||
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
|
||||
const start = new Date(t)
|
||||
start.setDate(t.getDate() - 6)
|
||||
return {
|
||||
from: d(start).toISOString(),
|
||||
to: d(new Date(), 23, 59, 59, 999).toISOString(),
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -79,7 +96,10 @@ const DATE_RANGE_PRESETS = [
|
||||
const n = new Date()
|
||||
const start = new Date(n.getFullYear(), n.getMonth(), 1)
|
||||
const end = new Date(n.getFullYear(), n.getMonth() + 1, 0)
|
||||
return { from: start.toISOString(), to: d(end, 23, 59, 59, 999).toISOString() }
|
||||
return {
|
||||
from: start.toISOString(),
|
||||
to: d(end, 23, 59, 59, 999).toISOString(),
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -87,8 +107,12 @@ const DATE_RANGE_PRESETS = [
|
||||
label: 'Last 30 Days',
|
||||
getRange: () => {
|
||||
const t = d(new Date())
|
||||
const start = new Date(t); start.setDate(t.getDate() - 29)
|
||||
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
|
||||
const start = new Date(t)
|
||||
start.setDate(t.getDate() - 29)
|
||||
return {
|
||||
from: d(start).toISOString(),
|
||||
to: d(new Date(), 23, 59, 59, 999).toISOString(),
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -96,8 +120,12 @@ const DATE_RANGE_PRESETS = [
|
||||
label: 'Last 3 Months',
|
||||
getRange: () => {
|
||||
const t = d(new Date())
|
||||
const start = new Date(t); start.setMonth(t.getMonth() - 3)
|
||||
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
|
||||
const start = new Date(t)
|
||||
start.setMonth(t.getMonth() - 3)
|
||||
return {
|
||||
from: d(start).toISOString(),
|
||||
to: d(new Date(), 23, 59, 59, 999).toISOString(),
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -127,7 +155,8 @@ const FilterBar = ({
|
||||
const activeFilterCount = filterDefs.filter(def => {
|
||||
const value = activeFilters[def.id]
|
||||
if (value === undefined || value === null) return false
|
||||
if (def.defaultValue !== undefined && value === def.defaultValue) return false
|
||||
if (def.defaultValue !== undefined && value === def.defaultValue)
|
||||
return false
|
||||
if (Array.isArray(value) && value.length === 0) return false
|
||||
if (def.type === 'date-range') return !!(value?.from || value?.to)
|
||||
return true
|
||||
@@ -183,13 +212,18 @@ const FilterBar = ({
|
||||
if (value === undefined || value === null) return null
|
||||
|
||||
if (def.type === 'single-select') {
|
||||
if (def.defaultValue !== undefined && value === def.defaultValue) return null
|
||||
if (def.defaultValue !== undefined && value === def.defaultValue)
|
||||
return null
|
||||
return def.options?.find(o => o.value === value)?.label ?? def.label
|
||||
}
|
||||
|
||||
if (def.type === 'boolean') return def.label
|
||||
|
||||
if (def.type === 'multi-select' && Array.isArray(value) && value.length > 0) {
|
||||
if (
|
||||
def.type === 'multi-select' &&
|
||||
Array.isArray(value) &&
|
||||
value.length > 0
|
||||
) {
|
||||
if (value.length === 1) {
|
||||
return def.options?.find(o => o.value === value[0])?.label ?? def.label
|
||||
}
|
||||
@@ -199,7 +233,10 @@ const FilterBar = ({
|
||||
if (def.type === 'date-range') {
|
||||
if (!value?.from && !value?.to) return null
|
||||
if (value.preset) {
|
||||
return DATE_RANGE_PRESETS.find(p => p.value === value.preset)?.label ?? 'Date Range'
|
||||
return (
|
||||
DATE_RANGE_PRESETS.find(p => p.value === value.preset)?.label ??
|
||||
'Date Range'
|
||||
)
|
||||
}
|
||||
const from = fmtDisplayDate(value.from)
|
||||
const to = fmtDisplayDate(value.to)
|
||||
@@ -259,7 +296,15 @@ const FilterBar = ({
|
||||
return (
|
||||
<>
|
||||
{/* ── Inline bar ─────────────────────────────────────── */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flexWrap: 'wrap',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Badge
|
||||
badgeContent={activeFilterCount || null}
|
||||
color='primary'
|
||||
@@ -309,37 +354,42 @@ const FilterBar = ({
|
||||
</Box>
|
||||
|
||||
{/* ── Bottom sheet ────────────────────────────────────── */}
|
||||
<BottomSheetModal
|
||||
<AppModal
|
||||
open={isOpen}
|
||||
isMobile
|
||||
onClose={() => setIsOpen(false)}
|
||||
title={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Tune sx={{ fontSize: 20 }} />
|
||||
Filters
|
||||
{hasActive && (
|
||||
<Chip size='sm' variant='solid' color='primary' sx={modalCountChipSx}>
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
sx={modalCountChipSx}
|
||||
>
|
||||
{activeFilterCount}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
footer={
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 1 }}>
|
||||
<Button
|
||||
variant='plain'
|
||||
color='danger'
|
||||
size='sm'
|
||||
disabled={!hasActive}
|
||||
onClick={onClearAll}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
<Button onClick={() => setIsOpen(false)} sx={{ minWidth: 140 }}>
|
||||
{resultCount !== undefined
|
||||
<ModalActions
|
||||
tertiary={{
|
||||
label: 'Clear all',
|
||||
color: 'danger',
|
||||
disabled: !hasActive,
|
||||
onClick: onClearAll,
|
||||
}}
|
||||
primary={{
|
||||
label:
|
||||
resultCount !== undefined
|
||||
? `Show ${resultCount} result${resultCount !== 1 ? 's' : ''}`
|
||||
: 'Done'}
|
||||
</Button>
|
||||
</Box>
|
||||
: 'Done',
|
||||
onClick: () => setIsOpen(false),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
@@ -348,9 +398,18 @@ const FilterBar = ({
|
||||
{idx > 0 && <Divider sx={{ my: 2.5 }} />}
|
||||
|
||||
{/* Section header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Box
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}
|
||||
>
|
||||
{def.icon && (
|
||||
<Box sx={{ color: 'text.secondary', display: 'flex', alignItems: 'center', '& svg': { fontSize: 18 } }}>
|
||||
<Box
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
'& svg': { fontSize: 18 },
|
||||
}}
|
||||
>
|
||||
{def.icon}
|
||||
</Box>
|
||||
)}
|
||||
@@ -359,21 +418,41 @@ const FilterBar = ({
|
||||
</Typography>
|
||||
|
||||
{/* active badge in header */}
|
||||
{def.type === 'multi-select' && (activeFilters[def.id]?.length ?? 0) > 0 && (
|
||||
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
|
||||
{def.type === 'multi-select' &&
|
||||
(activeFilters[def.id]?.length ?? 0) > 0 && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
sx={sectionBadgeChipSx}
|
||||
>
|
||||
{activeFilters[def.id].length} selected
|
||||
</Chip>
|
||||
)}
|
||||
{def.type === 'single-select' && activeFilters[def.id] != null && (() => {
|
||||
const opt = def.options?.find(o => o.value === activeFilters[def.id])
|
||||
{def.type === 'single-select' &&
|
||||
activeFilters[def.id] != null &&
|
||||
(() => {
|
||||
const opt = def.options?.find(
|
||||
o => o.value === activeFilters[def.id],
|
||||
)
|
||||
return opt ? (
|
||||
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
sx={sectionBadgeChipSx}
|
||||
>
|
||||
{opt.label}
|
||||
</Chip>
|
||||
) : null
|
||||
})()}
|
||||
{def.type === 'date-range' && getActiveChipLabel(def) && (
|
||||
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
sx={sectionBadgeChipSx}
|
||||
>
|
||||
{getActiveChipLabel(def)}
|
||||
</Chip>
|
||||
)}
|
||||
@@ -383,18 +462,28 @@ const FilterBar = ({
|
||||
{def.type === 'multi-select' && (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{def.options?.map(opt => {
|
||||
const isSelected = (activeFilters[def.id] || []).includes(opt.value)
|
||||
const isSelected = (activeFilters[def.id] || []).includes(
|
||||
opt.value,
|
||||
)
|
||||
return (
|
||||
<Chip
|
||||
key={opt.value}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
|
||||
color={
|
||||
isSelected ? (opt.color ?? 'primary') : 'neutral'
|
||||
}
|
||||
startDecorator={
|
||||
opt.avatar ? (
|
||||
<Avatar src={opt.avatar} alt={opt.label} sx={{ '--Avatar-size': '20px' }} />
|
||||
<Avatar
|
||||
src={opt.avatar}
|
||||
alt={opt.label}
|
||||
sx={{ '--Avatar-size': '20px' }}
|
||||
/>
|
||||
) : isSelected ? (
|
||||
<Check sx={{ fontSize: 14 }} />
|
||||
) : (opt.icon ?? null)
|
||||
) : (
|
||||
(opt.icon ?? null)
|
||||
)
|
||||
}
|
||||
onClick={() => handleMultiToggle(def.id, opt.value)}
|
||||
sx={selectableChipSx}
|
||||
@@ -415,13 +504,21 @@ const FilterBar = ({
|
||||
<Chip
|
||||
key={opt.value}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
|
||||
color={
|
||||
isSelected ? (opt.color ?? 'primary') : 'neutral'
|
||||
}
|
||||
startDecorator={
|
||||
opt.avatar ? (
|
||||
<Avatar src={opt.avatar} alt={opt.label} sx={{ '--Avatar-size': '20px' }} />
|
||||
<Avatar
|
||||
src={opt.avatar}
|
||||
alt={opt.label}
|
||||
sx={{ '--Avatar-size': '20px' }}
|
||||
/>
|
||||
) : isSelected ? (
|
||||
<Check sx={{ fontSize: 14 }} />
|
||||
) : (opt.icon ?? null)
|
||||
) : (
|
||||
(opt.icon ?? null)
|
||||
)
|
||||
}
|
||||
onClick={() => handleSingleToggle(def.id, opt.value)}
|
||||
sx={selectableChipSx}
|
||||
@@ -438,7 +535,11 @@ const FilterBar = ({
|
||||
<Chip
|
||||
variant={activeFilters[def.id] ? 'solid' : 'soft'}
|
||||
color={activeFilters[def.id] ? 'primary' : 'neutral'}
|
||||
startDecorator={activeFilters[def.id] ? <Check sx={{ fontSize: 14 }} /> : null}
|
||||
startDecorator={
|
||||
activeFilters[def.id] ? (
|
||||
<Check sx={{ fontSize: 14 }} />
|
||||
) : null
|
||||
}
|
||||
onClick={() => handleBoolToggle(def.id)}
|
||||
sx={selectableChipSx}
|
||||
>
|
||||
@@ -447,10 +548,17 @@ const FilterBar = ({
|
||||
)}
|
||||
|
||||
{/* date-range */}
|
||||
{def.type === 'date-range' && (() => {
|
||||
{def.type === 'date-range' &&
|
||||
(() => {
|
||||
const val = activeFilters[def.id] || {}
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
{/* Preset chips */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{DATE_RANGE_PRESETS.map(preset => {
|
||||
@@ -460,8 +568,14 @@ const FilterBar = ({
|
||||
key={preset.value}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color={isSelected ? 'primary' : 'neutral'}
|
||||
startDecorator={isSelected ? <Check sx={{ fontSize: 14 }} /> : null}
|
||||
onClick={() => handleDateRangePreset(def.id, preset.value)}
|
||||
startDecorator={
|
||||
isSelected ? (
|
||||
<Check sx={{ fontSize: 14 }} />
|
||||
) : null
|
||||
}
|
||||
onClick={() =>
|
||||
handleDateRangePreset(def.id, preset.value)
|
||||
}
|
||||
sx={selectableChipSx}
|
||||
>
|
||||
{preset.label}
|
||||
@@ -471,24 +585,37 @@ const FilterBar = ({
|
||||
</Box>
|
||||
|
||||
{/* Custom date inputs */}
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<Box
|
||||
sx={{ display: 'flex', gap: 1, alignItems: 'center' }}
|
||||
>
|
||||
<Input
|
||||
type='date'
|
||||
size='sm'
|
||||
value={toInputDate(val.from)}
|
||||
onChange={e => handleDateRangeInput(def.id, 'from', e.target.value)}
|
||||
slotProps={{ input: { max: toInputDate(val.to) || undefined } }}
|
||||
onChange={e =>
|
||||
handleDateRangeInput(def.id, 'from', e.target.value)
|
||||
}
|
||||
slotProps={{
|
||||
input: { max: toInputDate(val.to) || undefined },
|
||||
}}
|
||||
sx={{ flex: 1, fontSize: '0.8rem' }}
|
||||
/>
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary', flexShrink: 0 }}>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'text.tertiary', flexShrink: 0 }}
|
||||
>
|
||||
–
|
||||
</Typography>
|
||||
<Input
|
||||
type='date'
|
||||
size='sm'
|
||||
value={toInputDate(val.to)}
|
||||
onChange={e => handleDateRangeInput(def.id, 'to', e.target.value)}
|
||||
slotProps={{ input: { min: toInputDate(val.from) || undefined } }}
|
||||
onChange={e =>
|
||||
handleDateRangeInput(def.id, 'to', e.target.value)
|
||||
}
|
||||
slotProps={{
|
||||
input: { min: toInputDate(val.from) || undefined },
|
||||
}}
|
||||
sx={{ flex: 1, fontSize: '0.8rem' }}
|
||||
/>
|
||||
</Box>
|
||||
@@ -498,7 +625,7 @@ const FilterBar = ({
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</BottomSheetModal>
|
||||
</AppModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
61
src/components/common/ModalActions.jsx
Normal file
61
src/components/common/ModalActions.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Box, Button } from '@mui/joy'
|
||||
|
||||
const ActionButton = ({ action, defaults, sx }) => {
|
||||
if (!action) return null
|
||||
|
||||
const { label, sx: actionSx, ...props } = action
|
||||
return (
|
||||
<Button {...defaults} {...props} sx={{ ...sx, ...actionSx }}>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Consistent modal action row. Secondary actions are rendered first and the
|
||||
* primary action is always the final, highest-emphasis control.
|
||||
*/
|
||||
const ModalActions = ({
|
||||
primary,
|
||||
secondary,
|
||||
tertiary,
|
||||
children,
|
||||
stackOnMobile = false,
|
||||
sx,
|
||||
}) => {
|
||||
const responsiveButtonStyles = stackOnMobile
|
||||
? { '& > button': { width: { xs: '100%', sm: 'auto' } } }
|
||||
: undefined
|
||||
|
||||
const layoutSx = {
|
||||
display: 'flex',
|
||||
flexDirection: stackOnMobile ? { xs: 'column-reverse', sm: 'row' } : 'row',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
...responsiveButtonStyles,
|
||||
...sx,
|
||||
}
|
||||
|
||||
if (children) return <Box sx={layoutSx}>{children}</Box>
|
||||
|
||||
return (
|
||||
<Box sx={layoutSx}>
|
||||
<ActionButton
|
||||
action={tertiary}
|
||||
defaults={{ color: 'neutral', variant: 'plain' }}
|
||||
sx={{ mr: { sm: 'auto' } }}
|
||||
/>
|
||||
<ActionButton
|
||||
action={secondary}
|
||||
defaults={{ color: 'neutral', variant: 'outlined' }}
|
||||
/>
|
||||
<ActionButton
|
||||
action={primary}
|
||||
defaults={{ color: 'primary', variant: 'solid' }}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModalActions
|
||||
52
src/components/common/README.md
Normal file
52
src/components/common/README.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# UI foundations
|
||||
|
||||
## Modals
|
||||
|
||||
Use `AppModal` for new work. Existing features may continue using
|
||||
`useResponsiveModal`; it now renders the same primitive.
|
||||
|
||||
### Presentations
|
||||
|
||||
- Desktop: centered, constrained dialog (`sm` 400, `md` 520, `lg` 680,
|
||||
`xl` 840 pixels).
|
||||
- Mobile default: bottom sheet.
|
||||
- Long mobile workflows: `mobilePresentation='fullscreen'`.
|
||||
- Destructive confirmation: `size='sm'`, `role='alertdialog'`, and
|
||||
`closeOnBackdrop={false}`.
|
||||
|
||||
`AppModal` owns the header, close control, content scrolling, safe-area spacing,
|
||||
and footer. Do not add another close button or duplicate the title inside the
|
||||
content.
|
||||
|
||||
```jsx
|
||||
<AppModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title='Create item'
|
||||
description='Add a recognizable name.'
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{ label: 'Cancel', onClick: onClose }}
|
||||
primary={{ label: 'Create', onClick: onCreate, loading: isSaving }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{content}
|
||||
</AppModal>
|
||||
```
|
||||
|
||||
## Buttons
|
||||
|
||||
- Primary: `solid primary`; one primary action per surface.
|
||||
- Secondary: `outlined neutral`.
|
||||
- Tertiary: `plain neutral`.
|
||||
- Destructive confirmation: `solid danger`.
|
||||
- Destructive trigger: usually `outlined danger` or `plain danger`.
|
||||
- Modal order: secondary first, primary last.
|
||||
- Every icon-only button requires an `aria-label`.
|
||||
- Use the built-in `loading` state to prevent repeated submission.
|
||||
|
||||
Button heights, radii, focus states, and reduced-motion behavior are defined in
|
||||
`src/contexts/ThemeContext.jsx`. Avoid local overrides for those properties.
|
||||
|
||||
The live reference is available at `/test`.
|
||||
@@ -4,22 +4,33 @@ import { CssVarsProvider, extendTheme } from '@mui/joy/styles'
|
||||
import PropType from 'prop-types'
|
||||
|
||||
const primaryColor = 'cyan'
|
||||
|
||||
const shades = [
|
||||
'50',
|
||||
...Array.from({ length: 9 }, (_, i) => String((i + 1) * 100)),
|
||||
]
|
||||
|
||||
const getPallete = (key = primaryColor) => {
|
||||
return shades.reduce((acc, shade) => {
|
||||
acc[shade] = COLORS[key][shade]
|
||||
return acc
|
||||
const getPalette = (key = primaryColor) =>
|
||||
shades.reduce((palette, shade) => {
|
||||
palette[shade] = COLORS[key][shade]
|
||||
return palette
|
||||
}, {})
|
||||
}
|
||||
|
||||
const primaryPalette = getPallete(primaryColor)
|
||||
const primaryPalette = getPalette(primaryColor)
|
||||
|
||||
// Fallbacks only. A parent that owns the radius (ButtonGroup, Input/Select
|
||||
// decorator slots, CardActions) sets --Button-radius / --IconButton-radius and
|
||||
// takes precedence, which is what keeps connected groups looking connected.
|
||||
const CONTROL_RADIUS = '12px'
|
||||
const ICON_BUTTON_RADIUS = '10px'
|
||||
|
||||
const theme = extendTheme({
|
||||
radius: {
|
||||
xs: '6px',
|
||||
sm: '8px',
|
||||
md: '10px',
|
||||
lg: '12px',
|
||||
xl: '16px',
|
||||
},
|
||||
colorSchemes: {
|
||||
light: {
|
||||
palette: {
|
||||
@@ -42,12 +53,11 @@ const theme = extendTheme({
|
||||
200: '#fbd5d5',
|
||||
300: '#f9c1c1',
|
||||
400: '#f6a8a8',
|
||||
500: '',
|
||||
600: '#f47272',
|
||||
700: '#e33434',
|
||||
800: '#cc1f1a',
|
||||
900: '#b91c1c',
|
||||
},
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
},
|
||||
warning: {
|
||||
50: '#fffdf7',
|
||||
@@ -68,16 +78,75 @@ const theme = extendTheme({
|
||||
primary: primaryPalette,
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
JoyButton: {
|
||||
styleOverrides: {
|
||||
root: ({ ownerState }) => ({
|
||||
minHeight:
|
||||
ownerState.size === 'lg'
|
||||
? '44px'
|
||||
: ownerState.size === 'sm'
|
||||
? '36px'
|
||||
: '40px',
|
||||
// Read through the CSS variable so parents that own the radius
|
||||
// (ButtonGroup, Input/Select decorators, Card actions) still win.
|
||||
borderRadius: `var(--Button-radius, ${CONTROL_RADIUS})`,
|
||||
fontWeight: 600,
|
||||
transition:
|
||||
'background-color 140ms ease, border-color 140ms ease, color 140ms ease, box-shadow 140ms ease, transform 100ms ease',
|
||||
'&:active:not(:disabled)': {
|
||||
transform: 'scale(0.98)',
|
||||
},
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transition: 'none',
|
||||
'&:active:not(:disabled)': { transform: 'none' },
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
JoyIconButton: {
|
||||
styleOverrides: {
|
||||
root: ({ ownerState }) => ({
|
||||
borderRadius: `var(--IconButton-radius, ${ICON_BUTTON_RADIUS})`,
|
||||
transition:
|
||||
'background-color 140ms ease, border-color 140ms ease, color 140ms ease, transform 100ms ease',
|
||||
'&:active:not(:disabled)': {
|
||||
transform: 'scale(0.96)',
|
||||
},
|
||||
// Touch target only for the default size. `sm`/`lg` are explicit
|
||||
// choices by the call site and keep Joy's own sizing.
|
||||
...(ownerState.size === 'md' && {
|
||||
minWidth: '40px',
|
||||
minHeight: '40px',
|
||||
'@media (max-width: 768px)': {
|
||||
minWidth: '44px',
|
||||
minHeight: '44px',
|
||||
},
|
||||
}),
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transition: 'none',
|
||||
'&:active:not(:disabled)': { transform: 'none' },
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
JoyButtonGroup: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'--ButtonGroup-radius': CONTROL_RADIUS,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const ThemeContext = ({ children }) => {
|
||||
return (
|
||||
const ThemeContext = ({ children }) => (
|
||||
<CssVarsProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</CssVarsProvider>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ThemeContext.propTypes = {
|
||||
children: PropType.node,
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import BottomSheetModal from '../components/common/BottomSheetModal'
|
||||
import FadeModal from '../components/common/FadeModal'
|
||||
import useWindowWidth from './useWindowWidth'
|
||||
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||
import { createElement } from 'react'
|
||||
import AppModal from '../components/common/AppModal'
|
||||
|
||||
const MobileAppModal = props =>
|
||||
createElement(AppModal, { ...props, isMobile: true })
|
||||
const DesktopAppModal = props =>
|
||||
createElement(AppModal, { ...props, isMobile: false })
|
||||
|
||||
/**
|
||||
* 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 }
|
||||
* Backwards-compatible access to the app modal system.
|
||||
*
|
||||
* New code may render AppModal directly when it already knows the desired
|
||||
* presentation. Existing callers can continue using ResponsiveModal.
|
||||
*/
|
||||
export const useResponsiveModal = (breakpoint = 768) => {
|
||||
const windowWidth = useWindowWidth()
|
||||
const isMobile = windowWidth <= breakpoint
|
||||
const isMobile = useMediaQuery(`(max-width:${breakpoint}px)`)
|
||||
|
||||
return {
|
||||
ResponsiveModal: isMobile ? BottomSheetModal : FadeModal,
|
||||
ResponsiveModal: isMobile ? MobileAppModal : DesktopAppModal,
|
||||
isMobile,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
import { Security, Smartphone } from '@mui/icons-material'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Input,
|
||||
Link,
|
||||
ModalClose,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { Alert, Box, Input, Link, Stack, Typography } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
import { VerifyMFA } from '../../utils/Fetcher'
|
||||
|
||||
@@ -43,12 +35,15 @@ const MFAVerificationModal = ({
|
||||
onSuccess(data)
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
setError(
|
||||
errorData.message || 'Invalid verification code. Please try again.',
|
||||
)
|
||||
const message =
|
||||
errorData.message || 'Invalid verification code. Please try again.'
|
||||
setError(message)
|
||||
onError?.(message)
|
||||
}
|
||||
} catch (error) {
|
||||
setError('Failed to verify code. Please try again.')
|
||||
const message = 'Failed to verify code. Please try again.'
|
||||
setError(message)
|
||||
onError?.(message)
|
||||
console.error('MFA verification error:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
@@ -73,17 +68,29 @@ const MFAVerificationModal = ({
|
||||
<ResponsiveModal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
size='lg'
|
||||
fullWidth={true}
|
||||
size='md'
|
||||
title='Two-Factor Authentication'
|
||||
description='Enter the verification code from your authenticator app.'
|
||||
closeOnBackdrop={!loading}
|
||||
closeOnEscape={!loading}
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{
|
||||
label: 'Cancel',
|
||||
onClick: handleClose,
|
||||
disabled: loading,
|
||||
}}
|
||||
primary={{
|
||||
label: 'Verify & Sign In',
|
||||
onClick: handleVerify,
|
||||
loading,
|
||||
disabled: !verificationCode.trim(),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ModalClose />
|
||||
|
||||
<Box className='mb-4 text-center'>
|
||||
<Security sx={{ fontSize: 48, color: 'primary.main', mb: 2 }} />
|
||||
<Typography level='body-md' sx={{ color: 'text.secondary' }}>
|
||||
Enter the verification code from your authenticator app
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Stack spacing={3}>
|
||||
@@ -120,16 +127,6 @@ const MFAVerificationModal = ({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
color='primary'
|
||||
loading={loading}
|
||||
onClick={handleVerify}
|
||||
disabled={!verificationCode.trim()}
|
||||
size='lg'
|
||||
>
|
||||
Verify & Sign In
|
||||
</Button>
|
||||
|
||||
<Box className='text-center'>
|
||||
<Link
|
||||
component='button'
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Pause,
|
||||
PlayArrow,
|
||||
} from '@mui/icons-material'
|
||||
import { Box, ButtonGroup, IconButton, Menu, MenuItem } from '@mui/joy'
|
||||
import { Box, Button, ButtonGroup, IconButton, Menu, MenuItem } from '@mui/joy'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
const TimerSplitButton = ({
|
||||
@@ -95,36 +95,25 @@ const TimerSplitButton = ({
|
||||
disabled={disabled}
|
||||
>
|
||||
{/* Main action button */}
|
||||
<IconButton
|
||||
<Button
|
||||
onClick={handleMainAction}
|
||||
disabled={disabled}
|
||||
size='md'
|
||||
startDecorator={chore.status === 1 ? <Pause /> : <PlayArrow />}
|
||||
sx={{
|
||||
px: 3,
|
||||
py: 1,
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
minWidth: fullWidth ? 'auto' : 120,
|
||||
flex: fullWidth ? 1 : 'none',
|
||||
}}
|
||||
>
|
||||
{chore.status === 1 ? <Pause /> : <PlayArrow />}
|
||||
{chore.status === 1 ? 'Pause' : 'Resume'}
|
||||
</IconButton>
|
||||
</Button>
|
||||
|
||||
{/* Dropdown arrow button */}
|
||||
<IconButton
|
||||
onClick={handleMenuOpen}
|
||||
disabled={disabled}
|
||||
size='lg'
|
||||
sx={{
|
||||
px: 1,
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
borderLeft: '1px solid',
|
||||
borderLeftColor: 'divider',
|
||||
minWidth: 'auto',
|
||||
}}
|
||||
size='md'
|
||||
sx={{ px: 1, minWidth: 'auto' }}
|
||||
>
|
||||
<ArrowDropDown />
|
||||
</IconButton>
|
||||
|
||||
@@ -706,7 +706,7 @@ const ArchivedTasks = () => {
|
||||
}}
|
||||
onChange={handleSearchChange}
|
||||
startDecorator={
|
||||
<KeyboardShortcutHint shortcut='F' show={showKeyboardShortcuts} />
|
||||
showKeyboardShortcuts ? <KeyboardShortcutHint shortcut='F' /> : null
|
||||
}
|
||||
endDecorator={
|
||||
searchTerm && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Close, HelpOutline, Keyboard } from '@mui/icons-material'
|
||||
import { Box, Button, Card, Divider, IconButton, Typography } from '@mui/joy'
|
||||
import { HelpOutline } from '@mui/icons-material'
|
||||
import { Box, Card, IconButton, Typography } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
|
||||
const MultiSelectHelp = ({ isVisible = true }) => {
|
||||
@@ -28,37 +29,24 @@ const MultiSelectHelp = ({ isVisible = true }) => {
|
||||
borderRadius: '50%',
|
||||
boxShadow: 'lg',
|
||||
}}
|
||||
aria-label='Show keyboard shortcuts'
|
||||
title='Show keyboard shortcuts'
|
||||
>
|
||||
<HelpOutline />
|
||||
</IconButton>
|
||||
|
||||
{/* Help Modal */}
|
||||
<ResponsiveModal open={isHelpOpen} onClose={() => setIsHelpOpen(false)}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
mb: 2,
|
||||
}}
|
||||
<ResponsiveModal
|
||||
open={isHelpOpen}
|
||||
onClose={() => setIsHelpOpen(false)}
|
||||
title='Multi-select Mode'
|
||||
description='Use these keyboard shortcuts to work more efficiently.'
|
||||
footer={
|
||||
<ModalActions
|
||||
primary={{ label: 'Got it', onClick: () => setIsHelpOpen(false) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Keyboard color='primary' />
|
||||
<Typography level='title-lg'>Multi-select Mode</Typography>
|
||||
</Box>
|
||||
<IconButton
|
||||
variant='plain'
|
||||
size='sm'
|
||||
onClick={() => setIsHelpOpen(false)}
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Typography level='body-md' sx={{ mb: 3, color: 'text.secondary' }}>
|
||||
Use these keyboard shortcuts to work more efficiently with multiple
|
||||
tasks:
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{/* Selection shortcuts */}
|
||||
<Card variant='soft' sx={{ p: 2 }}>
|
||||
@@ -107,16 +95,6 @@ const MultiSelectHelp = ({ isVisible = true }) => {
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
<Divider sx={{ my: 3 }} />
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Button
|
||||
variant='soft'
|
||||
onClick={() => setIsHelpOpen(false)}
|
||||
sx={{ minWidth: 120 }}
|
||||
>
|
||||
Got it!
|
||||
</Button>
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import BottomSheetModal from '../../../components/common/BottomSheetModal'
|
||||
import AppModal from '../../../components/common/AppModal'
|
||||
import ActiveFilterChips from '../../../components/common/filter/ActiveFilterChips'
|
||||
import { Z_INDEX } from '../../../constants/zIndex'
|
||||
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
||||
@@ -105,13 +105,15 @@ const OptionChips = ({ options, selected, multi, onToggle }) => (
|
||||
<Chip
|
||||
key={opt.value}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color={isSelected ? opt.color ?? 'primary' : 'neutral'}
|
||||
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
|
||||
startDecorator={
|
||||
opt.icon != null
|
||||
? isSelected
|
||||
? <Check sx={{ fontSize: 14 }} />
|
||||
: opt.icon
|
||||
: undefined
|
||||
opt.icon != null ? (
|
||||
isSelected ? (
|
||||
<Check sx={{ fontSize: 14 }} />
|
||||
) : (
|
||||
opt.icon
|
||||
)
|
||||
) : undefined
|
||||
}
|
||||
onClick={() => onToggle(opt.value)}
|
||||
sx={{
|
||||
@@ -287,7 +289,9 @@ const ChoreToolbar = ({
|
||||
return member?.displayName || member?.username || String(value)
|
||||
}
|
||||
if (condition.type === 'status') {
|
||||
return CHORE_STATUSES.find(s => s.value === value)?.label || String(value)
|
||||
return (
|
||||
CHORE_STATUSES.find(s => s.value === value)?.label || String(value)
|
||||
)
|
||||
}
|
||||
if (condition.type === 'priority') {
|
||||
return Priorities.find(p => p.value === value)?.name || String(value)
|
||||
@@ -363,8 +367,7 @@ const ChoreToolbar = ({
|
||||
setLocalSelections(conditionsToSelections(tempFilter.conditions))
|
||||
if (tempFilterMeta?.sourceFilterId) {
|
||||
const sourceFilter =
|
||||
savedFilters.find(f => f.id === tempFilterMeta.sourceFilterId) ||
|
||||
null
|
||||
savedFilters.find(f => f.id === tempFilterMeta.sourceFilterId) || null
|
||||
setEditingSavedFilter(
|
||||
sourceFilter ||
|
||||
(tempFilterMeta.sourceFilterId
|
||||
@@ -444,7 +447,13 @@ const ChoreToolbar = ({
|
||||
FILTER_COLORS.find(c => !usedColors.includes(c.value))?.value ??
|
||||
FILTER_COLORS[0].value
|
||||
|
||||
saveFilter?.({ name, description: '', color, conditions, operator: 'AND' })?.then?.(() => {
|
||||
saveFilter?.({
|
||||
name,
|
||||
description: '',
|
||||
color,
|
||||
conditions,
|
||||
operator: 'AND',
|
||||
})?.then?.(() => {
|
||||
applyTempFilter?.({ conditions, operator: 'AND' }, { name })
|
||||
onFilterSaved?.(name)
|
||||
})
|
||||
@@ -460,16 +469,13 @@ const ChoreToolbar = ({
|
||||
const conditions = selectionsToConditions(localSelections)
|
||||
if (conditions.length === 0) return
|
||||
|
||||
updateFilter(
|
||||
editingSavedFilter.id,
|
||||
{
|
||||
updateFilter(editingSavedFilter.id, {
|
||||
name: editingSavedFilter.name,
|
||||
description: editingSavedFilter.description || '',
|
||||
color: editingSavedFilter.color,
|
||||
conditions,
|
||||
operator: 'AND',
|
||||
},
|
||||
)?.then?.(() => {
|
||||
})?.then?.(() => {
|
||||
clearTempFilter?.()
|
||||
onSavedFilterClick?.(editingSavedFilter.id)
|
||||
onFilterSaved?.(editingSavedFilter.name)
|
||||
@@ -498,9 +504,21 @@ const ChoreToolbar = ({
|
||||
]
|
||||
|
||||
const viewOptions = [
|
||||
{ value: 'default', label: 'Cards', icon: <ViewAgenda sx={{ fontSize: 16 }} /> },
|
||||
{ value: 'compact', label: 'Compact', icon: <ViewComfy sx={{ fontSize: 16 }} /> },
|
||||
{ value: 'calendar', label: 'Calendar', icon: <CalendarMonth sx={{ fontSize: 16 }} /> },
|
||||
{
|
||||
value: 'default',
|
||||
label: 'Cards',
|
||||
icon: <ViewAgenda sx={{ fontSize: 16 }} />,
|
||||
},
|
||||
{
|
||||
value: 'compact',
|
||||
label: 'Compact',
|
||||
icon: <ViewComfy sx={{ fontSize: 16 }} />,
|
||||
},
|
||||
{
|
||||
value: 'calendar',
|
||||
label: 'Calendar',
|
||||
icon: <CalendarMonth sx={{ fontSize: 16 }} />,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -536,6 +554,7 @@ const ChoreToolbar = ({
|
||||
size='sm'
|
||||
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
||||
onClick={openFilterSheet}
|
||||
aria-label='Filters'
|
||||
title='Filters'
|
||||
>
|
||||
<FilterList />
|
||||
@@ -543,7 +562,8 @@ const ChoreToolbar = ({
|
||||
</Badge>
|
||||
|
||||
{/* Project selector */}
|
||||
{!filterActive && projects.filter(p => p.id !== 'default').length > 0 && (
|
||||
{!filterActive &&
|
||||
projects.filter(p => p.id !== 'default').length > 0 && (
|
||||
<ProjectSelector
|
||||
selectedProject={selectedProject?.name || 'Default Project'}
|
||||
onProjectSelect={onProjectSelect}
|
||||
@@ -558,6 +578,7 @@ const ChoreToolbar = ({
|
||||
size='sm'
|
||||
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
||||
onClick={() => setDisplaySheetOpen(true)}
|
||||
aria-label='View and group options'
|
||||
title='View & Group'
|
||||
>
|
||||
{viewMode === 'calendar' ? (
|
||||
@@ -577,6 +598,9 @@ const ChoreToolbar = ({
|
||||
size='sm'
|
||||
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
||||
onClick={onToggleMultiSelect}
|
||||
aria-label={
|
||||
isMultiSelectMode ? 'Exit multi-select' : 'Enter multi-select'
|
||||
}
|
||||
title={
|
||||
isMultiSelectMode
|
||||
? 'Exit multi-select (Ctrl+S)'
|
||||
@@ -628,8 +652,9 @@ const ChoreToolbar = ({
|
||||
)}
|
||||
|
||||
{/* ── Unified Filter bottom sheet ─────────────────────────────────────── */}
|
||||
<BottomSheetModal
|
||||
<AppModal
|
||||
open={filterSheetOpen}
|
||||
isMobile
|
||||
onClose={() => {
|
||||
setSaveMenuAnchorEl(null)
|
||||
setFilterSheetOpen(false)
|
||||
@@ -649,7 +674,12 @@ const ChoreToolbar = ({
|
||||
footer={
|
||||
savingFilter ? (
|
||||
<Box
|
||||
sx={{ display: 'flex', gap: 1, width: '100%', alignItems: 'center' }}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
size='sm'
|
||||
@@ -710,12 +740,11 @@ const ChoreToolbar = ({
|
||||
}}
|
||||
sx={{ minWidth: 140 }}
|
||||
>
|
||||
{resultCount != null
|
||||
? `Show ${resultCount}`
|
||||
: 'Done'}
|
||||
{resultCount != null ? `Show ${resultCount}` : 'Done'}
|
||||
</Button>
|
||||
<IconButton
|
||||
ref={saveMenuRef}
|
||||
aria-label='More save options'
|
||||
onClick={e => setSaveMenuAnchorEl(e.currentTarget)}
|
||||
>
|
||||
<ArrowDropDown />
|
||||
@@ -825,11 +854,12 @@ const ChoreToolbar = ({
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</BottomSheetModal>
|
||||
</AppModal>
|
||||
|
||||
{/* ── Display bottom sheet (View + Group + Assignee + Project) ──────────── */}
|
||||
<BottomSheetModal
|
||||
<AppModal
|
||||
open={displaySheetOpen}
|
||||
isMobile
|
||||
onClose={() => setDisplaySheetOpen(false)}
|
||||
title={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
@@ -838,7 +868,10 @@ const ChoreToolbar = ({
|
||||
</Box>
|
||||
}
|
||||
footer={
|
||||
<Button onClick={() => setDisplaySheetOpen(false)} sx={{ minWidth: 140 }}>
|
||||
<Button
|
||||
onClick={() => setDisplaySheetOpen(false)}
|
||||
sx={{ minWidth: 140 }}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
}
|
||||
@@ -853,9 +886,11 @@ const ChoreToolbar = ({
|
||||
variant={viewMode === opt.value ? 'solid' : 'soft'}
|
||||
color={viewMode === opt.value ? 'primary' : 'neutral'}
|
||||
startDecorator={
|
||||
viewMode === opt.value
|
||||
? <Check sx={{ fontSize: 14 }} />
|
||||
: opt.icon
|
||||
viewMode === opt.value ? (
|
||||
<Check sx={{ fontSize: 14 }} />
|
||||
) : (
|
||||
opt.icon
|
||||
)
|
||||
}
|
||||
onClick={() => onToggleViewMode?.(opt.value)}
|
||||
sx={{
|
||||
@@ -910,7 +945,8 @@ const ChoreToolbar = ({
|
||||
label='Show tasks for'
|
||||
badge={
|
||||
selectedAssigneeFilter !== 'anyone'
|
||||
? assigneeOptions.find(o => o.value === selectedAssigneeFilter)?.label
|
||||
? assigneeOptions.find(o => o.value === selectedAssigneeFilter)
|
||||
?.label
|
||||
: null
|
||||
}
|
||||
/>
|
||||
@@ -920,9 +956,8 @@ const ChoreToolbar = ({
|
||||
multi={false}
|
||||
onToggle={v => onAssigneeFilterChange?.(v)}
|
||||
/>
|
||||
|
||||
</Box>
|
||||
</BottomSheetModal>
|
||||
</AppModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -15,6 +15,7 @@ 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'
|
||||
@@ -32,11 +33,22 @@ const STATUS_CONFIG = {
|
||||
|
||||
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,7 +287,10 @@ 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' }}>
|
||||
@@ -194,35 +300,6 @@ function HistoryDetailModal({ config }) {
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,19 +46,25 @@ function AcknowledgmentModal({ config }) {
|
||||
return (
|
||||
<ResponsiveModal
|
||||
open={config?.isOpen}
|
||||
onClose={config?.onClose}
|
||||
size='lg'
|
||||
fullWidth={true}
|
||||
unmountDelay={250}
|
||||
onClose={handleAction}
|
||||
size='sm'
|
||||
title={config?.title}
|
||||
showCloseButton={false}
|
||||
footer={
|
||||
<ModalActions
|
||||
primary={{
|
||||
label: config?.acknowledgeText,
|
||||
color: config?.color || 'primary',
|
||||
onClick: handleAction,
|
||||
endDecorator: showKeyboardShortcuts ? (
|
||||
<KeyboardShortcutHint shortcut='Y' />
|
||||
) : undefined,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
sx={{ p: 2, minWidth: { xs: '100%', sm: '400px' }, maxWidth: '500px' }}
|
||||
>
|
||||
|
||||
<Typography
|
||||
level='body-md'
|
||||
mb={3}
|
||||
sx={{
|
||||
lineHeight: 1.6,
|
||||
whiteSpace: 'pre-wrap',
|
||||
@@ -84,22 +73,6 @@ function AcknowledgmentModal({ config }) {
|
||||
>
|
||||
{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>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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 safe—you'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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
Input,
|
||||
@@ -10,6 +8,7 @@ import {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'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>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Switch,
|
||||
@@ -10,6 +9,7 @@ import {
|
||||
} 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'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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<Select placeholder={placeholder}>
|
||||
{options.map((item, index) => (
|
||||
<Option
|
||||
value={item.id}
|
||||
key={item[displayKey]}
|
||||
onClick={() => {
|
||||
setSelected(item.id)
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{ label: 'Cancel', onClick: onClose }}
|
||||
primary={{
|
||||
label: 'Save',
|
||||
onClick: handleSave,
|
||||
disabled: selected == null,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 "DELETE" 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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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 */}
|
||||
<Stack spacing={2}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<CreditCard
|
||||
sx={{
|
||||
fontSize: '1.5rem',
|
||||
<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,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<Typography level='h4' sx={{ fontWeight: 600 }}>
|
||||
Redeem Points
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
{/* 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>
|
||||
)
|
||||
|
||||
@@ -1,23 +1,13 @@
|
||||
import { CheckCircle, Security, Smartphone } from '@mui/icons-material'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Input,
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalDialog,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { Alert, Box, Button, Card, Input, Stack, Typography } from '@mui/joy'
|
||||
import QRCode from 'qrcode'
|
||||
import { useEffect, useState } from 'react'
|
||||
import AppModal from '../../components/common/AppModal'
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import {
|
||||
ConfirmMFA,
|
||||
DisableMFA,
|
||||
GetMFAStatus,
|
||||
RegenerateBackupCodes,
|
||||
SetupMFA,
|
||||
} from '../../utils/Fetcher'
|
||||
import LoadingComponent from '../components/Loading'
|
||||
@@ -168,24 +158,6 @@ const MFASettings = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleRegenerateBackupCodes = async () => {
|
||||
try {
|
||||
setError('')
|
||||
const response = await RegenerateBackupCodes()
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setBackupCodes(data.backupCodes)
|
||||
setBackupCodesModalOpen(true)
|
||||
setSuccess('New backup codes have been generated!')
|
||||
} else {
|
||||
setError('Failed to regenerate backup codes. Please try again.')
|
||||
}
|
||||
} catch (error) {
|
||||
setError('Failed to regenerate backup codes. Please try again.')
|
||||
console.error('Error regenerating backup codes:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const closeSetupModal = () => {
|
||||
setSetupModalOpen(false)
|
||||
setSetupStep(1)
|
||||
@@ -290,13 +262,40 @@ const MFASettings = () => {
|
||||
)} */}
|
||||
|
||||
{/* Setup MFA Modal */}
|
||||
<Modal open={setupModalOpen} onClose={closeSetupModal}>
|
||||
<ModalDialog size='md' sx={{ maxWidth: 500 }}>
|
||||
<ModalClose />
|
||||
<Typography level='h4' sx={{ mb: 2 }}>
|
||||
Set up Multi-Factor Authentication
|
||||
</Typography>
|
||||
|
||||
<AppModal
|
||||
open={setupModalOpen}
|
||||
onClose={closeSetupModal}
|
||||
title='Set up Multi-Factor Authentication'
|
||||
size='md'
|
||||
footer={
|
||||
setupStep === 1 ? (
|
||||
<ModalActions
|
||||
secondary={{ label: 'Cancel', onClick: closeSetupModal }}
|
||||
primary={{
|
||||
label: "I've added the account",
|
||||
onClick: () => setSetupStep(2),
|
||||
startDecorator: <Smartphone />,
|
||||
}}
|
||||
/>
|
||||
) : setupStep === 2 ? (
|
||||
<ModalActions
|
||||
secondary={{ label: 'Back', onClick: () => setSetupStep(1) }}
|
||||
primary={{
|
||||
label: 'Verify & Enable',
|
||||
onClick: handleConfirmMFA,
|
||||
disabled: verificationCode.length !== 6,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ModalActions
|
||||
primary={{
|
||||
label: "I've saved my backup codes",
|
||||
onClick: closeSetupModal,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
{setupStep === 1 && setupData && (
|
||||
<Stack spacing={3}>
|
||||
<Typography level='body-md'>
|
||||
@@ -316,8 +315,8 @@ const MFASettings = () => {
|
||||
/>
|
||||
) : (
|
||||
<Alert color='danger'>
|
||||
QR code could not be generated. Please try again or use
|
||||
the manual entry key below.
|
||||
QR code could not be generated. Please try again or use the
|
||||
manual entry key below.
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
@@ -341,14 +340,6 @@ const MFASettings = () => {
|
||||
{setupData.secret}
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
<Button
|
||||
color='primary'
|
||||
onClick={() => setSetupStep(2)}
|
||||
startDecorator={<Smartphone />}
|
||||
>
|
||||
I've added the account to my app
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
@@ -384,24 +375,6 @@ const MFASettings = () => {
|
||||
/>
|
||||
|
||||
{error && <Alert color='danger'>{error}</Alert>}
|
||||
|
||||
<Box className='flex gap-2'>
|
||||
<Button
|
||||
variant='outlined'
|
||||
onClick={() => setSetupStep(1)}
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
color='primary'
|
||||
onClick={handleConfirmMFA}
|
||||
disabled={verificationCode.length !== 6}
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
Verify & Enable
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
@@ -437,23 +410,30 @@ const MFASettings = () => {
|
||||
))}
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<Button color='primary' onClick={closeSetupModal}>
|
||||
I've saved my backup codes
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</ModalDialog>
|
||||
</Modal>
|
||||
</AppModal>
|
||||
|
||||
{/* Disable MFA Modal */}
|
||||
<Modal open={disableModalOpen} onClose={closeDisableModal}>
|
||||
<ModalDialog size='sm'>
|
||||
<ModalClose />
|
||||
<Typography level='h4' sx={{ mb: 2 }}>
|
||||
Disable Multi-Factor Authentication
|
||||
</Typography>
|
||||
|
||||
<AppModal
|
||||
open={disableModalOpen}
|
||||
onClose={closeDisableModal}
|
||||
title='Disable Multi-Factor Authentication'
|
||||
size='sm'
|
||||
role='alertdialog'
|
||||
closeOnBackdrop={false}
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{ label: 'Cancel', onClick: closeDisableModal }}
|
||||
primary={{
|
||||
label: 'Disable MFA',
|
||||
color: 'danger',
|
||||
onClick: handleDisableMFA,
|
||||
disabled: disableCode.length !== 6,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Stack spacing={3}>
|
||||
<Alert color='warning'>
|
||||
<Typography level='body-sm'>
|
||||
@@ -463,8 +443,7 @@ const MFASettings = () => {
|
||||
</Alert>
|
||||
|
||||
<Typography level='body-md'>
|
||||
Enter a verification code from your authenticator app to
|
||||
confirm:
|
||||
Enter a verification code from your authenticator app to confirm:
|
||||
</Typography>
|
||||
|
||||
<Input
|
||||
@@ -491,44 +470,29 @@ const MFASettings = () => {
|
||||
/>
|
||||
|
||||
{error && <Alert color='danger'>{error}</Alert>}
|
||||
|
||||
<Box className='flex gap-2'>
|
||||
<Button
|
||||
variant='outlined'
|
||||
onClick={closeDisableModal}
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color='danger'
|
||||
onClick={handleDisableMFA}
|
||||
disabled={disableCode.length !== 6}
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
Disable MFA
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</ModalDialog>
|
||||
</Modal>
|
||||
</AppModal>
|
||||
|
||||
{/* Backup Codes Modal */}
|
||||
<Modal
|
||||
<AppModal
|
||||
open={backupCodesModalOpen}
|
||||
onClose={() => setBackupCodesModalOpen(false)}
|
||||
title='New Backup Codes'
|
||||
size='sm'
|
||||
footer={
|
||||
<ModalActions
|
||||
primary={{
|
||||
label: "I've saved my backup codes",
|
||||
onClick: () => setBackupCodesModalOpen(false),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ModalDialog size='sm'>
|
||||
<ModalClose />
|
||||
<Typography level='h4' sx={{ mb: 2 }}>
|
||||
New Backup Codes
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={3}>
|
||||
<Alert color='warning'>
|
||||
<Typography level='body-sm'>
|
||||
Your previous backup codes are now invalid. Save these new
|
||||
codes in a safe place. Each code can only be used once.
|
||||
Your previous backup codes are now invalid. Save these new codes
|
||||
in a safe place. Each code can only be used once.
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
@@ -545,16 +509,8 @@ const MFASettings = () => {
|
||||
))}
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
color='primary'
|
||||
onClick={() => setBackupCodesModalOpen(false)}
|
||||
>
|
||||
I've saved my backup codes
|
||||
</Button>
|
||||
</Stack>
|
||||
</ModalDialog>
|
||||
</Modal>
|
||||
</AppModal>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
)
|
||||
|
||||
@@ -7,13 +7,13 @@ import {
|
||||
Input,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import Modal from '@mui/joy/Modal'
|
||||
import ModalDialog from '@mui/joy/ModalDialog'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import imageCompression from 'browser-image-compression'
|
||||
import { useRef, useState } from 'react'
|
||||
import Cropper from 'react-easy-crop'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppModal from '../../components/common/AppModal'
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
@@ -141,9 +141,7 @@ const ProfileSettings = () => {
|
||||
return (
|
||||
<SettingsLayout title={t('profile.title')}>
|
||||
<div className='grid gap-4 py-4' id='profile'>
|
||||
<Typography level='body-md'>
|
||||
{t('profile.description')}
|
||||
</Typography>
|
||||
<Typography level='body-md'>{t('profile.description')}</Typography>
|
||||
<Card
|
||||
sx={{
|
||||
display: 'flex',
|
||||
@@ -153,7 +151,10 @@ const ProfileSettings = () => {
|
||||
maxWidth: 400,
|
||||
}}
|
||||
>
|
||||
<Avatar src={resolvePhotoURL(userProfile?.image)} sx={{ width: 64, height: 64 }} />
|
||||
<Avatar
|
||||
src={resolvePhotoURL(userProfile?.image)}
|
||||
sx={{ width: 64, height: 64 }}
|
||||
/>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Button
|
||||
variant='soft'
|
||||
@@ -173,30 +174,43 @@ const ProfileSettings = () => {
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
<Modal
|
||||
<AppModal
|
||||
open={showCropper}
|
||||
onClose={() => {
|
||||
setShowCropper(false)
|
||||
setSelectedFile(null)
|
||||
}}
|
||||
title={t('profile.editPhoto', { defaultValue: 'Edit profile photo' })}
|
||||
size='sm'
|
||||
closeOnBackdrop={!isUploading}
|
||||
closeOnEscape={!isUploading}
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{
|
||||
label: t('profile.cancel'),
|
||||
disabled: isUploading,
|
||||
onClick: () => {
|
||||
setShowCropper(false)
|
||||
setSelectedFile(null)
|
||||
},
|
||||
}}
|
||||
primary={{
|
||||
label: t('profile.save'),
|
||||
loading: isUploading,
|
||||
onClick: handleCropSave,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ModalDialog
|
||||
layout='center'
|
||||
<Box
|
||||
sx={{
|
||||
width: 360,
|
||||
maxWidth: '90vw',
|
||||
bgcolor: '#fff',
|
||||
borderRadius: 2,
|
||||
boxShadow: 24,
|
||||
p: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: 420,
|
||||
width: '100%',
|
||||
maxWidth: 320,
|
||||
aspectRatio: '1',
|
||||
position: 'relative',
|
||||
mx: 'auto',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: 320, height: 320, position: 'relative', mt: 2 }}>
|
||||
<Cropper
|
||||
image={selectedFile}
|
||||
crop={crop}
|
||||
@@ -209,38 +223,7 @@ const ProfileSettings = () => {
|
||||
onCropComplete={onCropComplete}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
width: '100%',
|
||||
p: 2,
|
||||
mt: 2,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={handleCropSave}
|
||||
loading={isUploading}
|
||||
variant='solid'
|
||||
color='primary'
|
||||
size='md'
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
{t('profile.save')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowCropper(false)
|
||||
setSelectedFile(null)
|
||||
}}
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
>
|
||||
{t('profile.cancel')}
|
||||
</Button>
|
||||
</Box>
|
||||
</ModalDialog>
|
||||
</Modal>
|
||||
</AppModal>
|
||||
<Box sx={{ maxWidth: 400, mt: 3 }}>
|
||||
<Typography level='body-sm' sx={{ mb: 0.5 }}>
|
||||
{t('profile.displayName')}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import SmartTaskTitleInput from './SmartTaskTitleInput'
|
||||
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
|
||||
import { localAIService } from '../../service/LocalAIService'
|
||||
import { voiceInputService } from '../../service/VoiceInputService'
|
||||
@@ -831,16 +832,8 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
fullWidth={true}
|
||||
title='Create new task'
|
||||
footer={
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'end',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<ModalActions>
|
||||
<Button
|
||||
size='lg'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
onClick={handleCloseModal}
|
||||
@@ -857,7 +850,6 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
{/* Sub-panels (voice/scan) own their own confirm action */}
|
||||
{!showScan && !showVoice && (
|
||||
<Button
|
||||
size='lg'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
disabled={!taskTitle.trim()}
|
||||
@@ -869,7 +861,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</ModalActions>
|
||||
}
|
||||
>
|
||||
{!showScan && !showVoice && (
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import moment from 'moment'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Calendar from 'react-calendar'
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
|
||||
@@ -202,6 +203,7 @@ const DueDatePickerField = ({
|
||||
</Button>
|
||||
{hasDueDate && onClear && (
|
||||
<IconButton
|
||||
aria-label='Clear due date'
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
@@ -211,11 +213,9 @@ const DueDatePickerField = ({
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
right: -16,
|
||||
top: -18,
|
||||
right: -18,
|
||||
zIndex: 10,
|
||||
maxHeight: 18,
|
||||
maxWidth: 18,
|
||||
borderRadius: '50%',
|
||||
'&:hover': {
|
||||
bgcolor: 'danger.softBg',
|
||||
@@ -233,38 +233,22 @@ const DueDatePickerField = ({
|
||||
title='Due Date'
|
||||
fullWidth={false}
|
||||
footer={
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
{hasDueDate && (
|
||||
<Button
|
||||
variant='plain'
|
||||
color='danger'
|
||||
size='lg'
|
||||
onClick={() => {
|
||||
<ModalActions
|
||||
tertiary={
|
||||
hasDueDate
|
||||
? {
|
||||
label: 'Remove',
|
||||
color: 'danger',
|
||||
onClick: () => {
|
||||
onClear?.()
|
||||
setIsOpen(false)
|
||||
}}
|
||||
sx={{ mr: 'auto' }}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='lg'
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
size='lg'
|
||||
onClick={handleSave}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</Box>
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
secondary={{ label: 'Cancel', onClick: () => setIsOpen(false) }}
|
||||
primary={{ label: 'Apply', onClick: handleSave }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Box sx={{ fontFamily: 'var(--joy-fontFamily-body)', maxWidth: 360 }}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Close, NotificationsNone } from '@mui/icons-material'
|
||||
import { Box, Button, IconButton, Typography } from '@mui/joy'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import NotificationTemplate from '../../components/NotificationTemplate'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
|
||||
@@ -11,8 +12,7 @@ const getDisplayLabel = templates => {
|
||||
const n = templates[0]
|
||||
const numericValue = Number(n.value)
|
||||
if (numericValue === 0) return 'On due date'
|
||||
const unitName =
|
||||
n.unit === 'm' ? 'min' : n.unit === 'h' ? 'hr' : 'day'
|
||||
const unitName = n.unit === 'm' ? 'min' : n.unit === 'h' ? 'hr' : 'day'
|
||||
const absValue = Math.abs(numericValue)
|
||||
const plural = absValue !== 1 ? 's' : ''
|
||||
return `${absValue} ${unitName}${plural} ${numericValue < 0 ? 'before' : 'after'}`
|
||||
@@ -48,33 +48,22 @@ const NotificationPickerField = ({
|
||||
}
|
||||
|
||||
const footer = (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
{hasNotifications && (
|
||||
<Button
|
||||
variant='plain'
|
||||
color='danger'
|
||||
size='lg'
|
||||
onClick={() => {
|
||||
<ModalActions
|
||||
tertiary={
|
||||
hasNotifications
|
||||
? {
|
||||
label: 'Remove all',
|
||||
color: 'danger',
|
||||
onClick: () => {
|
||||
onClear?.()
|
||||
setIsOpen(false)
|
||||
}}
|
||||
sx={{ mr: 'auto' }}
|
||||
>
|
||||
Remove all
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='lg'
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='solid' color='primary' size='lg' onClick={handleSave}>
|
||||
Apply
|
||||
</Button>
|
||||
</Box>
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
secondary={{ label: 'Cancel', onClick: () => setIsOpen(false) }}
|
||||
primary={{ label: 'Apply', onClick: handleSave }}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -116,6 +105,7 @@ const NotificationPickerField = ({
|
||||
|
||||
{hasNotifications && onClear && (
|
||||
<IconButton
|
||||
aria-label='Remove reminders'
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
@@ -125,11 +115,9 @@ const NotificationPickerField = ({
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
right: -16,
|
||||
top: -18,
|
||||
right: -18,
|
||||
zIndex: 10,
|
||||
maxHeight: 18,
|
||||
maxWidth: 18,
|
||||
borderRadius: '50%',
|
||||
'&:hover': { bgcolor: 'danger.softBg' },
|
||||
}}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Close, CloudSync } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
@@ -11,6 +9,7 @@ import {
|
||||
} from '@mui/joy'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
import { commandQueue } from '../../utils/CommandQueue'
|
||||
|
||||
@@ -139,10 +138,24 @@ function PendingBadge({ commands, size = 'sm', sx = {} }) {
|
||||
{/* </Badge> */}
|
||||
</IconButton>
|
||||
|
||||
<ResponsiveModal open={isOpen} onClose={handleClose} size='sm'>
|
||||
<Typography level='title-lg' mb={0.5}>
|
||||
Pending actions
|
||||
</Typography>
|
||||
<ResponsiveModal
|
||||
open={isOpen}
|
||||
onClose={handleClose}
|
||||
size='sm'
|
||||
title='Pending actions'
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{ label: 'Close', onClick: handleClose }}
|
||||
primary={{
|
||||
label: 'Cancel all',
|
||||
color: 'danger',
|
||||
onClick: handleCancelAll,
|
||||
loading: isCancelingAll,
|
||||
disabled: commands.length === 0,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Typography level='body-sm' sx={{ color: 'text.tertiary', mb: 1.5 }}>
|
||||
{commands.length} action{commands.length > 1 ? 's' : ''} waiting to be
|
||||
synced.
|
||||
@@ -171,6 +184,7 @@ function PendingBadge({ commands, size = 'sm', sx = {} }) {
|
||||
</ListItemContent>
|
||||
|
||||
<IconButton
|
||||
aria-label={`Cancel ${formatCommandLabel(cmd.commandType)}`}
|
||||
variant='plain'
|
||||
color='danger'
|
||||
size='sm'
|
||||
@@ -182,22 +196,6 @@ function PendingBadge({ commands, size = 'sm', sx = {} }) {
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Divider sx={{ mb: 1 }} />
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
<Button variant='outlined' onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
color='danger'
|
||||
onClick={handleCancelAll}
|
||||
loading={isCancelingAll}
|
||||
disabled={commands.length === 0}
|
||||
>
|
||||
Cancel all
|
||||
</Button>
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
ArrowBack,
|
||||
CameraAlt,
|
||||
CheckCircle,
|
||||
Close,
|
||||
DocumentScanner,
|
||||
PhotoCamera,
|
||||
Replay,
|
||||
@@ -12,12 +11,12 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
LinearProgress,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
import { localAIService } from '../../service/LocalAIService'
|
||||
|
||||
@@ -82,7 +81,10 @@ async function runNativeOCR(imageSource) {
|
||||
}
|
||||
|
||||
const result = await Ocr.process({ image })
|
||||
return result.results.map(r => r.text).join('\n').trim()
|
||||
return result.results
|
||||
.map(r => r.text)
|
||||
.join('\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
async function runOCR(imageSource, onProgress) {
|
||||
@@ -214,7 +216,9 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
|
||||
try {
|
||||
text = await runNativeOCR(capturedImage)
|
||||
} catch {
|
||||
throw new Error('Native OCR is only available on iOS and Android devices.')
|
||||
throw new Error(
|
||||
'Native OCR is only available on iOS and Android devices.',
|
||||
)
|
||||
}
|
||||
} else {
|
||||
text = await runOCR(capturedImage, pct => setOcrProgress(pct))
|
||||
@@ -231,7 +235,9 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
|
||||
const task = await extractTaskFromOCR(text)
|
||||
|
||||
if (!task || !task.taskName) {
|
||||
setErrorMsg('Could not identify a task from this image. Please try a different photo.')
|
||||
setErrorMsg(
|
||||
'Could not identify a task from this image. Please try a different photo.',
|
||||
)
|
||||
setPhase('error')
|
||||
return
|
||||
}
|
||||
@@ -258,7 +264,9 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
|
||||
const { image, cancelled, error } = await scanDocument()
|
||||
if (cancelled) return
|
||||
if (error || !image) {
|
||||
setErrorMsg(error ? `Scanner error: ${error}` : 'Scan cancelled or failed.')
|
||||
setErrorMsg(
|
||||
error ? `Scanner error: ${error}` : 'Scan cancelled or failed.',
|
||||
)
|
||||
setPhase('error')
|
||||
return
|
||||
}
|
||||
@@ -377,7 +385,11 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
|
||||
<Typography level='body-sm'>
|
||||
Reading text from image… {ocrProgress}%
|
||||
</Typography>
|
||||
<LinearProgress determinate value={ocrProgress} sx={{ width: '100%' }} />
|
||||
<LinearProgress
|
||||
determinate
|
||||
value={ocrProgress}
|
||||
sx={{ width: '100%' }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{phase === 'ocr' && ocrMethod === 'native' && (
|
||||
@@ -466,7 +478,7 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
<ModalActions sx={{ mt: 1 }}>
|
||||
{phase === 'capture' && (
|
||||
<>
|
||||
<Button
|
||||
@@ -583,18 +595,7 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isProcessing && (
|
||||
<IconButton
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
onClick={handleClose}
|
||||
sx={{ ml: 'auto' }}
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</ModalActions>
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useState } from 'react'
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import { getRecurrentChipText } from '../../utils/ChoreCardHelpers'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
|
||||
@@ -472,6 +473,7 @@ const RepeatPickerField = ({
|
||||
|
||||
{hasRepeat && onClear && (
|
||||
<IconButton
|
||||
aria-label='Clear repeat schedule'
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
@@ -481,11 +483,9 @@ const RepeatPickerField = ({
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
right: -16,
|
||||
top: -18,
|
||||
right: -18,
|
||||
zIndex: 10,
|
||||
maxHeight: 18,
|
||||
maxWidth: 18,
|
||||
borderRadius: '50%',
|
||||
'&:hover': { bgcolor: 'danger.softBg' },
|
||||
}}
|
||||
@@ -500,38 +500,22 @@ const RepeatPickerField = ({
|
||||
onClose={() => setIsOpen(false)}
|
||||
title='Repeat Schedule'
|
||||
footer={
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
{hasRepeat && (
|
||||
<Button
|
||||
variant='plain'
|
||||
color='danger'
|
||||
size='lg'
|
||||
onClick={() => {
|
||||
<ModalActions
|
||||
tertiary={
|
||||
hasRepeat
|
||||
? {
|
||||
label: 'Remove',
|
||||
color: 'danger',
|
||||
onClick: () => {
|
||||
onClear?.()
|
||||
setIsOpen(false)
|
||||
}}
|
||||
sx={{ mr: 'auto' }}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='lg'
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
size='lg'
|
||||
onClick={handleSave}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</Box>
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
secondary={{ label: 'Cancel', onClick: () => setIsOpen(false) }}
|
||||
primary={{ label: 'Apply', onClick: handleSave }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{/* Frequency type selector */}
|
||||
|
||||
Reference in New Issue
Block a user