Unify modals and buttons style and layout

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

View File

@@ -1,16 +1,8 @@
import { Check, Star } from '@mui/icons-material' import { Check, Star } from '@mui/icons-material'
import { import { Box, Card, Chip, Divider, Radio, Typography } from '@mui/joy'
Box,
Button,
Card,
Chip,
Divider,
Modal,
ModalDialog,
Radio,
Typography,
} from '@mui/joy'
import { useState } from 'react' import { useState } from 'react'
import AppModal from './common/AppModal'
import ModalActions from './common/ModalActions'
import { useNotification } from '../service/NotificationProvider' import { useNotification } from '../service/NotificationProvider'
import { GetSubscriptionSession } from '../utils/Fetcher' import { GetSubscriptionSession } from '../utils/Fetcher'
@@ -76,183 +68,158 @@ const SubscriptionModal = ({ open, onClose }) => {
} }
return ( return (
<Modal open={open} onClose={onClose}> <AppModal
<ModalDialog open={open}
layout='center' onClose={onClose}
sx={{ title='Upgrade to Plus'
width: 600, description='Unlock reminders, rich task details, and advanced automation.'
maxWidth: '95vw', size='lg'
maxHeight: '95vh', closeOnBackdrop={!isLoading}
overflow: 'auto', closeOnEscape={!isLoading}
p: 0, footer={
}} <ModalActions
> stackOnMobile
<Box sx={{ p: 4 }}> secondary={{ label: 'Cancel', onClick: onClose, disabled: isLoading }}
{/* Header */} primary={{
<Box sx={{ textAlign: 'center', mb: 4 }}> label: 'Subscribe',
<Typography level='h3' sx={{ mb: 1 }}> onClick: handleSubscribe,
Upgrade to Plus loading: isLoading,
</Typography> }}
</Box> />
}
{/* Features List */} >
<Box sx={{ mb: 2 }}> {/* Features List */}
<Typography level='title-lg' sx={{ mb: 2 }}> <Box sx={{ mb: 2 }}>
What&apos;s included: <Typography level='title-lg' sx={{ mb: 2 }}>
</Typography> What&apos;s included:
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}> </Typography>
{features.map((feature, index) => ( <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Box {features.map((feature, index) => (
key={index} <Box
sx={{ display: 'flex', alignItems: 'center', gap: 2 }} key={index}
> sx={{ display: 'flex', alignItems: 'center', gap: 2 }}
<Check color='success' sx={{ fontSize: 20 }} /> >
<Typography level='body-md'>{feature}</Typography> <Check color='success' sx={{ fontSize: 20 }} />
</Box> <Typography level='body-md'>{feature}</Typography>
))}
</Box> </Box>
</Box> ))}
<Divider sx={{ my: 3 }} />
{/* Plan Selection */}
<Box
sx={{ display: 'flex', flexDirection: 'column', gap: 1.2, mb: 4 }}
>
{Object.entries(plans).map(([key, plan]) => (
<Card
key={key}
color={selectedPlan === key ? 'primary' : 'neutral'}
onClick={() => setSelectedPlan(key)}
sx={{
width: '100%',
minHeight: 48,
maxHeight: 64,
cursor: 'pointer',
transition: 'all 0.2s',
mb: 0.2,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 2.5,
py: 1.2,
position: 'relative',
overflow: 'visible',
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 2,
justifyContent: 'flex-start',
width: '100%',
}}
>
<Radio
checked={selectedPlan === key}
onChange={() => setSelectedPlan(key)}
value={key}
name='subscription-plan'
color='primary'
sx={{ mr: 1 }}
/>
<Typography level='body-md' sx={{ fontWeight: 600 }}>
{key.charAt(0).toUpperCase() + key.slice(1)}
</Typography>
<Typography level='body-sm' sx={{ fontWeight: 500, ml: 1 }}>
{plan.price}
<span style={{ color: '#888', fontWeight: 400 }}>
{' '}
/ {plan.period}
</span>
</Typography>
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
position: 'absolute',
right: 16,
top: -18,
}}
>
{plan.popular && (
<Chip
variant='solid'
color='warning'
size='sm'
startDecorator={<Star />}
sx={{
fontWeight: 600,
fontSize: 12,
px: 1,
py: 0.1,
boxShadow: 2,
mt: 0.8,
}}
>
Most Popular
</Chip>
)}
{plan.savings && (
<Chip
variant='soft'
color='success'
size='sm'
sx={{
fontWeight: 600,
fontSize: 12,
px: 1,
py: 0.1,
boxShadow: 2,
mt: 0.8,
}}
>
{plan.savings}
</Chip>
)}
</Box>
</Card>
))}
</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'
color='neutral'
sx={{ textAlign: 'center', mt: 3 }}
>
Cancel anytime. No hidden fees. Secure payment powered by Stripe.
</Typography>
</Box> </Box>
</ModalDialog> </Box>
</Modal> <Divider sx={{ my: 3 }} />
{/* Plan Selection */}
<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)}
sx={{
width: '100%',
minHeight: 48,
maxHeight: 64,
cursor: 'pointer',
transition: 'all 0.2s',
mb: 0.2,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 2.5,
py: 1.2,
position: 'relative',
overflow: 'visible',
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 2,
justifyContent: 'flex-start',
width: '100%',
}}
>
<Radio
id={`subscription-plan-${key}`}
checked={selectedPlan === key}
onChange={() => setSelectedPlan(key)}
value={key}
name='subscription-plan'
color='primary'
sx={{ mr: 1 }}
/>
<Typography level='body-md' sx={{ fontWeight: 600 }}>
{key.charAt(0).toUpperCase() + key.slice(1)}
</Typography>
<Typography level='body-sm' sx={{ fontWeight: 500, ml: 1 }}>
{plan.price}
<span style={{ color: '#888', fontWeight: 400 }}>
{' '}
/ {plan.period}
</span>
</Typography>
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
position: 'absolute',
right: 16,
top: -18,
}}
>
{plan.popular && (
<Chip
variant='solid'
color='warning'
size='sm'
startDecorator={<Star />}
sx={{
fontWeight: 600,
fontSize: 12,
px: 1,
py: 0.1,
boxShadow: 2,
mt: 0.8,
}}
>
Most Popular
</Chip>
)}
{plan.savings && (
<Chip
variant='soft'
color='success'
size='sm'
sx={{
fontWeight: 600,
fontSize: 12,
px: 1,
py: 0.1,
boxShadow: 2,
mt: 0.8,
}}
>
{plan.savings}
</Chip>
)}
</Box>
</Card>
))}
</Box>
{/* Footer */}
<Typography
level='body-xs'
color='neutral'
sx={{ textAlign: 'center', mt: 3 }}
>
Cancel anytime. No hidden fees. Secure payment powered by Stripe.
</Typography>
</AppModal>
) )
} }

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -10,7 +10,8 @@ import {
Typography, Typography,
} from '@mui/joy' } from '@mui/joy'
import { useState } from 'react' import { useState } from 'react'
import BottomSheetModal from './BottomSheetModal' import AppModal from './AppModal'
import ModalActions from './ModalActions'
import ActiveFilterChips from './filter/ActiveFilterChips' import ActiveFilterChips from './filter/ActiveFilterChips'
/** /**
@@ -41,7 +42,10 @@ const DATE_RANGE_PRESETS = [
label: 'Today', label: 'Today',
getRange: () => { getRange: () => {
const t = d(new Date()) 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', label: 'Yesterday',
getRange: () => { getRange: () => {
const t = d(new Date()) const t = d(new Date())
const y = new Date(t); y.setDate(t.getDate() - 1) const y = new Date(t)
return { from: d(y).toISOString(), to: d(y, 23, 59, 59, 999).toISOString() } 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', label: 'This Week',
getRange: () => { getRange: () => {
const t = d(new Date()) const t = d(new Date())
const start = new Date(t); start.setDate(t.getDate() - t.getDay()) const start = new Date(t)
const end = new Date(start); end.setDate(start.getDate() + 6) start.setDate(t.getDate() - t.getDay())
return { from: d(start).toISOString(), to: d(end, 23, 59, 59, 999).toISOString() } 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', label: 'Last 7 Days',
getRange: () => { getRange: () => {
const t = d(new Date()) const t = d(new Date())
const start = new Date(t); start.setDate(t.getDate() - 6) const start = new Date(t)
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() } 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 n = new Date()
const start = new Date(n.getFullYear(), n.getMonth(), 1) const start = new Date(n.getFullYear(), n.getMonth(), 1)
const end = new Date(n.getFullYear(), n.getMonth() + 1, 0) 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', label: 'Last 30 Days',
getRange: () => { getRange: () => {
const t = d(new Date()) const t = d(new Date())
const start = new Date(t); start.setDate(t.getDate() - 29) const start = new Date(t)
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() } 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', label: 'Last 3 Months',
getRange: () => { getRange: () => {
const t = d(new Date()) const t = d(new Date())
const start = new Date(t); start.setMonth(t.getMonth() - 3) const start = new Date(t)
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() } 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 activeFilterCount = filterDefs.filter(def => {
const value = activeFilters[def.id] const value = activeFilters[def.id]
if (value === undefined || value === null) return false 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 (Array.isArray(value) && value.length === 0) return false
if (def.type === 'date-range') return !!(value?.from || value?.to) if (def.type === 'date-range') return !!(value?.from || value?.to)
return true return true
@@ -183,13 +212,18 @@ const FilterBar = ({
if (value === undefined || value === null) return null if (value === undefined || value === null) return null
if (def.type === 'single-select') { 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 return def.options?.find(o => o.value === value)?.label ?? def.label
} }
if (def.type === 'boolean') return 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) { if (value.length === 1) {
return def.options?.find(o => o.value === value[0])?.label ?? def.label return def.options?.find(o => o.value === value[0])?.label ?? def.label
} }
@@ -199,7 +233,10 @@ const FilterBar = ({
if (def.type === 'date-range') { if (def.type === 'date-range') {
if (!value?.from && !value?.to) return null if (!value?.from && !value?.to) return null
if (value.preset) { 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 from = fmtDisplayDate(value.from)
const to = fmtDisplayDate(value.to) const to = fmtDisplayDate(value.to)
@@ -259,7 +296,15 @@ const FilterBar = ({
return ( return (
<> <>
{/* ── Inline bar ─────────────────────────────────────── */} {/* ── 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 <Badge
badgeContent={activeFilterCount || null} badgeContent={activeFilterCount || null}
color='primary' color='primary'
@@ -309,37 +354,42 @@ const FilterBar = ({
</Box> </Box>
{/* ── Bottom sheet ────────────────────────────────────── */} {/* ── Bottom sheet ────────────────────────────────────── */}
<BottomSheetModal <AppModal
open={isOpen} open={isOpen}
isMobile
onClose={() => setIsOpen(false)} onClose={() => setIsOpen(false)}
title={ title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Tune sx={{ fontSize: 20 }} /> <Tune sx={{ fontSize: 20 }} />
Filters Filters
{hasActive && ( {hasActive && (
<Chip size='sm' variant='solid' color='primary' sx={modalCountChipSx}> <Chip
size='sm'
variant='solid'
color='primary'
sx={modalCountChipSx}
>
{activeFilterCount} {activeFilterCount}
</Chip> </Chip>
)} )}
</Box> </Box>
} }
footer={ footer={
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 1 }}> <ModalActions
<Button tertiary={{
variant='plain' label: 'Clear all',
color='danger' color: 'danger',
size='sm' disabled: !hasActive,
disabled={!hasActive} onClick: onClearAll,
onClick={onClearAll} }}
> primary={{
Clear all label:
</Button> resultCount !== undefined
<Button onClick={() => setIsOpen(false)} sx={{ minWidth: 140 }}> ? `Show ${resultCount} result${resultCount !== 1 ? 's' : ''}`
{resultCount !== undefined : 'Done',
? `Show ${resultCount} result${resultCount !== 1 ? 's' : ''}` onClick: () => setIsOpen(false),
: 'Done'} }}
</Button> />
</Box>
} }
> >
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
@@ -348,9 +398,18 @@ const FilterBar = ({
{idx > 0 && <Divider sx={{ my: 2.5 }} />} {idx > 0 && <Divider sx={{ my: 2.5 }} />}
{/* Section header */} {/* 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 && ( {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} {def.icon}
</Box> </Box>
)} )}
@@ -359,21 +418,41 @@ const FilterBar = ({
</Typography> </Typography>
{/* active badge in header */} {/* active badge in header */}
{def.type === 'multi-select' && (activeFilters[def.id]?.length ?? 0) > 0 && ( {def.type === 'multi-select' &&
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}> (activeFilters[def.id]?.length ?? 0) > 0 && (
{activeFilters[def.id].length} selected <Chip
</Chip> size='sm'
)} variant='solid'
{def.type === 'single-select' && activeFilters[def.id] != null && (() => { color='primary'
const opt = def.options?.find(o => o.value === activeFilters[def.id]) sx={sectionBadgeChipSx}
return opt ? ( >
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}> {activeFilters[def.id].length} selected
{opt.label}
</Chip> </Chip>
) : null )}
})()} {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}
>
{opt.label}
</Chip>
) : null
})()}
{def.type === 'date-range' && getActiveChipLabel(def) && ( {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)} {getActiveChipLabel(def)}
</Chip> </Chip>
)} )}
@@ -383,18 +462,28 @@ const FilterBar = ({
{def.type === 'multi-select' && ( {def.type === 'multi-select' && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{def.options?.map(opt => { {def.options?.map(opt => {
const isSelected = (activeFilters[def.id] || []).includes(opt.value) const isSelected = (activeFilters[def.id] || []).includes(
opt.value,
)
return ( return (
<Chip <Chip
key={opt.value} key={opt.value}
variant={isSelected ? 'solid' : 'soft'} variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? (opt.color ?? 'primary') : 'neutral'} color={
isSelected ? (opt.color ?? 'primary') : 'neutral'
}
startDecorator={ startDecorator={
opt.avatar ? ( 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 ? ( ) : isSelected ? (
<Check sx={{ fontSize: 14 }} /> <Check sx={{ fontSize: 14 }} />
) : (opt.icon ?? null) ) : (
(opt.icon ?? null)
)
} }
onClick={() => handleMultiToggle(def.id, opt.value)} onClick={() => handleMultiToggle(def.id, opt.value)}
sx={selectableChipSx} sx={selectableChipSx}
@@ -415,13 +504,21 @@ const FilterBar = ({
<Chip <Chip
key={opt.value} key={opt.value}
variant={isSelected ? 'solid' : 'soft'} variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? (opt.color ?? 'primary') : 'neutral'} color={
isSelected ? (opt.color ?? 'primary') : 'neutral'
}
startDecorator={ startDecorator={
opt.avatar ? ( 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 ? ( ) : isSelected ? (
<Check sx={{ fontSize: 14 }} /> <Check sx={{ fontSize: 14 }} />
) : (opt.icon ?? null) ) : (
(opt.icon ?? null)
)
} }
onClick={() => handleSingleToggle(def.id, opt.value)} onClick={() => handleSingleToggle(def.id, opt.value)}
sx={selectableChipSx} sx={selectableChipSx}
@@ -438,7 +535,11 @@ const FilterBar = ({
<Chip <Chip
variant={activeFilters[def.id] ? 'solid' : 'soft'} variant={activeFilters[def.id] ? 'solid' : 'soft'}
color={activeFilters[def.id] ? 'primary' : 'neutral'} 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)} onClick={() => handleBoolToggle(def.id)}
sx={selectableChipSx} sx={selectableChipSx}
> >
@@ -447,58 +548,84 @@ const FilterBar = ({
)} )}
{/* date-range */} {/* date-range */}
{def.type === 'date-range' && (() => { {def.type === 'date-range' &&
const val = activeFilters[def.id] || {} (() => {
return ( const val = activeFilters[def.id] || {}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}> return (
{/* Preset chips */} <Box
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}> sx={{
{DATE_RANGE_PRESETS.map(preset => { display: 'flex',
const isSelected = val.preset === preset.value flexDirection: 'column',
return ( gap: 1.5,
<Chip }}
key={preset.value} >
variant={isSelected ? 'solid' : 'soft'} {/* Preset chips */}
color={isSelected ? 'primary' : 'neutral'} <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
startDecorator={isSelected ? <Check sx={{ fontSize: 14 }} /> : null} {DATE_RANGE_PRESETS.map(preset => {
onClick={() => handleDateRangePreset(def.id, preset.value)} const isSelected = val.preset === preset.value
sx={selectableChipSx} return (
> <Chip
{preset.label} key={preset.value}
</Chip> variant={isSelected ? 'solid' : 'soft'}
) color={isSelected ? 'primary' : 'neutral'}
})} startDecorator={
</Box> isSelected ? (
<Check sx={{ fontSize: 14 }} />
) : null
}
onClick={() =>
handleDateRangePreset(def.id, preset.value)
}
sx={selectableChipSx}
>
{preset.label}
</Chip>
)
})}
</Box>
{/* Custom date inputs */} {/* Custom date inputs */}
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}> <Box
<Input sx={{ display: 'flex', gap: 1, alignItems: 'center' }}
type='date' >
size='sm' <Input
value={toInputDate(val.from)} type='date'
onChange={e => handleDateRangeInput(def.id, 'from', e.target.value)} size='sm'
slotProps={{ input: { max: toInputDate(val.to) || undefined } }} value={toInputDate(val.from)}
sx={{ flex: 1, fontSize: '0.8rem' }} onChange={e =>
/> handleDateRangeInput(def.id, 'from', e.target.value)
<Typography level='body-xs' sx={{ color: 'text.tertiary', flexShrink: 0 }}> }
slotProps={{
</Typography> input: { max: toInputDate(val.to) || undefined },
<Input }}
type='date' sx={{ flex: 1, fontSize: '0.8rem' }}
size='sm' />
value={toInputDate(val.to)} <Typography
onChange={e => handleDateRangeInput(def.id, 'to', e.target.value)} level='body-xs'
slotProps={{ input: { min: toInputDate(val.from) || undefined } }} sx={{ color: 'text.tertiary', flexShrink: 0 }}
sx={{ flex: 1, fontSize: '0.8rem' }} >
/>
</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 },
}}
sx={{ flex: 1, fontSize: '0.8rem' }}
/>
</Box>
</Box> </Box>
</Box> )
) })()}
})()}
</Box> </Box>
))} ))}
</Box> </Box>
</BottomSheetModal> </AppModal>
</> </>
) )
} }

