diff --git a/src/components/SubscriptionModal.jsx b/src/components/SubscriptionModal.jsx index 43bb2d3..fcd95b5 100644 --- a/src/components/SubscriptionModal.jsx +++ b/src/components/SubscriptionModal.jsx @@ -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,183 +68,158 @@ const SubscriptionModal = ({ open, onClose }) => { } return ( - - - - {/* Header */} - - - Upgrade to Plus - - - - {/* Features List */} - - - What's included: - - - {features.map((feature, index) => ( - - - {feature} - - ))} + + } + > + {/* Features List */} + + + What's included: + + + {features.map((feature, index) => ( + + + {feature} - - - - {/* Plan Selection */} - - {Object.entries(plans).map(([key, plan]) => ( - setSelectedPlan(key)} - sx={{ - width: '100%', - minHeight: 48, - maxHeight: 64, - cursor: 'pointer', - transition: 'all 0.2s', - mb: 0.2, - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - px: 2.5, - py: 1.2, - position: 'relative', - overflow: 'visible', - }} - > - - setSelectedPlan(key)} - value={key} - name='subscription-plan' - color='primary' - sx={{ mr: 1 }} - /> - - {key.charAt(0).toUpperCase() + key.slice(1)} - - - {plan.price} - - {' '} - / {plan.period} - - - - - {plan.popular && ( - } - sx={{ - fontWeight: 600, - fontSize: 12, - px: 1, - py: 0.1, - boxShadow: 2, - mt: 0.8, - }} - > - Most Popular - - )} - {plan.savings && ( - - {plan.savings} - - )} - - - ))} - - - {/* Action Buttons */} - - - - - - {/* Footer */} - - Cancel anytime. No hidden fees. Secure payment powered by Stripe. - + ))} - - + + + + {/* Plan Selection */} + + {Object.entries(plans).map(([key, plan]) => ( + setSelectedPlan(key)} + sx={{ + width: '100%', + minHeight: 48, + maxHeight: 64, + cursor: 'pointer', + transition: 'all 0.2s', + mb: 0.2, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + px: 2.5, + py: 1.2, + position: 'relative', + overflow: 'visible', + }} + > + + setSelectedPlan(key)} + value={key} + name='subscription-plan' + color='primary' + sx={{ mr: 1 }} + /> + + {key.charAt(0).toUpperCase() + key.slice(1)} + + + {plan.price} + + {' '} + / {plan.period} + + + + + {plan.popular && ( + } + sx={{ + fontWeight: 600, + fontSize: 12, + px: 1, + py: 0.1, + boxShadow: 2, + mt: 0.8, + }} + > + Most Popular + + )} + {plan.savings && ( + + {plan.savings} + + )} + + + ))} + + + {/* Footer */} + + Cancel anytime. No hidden fees. Secure payment powered by Stripe. + + ) } diff --git a/src/components/common/AppModal.jsx b/src/components/common/AppModal.jsx new file mode 100644 index 0000000..55fefc6 --- /dev/null +++ b/src/components/common/AppModal.jsx @@ -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 ( + + + {isSheet && showHandle && ( + + + ) + }, +) + +AppModal.displayName = 'AppModal' + +export default AppModal diff --git a/src/components/common/BottomSheetModal.jsx b/src/components/common/BottomSheetModal.jsx deleted file mode 100644 index 029475d..0000000 --- a/src/components/common/BottomSheetModal.jsx +++ /dev/null @@ -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 ( - - - {/* Header Section with drag handle, title, and close button */} -
- {/* Close button positioned absolutely in top-right */} - {showCloseButton && ( - - - - )} - - {/* Drag Handle */} - {showHandle && ( -
-
-
- )} - - {/* Title Row */} - {title && ( -
- - {title} - -
- )} -
- {/* Content area */} -
- {children} -
- - {footer && ( - <> - -
- {footer} -
- - )} - - - ) - }, -) - -BottomSheetModal.displayName = 'BottomSheetModal' - -export default BottomSheetModal diff --git a/src/components/common/FadeModal.jsx b/src/components/common/FadeModal.jsx deleted file mode 100644 index f6c6d9e..0000000 --- a/src/components/common/FadeModal.jsx +++ /dev/null @@ -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 ( - - - *': { - 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 }, - }, - }} - > - - {title && ( - - {title} - - )} -
{children}
- {footer &&
{footer}
} -
-
-
- ) -} - -export default FadeModal diff --git a/src/components/common/FilterBar.jsx b/src/components/common/FilterBar.jsx index 6f221e1..bbc3a76 100644 --- a/src/components/common/FilterBar.jsx +++ b/src/components/common/FilterBar.jsx @@ -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 ─────────────────────────────────────── */} - + {/* ── Bottom sheet ────────────────────────────────────── */} - setIsOpen(false)} title={ Filters {hasActive && ( - + {activeFilterCount} )} } footer={ - - - - + setIsOpen(false), + }} + /> } > @@ -348,9 +398,18 @@ const FilterBar = ({ {idx > 0 && } {/* Section header */} - + {def.icon && ( - + {def.icon} )} @@ -359,21 +418,41 @@ const FilterBar = ({ {/* active badge in header */} - {def.type === 'multi-select' && (activeFilters[def.id]?.length ?? 0) > 0 && ( - - {activeFilters[def.id].length} selected - - )} - {def.type === 'single-select' && activeFilters[def.id] != null && (() => { - const opt = def.options?.find(o => o.value === activeFilters[def.id]) - return opt ? ( - - {opt.label} + {def.type === 'multi-select' && + (activeFilters[def.id]?.length ?? 0) > 0 && ( + + {activeFilters[def.id].length} selected - ) : null - })()} + )} + {def.type === 'single-select' && + activeFilters[def.id] != null && + (() => { + const opt = def.options?.find( + o => o.value === activeFilters[def.id], + ) + return opt ? ( + + {opt.label} + + ) : null + })()} {def.type === 'date-range' && getActiveChipLabel(def) && ( - + {getActiveChipLabel(def)} )} @@ -383,18 +462,28 @@ const FilterBar = ({ {def.type === 'multi-select' && ( {def.options?.map(opt => { - const isSelected = (activeFilters[def.id] || []).includes(opt.value) + const isSelected = (activeFilters[def.id] || []).includes( + opt.value, + ) return ( + ) : isSelected ? ( - ) : (opt.icon ?? null) + ) : ( + (opt.icon ?? null) + ) } onClick={() => handleMultiToggle(def.id, opt.value)} sx={selectableChipSx} @@ -415,13 +504,21 @@ const FilterBar = ({ + ) : isSelected ? ( - ) : (opt.icon ?? null) + ) : ( + (opt.icon ?? null) + ) } onClick={() => handleSingleToggle(def.id, opt.value)} sx={selectableChipSx} @@ -438,7 +535,11 @@ const FilterBar = ({ : null} + startDecorator={ + activeFilters[def.id] ? ( + + ) : null + } onClick={() => handleBoolToggle(def.id)} sx={selectableChipSx} > @@ -447,58 +548,84 @@ const FilterBar = ({ )} {/* date-range */} - {def.type === 'date-range' && (() => { - const val = activeFilters[def.id] || {} - return ( - - {/* Preset chips */} - - {DATE_RANGE_PRESETS.map(preset => { - const isSelected = val.preset === preset.value - return ( - : null} - onClick={() => handleDateRangePreset(def.id, preset.value)} - sx={selectableChipSx} - > - {preset.label} - - ) - })} - + {def.type === 'date-range' && + (() => { + const val = activeFilters[def.id] || {} + return ( + + {/* Preset chips */} + + {DATE_RANGE_PRESETS.map(preset => { + const isSelected = val.preset === preset.value + return ( + + ) : null + } + onClick={() => + handleDateRangePreset(def.id, preset.value) + } + sx={selectableChipSx} + > + {preset.label} + + ) + })} + - {/* Custom date inputs */} - - handleDateRangeInput(def.id, 'from', e.target.value)} - slotProps={{ input: { max: toInputDate(val.to) || undefined } }} - sx={{ flex: 1, fontSize: '0.8rem' }} - /> - - – - - handleDateRangeInput(def.id, 'to', e.target.value)} - slotProps={{ input: { min: toInputDate(val.from) || undefined } }} - sx={{ flex: 1, fontSize: '0.8rem' }} - /> + {/* Custom date inputs */} + + + handleDateRangeInput(def.id, 'from', e.target.value) + } + slotProps={{ + input: { max: toInputDate(val.to) || undefined }, + }} + sx={{ flex: 1, fontSize: '0.8rem' }} + /> + + – + + + handleDateRangeInput(def.id, 'to', e.target.value) + } + slotProps={{ + input: { min: toInputDate(val.from) || undefined }, + }} + sx={{ flex: 1, fontSize: '0.8rem' }} + /> + - - ) - })()} + ) + })()} ))} - + ) } diff --git a/src/components/common/ModalActions.jsx b/src/components/common/ModalActions.jsx new file mode 100644 index 0000000..23e67d7 --- /dev/null +++ b/src/components/common/ModalActions.jsx @@ -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 ( + + ) +} + +/** + * 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 {children} + + return ( + + + + + + ) +} + +export default ModalActions diff --git a/src/components/common/README.md b/src/components/common/README.md new file mode 100644 index 0000000..31fad3c --- /dev/null +++ b/src/components/common/README.md @@ -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 + + } +> + {content} + +``` + +## 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`. diff --git a/src/contexts/ThemeContext.jsx b/src/contexts/ThemeContext.jsx index 45e91b5..3aab2d5 100644 --- a/src/contexts/ThemeContext.jsx +++ b/src/contexts/ThemeContext.jsx @@ -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 = '24px' +const ICON_BUTTON_RADIUS = '10px' const theme = extendTheme({ + radius: { + xs: '6px', + sm: '8px', + md: '10px', + lg: '12px', + xl: '16px', + }, colorSchemes: { light: { palette: { @@ -42,42 +53,100 @@ 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', + 100: '#fef8e1', + 200: '#fdecb2', + 300: '#fcd982', + 400: '#fbcf52', + 500: '#f9c222', + 600: '#f6b81e', + 700: '#f3ae1a', + 800: '#f0a416', + 900: '#e99b0e', }, }, - warning: { - 50: '#fffdf7', - 100: '#fef8e1', - 200: '#fdecb2', - 300: '#fcd982', - 400: '#fbcf52', - 500: '#f9c222', - 600: '#f6b81e', - 700: '#f3ae1a', - 800: '#f0a416', - 900: '#e99b0e', + }, + dark: { + palette: { + primary: primaryPalette, }, }, }, - dark: { - palette: { - 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 ( - - - {children} - - ) -} +const ThemeContext = ({ children }) => ( + + + {children} + +) ThemeContext.propTypes = { children: PropType.node, diff --git a/src/hooks/useResponsiveModal.js b/src/hooks/useResponsiveModal.js index 297f5fa..3d85452 100644 --- a/src/hooks/useResponsiveModal.js +++ b/src/hooks/useResponsiveModal.js @@ -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, } } diff --git a/src/views/Authorization/MFAVerificationModal.jsx b/src/views/Authorization/MFAVerificationModal.jsx index 6d5d41b..235d21a 100644 --- a/src/views/Authorization/MFAVerificationModal.jsx +++ b/src/views/Authorization/MFAVerificationModal.jsx @@ -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 = ({ + } > - - - - Enter the verification code from your authenticator app - @@ -120,16 +127,6 @@ const MFAVerificationModal = ({ )} - - {/* Main action button */} - : } sx={{ - px: 3, - py: 1, - borderTopRightRadius: 0, - borderBottomRightRadius: 0, minWidth: fullWidth ? 'auto' : 120, flex: fullWidth ? 1 : 'none', }} > - {chore.status === 1 ? : } {chore.status === 1 ? 'Pause' : 'Resume'} - + {/* Dropdown arrow button */} diff --git a/src/views/Chores/ArchivedTasks.jsx b/src/views/Chores/ArchivedTasks.jsx index de2e375..a2b1d10 100644 --- a/src/views/Chores/ArchivedTasks.jsx +++ b/src/views/Chores/ArchivedTasks.jsx @@ -706,7 +706,7 @@ const ArchivedTasks = () => { }} onChange={handleSearchChange} startDecorator={ - + showKeyboardShortcuts ? : null } endDecorator={ searchTerm && ( diff --git a/src/views/Chores/MultiSelectHelp.jsx b/src/views/Chores/MultiSelectHelp.jsx index 1aabe7b..1a030e9 100644 --- a/src/views/Chores/MultiSelectHelp.jsx +++ b/src/views/Chores/MultiSelectHelp.jsx @@ -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' > {/* Help Modal */} - setIsHelpOpen(false)}> - - - - Multi-select Mode - - setIsHelpOpen(false)} - > - - - - - Use these keyboard shortcuts to work more efficiently with multiple - tasks: - + setIsHelpOpen(false)} + title='Multi-select Mode' + description='Use these keyboard shortcuts to work more efficiently.' + footer={ + setIsHelpOpen(false) }} + /> + } + > {/* Selection shortcuts */} @@ -107,16 +95,6 @@ const MultiSelectHelp = ({ isVisible = true }) => { - - - - ) diff --git a/src/views/Chores/components/ChoreToolbarPrototype.jsx b/src/views/Chores/components/ChoreToolbarPrototype.jsx index 0c5b5d1..986884a 100644 --- a/src/views/Chores/components/ChoreToolbarPrototype.jsx +++ b/src/views/Chores/components/ChoreToolbarPrototype.jsx @@ -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 }) => ( - : opt.icon - : undefined + opt.icon != null ? ( + isSelected ? ( + + ) : ( + 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, - { - name: editingSavedFilter.name, - description: editingSavedFilter.description || '', - color: editingSavedFilter.color, - conditions, - operator: 'AND', - }, - )?.then?.(() => { + updateFilter(editingSavedFilter.id, { + name: editingSavedFilter.name, + description: editingSavedFilter.description || '', + color: editingSavedFilter.color, + conditions, + operator: 'AND', + })?.then?.(() => { clearTempFilter?.() onSavedFilterClick?.(editingSavedFilter.id) onFilterSaved?.(editingSavedFilter.name) @@ -498,9 +504,21 @@ const ChoreToolbar = ({ ] const viewOptions = [ - { value: 'default', label: 'Cards', icon: }, - { value: 'compact', label: 'Compact', icon: }, - { value: 'calendar', label: 'Calendar', icon: }, + { + value: 'default', + label: 'Cards', + icon: , + }, + { + value: 'compact', + label: 'Compact', + icon: , + }, + { + value: 'calendar', + label: 'Calendar', + icon: , + }, ] return ( @@ -536,6 +554,7 @@ const ChoreToolbar = ({ size='sm' sx={{ height: 32, width: 32, borderRadius: '50%' }} onClick={openFilterSheet} + aria-label='Filters' title='Filters' > @@ -543,13 +562,14 @@ const ChoreToolbar = ({ {/* Project selector */} - {!filterActive && projects.filter(p => p.id !== 'default').length > 0 && ( - - )} + {!filterActive && + projects.filter(p => p.id !== 'default').length > 0 && ( + + )} {/* Display button — View + Group combined */} 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 ─────────────────────────────────────── */} - { setSaveMenuAnchorEl(null) setFilterSheetOpen(false) @@ -649,7 +674,12 @@ const ChoreToolbar = ({ footer={ savingFilter ? ( - {resultCount != null - ? `Show ${resultCount}` - : 'Done'} + {resultCount != null ? `Show ${resultCount}` : 'Done'} setSaveMenuAnchorEl(e.currentTarget)} > @@ -825,11 +854,12 @@ const ChoreToolbar = ({ )} - + {/* ── Display bottom sheet (View + Group + Assignee + Project) ──────────── */} - setDisplaySheetOpen(false)} title={ @@ -838,7 +868,10 @@ const ChoreToolbar = ({ } footer={ - } @@ -853,9 +886,11 @@ const ChoreToolbar = ({ variant={viewMode === opt.value ? 'solid' : 'soft'} color={viewMode === opt.value ? 'primary' : 'neutral'} startDecorator={ - viewMode === opt.value - ? - : opt.icon + viewMode === opt.value ? ( + + ) : ( + 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)} /> - - + ) } diff --git a/src/views/Modals/EditHistoryModal.jsx b/src/views/Modals/EditHistoryModal.jsx index 959441c..6321ea6 100644 --- a/src/views/Modals/EditHistoryModal.jsx +++ b/src/views/Modals/EditHistoryModal.jsx @@ -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={ - - - - + }), + }} + /> } > Due Date @@ -119,6 +108,7 @@ function EditHistoryModal({ config, historyRecord }) { message: 'Are you sure you want to delete this history?', confirmText: 'Delete', cancelText: 'Cancel', + color: 'danger', }} /> diff --git a/src/views/Modals/HistoryDetailModal.jsx b/src/views/Modals/HistoryDetailModal.jsx index e105549..23400c6 100644 --- a/src/views/Modals/HistoryDetailModal.jsx +++ b/src/views/Modals/HistoryDetailModal.jsx @@ -15,28 +15,40 @@ 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' import RichTextEditor from '../components/RichTextEditor.jsx' const STATUS_CONFIG = { - 0: { label: 'In Progress', color: 'primary', icon: }, - 1: { label: 'Completed', color: 'success', icon: }, - 2: { label: 'Skipped', color: 'warning', icon: }, + 0: { label: 'In Progress', color: 'primary', icon: }, + 1: { label: 'Completed', color: 'success', icon: }, + 2: { label: 'Skipped', color: 'warning', icon: }, 3: { label: 'Pending Approval', color: 'neutral', icon: }, - 4: { label: 'Rejected', color: 'danger', icon: }, - 5: { label: 'Missed', color: 'danger', icon: }, - 6: { label: 'Rescheduled', color: 'warning', icon: }, + 4: { label: 'Rejected', color: 'danger', icon: }, + 5: { label: 'Missed', color: 'danger', icon: }, + 6: { label: 'Rescheduled', color: 'warning', icon: }, } const DetailRow = ({ icon, label, value, children }) => ( - {icon} + + {icon} + - {label} + + {label} + {children ?? ( - {value} + + {value} + )} @@ -52,15 +64,41 @@ const TimingBadge = ({ historyEntry }) => { const gracePeriod = 6 * 60 * 60 * 1000 if (Math.abs(performedAt - dueDate) <= gracePeriod) { - return }>On Time + return ( + } + > + On Time + + ) } else if (performedAt.isBefore(dueDate)) { const abs = Math.abs(diffHours) const label = abs >= 48 ? `${Math.floor(abs / 24)}d early` : `${abs}h early` - return }>{label} + return ( + } + > + {label} + + ) } else { const abs = Math.abs(diffHours) const label = abs >= 48 ? `${Math.floor(abs / 24)}d late` : `${abs}h late` - return {label} + return ( + + {label} + + ) } } @@ -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={ + + {entry.choreId && ( + + )} + {config?.onEdit && ( + + )} + + } > {/* Status header */} - + {statusCfg.icon} - + {statusLabel} @@ -119,17 +194,31 @@ function HistoryDetailModal({ config }) { {/* Who performed it */} {performer && ( - } label='Performed by'> + } + label='Performed by' + > - - {performer.displayName} + + + {performer.displayName} + )} {/* Assigned to (only if different) */} {isDifferentAssignee && assignedTo && ( - } label='Assigned to' value={assignedTo.displayName} /> + } + label='Assigned to' + value={assignedTo.displayName} + /> )} @@ -138,7 +227,15 @@ function HistoryDetailModal({ config }) { {entry.performedAt && ( } - 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 && ( } - 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,45 +287,19 @@ function HistoryDetailModal({ config }) { <> - + {entry.status === 2 || entry.status === 4 ? 'Reason' : 'Notes'} - - - + + + )} - - {/* Action buttons */} - - {entry.choreId && ( - - )} - {config?.onEdit && ( - - )} - ) } diff --git a/src/views/Modals/Inputs/AcknowledgmentModal.jsx b/src/views/Modals/Inputs/AcknowledgmentModal.jsx index 36b2ab7..947c857 100644 --- a/src/views/Modals/Inputs/AcknowledgmentModal.jsx +++ b/src/views/Modals/Inputs/AcknowledgmentModal.jsx @@ -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,43 +46,33 @@ function AcknowledgmentModal({ config }) { return ( - - - + ) : undefined, }} - > - {config?.message} - - - - - - + /> + } + > + + {config?.message} + ) } diff --git a/src/views/Modals/Inputs/AdvancedFilterBuilder.jsx b/src/views/Modals/Inputs/AdvancedFilterBuilder.jsx index 04bea0e..431ac71 100644 --- a/src/views/Modals/Inputs/AdvancedFilterBuilder.jsx +++ b/src/views/Modals/Inputs/AdvancedFilterBuilder.jsx @@ -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 ( - 0 && ( - {activeConditionCount} condition{activeConditionCount !== 1 ? 's' : ''} + {activeConditionCount} condition + {activeConditionCount !== 1 ? 's' : ''} )} @@ -144,20 +145,19 @@ const AdvancedFilterBuilder = ({ {/* Actions */} - - - + } > @@ -231,7 +231,7 @@ const AdvancedFilterBuilder = ({ projects={projects} /> - + ) } diff --git a/src/views/Modals/Inputs/AttachmentBrowserModal.jsx b/src/views/Modals/Inputs/AttachmentBrowserModal.jsx index e96d90a..8165a17 100644 --- a/src/views/Modals/Inputs/AttachmentBrowserModal.jsx +++ b/src/views/Modals/Inputs/AttachmentBrowserModal.jsx @@ -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={ - - - + } > {isLoading ? ( diff --git a/src/views/Modals/Inputs/AttachmentViewerModal.jsx b/src/views/Modals/Inputs/AttachmentViewerModal.jsx index 317b2f9..c0eefc2 100644 --- a/src/views/Modals/Inputs/AttachmentViewerModal.jsx +++ b/src/views/Modals/Inputs/AttachmentViewerModal.jsx @@ -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={ - - - - + , + onClick: () => downloadUrl(url, fileName), + disabled: !url, + }} + /> } > {!imgLoaded && !imgError && ( - + )} {imgError ? ( diff --git a/src/views/Modals/Inputs/BackupRestoreModal.jsx b/src/views/Modals/Inputs/BackupRestoreModal.jsx index a8f88be..8fb4795 100644 --- a/src/views/Modals/Inputs/BackupRestoreModal.jsx +++ b/src/views/Modals/Inputs/BackupRestoreModal.jsx @@ -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' /> - Keep this key safe - you'll need it to restore your backup + Keep this key safe—you'll need it to restore your backup @@ -238,22 +237,6 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) { {error} )} - - - - - ) @@ -294,22 +277,6 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) { {error} )} - - - - - ) @@ -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={ + + } > {loading ? ( { 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 ( handleAction(false)} size='sm' - unmountDelay={250} + role={isDestructive ? 'alertdialog' : 'dialog'} + title={config?.title} + showCloseButton={false} + closeOnBackdrop={!isDestructive} + footer={ + handleAction(false), + endDecorator: showKeyboardShortcuts ? ( + + ) : undefined, + }} + primary={{ + label: config?.confirmText, + color: config?.color || 'primary', + onClick: () => handleAction(true), + endDecorator: showKeyboardShortcuts ? ( + + ) : undefined, + }} + /> + } > - - {config?.title} - - + {config?.message} - - - - - - ) } + export default ConfirmationModal diff --git a/src/views/Modals/Inputs/CreateChildUserModal.jsx b/src/views/Modals/Inputs/CreateChildUserModal.jsx index 173d618..ecf6189 100644 --- a/src/views/Modals/Inputs/CreateChildUserModal.jsx +++ b/src/views/Modals/Inputs/CreateChildUserModal.jsx @@ -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 ( - - - Create Sub Account - - - - Create a new sub account. The user will be able to log in using their - combined username and complete tasks assigned to them. - - + + } + > Sub Account Name * @@ -196,27 +204,6 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) { {errors.confirmPassword} )} - - - - - ) } diff --git a/src/views/Modals/Inputs/CreateThingModal.jsx b/src/views/Modals/Inputs/CreateThingModal.jsx index 164cc4b..9c2f367 100644 --- a/src/views/Modals/Inputs/CreateThingModal.jsx +++ b/src/views/Modals/Inputs/CreateThingModal.jsx @@ -1,15 +1,14 @@ import { - Box, - Button, - FormControl, - FormHelperText, - Input, - Option, - Select, - Textarea, - Typography, + FormControl, + FormHelperText, + Input, + Option, + Select, + Textarea, + 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 }) { + } > Name @@ -79,9 +89,9 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) { Type - setType(value)}> {['text', 'number', 'boolean'].map(type => ( - ))} @@ -118,24 +128,15 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) { {type === 'boolean' && ( Value - setState(value)}> {['true', 'false'].map(value => ( - ))} )} - - - - - ) } diff --git a/src/views/Modals/Inputs/DateModal.jsx b/src/views/Modals/Inputs/DateModal.jsx index 52ed0d1..a588726 100644 --- a/src/views/Modals/Inputs/DateModal.jsx +++ b/src/views/Modals/Inputs/DateModal.jsx @@ -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 }) { + } > setDate(e.target.value)} + onChange={event => setDate(event.target.value)} /> - - {/* - - Quick select: - - - } - size='lg' - onClick={() => handleQuickSchedule('today')} - sx={{ cursor: 'pointer' }} - > - Today - - } - size='lg' - onClick={() => handleQuickSchedule('tomorrow')} - sx={{ cursor: 'pointer' }} - > - Tomorrow - - } - size='lg' - onClick={() => handleQuickSchedule('weekend')} - sx={{ cursor: 'pointer' }} - > - Weekend - - } - size='lg' - onClick={() => handleQuickSchedule('next-week')} - sx={{ cursor: 'pointer' }} - > - Next week - - - */} - - - - - ) } + export default DateModal diff --git a/src/views/Modals/Inputs/EditThingState.jsx b/src/views/Modals/Inputs/EditThingState.jsx index 2871b44..1d77513 100644 --- a/src/views/Modals/Inputs/EditThingState.jsx +++ b/src/views/Modals/Inputs/EditThingState.jsx @@ -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 }) { + } > Value @@ -57,15 +56,6 @@ function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) { /> {errors.state} - - - - - ) } diff --git a/src/views/Modals/Inputs/IconPickerModal.jsx b/src/views/Modals/Inputs/IconPickerModal.jsx index 2a32844..b1be623 100644 --- a/src/views/Modals/Inputs/IconPickerModal.jsx +++ b/src/views/Modals/Inputs/IconPickerModal.jsx @@ -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={ + + } > - Available Icons - - - - ) } diff --git a/src/views/Modals/Inputs/LabelModal.jsx b/src/views/Modals/Inputs/LabelModal.jsx index 20509b0..7b771f0 100644 --- a/src/views/Modals/Inputs/LabelModal.jsx +++ b/src/views/Modals/Inputs/LabelModal.jsx @@ -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={ - - - - + } > @@ -120,12 +120,18 @@ function LabelModal({ isOpen, onClose, label }) { {LABEL_COLORS.map(colorOption => ( setColor(colorOption.value)} sx={{ - width: 26, - height: 26, + width: 40, + height: 40, + border: 0, + p: 0, borderRadius: '50%', background: colorOption.value, cursor: 'pointer', diff --git a/src/views/Modals/Inputs/NativeCancelSubscriptionModal.jsx b/src/views/Modals/Inputs/NativeCancelSubscriptionModal.jsx index 96ea708..88cb8da 100644 --- a/src/views/Modals/Inputs/NativeCancelSubscriptionModal.jsx +++ b/src/views/Modals/Inputs/NativeCancelSubscriptionModal.jsx @@ -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 ( - - - Cancel Subscription - - + onClose('desktop'), + }} + /> + } + > + 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 }) => { Important: You must cancel your subscription through the same platform where you originally subscribed. If you subscribed through the iOS App Store or Google Play Store (even if - you're now using the web/desktop version), you must cancel through - that original platform using the instructions above. + you're now using the web/desktop version), you must cancel + through that original platform using the instructions above. @@ -93,24 +111,6 @@ const NativeCancelSubscriptionModal = ({ isOpen, onClose }) => { Your subscription will remain active until the end of your current billing period. - - - - - - ) diff --git a/src/views/Modals/Inputs/NudgeModal.jsx b/src/views/Modals/Inputs/NudgeModal.jsx index 75babf0..47d2260 100644 --- a/src/views/Modals/Inputs/NudgeModal.jsx +++ b/src/views/Modals/Inputs/NudgeModal.jsx @@ -1,15 +1,15 @@ import { - Alert, - Box, - Button, - FormControl, - FormLabel, - Switch, - Textarea, - Typography, + Alert, + Box, + FormControl, + FormLabel, + Switch, + Textarea, + 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' 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={ + handleAction(false), + endDecorator: showKeyboardShortcuts ? ( + + ) : undefined, + }} + primary={{ + label: 'Send Nudge', + onClick: () => handleAction(true), + disabled: !isOfficialInstance, + endDecorator: showKeyboardShortcuts ? ( + + ) : undefined, + }} + /> + } > - - Send a gentle reminder to the assignee about this task. You can - customize the message and choose who gets notified. - - {!isOfficialInstance && ( Heads up!This feature avaiable on Donetick Cloud! - Since you're using a self-hosted instance, nudges will requires you - to setup Google cloud account and Firebase Cloud Messaging (FCM). - and build the Android or the iOS app by yourself. + Since you're using a self-hosted instance, nudges will requires + you to setup Google cloud account and Firebase Cloud Messaging + (FCM). and build the Android or the iOS app by yourself.
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)} /> - - - - - - ) } diff --git a/src/views/Modals/Inputs/PasswordChangeModal.jsx b/src/views/Modals/Inputs/PasswordChangeModal.jsx index 9d3cad7..485f083 100644 --- a/src/views/Modals/Inputs/PasswordChangeModal.jsx +++ b/src/views/Modals/Inputs/PasswordChangeModal.jsx @@ -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 ( handleAction(false)} + size='sm' title='Change Password' + description='Choose a password between 8 and 64 characters.' + footer={ + handleAction(false) }} + primary={{ + label: 'Change Password', + disabled: !canSubmit, + onClick: () => handleAction(true), + }} + /> + } > - - Please enter your new password. - - - - New Password - + + New password { + onChange={event => { setPasswordTouched(true) - setPassword(e.target.value) + setPassword(event.target.value) }} /> - - - Confirm Password - + + Confirm password { + onChange={event => { setConfirmPasswordTouched(true) - setConfirmPassword(e.target.value) + setConfirmPassword(event.target.value) }} /> - - {passwordError} + {passwordError && {passwordError}} - - - - ) } -export default PassowrdChangeModal + +export default PasswordChangeModal diff --git a/src/views/Modals/Inputs/ProjectModal.jsx b/src/views/Modals/Inputs/ProjectModal.jsx index 0b97feb..da6262b 100644 --- a/src/views/Modals/Inputs/ProjectModal.jsx +++ b/src/views/Modals/Inputs/ProjectModal.jsx @@ -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={ - - - - + } >
diff --git a/src/views/Modals/Inputs/SelectModal.jsx b/src/views/Modals/Inputs/SelectModal.jsx index ed7a041..d04bd08 100644 --- a/src/views/Modals/Inputs/SelectModal.jsx +++ b/src/views/Modals/Inputs/SelectModal.jsx @@ -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({ + } > - setSelected(value)} + > + {options.map(item => ( + ))} - - - - - ) } + export default SelectModal diff --git a/src/views/Modals/Inputs/TextModal.jsx b/src/views/Modals/Inputs/TextModal.jsx index fac5558..1a1c8a2 100644 --- a/src/views/Modals/Inputs/TextModal.jsx +++ b/src/views/Modals/Inputs/TextModal.jsx @@ -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({ + } >