Merge pull request #179 from donetick/revamp-modals

Revamp modals
This commit is contained in:
Mohamad Tarbin
2026-07-30 01:34:26 -04:00
committed by GitHub
47 changed files with 1997 additions and 2215 deletions

View File

@@ -1,16 +1,8 @@
import { Check, Star } from '@mui/icons-material'
import {
Box,
Button,
Card,
Chip,
Divider,
Modal,
ModalDialog,
Radio,
Typography,
} from '@mui/joy'
import { Box, Card, Chip, Divider, Radio, Typography } from '@mui/joy'
import { useState } from 'react'
import AppModal from './common/AppModal'
import ModalActions from './common/ModalActions'
import { useNotification } from '../service/NotificationProvider'
import { GetSubscriptionSession } from '../utils/Fetcher'
@@ -76,25 +68,26 @@ const SubscriptionModal = ({ open, onClose }) => {
}
return (
<Modal open={open} onClose={onClose}>
<ModalDialog
layout='center'
sx={{
width: 600,
maxWidth: '95vw',
maxHeight: '95vh',
overflow: 'auto',
p: 0,
<AppModal
open={open}
onClose={onClose}
title='Upgrade to Plus'
description='Unlock reminders, rich task details, and advanced automation.'
size='lg'
closeOnBackdrop={!isLoading}
closeOnEscape={!isLoading}
footer={
<ModalActions
stackOnMobile
secondary={{ label: 'Cancel', onClick: onClose, disabled: isLoading }}
primary={{
label: 'Subscribe',
onClick: handleSubscribe,
loading: isLoading,
}}
/>
}
>
<Box sx={{ p: 4 }}>
{/* Header */}
<Box sx={{ textAlign: 'center', mb: 4 }}>
<Typography level='h3' sx={{ mb: 1 }}>
Upgrade to Plus
</Typography>
</Box>
{/* Features List */}
<Box sx={{ mb: 2 }}>
<Typography level='title-lg' sx={{ mb: 2 }}>
@@ -115,11 +108,11 @@ const SubscriptionModal = ({ open, onClose }) => {
<Divider sx={{ my: 3 }} />
{/* Plan Selection */}
<Box
sx={{ display: 'flex', flexDirection: 'column', gap: 1.2, mb: 4 }}
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.2, mb: 4 }}>
{Object.entries(plans).map(([key, plan]) => (
<Card
component='label'
htmlFor={`subscription-plan-${key}`}
key={key}
color={selectedPlan === key ? 'primary' : 'neutral'}
onClick={() => setSelectedPlan(key)}
@@ -149,6 +142,7 @@ const SubscriptionModal = ({ open, onClose }) => {
}}
>
<Radio
id={`subscription-plan-${key}`}
checked={selectedPlan === key}
onChange={() => setSelectedPlan(key)}
value={key}
@@ -217,31 +211,6 @@ const SubscriptionModal = ({ open, onClose }) => {
))}
</Box>
{/* Action Buttons */}
<Box
sx={{ display: 'flex', flexDirection: 'column', gap: 1.2, mt: 2 }}
>
<Button
variant='solid'
color='primary'
onClick={handleSubscribe}
loading={isLoading}
fullWidth
size='lg'
sx={{ mb: 1 }}
>
Subscribe
</Button>
<Button
variant='plain'
onClick={onClose}
disabled={isLoading}
fullWidth
>
Cancel
</Button>
</Box>
{/* Footer */}
<Typography
level='body-xs'
@@ -250,9 +219,7 @@ const SubscriptionModal = ({ open, onClose }) => {
>
Cancel anytime. No hidden fees. Secure payment powered by Stripe.
</Typography>
</Box>
</ModalDialog>
</Modal>
</AppModal>
)
}

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

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'
const primaryColor = 'cyan'
const shades = [
'50',
...Array.from({ length: 9 }, (_, i) => String((i + 1) * 100)),
]
const getPallete = (key = primaryColor) => {
return shades.reduce((acc, shade) => {
acc[shade] = COLORS[key][shade]
return acc
const getPalette = (key = primaryColor) =>
shades.reduce((palette, shade) => {
palette[shade] = COLORS[key][shade]
return palette
}, {})
}
const primaryPalette = getPallete(primaryColor)
const primaryPalette = getPalette(primaryColor)
// Fallbacks only. A parent that owns the radius (ButtonGroup, Input/Select
// decorator slots, CardActions) sets --Button-radius / --IconButton-radius and
// takes precedence, which is what keeps connected groups looking connected.
const CONTROL_RADIUS = '12px'
const ICON_BUTTON_RADIUS = '10px'
const theme = extendTheme({
radius: {
xs: '6px',
sm: '8px',
md: '10px',
lg: '12px',
xl: '16px',
},
colorSchemes: {
light: {
palette: {
@@ -42,12 +53,11 @@ const theme = extendTheme({
200: '#fbd5d5',
300: '#f9c1c1',
400: '#f6a8a8',
500: '',
600: '#f47272',
700: '#e33434',
800: '#cc1f1a',
900: '#b91c1c',
},
500: '#ef4444',
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
900: '#7f1d1d',
},
warning: {
50: '#fffdf7',
@@ -68,16 +78,75 @@ const theme = extendTheme({
primary: primaryPalette,
},
},
},
components: {
JoyButton: {
styleOverrides: {
root: ({ ownerState }) => ({
minHeight:
ownerState.size === 'lg'
? '44px'
: ownerState.size === 'sm'
? '36px'
: '40px',
// Read through the CSS variable so parents that own the radius
// (ButtonGroup, Input/Select decorators, Card actions) still win.
borderRadius: `var(--Button-radius, ${CONTROL_RADIUS})`,
fontWeight: 600,
transition:
'background-color 140ms ease, border-color 140ms ease, color 140ms ease, box-shadow 140ms ease, transform 100ms ease',
'&:active:not(:disabled)': {
transform: 'scale(0.98)',
},
'@media (prefers-reduced-motion: reduce)': {
transition: 'none',
'&:active:not(:disabled)': { transform: 'none' },
},
}),
},
},
JoyIconButton: {
styleOverrides: {
root: ({ ownerState }) => ({
borderRadius: `var(--IconButton-radius, ${ICON_BUTTON_RADIUS})`,
transition:
'background-color 140ms ease, border-color 140ms ease, color 140ms ease, transform 100ms ease',
'&:active:not(:disabled)': {
transform: 'scale(0.96)',
},
// Touch target only for the default size. `sm`/`lg` are explicit
// choices by the call site and keep Joy's own sizing.
...(ownerState.size === 'md' && {
minWidth: '40px',
minHeight: '40px',
'@media (max-width: 768px)': {
minWidth: '44px',
minHeight: '44px',
},
}),
'@media (prefers-reduced-motion: reduce)': {
transition: 'none',
'&:active:not(:disabled)': { transform: 'none' },
},
}),
},
},
JoyButtonGroup: {
styleOverrides: {
root: {
'--ButtonGroup-radius': CONTROL_RADIUS,
},
},
},
},
})
const ThemeContext = ({ children }) => {
return (
const ThemeContext = ({ children }) => (
<CssVarsProvider theme={theme}>
<CssBaseline />
{children}
</CssVarsProvider>
)
}
)
ThemeContext.propTypes = {
children: PropType.node,

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { Close, HelpOutline, Keyboard } from '@mui/icons-material'
import { Box, Button, Card, Divider, IconButton, Typography } from '@mui/joy'
import { HelpOutline } from '@mui/icons-material'
import { Box, Card, IconButton, Typography } from '@mui/joy'
import { useState } from 'react'
import ModalActions from '../../components/common/ModalActions'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
const MultiSelectHelp = ({ isVisible = true }) => {
@@ -28,37 +29,24 @@ const MultiSelectHelp = ({ isVisible = true }) => {
borderRadius: '50%',
boxShadow: 'lg',
}}
aria-label='Show keyboard shortcuts'
title='Show keyboard shortcuts'
>
<HelpOutline />
</IconButton>
{/* Help Modal */}
<ResponsiveModal open={isHelpOpen} onClose={() => setIsHelpOpen(false)}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 2,
}}
<ResponsiveModal
open={isHelpOpen}
onClose={() => setIsHelpOpen(false)}
title='Multi-select Mode'
description='Use these keyboard shortcuts to work more efficiently.'
footer={
<ModalActions
primary={{ label: 'Got it', onClick: () => setIsHelpOpen(false) }}
/>
}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Keyboard color='primary' />
<Typography level='title-lg'>Multi-select Mode</Typography>
</Box>
<IconButton
variant='plain'
size='sm'
onClick={() => setIsHelpOpen(false)}
>
<Close />
</IconButton>
</Box>
<Typography level='body-md' sx={{ mb: 3, color: 'text.secondary' }}>
Use these keyboard shortcuts to work more efficiently with multiple
tasks:
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Selection shortcuts */}
<Card variant='soft' sx={{ p: 2 }}>
@@ -107,16 +95,6 @@ const MultiSelectHelp = ({ isVisible = true }) => {
</Box>
</Card>
</Box>
<Divider sx={{ my: 3 }} />
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Button
variant='soft'
onClick={() => setIsHelpOpen(false)}
sx={{ minWidth: 120 }}
>
Got it!
</Button>
</Box>
</ResponsiveModal>
</>
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,23 +1,13 @@
import { CheckCircle, Security, Smartphone } from '@mui/icons-material'
import {
Alert,
Box,
Button,
Card,
Input,
Modal,
ModalClose,
ModalDialog,
Stack,
Typography,
} from '@mui/joy'
import { Alert, Box, Button, Card, Input, Stack, Typography } from '@mui/joy'
import QRCode from 'qrcode'
import { useEffect, useState } from 'react'
import AppModal from '../../components/common/AppModal'
import ModalActions from '../../components/common/ModalActions'
import {
ConfirmMFA,
DisableMFA,
GetMFAStatus,
RegenerateBackupCodes,
SetupMFA,
} from '../../utils/Fetcher'
import LoadingComponent from '../components/Loading'
@@ -168,24 +158,6 @@ const MFASettings = () => {
}
}
const handleRegenerateBackupCodes = async () => {
try {
setError('')
const response = await RegenerateBackupCodes()
if (response.ok) {
const data = await response.json()
setBackupCodes(data.backupCodes)
setBackupCodesModalOpen(true)
setSuccess('New backup codes have been generated!')
} else {
setError('Failed to regenerate backup codes. Please try again.')
}
} catch (error) {
setError('Failed to regenerate backup codes. Please try again.')
console.error('Error regenerating backup codes:', error)
}
}
const closeSetupModal = () => {
setSetupModalOpen(false)
setSetupStep(1)
@@ -290,13 +262,40 @@ const MFASettings = () => {
)} */}
{/* Setup MFA Modal */}
<Modal open={setupModalOpen} onClose={closeSetupModal}>
<ModalDialog size='md' sx={{ maxWidth: 500 }}>
<ModalClose />
<Typography level='h4' sx={{ mb: 2 }}>
Set up Multi-Factor Authentication
</Typography>
<AppModal
open={setupModalOpen}
onClose={closeSetupModal}
title='Set up Multi-Factor Authentication'
size='md'
footer={
setupStep === 1 ? (
<ModalActions
secondary={{ label: 'Cancel', onClick: closeSetupModal }}
primary={{
label: "I've added the account",
onClick: () => setSetupStep(2),
startDecorator: <Smartphone />,
}}
/>
) : setupStep === 2 ? (
<ModalActions
secondary={{ label: 'Back', onClick: () => setSetupStep(1) }}
primary={{
label: 'Verify & Enable',
onClick: handleConfirmMFA,
disabled: verificationCode.length !== 6,
}}
/>
) : (
<ModalActions
primary={{
label: "I've saved my backup codes",
onClick: closeSetupModal,
}}
/>
)
}
>
{setupStep === 1 && setupData && (
<Stack spacing={3}>
<Typography level='body-md'>
@@ -316,8 +315,8 @@ const MFASettings = () => {
/>
) : (
<Alert color='danger'>
QR code could not be generated. Please try again or use
the manual entry key below.
QR code could not be generated. Please try again or use the
manual entry key below.
</Alert>
)}
</Box>
@@ -341,14 +340,6 @@ const MFASettings = () => {
{setupData.secret}
</Typography>
</Alert>
<Button
color='primary'
onClick={() => setSetupStep(2)}
startDecorator={<Smartphone />}
>
I've added the account to my app
</Button>
</Stack>
)}
@@ -384,24 +375,6 @@ const MFASettings = () => {
/>
{error && <Alert color='danger'>{error}</Alert>}
<Box className='flex gap-2'>
<Button
variant='outlined'
onClick={() => setSetupStep(1)}
sx={{ flex: 1 }}
>
Back
</Button>
<Button
color='primary'
onClick={handleConfirmMFA}
disabled={verificationCode.length !== 6}
sx={{ flex: 1 }}
>
Verify & Enable
</Button>
</Box>
</Stack>
)}
@@ -437,23 +410,30 @@ const MFASettings = () => {
))}
</Box>
</Card>
<Button color='primary' onClick={closeSetupModal}>
I've saved my backup codes
</Button>
</Stack>
)}
</ModalDialog>
</Modal>
</AppModal>
{/* Disable MFA Modal */}
<Modal open={disableModalOpen} onClose={closeDisableModal}>
<ModalDialog size='sm'>
<ModalClose />
<Typography level='h4' sx={{ mb: 2 }}>
Disable Multi-Factor Authentication
</Typography>
<AppModal
open={disableModalOpen}
onClose={closeDisableModal}
title='Disable Multi-Factor Authentication'
size='sm'
role='alertdialog'
closeOnBackdrop={false}
footer={
<ModalActions
secondary={{ label: 'Cancel', onClick: closeDisableModal }}
primary={{
label: 'Disable MFA',
color: 'danger',
onClick: handleDisableMFA,
disabled: disableCode.length !== 6,
}}
/>
}
>
<Stack spacing={3}>
<Alert color='warning'>
<Typography level='body-sm'>
@@ -463,8 +443,7 @@ const MFASettings = () => {
</Alert>
<Typography level='body-md'>
Enter a verification code from your authenticator app to
confirm:
Enter a verification code from your authenticator app to confirm:
</Typography>
<Input
@@ -491,44 +470,29 @@ const MFASettings = () => {
/>
{error && <Alert color='danger'>{error}</Alert>}
<Box className='flex gap-2'>
<Button
variant='outlined'
onClick={closeDisableModal}
sx={{ flex: 1 }}
>
Cancel
</Button>
<Button
color='danger'
onClick={handleDisableMFA}
disabled={disableCode.length !== 6}
sx={{ flex: 1 }}
>
Disable MFA
</Button>
</Box>
</Stack>
</ModalDialog>
</Modal>
</AppModal>
{/* Backup Codes Modal */}
<Modal
<AppModal
open={backupCodesModalOpen}
onClose={() => setBackupCodesModalOpen(false)}
title='New Backup Codes'
size='sm'
footer={
<ModalActions
primary={{
label: "I've saved my backup codes",
onClick: () => setBackupCodesModalOpen(false),
}}
/>
}
>
<ModalDialog size='sm'>
<ModalClose />
<Typography level='h4' sx={{ mb: 2 }}>
New Backup Codes
</Typography>
<Stack spacing={3}>
<Alert color='warning'>
<Typography level='body-sm'>
Your previous backup codes are now invalid. Save these new
codes in a safe place. Each code can only be used once.
Your previous backup codes are now invalid. Save these new codes
in a safe place. Each code can only be used once.
</Typography>
</Alert>
@@ -545,16 +509,8 @@ const MFASettings = () => {
))}
</Box>
</Card>
<Button
color='primary'
onClick={() => setBackupCodesModalOpen(false)}
>
I've saved my backup codes
</Button>
</Stack>
</ModalDialog>
</Modal>
</AppModal>
</div>
</SettingsLayout>
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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