View 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

View 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`.

View File

@@ -4,22 +4,33 @@ import { CssVarsProvider, extendTheme } from '@mui/joy/styles'
import PropType from 'prop-types' import PropType from 'prop-types'
const primaryColor = 'cyan' const primaryColor = 'cyan'
const shades = [ const shades = [
'50', '50',
...Array.from({ length: 9 }, (_, i) => String((i + 1) * 100)), ...Array.from({ length: 9 }, (_, i) => String((i + 1) * 100)),
] ]
const getPallete = (key = primaryColor) => { const getPalette = (key = primaryColor) =>
return shades.reduce((acc, shade) => { shades.reduce((palette, shade) => {
acc[shade] = COLORS[key][shade] palette[shade] = COLORS[key][shade]
return acc 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 = '24px'
const ICON_BUTTON_RADIUS = '10px'
const theme = extendTheme({ const theme = extendTheme({
radius: {
xs: '6px',
sm: '8px',
md: '10px',
lg: '12px',
xl: '16px',
},
colorSchemes: { colorSchemes: {
light: { light: {
palette: { palette: {
@@ -42,42 +53,100 @@ const theme = extendTheme({
200: '#fbd5d5', 200: '#fbd5d5',
300: '#f9c1c1', 300: '#f9c1c1',
400: '#f6a8a8', 400: '#f6a8a8',
500: '', 500: '#ef4444',
600: '#f47272', 600: '#dc2626',
700: '#e33434', 700: '#b91c1c',
800: '#cc1f1a', 800: '#991b1b',
900: '#b91c1c', 900: '#7f1d1d',
},
warning: {
50: '#fffdf7',
100: '#fef8e1',
200: '#fdecb2',
300: '#fcd982',
400: '#fbcf52',
500: '#f9c222',
600: '#f6b81e',
700: '#f3ae1a',
800: '#f0a416',
900: '#e99b0e',
}, },
}, },
warning: { },
50: '#fffdf7', dark: {
100: '#fef8e1', palette: {
200: '#fdecb2', primary: primaryPalette,
300: '#fcd982',
400: '#fbcf52',
500: '#f9c222',
600: '#f6b81e',
700: '#f3ae1a',
800: '#f0a416',
900: '#e99b0e',
}, },
}, },
}, },
dark: { components: {
palette: { JoyButton: {
primary: primaryPalette, 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 }) => { const ThemeContext = ({ children }) => (
return ( <CssVarsProvider theme={theme}>
<CssVarsProvider theme={theme}> <CssBaseline />
<CssBaseline /> {children}
{children} </CssVarsProvider>
</CssVarsProvider> )
)
}
ThemeContext.propTypes = { ThemeContext.propTypes = {
children: PropType.node, children: PropType.node,

View File

@@ -1,18 +1,23 @@
import BottomSheetModal from '../components/common/BottomSheetModal' import useMediaQuery from '@mui/material/useMediaQuery'
import FadeModal from '../components/common/FadeModal' import { createElement } from 'react'
import useWindowWidth from './useWindowWidth' 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 * Backwards-compatible access to the app modal system.
* @param {number} breakpoint - Screen width breakpoint to switch between modals (default: 768px) *
* @returns {Object} - { Modal: Component, isMobile: boolean } * New code may render AppModal directly when it already knows the desired
* presentation. Existing callers can continue using ResponsiveModal.
*/ */
export const useResponsiveModal = (breakpoint = 768) => { export const useResponsiveModal = (breakpoint = 768) => {
const windowWidth = useWindowWidth() const isMobile = useMediaQuery(`(max-width:${breakpoint}px)`)
const isMobile = windowWidth <= breakpoint
return { return {
ResponsiveModal: isMobile ? BottomSheetModal : FadeModal, ResponsiveModal: isMobile ? MobileAppModal : DesktopAppModal,
isMobile, isMobile,
} }
} }

View File

@@ -1,16 +1,8 @@
import { Security, Smartphone } from '@mui/icons-material' import { Security, Smartphone } from '@mui/icons-material'
import { import { Alert, Box, Input, Link, Stack, Typography } from '@mui/joy'
Alert,
Box,
Button,
Input,
Link,
ModalClose,
Stack,
Typography,
} from '@mui/joy'
import { useState } from 'react' import { useState } from 'react'
import ModalActions from '../../components/common/ModalActions'
import { useResponsiveModal } from '../../hooks/useResponsiveModal' import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import { VerifyMFA } from '../../utils/Fetcher' import { VerifyMFA } from '../../utils/Fetcher'
@@ -43,12 +35,15 @@ const MFAVerificationModal = ({
onSuccess(data) onSuccess(data)
} else { } else {
const errorData = await response.json() const errorData = await response.json()
setError( const message =
errorData.message || 'Invalid verification code. Please try again.', errorData.message || 'Invalid verification code. Please try again.'
) setError(message)
onError?.(message)
} }
} catch (error) { } 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) console.error('MFA verification error:', error)
} finally { } finally {
setLoading(false) setLoading(false)
@@ -73,17 +68,29 @@ const MFAVerificationModal = ({
<ResponsiveModal <ResponsiveModal
open={open} open={open}
onClose={handleClose} onClose={handleClose}
size='lg' size='md'
fullWidth={true}
title='Two-Factor Authentication' 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'> <Box className='mb-4 text-center'>
<Security sx={{ fontSize: 48, color: 'primary.main', mb: 2 }} /> <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> </Box>
<Stack spacing={3}> <Stack spacing={3}>
@@ -120,16 +127,6 @@ const MFAVerificationModal = ({
</Alert> </Alert>
)} )}
<Button
color='primary'
loading={loading}
onClick={handleVerify}
disabled={!verificationCode.trim()}
size='lg'
>
Verify & Sign In
</Button>
<Box className='text-center'> <Box className='text-center'>
<Link <Link
component='button' component='button'

View File

@@ -5,7 +5,7 @@ import {
Pause, Pause,
PlayArrow, PlayArrow,
} from '@mui/icons-material' } 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' import { useEffect, useRef, useState } from 'react'
const TimerSplitButton = ({ const TimerSplitButton = ({
@@ -95,36 +95,25 @@ const TimerSplitButton = ({
disabled={disabled} disabled={disabled}
> >
{/* Main action button */} {/* Main action button */}
<IconButton <Button
onClick={handleMainAction} onClick={handleMainAction}
disabled={disabled} disabled={disabled}
size='md' size='md'
startDecorator={chore.status === 1 ? <Pause /> : <PlayArrow />}
sx={{ sx={{
px: 3,
py: 1,
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
minWidth: fullWidth ? 'auto' : 120, minWidth: fullWidth ? 'auto' : 120,
flex: fullWidth ? 1 : 'none', flex: fullWidth ? 1 : 'none',
}} }}
> >
{chore.status === 1 ? <Pause /> : <PlayArrow />}
{chore.status === 1 ? 'Pause' : 'Resume'} {chore.status === 1 ? 'Pause' : 'Resume'}
</IconButton> </Button>
{/* Dropdown arrow button */} {/* Dropdown arrow button */}
<IconButton <IconButton
onClick={handleMenuOpen} onClick={handleMenuOpen}
disabled={disabled} disabled={disabled}
size='lg' size='md'
sx={{ sx={{ px: 1, minWidth: 'auto' }}
px: 1,
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderLeft: '1px solid',
borderLeftColor: 'divider',
minWidth: 'auto',
}}
> >
<ArrowDropDown /> <ArrowDropDown />
</IconButton> </IconButton>

View File

@@ -706,7 +706,7 @@ const ArchivedTasks = () => {
}} }}
onChange={handleSearchChange} onChange={handleSearchChange}
startDecorator={ startDecorator={
<KeyboardShortcutHint shortcut='F' show={showKeyboardShortcuts} /> showKeyboardShortcuts ? <KeyboardShortcutHint shortcut='F' /> : null
} }
endDecorator={ endDecorator={
searchTerm && ( searchTerm && (

View File

@@ -1,6 +1,7 @@
import { Close, HelpOutline, Keyboard } from '@mui/icons-material' import { HelpOutline } from '@mui/icons-material'
import { Box, Button, Card, Divider, IconButton, Typography } from '@mui/joy' import { Box, Card, IconButton, Typography } from '@mui/joy'
import { useState } from 'react' import { useState } from 'react'
import ModalActions from '../../components/common/ModalActions'
import { useResponsiveModal } from '../../hooks/useResponsiveModal' import { useResponsiveModal } from '../../hooks/useResponsiveModal'
const MultiSelectHelp = ({ isVisible = true }) => { const MultiSelectHelp = ({ isVisible = true }) => {
@@ -28,37 +29,24 @@ const MultiSelectHelp = ({ isVisible = true }) => {
borderRadius: '50%', borderRadius: '50%',
boxShadow: 'lg', boxShadow: 'lg',
}} }}
aria-label='Show keyboard shortcuts'
title='Show keyboard shortcuts' title='Show keyboard shortcuts'
> >
<HelpOutline /> <HelpOutline />
</IconButton> </IconButton>
{/* Help Modal */} {/* Help Modal */}
<ResponsiveModal open={isHelpOpen} onClose={() => setIsHelpOpen(false)}> <ResponsiveModal
<Box open={isHelpOpen}
sx={{ onClose={() => setIsHelpOpen(false)}
display: 'flex', title='Multi-select Mode'
alignItems: 'center', description='Use these keyboard shortcuts to work more efficiently.'
justifyContent: 'space-between', footer={
mb: 2, <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 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Selection shortcuts */} {/* Selection shortcuts */}
<Card variant='soft' sx={{ p: 2 }}> <Card variant='soft' sx={{ p: 2 }}>
@@ -107,16 +95,6 @@ const MultiSelectHelp = ({ isVisible = true }) => {
</Box> </Box>
</Card> </Card>
</Box> </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> </ResponsiveModal>
</> </>
) )

View File

@@ -45,7 +45,7 @@ import {
Typography, Typography,
} from '@mui/joy' } from '@mui/joy'
import { useEffect, useRef, useState } from 'react' 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 ActiveFilterChips from '../../../components/common/filter/ActiveFilterChips'
import { Z_INDEX } from '../../../constants/zIndex' import { Z_INDEX } from '../../../constants/zIndex'
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint' import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
@@ -105,13 +105,15 @@ const OptionChips = ({ options, selected, multi, onToggle }) => (
<Chip <Chip
key={opt.value} key={opt.value}
variant={isSelected ? 'solid' : 'soft'} variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? opt.color ?? 'primary' : 'neutral'} color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
startDecorator={ startDecorator={
opt.icon != null opt.icon != null ? (
? isSelected isSelected ? (
? <Check sx={{ fontSize: 14 }} /> <Check sx={{ fontSize: 14 }} />
: opt.icon ) : (
: undefined opt.icon
)
) : undefined
} }
onClick={() => onToggle(opt.value)} onClick={() => onToggle(opt.value)}
sx={{ sx={{
@@ -287,7 +289,9 @@ const ChoreToolbar = ({
return member?.displayName || member?.username || String(value) return member?.displayName || member?.username || String(value)
} }
if (condition.type === 'status') { 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') { if (condition.type === 'priority') {
return Priorities.find(p => p.value === value)?.name || String(value) return Priorities.find(p => p.value === value)?.name || String(value)
@@ -363,8 +367,7 @@ const ChoreToolbar = ({
setLocalSelections(conditionsToSelections(tempFilter.conditions)) setLocalSelections(conditionsToSelections(tempFilter.conditions))
if (tempFilterMeta?.sourceFilterId) { if (tempFilterMeta?.sourceFilterId) {
const sourceFilter = const sourceFilter =
savedFilters.find(f => f.id === tempFilterMeta.sourceFilterId) || savedFilters.find(f => f.id === tempFilterMeta.sourceFilterId) || null
null
setEditingSavedFilter( setEditingSavedFilter(
sourceFilter || sourceFilter ||
(tempFilterMeta.sourceFilterId (tempFilterMeta.sourceFilterId
@@ -444,7 +447,13 @@ const ChoreToolbar = ({
FILTER_COLORS.find(c => !usedColors.includes(c.value))?.value ?? FILTER_COLORS.find(c => !usedColors.includes(c.value))?.value ??
FILTER_COLORS[0].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 }) applyTempFilter?.({ conditions, operator: 'AND' }, { name })
onFilterSaved?.(name) onFilterSaved?.(name)
}) })
@@ -460,16 +469,13 @@ const ChoreToolbar = ({
const conditions = selectionsToConditions(localSelections) const conditions = selectionsToConditions(localSelections)
if (conditions.length === 0) return if (conditions.length === 0) return
updateFilter( updateFilter(editingSavedFilter.id, {
editingSavedFilter.id, name: editingSavedFilter.name,
{ description: editingSavedFilter.description || '',
name: editingSavedFilter.name, color: editingSavedFilter.color,
description: editingSavedFilter.description || '', conditions,
color: editingSavedFilter.color, operator: 'AND',
conditions, })?.then?.(() => {
operator: 'AND',
},
)?.then?.(() => {
clearTempFilter?.() clearTempFilter?.()
onSavedFilterClick?.(editingSavedFilter.id) onSavedFilterClick?.(editingSavedFilter.id)
onFilterSaved?.(editingSavedFilter.name) onFilterSaved?.(editingSavedFilter.name)
@@ -498,9 +504,21 @@ const ChoreToolbar = ({
] ]
const viewOptions = [ const viewOptions = [
{ value: 'default', label: 'Cards', icon: <ViewAgenda sx={{ fontSize: 16 }} /> }, {
{ value: 'compact', label: 'Compact', icon: <ViewComfy sx={{ fontSize: 16 }} /> }, value: 'default',
{ value: 'calendar', label: 'Calendar', icon: <CalendarMonth sx={{ fontSize: 16 }} /> }, 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 ( return (
@@ -536,6 +554,7 @@ const ChoreToolbar = ({
size='sm' size='sm'
sx={{ height: 32, width: 32, borderRadius: '50%' }} sx={{ height: 32, width: 32, borderRadius: '50%' }}
onClick={openFilterSheet} onClick={openFilterSheet}
aria-label='Filters'
title='Filters' title='Filters'
> >
<FilterList /> <FilterList />
@@ -543,13 +562,14 @@ const ChoreToolbar = ({
</Badge> </Badge>
{/* Project selector */} {/* Project selector */}
{!filterActive && projects.filter(p => p.id !== 'default').length > 0 && ( {!filterActive &&
<ProjectSelector projects.filter(p => p.id !== 'default').length > 0 && (
selectedProject={selectedProject?.name || 'Default Project'} <ProjectSelector
onProjectSelect={onProjectSelect} selectedProject={selectedProject?.name || 'Default Project'}
showKeyboardShortcuts={showKeyboardShortcuts} onProjectSelect={onProjectSelect}
/> showKeyboardShortcuts={showKeyboardShortcuts}
)} />
)}
{/* Display button — View + Group combined */} {/* Display button — View + Group combined */}
<IconButton <IconButton
@@ -558,6 +578,7 @@ const ChoreToolbar = ({
size='sm' size='sm'
sx={{ height: 32, width: 32, borderRadius: '50%' }} sx={{ height: 32, width: 32, borderRadius: '50%' }}
onClick={() => setDisplaySheetOpen(true)} onClick={() => setDisplaySheetOpen(true)}
aria-label='View and group options'
title='View & Group' title='View & Group'
> >
{viewMode === 'calendar' ? ( {viewMode === 'calendar' ? (
@@ -577,6 +598,9 @@ const ChoreToolbar = ({
size='sm' size='sm'
sx={{ height: 32, width: 32, borderRadius: '50%' }} sx={{ height: 32, width: 32, borderRadius: '50%' }}
onClick={onToggleMultiSelect} onClick={onToggleMultiSelect}
aria-label={
isMultiSelectMode ? 'Exit multi-select' : 'Enter multi-select'
}
title={ title={
isMultiSelectMode isMultiSelectMode
? 'Exit multi-select (Ctrl+S)' ? 'Exit multi-select (Ctrl+S)'
@@ -628,8 +652,9 @@ const ChoreToolbar = ({
)} )}
{/* ── Unified Filter bottom sheet ─────────────────────────────────────── */} {/* ── Unified Filter bottom sheet ─────────────────────────────────────── */}
<BottomSheetModal <AppModal
open={filterSheetOpen} open={filterSheetOpen}
isMobile
onClose={() => { onClose={() => {
setSaveMenuAnchorEl(null) setSaveMenuAnchorEl(null)
setFilterSheetOpen(false) setFilterSheetOpen(false)
@@ -649,7 +674,12 @@ const ChoreToolbar = ({
footer={ footer={
savingFilter ? ( savingFilter ? (
<Box <Box
sx={{ display: 'flex', gap: 1, width: '100%', alignItems: 'center' }} sx={{
display: 'flex',
gap: 1,
width: '100%',
alignItems: 'center',
}}
> >
<Input <Input
size='sm' size='sm'
@@ -710,12 +740,11 @@ const ChoreToolbar = ({
}} }}
sx={{ minWidth: 140 }} sx={{ minWidth: 140 }}
> >
{resultCount != null {resultCount != null ? `Show ${resultCount}` : 'Done'}
? `Show ${resultCount}`
: 'Done'}
</Button> </Button>
<IconButton <IconButton
ref={saveMenuRef} ref={saveMenuRef}
aria-label='More save options'
onClick={e => setSaveMenuAnchorEl(e.currentTarget)} onClick={e => setSaveMenuAnchorEl(e.currentTarget)}
> >
<ArrowDropDown /> <ArrowDropDown />
@@ -825,11 +854,12 @@ const ChoreToolbar = ({
</> </>
)} )}
</Box> </Box>
</BottomSheetModal> </AppModal>
{/* ── Display bottom sheet (View + Group + Assignee + Project) ──────────── */} {/* ── Display bottom sheet (View + Group + Assignee + Project) ──────────── */}
<BottomSheetModal <AppModal
open={displaySheetOpen} open={displaySheetOpen}
isMobile
onClose={() => setDisplaySheetOpen(false)} onClose={() => setDisplaySheetOpen(false)}
title={ title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
@@ -838,7 +868,10 @@ const ChoreToolbar = ({
</Box> </Box>
} }
footer={ footer={
<Button onClick={() => setDisplaySheetOpen(false)} sx={{ minWidth: 140 }}> <Button
onClick={() => setDisplaySheetOpen(false)}
sx={{ minWidth: 140 }}
>
Done Done
</Button> </Button>
} }
@@ -853,9 +886,11 @@ const ChoreToolbar = ({
variant={viewMode === opt.value ? 'solid' : 'soft'} variant={viewMode === opt.value ? 'solid' : 'soft'}
color={viewMode === opt.value ? 'primary' : 'neutral'} color={viewMode === opt.value ? 'primary' : 'neutral'}
startDecorator={ startDecorator={
viewMode === opt.value viewMode === opt.value ? (
? <Check sx={{ fontSize: 14 }} /> <Check sx={{ fontSize: 14 }} />
: opt.icon ) : (
opt.icon
)
} }
onClick={() => onToggleViewMode?.(opt.value)} onClick={() => onToggleViewMode?.(opt.value)}
sx={{ sx={{
@@ -910,7 +945,8 @@ const ChoreToolbar = ({
label='Show tasks for' label='Show tasks for'
badge={ badge={
selectedAssigneeFilter !== 'anyone' selectedAssigneeFilter !== 'anyone'
? assigneeOptions.find(o => o.value === selectedAssigneeFilter)?.label ? assigneeOptions.find(o => o.value === selectedAssigneeFilter)
?.label
: null : null
} }
/> />
@@ -920,9 +956,8 @@ const ChoreToolbar = ({
multi={false} multi={false}
onToggle={v => onAssigneeFilterChange?.(v)} onToggle={v => onAssigneeFilterChange?.(v)}
/> />
</Box> </Box>
</BottomSheetModal> </AppModal>
</> </>
) )
} }

View File

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

View File

@@ -15,28 +15,40 @@ import {
import { Avatar, Box, Button, Chip, Divider, Stack, Typography } from '@mui/joy' import { Avatar, Box, Button, Chip, Divider, Stack, Typography } from '@mui/joy'
import moment from 'moment' import moment from 'moment'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import ModalActions from '../../components/common/ModalActions'
import { useLocalization } from '../../contexts/LocalizationContext' import { useLocalization } from '../../contexts/LocalizationContext'
import { useResponsiveModal } from '../../hooks/useResponsiveModal' import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import { TASK_COLOR } from '../../utils/Colors.jsx' import { TASK_COLOR } from '../../utils/Colors.jsx'
import RichTextEditor from '../components/RichTextEditor.jsx' import RichTextEditor from '../components/RichTextEditor.jsx'
const STATUS_CONFIG = { const STATUS_CONFIG = {
0: { label: 'In Progress', color: 'primary', icon: <AccessTime /> }, 0: { label: 'In Progress', color: 'primary', icon: <AccessTime /> },
1: { label: 'Completed', color: 'success', icon: <Check /> }, 1: { label: 'Completed', color: 'success', icon: <Check /> },
2: { label: 'Skipped', color: 'warning', icon: <Redo /> }, 2: { label: 'Skipped', color: 'warning', icon: <Redo /> },
3: { label: 'Pending Approval', color: 'neutral', icon: <HourglassEmpty /> }, 3: { label: 'Pending Approval', color: 'neutral', icon: <HourglassEmpty /> },
4: { label: 'Rejected', color: 'danger', icon: <ThumbDown /> }, 4: { label: 'Rejected', color: 'danger', icon: <ThumbDown /> },
5: { label: 'Missed', color: 'danger', icon: <RunningWithErrors /> }, 5: { label: 'Missed', color: 'danger', icon: <RunningWithErrors /> },
6: { label: 'Rescheduled', color: 'warning', icon: <Schedule /> }, 6: { label: 'Rescheduled', color: 'warning', icon: <Schedule /> },
} }
const DetailRow = ({ icon, label, value, children }) => ( const DetailRow = ({ icon, label, value, children }) => (
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.5, py: 0.75 }}> <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 }}> <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 ?? ( {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>
</Box> </Box>
@@ -52,15 +64,41 @@ const TimingBadge = ({ historyEntry }) => {
const gracePeriod = 6 * 60 * 60 * 1000 const gracePeriod = 6 * 60 * 60 * 1000
if (Math.abs(performedAt - dueDate) <= gracePeriod) { 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)) { } else if (performedAt.isBefore(dueDate)) {
const abs = Math.abs(diffHours) const abs = Math.abs(diffHours)
const label = abs >= 48 ? `${Math.floor(abs / 24)}d early` : `${abs}h early` 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 { } else {
const abs = Math.abs(diffHours) const abs = Math.abs(diffHours)
const label = abs >= 48 ? `${Math.floor(abs / 24)}d late` : `${abs}h late` 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 statusLabel = isFirstSchedule ? 'Scheduled' : statusCfg.label
const performer = performers.find(p => p.userId === entry.completedBy) const performer = performers.find(p => p.userId === entry.completedBy)
const assignedTo = performers.find(p => p.userId === entry.assignedTo) 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 // updatedAt is only meaningful if it differs from performedAt by more than a minute
const showUpdatedAt = const showUpdatedAt =
@@ -100,14 +139,50 @@ function HistoryDetailModal({ config }) {
open={config?.isOpen} open={config?.isOpen}
onClose={config?.onClose} onClose={config?.onClose}
title='Activity Detail' 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 */} {/* 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 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Avatar size='sm' color={statusCfg.color} variant='soft'> <Avatar size='sm' color={statusCfg.color} variant='soft'>
{statusCfg.icon} {statusCfg.icon}
</Avatar> </Avatar>
<Typography level='title-md' fontWeight='lg' sx={{ color: `${statusCfg.color}.plainColor` }}> <Typography
level='title-md'
fontWeight='lg'
sx={{ color: `${statusCfg.color}.plainColor` }}
>
{statusLabel} {statusLabel}
</Typography> </Typography>
</Box> </Box>
@@ -119,17 +194,31 @@ function HistoryDetailModal({ config }) {
<Stack spacing={0}> <Stack spacing={0}>
{/* Who performed it */} {/* Who performed it */}
{performer && ( {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 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Avatar src={performer.image} alt={performer.displayName} size='sm' sx={{ width: 20, height: 20 }} /> <Avatar
<Typography level='body-sm' fontWeight='md'>{performer.displayName}</Typography> src={performer.image}
alt={performer.displayName}
size='sm'
sx={{ width: 20, height: 20 }}
/>
<Typography level='body-sm' fontWeight='md'>
{performer.displayName}
</Typography>
</Box> </Box>
</DetailRow> </DetailRow>
)} )}
{/* Assigned to (only if different) */} {/* Assigned to (only if different) */}
{isDifferentAssignee && assignedTo && ( {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 /> <Divider />
@@ -138,7 +227,15 @@ function HistoryDetailModal({ config }) {
{entry.performedAt && ( {entry.performedAt && (
<DetailRow <DetailRow
icon={<AccessTime sx={{ fontSize: 16 }} />} 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)} value={fmt.dateTime(entry.performedAt)}
/> />
)} )}
@@ -147,7 +244,13 @@ function HistoryDetailModal({ config }) {
{entry.dueDate && ( {entry.dueDate && (
<DetailRow <DetailRow
icon={<CalendarMonth sx={{ fontSize: 16 }} />} 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)} value={fmt.dateTime(entry.dueDate)}
/> />
)} )}
@@ -184,45 +287,19 @@ function HistoryDetailModal({ config }) {
<> <>
<Divider /> <Divider />
<Box sx={{ pt: 1 }}> <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'} {entry.status === 2 || entry.status === 4 ? 'Reason' : 'Notes'}
</Typography> </Typography>
<Box sx={{ overflowY: 'auto', maxHeight: '60vh' }}> <Box sx={{ overflowY: 'auto', maxHeight: '60vh' }}>
<RichTextEditor value={entry.notes || ''} isEditable={false} /> <RichTextEditor value={entry.notes || ''} isEditable={false} />
</Box> </Box>
</Box> </Box>
</> </>
)} )}
</Stack> </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> </ResponsiveModal>
) )
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
import { import {
Box, Box,
Button,
Checkbox, Checkbox,
CircularProgress, CircularProgress,
FormControl, FormControl,
@@ -13,6 +12,7 @@ import {
Typography, Typography,
} from '@mui/joy' } from '@mui/joy'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal' import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { CreateBackup, RestoreBackup } from '../../../utils/Fetcher' import { CreateBackup, RestoreBackup } from '../../../utils/Fetcher'
@@ -140,7 +140,6 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
const response = await RestoreBackup(restoreEncryptionKey, backupData) const response = await RestoreBackup(restoreEncryptionKey, backupData)
if (response.ok) { if (response.ok) {
const data = await response.json()
showNotification({ showNotification({
type: 'success', type: 'success',
message: 'Backup restored successfully. Please refresh the page.', message: 'Backup restored successfully. Please refresh the page.',
@@ -212,7 +211,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
placeholder='Enter a strong encryption key' placeholder='Enter a strong encryption key'
/> />
<Typography level='body-xs' sx={{ mt: 0.5 }}> <Typography level='body-xs' sx={{ mt: 0.5 }}>
Keep this key safe - you'll need it to restore your backup Keep this key safeyou&apos;ll need it to restore your backup
</Typography> </Typography>
</FormControl> </FormControl>
@@ -238,22 +237,6 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
{error} {error}
</Typography> </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> </Box>
) )
@@ -294,22 +277,6 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
{error} {error}
</Typography> </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> </Box>
) )
@@ -320,7 +287,28 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
size='lg' size='lg'
fullWidth={true} fullWidth={true}
unmountDelay={250} 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 ? ( {loading ? (
<Box <Box

View File

@@ -1,6 +1,7 @@
import { Box, Button, Typography } from '@mui/joy' import { Typography } from '@mui/joy'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint' import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal' import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
function ConfirmationModal({ config }) { function ConfirmationModal({ config }) {
@@ -14,49 +15,29 @@ function ConfirmationModal({ config }) {
[config], [config],
) )
// Keyboard shortcuts for confirmation modal
useEffect(() => { useEffect(() => {
const handleKeyDown = event => { const handleKeyDown = event => {
if (!config?.isOpen) return 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') { if ((event.ctrlKey || event.metaKey) && event.key === 'y') {
event.preventDefault() event.preventDefault()
handleAction(true) handleAction(true)
return } else if ((event.ctrlKey || event.metaKey) && event.key === 'x') {
}
// Ctrl/Cmd + X for cancel
if ((event.ctrlKey || event.metaKey) && event.key === 'x') {
event.preventDefault() event.preventDefault()
handleAction(false) handleAction(false)
return } else if (event.key === 'Escape') {
}
// Escape key for cancel
if (event.key === 'Escape') {
event.preventDefault() event.preventDefault()
handleAction(false) handleAction(false)
return } else if (event.key === 'Enter' && config?.color !== 'danger') {
}
// Enter key for confirm
if (event.key === 'Enter') {
event.preventDefault() event.preventDefault()
handleAction(true) handleAction(true)
return
} }
} }
const handleKeyUp = event => { const handleKeyUp = event => {
if (!event.ctrlKey && !event.metaKey) { if (!event.ctrlKey && !event.metaKey) setShowKeyboardShortcuts(false)
setShowKeyboardShortcuts(false)
}
} }
if (config?.isOpen) { if (config?.isOpen) {
@@ -68,51 +49,45 @@ function ConfirmationModal({ config }) {
document.removeEventListener('keydown', handleKeyDown) document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('keyup', handleKeyUp) document.removeEventListener('keyup', handleKeyUp)
} }
}, [config?.isOpen, handleAction]) }, [config?.isOpen, config?.color, handleAction])
const isDestructive = config?.color === 'danger'
return ( return (
<ResponsiveModal <ResponsiveModal
open={config?.isOpen} open={config?.isOpen}
onClose={() => handleAction(false)} onClose={() => handleAction(false)}
size='sm' 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}> <Typography level='body-md' sx={{ whiteSpace: 'pre-wrap' }}>
{config?.title}
</Typography>
<Typography level='body-md' gutterBottom>
{config?.message} {config?.message}
</Typography> </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> </ResponsiveModal>
) )
} }
export default ConfirmationModal export default ConfirmationModal

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
import { Box, Button, Input } from '@mui/joy' import { Input } from '@mui/joy'
import { useState } from 'react' import { useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal' import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
function DateModal({ isOpen, onClose, onSave, current, title }) { function DateModal({ isOpen, onClose, onSave, current, title }) {
const { ResponsiveModal } = useResponsiveModal() const { ResponsiveModal } = useResponsiveModal()
const [date, setDate] = useState( const [date, setDate] = useState(
current ? new Date(current).toISOString().split('T')[0] : '', current ? new Date(current).toISOString().split('T')[0] : '',
) )
@@ -18,74 +18,23 @@ function DateModal({ isOpen, onClose, onSave, current, title }) {
<ResponsiveModal <ResponsiveModal
open={isOpen} open={isOpen}
onClose={onClose} onClose={onClose}
size='lg' size='sm'
fullWidth={true}
title={title} title={title}
footer={
<ModalActions
secondary={{ label: 'Cancel', onClick: onClose }}
primary={{ label: 'Save', onClick: handleSave, disabled: !date }}
/>
}
> >
<Input <Input
sx={{ mt: 3 }} autoFocus
type='date' type='date'
value={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> </ResponsiveModal>
) )
} }
export default DateModal export default DateModal

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,33 @@
import { Box, Button, Typography } from '@mui/joy' import { Box, Typography } from '@mui/joy'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal' import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
const NativeCancelSubscriptionModal = ({ isOpen, onClose }) => { const NativeCancelSubscriptionModal = ({ isOpen, onClose }) => {
const { ResponsiveModal } = useResponsiveModal() const { ResponsiveModal } = useResponsiveModal()
return ( return (
<ResponsiveModal open={isOpen} onClose={onClose} size='md' fullWidth> <ResponsiveModal
<Typography level='h4' sx={{ mb: 2 }}> open={isOpen}
Cancel Subscription onClose={onClose}
</Typography> size='lg'
<Box sx={{ p: 2 }}> 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}> <Typography level='body-md' mb={3}>
To cancel your subscription, please follow the instructions for your To cancel your subscription, please follow the instructions for your
platform (you should cancel through the same platform you used to 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 <strong>Important:</strong> You must cancel your subscription
through the same platform where you originally subscribed. If you through the same platform where you originally subscribed. If you
subscribed through the iOS App Store or Google Play Store (even if subscribed through the iOS App Store or Google Play Store (even if
you're now using the web/desktop version), you must cancel through you&apos;re now using the web/desktop version), you must cancel
that original platform using the instructions above. through that original platform using the instructions above.
</Typography> </Typography>
</Box> </Box>
@@ -93,24 +111,6 @@ const NativeCancelSubscriptionModal = ({ isOpen, onClose }) => {
Your subscription will remain active until the end of your current Your subscription will remain active until the end of your current
billing period. billing period.
</Typography> </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> </Box>
</ResponsiveModal> </ResponsiveModal>
) )

View File

@@ -1,15 +1,15 @@
import { import {
Alert, Alert,
Box, Box,
Button, FormControl,
FormControl, FormLabel,
FormLabel, Switch,
Switch, Textarea,
Textarea, Typography,
Typography,
} from '@mui/joy' } from '@mui/joy'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint' import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal' import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { isOfficialDonetickInstanceSync } from '../../../utils/FeatureToggle' import { isOfficialDonetickInstanceSync } from '../../../utils/FeatureToggle'
@@ -108,19 +108,34 @@ function NudgeModal({ config }) {
fullWidth={true} fullWidth={true}
unmountDelay={250} unmountDelay={250}
title='Send Nudge' 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 && ( {!isOfficialInstance && (
<Alert color='warning' sx={{ mb: 2 }}> <Alert color='warning' sx={{ mb: 2 }}>
<Typography level='body-sm'> <Typography level='body-sm'>
<strong>Heads up!</strong>This feature avaiable on Donetick Cloud! <strong>Heads up!</strong>This feature avaiable on Donetick Cloud!
Since you're using a self-hosted instance, nudges will requires you Since you&apos;re using a self-hosted instance, nudges will requires
to setup Google cloud account and Firebase Cloud Messaging (FCM). you to setup Google cloud account and Firebase Cloud Messaging
and build the Android or the iOS app by yourself. (FCM). and build the Android or the iOS app by yourself.
<br /> <br />
Will update if we come up with a solution to make this easier for to Will update if we come up with a solution to make this easier for to
configure. for selfhosters configure. for selfhosters
@@ -152,33 +167,6 @@ function NudgeModal({ config }) {
onChange={e => setNotifyAllAssignees(e.target.checked)} onChange={e => setNotifyAllAssignees(e.target.checked)}
/> />
</FormControl> </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> </ResponsiveModal>
) )
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ import {
import moment from 'moment' import moment from 'moment'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useLocalization } from '../../../contexts/LocalizationContext' import { useLocalization } from '../../../contexts/LocalizationContext'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal' import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { useNotification } from '../../../service/NotificationProvider' import { useNotification } from '../../../service/NotificationProvider'
import { import {
@@ -59,7 +60,6 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
} }
}, [isOpen, timerData]) }, [isOpen, timerData])
const formatTime = seconds => { const formatTime = seconds => {
const hours = Math.floor(seconds / 3600) const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60) const minutes = Math.floor((seconds % 3600) / 60)
@@ -304,10 +304,37 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
open={isOpen} open={isOpen}
onClose={onClose} onClose={onClose}
size='lg' 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 && ( {loading && (
<Alert color='neutral' sx={{ mb: 2 }}> <Alert color='neutral' sx={{ mb: 2 }}>
Loading timer data... Loading timer data...
@@ -919,56 +946,6 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
)} )}
</Box> </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> </ResponsiveModal>
<ConfirmationModal config={confirmDeleteConfig} /> <ConfirmationModal config={confirmDeleteConfig} />

View File

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

View File

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

View File

@@ -5,16 +5,9 @@ import {
ErrorOutline, ErrorOutline,
Nfc, Nfc,
} from '@mui/icons-material' } from '@mui/icons-material'
import { import { Box, IconButton, Input, Switch, Typography } from '@mui/joy'
Box,
Button,
CircularProgress,
IconButton,
Input,
Switch,
Typography,
} from '@mui/joy'
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal' import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { startNativeNFCWrite } from '../../../service/NFCWriter' import { startNativeNFCWrite } from '../../../service/NFCWriter'
@@ -29,9 +22,6 @@ const pulseKeyframes = `
70% { transform: scale(2.1); opacity: 0; } 70% { transform: scale(2.1); opacity: 0; }
100% { 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 }) { function NFCIcon({ status }) {
@@ -55,7 +45,6 @@ function NFCIcon({ status }) {
{isWaiting && ( {isWaiting && (
<> <>
<Box <Box
className='nfc-pulse-ring'
sx={{ sx={{
position: 'absolute', position: 'absolute',
inset: 0, inset: 0,
@@ -63,10 +52,10 @@ function NFCIcon({ status }) {
border: '2px solid', border: '2px solid',
borderColor: 'primary.400', borderColor: 'primary.400',
animation: 'nfc-pulse 1.8s ease-out infinite', animation: 'nfc-pulse 1.8s ease-out infinite',
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
}} }}
/> />
<Box <Box
className='nfc-pulse-ring'
sx={{ sx={{
position: 'absolute', position: 'absolute',
inset: 0, inset: 0,
@@ -74,6 +63,7 @@ function NFCIcon({ status }) {
border: '2px solid', border: '2px solid',
borderColor: 'primary.300', borderColor: 'primary.300',
animation: 'nfc-pulse-2 1.8s ease-out infinite 0.4s', 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 ( return (
<> <>
<style>{pulseKeyframes}</style> <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 }}> <Box sx={{ px: 0.5, pb: 1 }}>
{/* Icon */} {/* Icon */}
<NFCIcon status={nfcStatus} /> <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 */} {/* Idle / Error: URL + toggle + CTA */}
{!isWaiting && !isSuccess && ( {!isWaiting && !isSuccess && (
<> <>
@@ -264,6 +264,7 @@ function WriteNFCModal({ config }) {
}} }}
endDecorator={ endDecorator={
<IconButton <IconButton
aria-label='Copy tag URL'
size='sm' size='sm'
variant='plain' variant='plain'
color={copied ? 'success' : 'neutral'} color={copied ? 'success' : 'neutral'}
@@ -310,55 +311,8 @@ function WriteNFCModal({ config }) {
size='sm' size='sm'
/> />
</Box> </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> </Box>
</ResponsiveModal> </ResponsiveModal>
</> </>

View File

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

View File

@@ -1,23 +1,13 @@
import { CheckCircle, Security, Smartphone } from '@mui/icons-material' import { CheckCircle, Security, Smartphone } from '@mui/icons-material'
import { import { Alert, Box, Button, Card, Input, Stack, Typography } from '@mui/joy'
Alert,
Box,
Button,
Card,
Input,
Modal,
ModalClose,
ModalDialog,
Stack,
Typography,
} from '@mui/joy'
import QRCode from 'qrcode' import QRCode from 'qrcode'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import AppModal from '../../components/common/AppModal'
import ModalActions from '../../components/common/ModalActions'
import { import {
ConfirmMFA, ConfirmMFA,
DisableMFA, DisableMFA,
GetMFAStatus, GetMFAStatus,
RegenerateBackupCodes,
SetupMFA, SetupMFA,
} from '../../utils/Fetcher' } from '../../utils/Fetcher'
import LoadingComponent from '../components/Loading' 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 = () => { const closeSetupModal = () => {
setSetupModalOpen(false) setSetupModalOpen(false)
setSetupStep(1) setSetupStep(1)
@@ -290,193 +262,105 @@ const MFASettings = () => {
)} */} )} */}
{/* Setup MFA Modal */} {/* Setup MFA Modal */}
<Modal open={setupModalOpen} onClose={closeSetupModal}> <AppModal
<ModalDialog size='md' sx={{ maxWidth: 500 }}> open={setupModalOpen}
<ModalClose /> onClose={closeSetupModal}
<Typography level='h4' sx={{ mb: 2 }}> title='Set up Multi-Factor Authentication'
Set up Multi-Factor Authentication size='md'
</Typography> footer={
setupStep === 1 ? (
{setupStep === 1 && setupData && ( <ModalActions
<Stack spacing={3}> secondary={{ label: 'Cancel', onClick: closeSetupModal }}
<Typography level='body-md'> primary={{
<strong>Step 1:</strong> Scan the QR code below with your label: "I've added the account",
authenticator app (Google Authenticator, Authy, etc.) onClick: () => setSetupStep(2),
</Typography> startDecorator: <Smartphone />,
}}
<Box className='flex justify-center rounded bg-white p-4'> />
{qrCodeDataUrl || setupData.qrCode ? ( ) : setupStep === 2 ? (
<img <ModalActions
src={ secondary={{ label: 'Back', onClick: () => setSetupStep(1) }}
qrCodeDataUrl || primary={{
`data:image/png;base64,${setupData.qrCode}` label: 'Verify & Enable',
} onClick: handleConfirmMFA,
alt='MFA QR Code' disabled: verificationCode.length !== 6,
style={{ maxWidth: '200px', maxHeight: '200px' }} }}
/> />
) : ( ) : (
<Alert color='danger'> <ModalActions
QR code could not be generated. Please try again or use primary={{
the manual entry key below. label: "I've saved my backup codes",
</Alert> onClick: closeSetupModal,
)} }}
</Box> />
)
<Alert }
color='neutral' >
variant='soft' {setupStep === 1 && setupData && (
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
}}
>
<Typography level='title-sm'>
<strong>Manual entry key:</strong>
</Typography>
<Typography
level='body-sm'
sx={{ wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}
>
{setupData.secret}
</Typography>
</Alert>
<Button
color='primary'
onClick={() => setSetupStep(2)}
startDecorator={<Smartphone />}
>
I've added the account to my app
</Button>
</Stack>
)}
{setupStep === 2 && (
<Stack spacing={3}>
<Typography level='body-md'>
<strong>Step 2:</strong> Enter the 6-digit verification code
from your authenticator app
</Typography>
<Input
placeholder='Enter 6-digit code'
value={verificationCode}
size='lg'
// send on enter:
onKeyDown={e => {
if (e.key === 'Enter' && verificationCode.length === 6) {
handleConfirmMFA()
}
}}
onChange={e => setVerificationCode(e.target.value)}
sx={{
textAlign: 'center',
fontSize: '1.2em',
letterSpacing: verificationCode.length === 0 ? '' : '0.4em',
}}
slotProps={{
input: {
maxLength: 6,
pattern: '[0-9]*',
},
}}
/>
{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>
)}
{setupStep === 3 && (
<Stack spacing={3}>
<Box className='text-center'>
<CheckCircle color='success' sx={{ fontSize: 48, mb: 2 }} />
<Typography level='h4' color='success'>
MFA Successfully Enabled!
</Typography>
</Box>
<Alert color='warning'>
<Typography level='title-sm' sx={{ mb: 1 }}>
Save these backup codes in a safe place
</Typography>
<Typography level='body-sm'>
You can use these codes to access your account if you lose
your authenticator device. Each code can only be used once.
</Typography>
</Alert>
<Card variant='outlined' sx={{ p: 2 }}>
<Box className='grid grid-cols-2 gap-2 font-mono text-sm'>
{backupCodes?.map((code, index) => (
<Typography
key={index}
level='body-sm'
sx={{ fontFamily: 'monospace' }}
>
{code}
</Typography>
))}
</Box>
</Card>
<Button color='primary' onClick={closeSetupModal}>
I've saved my backup codes
</Button>
</Stack>
)}
</ModalDialog>
</Modal>
{/* Disable MFA Modal */}
<Modal open={disableModalOpen} onClose={closeDisableModal}>
<ModalDialog size='sm'>
<ModalClose />
<Typography level='h4' sx={{ mb: 2 }}>
Disable Multi-Factor Authentication
</Typography>
<Stack spacing={3}> <Stack spacing={3}>
<Alert color='warning'> <Typography level='body-md'>
<Typography level='body-sm'> <strong>Step 1:</strong> Scan the QR code below with your
Disabling MFA will make your account less secure. Are you sure authenticator app (Google Authenticator, Authy, etc.)
you want to continue? </Typography>
<Box className='flex justify-center rounded bg-white p-4'>
{qrCodeDataUrl || setupData.qrCode ? (
<img
src={
qrCodeDataUrl ||
`data:image/png;base64,${setupData.qrCode}`
}
alt='MFA QR Code'
style={{ maxWidth: '200px', maxHeight: '200px' }}
/>
) : (
<Alert color='danger'>
QR code could not be generated. Please try again or use the
manual entry key below.
</Alert>
)}
</Box>
<Alert
color='neutral'
variant='soft'
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
}}
>
<Typography level='title-sm'>
<strong>Manual entry key:</strong>
</Typography>
<Typography
level='body-sm'
sx={{ wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}
>
{setupData.secret}
</Typography> </Typography>
</Alert> </Alert>
</Stack>
)}
{setupStep === 2 && (
<Stack spacing={3}>
<Typography level='body-md'> <Typography level='body-md'>
Enter a verification code from your authenticator app to <strong>Step 2:</strong> Enter the 6-digit verification code
confirm: from your authenticator app
</Typography> </Typography>
<Input <Input
placeholder='Enter 6-digit code' placeholder='Enter 6-digit code'
value={disableCode} value={verificationCode}
size='lg' size='lg'
// send on enter:
onKeyDown={e => { onKeyDown={e => {
if (e.key === 'Enter' && disableCode.length === 6) { if (e.key === 'Enter' && verificationCode.length === 6) {
handleDisableMFA() handleConfirmMFA()
} }
}} }}
onChange={e => setDisableCode(e.target.value)} onChange={e => setVerificationCode(e.target.value)}
sx={{ sx={{
textAlign: 'center', textAlign: 'center',
fontSize: '1.2em', fontSize: '1.2em',
@@ -491,44 +375,25 @@ const MFASettings = () => {
/> />
{error && <Alert color='danger'>{error}</Alert>} {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> </Stack>
</ModalDialog> )}
</Modal>
{/* Backup Codes Modal */}
<Modal
open={backupCodesModalOpen}
onClose={() => setBackupCodesModalOpen(false)}
>
<ModalDialog size='sm'>
<ModalClose />
<Typography level='h4' sx={{ mb: 2 }}>
New Backup Codes
</Typography>
{setupStep === 3 && (
<Stack spacing={3}> <Stack spacing={3}>
<Box className='text-center'>
<CheckCircle color='success' sx={{ fontSize: 48, mb: 2 }} />
<Typography level='h4' color='success'>
MFA Successfully Enabled!
</Typography>
</Box>
<Alert color='warning'> <Alert color='warning'>
<Typography level='title-sm' sx={{ mb: 1 }}>
Save these backup codes in a safe place
</Typography>
<Typography level='body-sm'> <Typography level='body-sm'>
Your previous backup codes are now invalid. Save these new You can use these codes to access your account if you lose
codes in a safe place. Each code can only be used once. your authenticator device. Each code can only be used once.
</Typography> </Typography>
</Alert> </Alert>
@@ -545,16 +410,107 @@ const MFASettings = () => {
))} ))}
</Box> </Box>
</Card> </Card>
<Button
color='primary'
onClick={() => setBackupCodesModalOpen(false)}
>
I've saved my backup codes
</Button>
</Stack> </Stack>
</ModalDialog> )}
</Modal> </AppModal>
{/* Disable MFA Modal */}
<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'>
Disabling MFA will make your account less secure. Are you sure
you want to continue?
</Typography>
</Alert>
<Typography level='body-md'>
Enter a verification code from your authenticator app to confirm:
</Typography>
<Input
placeholder='Enter 6-digit code'
value={disableCode}
size='lg'
onKeyDown={e => {
if (e.key === 'Enter' && disableCode.length === 6) {
handleDisableMFA()
}
}}
onChange={e => setDisableCode(e.target.value)}
sx={{
textAlign: 'center',
fontSize: '1.2em',
letterSpacing: verificationCode.length === 0 ? '' : '0.4em',
}}
slotProps={{
input: {
maxLength: 6,
pattern: '[0-9]*',
},
}}
/>
{error && <Alert color='danger'>{error}</Alert>}
</Stack>
</AppModal>
{/* Backup Codes 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),
}}
/>
}
>
<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.
</Typography>
</Alert>
<Card variant='outlined' sx={{ p: 2 }}>
<Box className='grid grid-cols-2 gap-2 font-mono text-sm'>
{backupCodes?.map((code, index) => (
<Typography
key={index}
level='body-sm'
sx={{ fontFamily: 'monospace' }}
>
{code}
</Typography>
))}
</Box>
</Card>
</Stack>
</AppModal>
</div> </div>
</SettingsLayout> </SettingsLayout>
) )

View File

@@ -7,13 +7,13 @@ import {
Input, Input,
Typography, Typography,
} from '@mui/joy' } from '@mui/joy'
import Modal from '@mui/joy/Modal'
import ModalDialog from '@mui/joy/ModalDialog'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import imageCompression from 'browser-image-compression' import imageCompression from 'browser-image-compression'
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import Cropper from 'react-easy-crop' import Cropper from 'react-easy-crop'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import AppModal from '../../components/common/AppModal'
import ModalActions from '../../components/common/ModalActions'
import { useUserProfile } from '../../queries/UserQueries' import { useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider' import { useNotification } from '../../service/NotificationProvider'
import { apiClient } from '../../utils/ApiClient' import { apiClient } from '../../utils/ApiClient'
@@ -141,9 +141,7 @@ const ProfileSettings = () => {
return ( return (
<SettingsLayout title={t('profile.title')}> <SettingsLayout title={t('profile.title')}>
<div className='grid gap-4 py-4' id='profile'> <div className='grid gap-4 py-4' id='profile'>
<Typography level='body-md'> <Typography level='body-md'>{t('profile.description')}</Typography>
{t('profile.description')}
</Typography>
<Card <Card
sx={{ sx={{
display: 'flex', display: 'flex',
@@ -153,7 +151,10 @@ const ProfileSettings = () => {
maxWidth: 400, 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 }}> <Box sx={{ flex: 1 }}>
<Button <Button
variant='soft' variant='soft'
@@ -173,74 +174,56 @@ const ProfileSettings = () => {
/> />
</Box> </Box>
</Card> </Card>
<Modal <AppModal
open={showCropper} open={showCropper}
onClose={() => { onClose={() => {
setShowCropper(false) setShowCropper(false)
setSelectedFile(null) setSelectedFile(null)
}} }}
> title={t('profile.editPhoto', { defaultValue: 'Edit profile photo' })}
<ModalDialog size='sm'
layout='center' closeOnBackdrop={!isUploading}
sx={{ closeOnEscape={!isUploading}
width: 360, footer={
maxWidth: '90vw', <ModalActions
bgcolor: '#fff', secondary={{
borderRadius: 2, label: t('profile.cancel'),
boxShadow: 24, disabled: isUploading,
p: 0, onClick: () => {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: 420,
}}
>
<Box sx={{ width: 320, height: 320, position: 'relative', mt: 2 }}>
<Cropper
image={selectedFile}
crop={crop}
zoom={zoom}
aspect={1}
cropShape='round'
showGrid={false}
onCropChange={setCrop}
onZoomChange={setZoom}
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) setShowCropper(false)
setSelectedFile(null) setSelectedFile(null)
}} },
variant='soft' }}
color='neutral' primary={{
> label: t('profile.save'),
{t('profile.cancel')} loading: isUploading,
</Button> onClick: handleCropSave,
</Box> }}
</ModalDialog> />
</Modal> }
>
<Box
sx={{
width: '100%',
maxWidth: 320,
aspectRatio: '1',
position: 'relative',
mx: 'auto',
}}
>
<Cropper
image={selectedFile}
crop={crop}
zoom={zoom}
aspect={1}
cropShape='round'
showGrid={false}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
/>
</Box>
</AppModal>
<Box sx={{ maxWidth: 400, mt: 3 }}> <Box sx={{ maxWidth: 400, mt: 3 }}>
<Typography level='body-sm' sx={{ mb: 0.5 }}> <Typography level='body-sm' sx={{ mb: 0.5 }}>
{t('profile.displayName')} {t('profile.displayName')}

View File

@@ -24,6 +24,7 @@ import {
import SmartTaskTitleInput from './SmartTaskTitleInput' import SmartTaskTitleInput from './SmartTaskTitleInput'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint' import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import ModalActions from '../../components/common/ModalActions'
import { useDocumentScanner } from '../../hooks/useDocumentScanner' import { useDocumentScanner } from '../../hooks/useDocumentScanner'
import { localAIService } from '../../service/LocalAIService' import { localAIService } from '../../service/LocalAIService'
import { voiceInputService } from '../../service/VoiceInputService' import { voiceInputService } from '../../service/VoiceInputService'
@@ -831,16 +832,8 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
fullWidth={true} fullWidth={true}
title='Create new task' title='Create new task'
footer={ footer={
<Box <ModalActions>
sx={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'end',
gap: 1,
}}
>
<Button <Button
size='lg'
variant='outlined' variant='outlined'
color='neutral' color='neutral'
onClick={handleCloseModal} onClick={handleCloseModal}
@@ -857,7 +850,6 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
{/* Sub-panels (voice/scan) own their own confirm action */} {/* Sub-panels (voice/scan) own their own confirm action */}
{!showScan && !showVoice && ( {!showScan && !showVoice && (
<Button <Button
size='lg'
variant='solid' variant='solid'
color='primary' color='primary'
disabled={!taskTitle.trim()} disabled={!taskTitle.trim()}
@@ -869,7 +861,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
)} )}
</Button> </Button>
)} )}
</Box> </ModalActions>
} }
> >
{!showScan && !showVoice && ( {!showScan && !showVoice && (

View File

@@ -24,6 +24,7 @@ import {
import moment from 'moment' import moment from 'moment'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import Calendar from 'react-calendar' import Calendar from 'react-calendar'
import ModalActions from '../../components/common/ModalActions'
import { useLocalization } from '../../contexts/LocalizationContext' import { useLocalization } from '../../contexts/LocalizationContext'
import { useResponsiveModal } from '../../hooks/useResponsiveModal' import { useResponsiveModal } from '../../hooks/useResponsiveModal'
@@ -202,6 +203,7 @@ const DueDatePickerField = ({
</Button> </Button>
{hasDueDate && onClear && ( {hasDueDate && onClear && (
<IconButton <IconButton
aria-label='Clear due date'
size='sm' size='sm'
variant='soft' variant='soft'
color='danger' color='danger'
@@ -211,11 +213,9 @@ const DueDatePickerField = ({
}} }}
sx={{ sx={{
position: 'absolute', position: 'absolute',
top: -12, top: -18,
right: -16, right: -18,
zIndex: 10, zIndex: 10,
maxHeight: 18,
maxWidth: 18,
borderRadius: '50%', borderRadius: '50%',
'&:hover': { '&:hover': {
bgcolor: 'danger.softBg', bgcolor: 'danger.softBg',
@@ -233,38 +233,22 @@ const DueDatePickerField = ({
title='Due Date' title='Due Date'
fullWidth={false} fullWidth={false}
footer={ footer={
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}> <ModalActions
{hasDueDate && ( tertiary={
<Button hasDueDate
variant='plain' ? {
color='danger' label: 'Remove',
size='lg' color: 'danger',
onClick={() => { onClick: () => {
onClear?.() onClear?.()
setIsOpen(false) setIsOpen(false)
}} },
sx={{ mr: 'auto' }} }
> : undefined
Remove }
</Button> secondary={{ label: 'Cancel', onClick: () => setIsOpen(false) }}
)} primary={{ label: 'Apply', onClick: handleSave }}
<Button />
variant='outlined'
color='neutral'
size='lg'
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
<Button
variant='solid'
color='primary'
size='lg'
onClick={handleSave}
>
Apply
</Button>
</Box>
} }
> >
<Box sx={{ fontFamily: 'var(--joy-fontFamily-body)', maxWidth: 360 }}> <Box sx={{ fontFamily: 'var(--joy-fontFamily-body)', maxWidth: 360 }}>

View File

@@ -1,6 +1,7 @@
import { Close, NotificationsNone } from '@mui/icons-material' import { Close, NotificationsNone } from '@mui/icons-material'
import { Box, Button, IconButton, Typography } from '@mui/joy' import { Box, Button, IconButton, Typography } from '@mui/joy'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import ModalActions from '../../components/common/ModalActions'
import NotificationTemplate from '../../components/NotificationTemplate' import NotificationTemplate from '../../components/NotificationTemplate'
import { useResponsiveModal } from '../../hooks/useResponsiveModal' import { useResponsiveModal } from '../../hooks/useResponsiveModal'
@@ -11,8 +12,7 @@ const getDisplayLabel = templates => {
const n = templates[0] const n = templates[0]
const numericValue = Number(n.value) const numericValue = Number(n.value)
if (numericValue === 0) return 'On due date' if (numericValue === 0) return 'On due date'
const unitName = const unitName = n.unit === 'm' ? 'min' : n.unit === 'h' ? 'hr' : 'day'
n.unit === 'm' ? 'min' : n.unit === 'h' ? 'hr' : 'day'
const absValue = Math.abs(numericValue) const absValue = Math.abs(numericValue)
const plural = absValue !== 1 ? 's' : '' const plural = absValue !== 1 ? 's' : ''
return `${absValue} ${unitName}${plural} ${numericValue < 0 ? 'before' : 'after'}` return `${absValue} ${unitName}${plural} ${numericValue < 0 ? 'before' : 'after'}`
@@ -48,33 +48,22 @@ const NotificationPickerField = ({
} }
const footer = ( const footer = (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}> <ModalActions
{hasNotifications && ( tertiary={
<Button hasNotifications
variant='plain' ? {
color='danger' label: 'Remove all',
size='lg' color: 'danger',
onClick={() => { onClick: () => {
onClear?.() onClear?.()
setIsOpen(false) setIsOpen(false)
}} },
sx={{ mr: 'auto' }} }
> : undefined
Remove all }
</Button> secondary={{ label: 'Cancel', onClick: () => setIsOpen(false) }}
)} primary={{ label: 'Apply', onClick: handleSave }}
<Button />
variant='outlined'
color='neutral'
size='lg'
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
<Button variant='solid' color='primary' size='lg' onClick={handleSave}>
Apply
</Button>
</Box>
) )
return ( return (
@@ -116,6 +105,7 @@ const NotificationPickerField = ({
{hasNotifications && onClear && ( {hasNotifications && onClear && (
<IconButton <IconButton
aria-label='Remove reminders'
size='sm' size='sm'
variant='soft' variant='soft'
color='danger' color='danger'
@@ -125,11 +115,9 @@ const NotificationPickerField = ({
}} }}
sx={{ sx={{
position: 'absolute', position: 'absolute',
top: -12, top: -18,
right: -16, right: -18,
zIndex: 10, zIndex: 10,
maxHeight: 18,
maxWidth: 18,
borderRadius: '50%', borderRadius: '50%',
'&:hover': { bgcolor: 'danger.softBg' }, '&:hover': { bgcolor: 'danger.softBg' },
}} }}

View File

@@ -1,8 +1,6 @@
import { Close, CloudSync } from '@mui/icons-material' import { Close, CloudSync } from '@mui/icons-material'
import { import {
Box, Box,
Button,
Divider,
IconButton, IconButton,
List, List,
ListItem, ListItem,
@@ -11,6 +9,7 @@ import {
} from '@mui/joy' } from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { useState } from 'react' import { useState } from 'react'
import ModalActions from '../../components/common/ModalActions'
import { useResponsiveModal } from '../../hooks/useResponsiveModal' import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import { commandQueue } from '../../utils/CommandQueue' import { commandQueue } from '../../utils/CommandQueue'
@@ -139,10 +138,24 @@ function PendingBadge({ commands, size = 'sm', sx = {} }) {
{/* </Badge> */} {/* </Badge> */}
</IconButton> </IconButton>
<ResponsiveModal open={isOpen} onClose={handleClose} size='sm'> <ResponsiveModal
<Typography level='title-lg' mb={0.5}> open={isOpen}
Pending actions onClose={handleClose}
</Typography> 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 }}> <Typography level='body-sm' sx={{ color: 'text.tertiary', mb: 1.5 }}>
{commands.length} action{commands.length > 1 ? 's' : ''} waiting to be {commands.length} action{commands.length > 1 ? 's' : ''} waiting to be
synced. synced.
@@ -171,6 +184,7 @@ function PendingBadge({ commands, size = 'sm', sx = {} }) {
</ListItemContent> </ListItemContent>
<IconButton <IconButton
aria-label={`Cancel ${formatCommandLabel(cmd.commandType)}`}
variant='plain' variant='plain'
color='danger' color='danger'
size='sm' size='sm'
@@ -182,22 +196,6 @@ function PendingBadge({ commands, size = 'sm', sx = {} }) {
</ListItem> </ListItem>
))} ))}
</List> </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> </ResponsiveModal>
</Box> </Box>
) )

View File

@@ -2,7 +2,6 @@ import {
ArrowBack, ArrowBack,
CameraAlt, CameraAlt,
CheckCircle, CheckCircle,
Close,
DocumentScanner, DocumentScanner,
PhotoCamera, PhotoCamera,
Replay, Replay,
@@ -12,12 +11,12 @@ import {
Box, Box,
Button, Button,
CircularProgress, CircularProgress,
IconButton,
LinearProgress, LinearProgress,
Typography, Typography,
} from '@mui/joy' } from '@mui/joy'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useDocumentScanner } from '../../hooks/useDocumentScanner' import { useDocumentScanner } from '../../hooks/useDocumentScanner'
import ModalActions from '../../components/common/ModalActions'
import { useResponsiveModal } from '../../hooks/useResponsiveModal' import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import { localAIService } from '../../service/LocalAIService' import { localAIService } from '../../service/LocalAIService'
@@ -82,7 +81,10 @@ async function runNativeOCR(imageSource) {
} }
const result = await Ocr.process({ image }) 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) { async function runOCR(imageSource, onProgress) {
@@ -214,7 +216,9 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
try { try {
text = await runNativeOCR(capturedImage) text = await runNativeOCR(capturedImage)
} catch { } 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 { } else {
text = await runOCR(capturedImage, pct => setOcrProgress(pct)) text = await runOCR(capturedImage, pct => setOcrProgress(pct))
@@ -231,7 +235,9 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
const task = await extractTaskFromOCR(text) const task = await extractTaskFromOCR(text)
if (!task || !task.taskName) { 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') setPhase('error')
return return
} }
@@ -258,7 +264,9 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
const { image, cancelled, error } = await scanDocument() const { image, cancelled, error } = await scanDocument()
if (cancelled) return if (cancelled) return
if (error || !image) { if (error || !image) {
setErrorMsg(error ? `Scanner error: ${error}` : 'Scan cancelled or failed.') setErrorMsg(
error ? `Scanner error: ${error}` : 'Scan cancelled or failed.',
)
setPhase('error') setPhase('error')
return return
} }
@@ -377,7 +385,11 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
<Typography level='body-sm'> <Typography level='body-sm'>
Reading text from image {ocrProgress}% Reading text from image {ocrProgress}%
</Typography> </Typography>
<LinearProgress determinate value={ocrProgress} sx={{ width: '100%' }} /> <LinearProgress
determinate
value={ocrProgress}
sx={{ width: '100%' }}
/>
</> </>
)} )}
{phase === 'ocr' && ocrMethod === 'native' && ( {phase === 'ocr' && ocrMethod === 'native' && (
@@ -466,7 +478,7 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
</Box> </Box>
)} )}
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}> <ModalActions sx={{ mt: 1 }}>
{phase === 'capture' && ( {phase === 'capture' && (
<> <>
<Button <Button
@@ -583,18 +595,7 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
</Button> </Button>
</> </>
)} )}
</ModalActions>
{!isProcessing && (
<IconButton
variant='plain'
color='neutral'
onClick={handleClose}
sx={{ ml: 'auto' }}
>
<Close />
</IconButton>
)}
</Box>
</Box> </Box>
</ResponsiveModal> </ResponsiveModal>
) )

View File

@@ -14,6 +14,7 @@ import {
} from '@mui/joy' } from '@mui/joy'
import moment from 'moment' import moment from 'moment'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import ModalActions from '../../components/common/ModalActions'
import { getRecurrentChipText } from '../../utils/ChoreCardHelpers' import { getRecurrentChipText } from '../../utils/ChoreCardHelpers'
import { useResponsiveModal } from '../../hooks/useResponsiveModal' import { useResponsiveModal } from '../../hooks/useResponsiveModal'
@@ -472,6 +473,7 @@ const RepeatPickerField = ({
{hasRepeat && onClear && ( {hasRepeat && onClear && (
<IconButton <IconButton
aria-label='Clear repeat schedule'
size='sm' size='sm'
variant='soft' variant='soft'
color='danger' color='danger'
@@ -481,11 +483,9 @@ const RepeatPickerField = ({
}} }}
sx={{ sx={{
position: 'absolute', position: 'absolute',
top: -12, top: -18,
right: -16, right: -18,
zIndex: 10, zIndex: 10,
maxHeight: 18,
maxWidth: 18,
borderRadius: '50%', borderRadius: '50%',
'&:hover': { bgcolor: 'danger.softBg' }, '&:hover': { bgcolor: 'danger.softBg' },
}} }}
@@ -500,38 +500,22 @@ const RepeatPickerField = ({
onClose={() => setIsOpen(false)} onClose={() => setIsOpen(false)}
title='Repeat Schedule' title='Repeat Schedule'
footer={ footer={
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}> <ModalActions
{hasRepeat && ( tertiary={
<Button hasRepeat
variant='plain' ? {
color='danger' label: 'Remove',
size='lg' color: 'danger',
onClick={() => { onClick: () => {
onClear?.() onClear?.()
setIsOpen(false) setIsOpen(false)
}} },
sx={{ mr: 'auto' }} }
> : undefined
Remove }
</Button> secondary={{ label: 'Cancel', onClick: () => setIsOpen(false) }}
)} primary={{ label: 'Apply', onClick: handleSave }}
<Button />
variant='outlined'
color='neutral'
size='lg'
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
<Button
variant='solid'
color='primary'
size='lg'
onClick={handleSave}
>
Apply
</Button>
</Box>
} }
> >
{/* Frequency type selector */} {/* Frequency type selector */}