Merge branch 'dev'

This commit is contained in:
Mo Tarbin
2025-06-21 00:38:14 -04:00
47 changed files with 3645 additions and 672 deletions

View File

@@ -8,21 +8,19 @@ import {
FormHelperText,
Input,
Sheet,
Snackbar,
Typography,
} from '@mui/joy'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { API_URL } from './../../Config'
import { ResetPassword } from '../../utils/Fetcher'
import { useNotification } from '../../service/NotificationProvider'
import { ResetPassword } from '../../utils/Fetcher'
const ForgotPasswordView = () => {
const navigate = useNavigate()
// const [showLoginSnackbar, setShowLoginSnackbar] = useState(false)
// const [snackbarMessage, setSnackbarMessage] = useState('')
const [resetStatusOk, setResetStatusOk] = useState(null)
const [email, setEmail] = useState('')
const [emailError, setEmailError] = useState(null)
const { showError, showNotification } = useNotification()
const validateEmail = email => {
return !/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(email)
@@ -48,12 +46,24 @@ const ForgotPasswordView = () => {
if (response.ok) {
setResetStatusOk(true)
// wait 3 seconds and then redirect to login:
showNotification({
type: 'success',
title: 'Reset Email Sent',
message: 'Check your email for password reset instructions',
})
} else {
setResetStatusOk(false)
showError({
title: 'Reset Failed',
message: 'Failed to send reset email, please try again later',
})
}
} catch (error) {
setResetStatusOk(false)
showError({
title: 'Reset Failed',
message: 'Failed to send reset email, please try again later',
})
}
}
@@ -195,19 +205,6 @@ const ForgotPasswordView = () => {
</Button>
</>
)}
<Snackbar
open={resetStatusOk ? resetStatusOk : resetStatusOk === false}
autoHideDuration={5000}
onClose={() => {
if (resetStatusOk) {
navigate('/login')
}
}}
>
{resetStatusOk
? 'Reset email sent, check your email'
: 'Reset email failed, try again later'}
</Snackbar>
</Sheet>
</Box>
</Container>

View File

@@ -1,23 +1,15 @@
import { Preferences } from '@capacitor/preferences'
import {
Box,
Button,
Container,
Input,
Sheet,
Snackbar,
Typography,
} from '@mui/joy'
import { Box, Button, Container, Input, Sheet, Typography } from '@mui/joy'
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { API_URL } from '../../Config'
import Logo from '../../Logo'
import { useNotification } from '../../service/NotificationProvider'
import { apiManager } from '../../utils/TokenManager'
const LoginSettings = () => {
const [error, setError] = React.useState(null)
const Navigate = useNavigate()
const [serverURL, setServerURL] = React.useState('')
const { showError } = useNotification()
React.useEffect(() => {
Preferences.get({ key: 'customServerUrl' }).then(result => {
@@ -112,7 +104,11 @@ const LoginSettings = () => {
return
}
if (!isValidServerURL()) {
setError('Invalid server URL')
showError({
title: 'Invalid Server URL',
message:
'Please enter a valid server URL with protocol (http:// or https://)',
})
return
}
Preferences.set({
@@ -150,14 +146,6 @@ const LoginSettings = () => {
</Button>
</Sheet>
</Box>
<Snackbar
open={error !== null}
onClose={() => setError(null)}
autoHideDuration={3000}
message={error}
>
{error}
</Snackbar>
</Container>
)
}

View File

@@ -12,7 +12,6 @@ import {
IconButton,
Input,
Sheet,
Snackbar,
Typography,
} from '@mui/joy'
import Cookies from 'js-cookie'
@@ -22,21 +21,21 @@ import { LoginSocialGoogle } from 'reactjs-social-login'
import { GOOGLE_CLIENT_ID, REDIRECT_URL } from '../../Config'
import Logo from '../../Logo'
import { useResource } from '../../queries/ResourceQueries'
import { useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { login } from '../../utils/Fetcher'
import { apiManager } from '../../utils/TokenManager'
import MFAVerificationModal from './MFAVerificationModal'
const LoginView = () => {
// Only fetch user profile if token is valid to prevent unnecessary queries
const { data: userProfileData } = useUserProfile()
// const { data: userProfileData } = useUserProfile()
const [userProfile, setUserProfile] = useState(null)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState(null)
const [mfaModalOpen, setMfaModalOpen] = useState(false)
const [mfaSessionToken, setMfaSessionToken] = useState('')
const { data: resource } = useResource()
const { showError } = useNotification()
const Navigate = useNavigate()
useEffect(() => {
const initializeSocialLogin = async () => {
@@ -76,14 +75,23 @@ const LoginView = () => {
}
})
} else if (response.status === 401) {
setError('Wrong username or password')
showError({
title: 'Login Failed',
message: 'Wrong username or password',
})
} else {
setError('An error occurred, please try again')
showError({
title: 'Login Failed',
message: 'An error occurred, please try again',
})
console.log('Login failed')
}
})
.catch(err => {
setError('Unable to communicate with server, please try again')
showError({
title: 'Connection Error',
message: 'Unable to communicate with server, please try again',
})
console.log('Login failed', err)
})
}
@@ -133,7 +141,10 @@ const LoginView = () => {
})
}
return response.json().then(() => {
setError("Couldn't log in with Google, please try again")
showError({
title: 'Google Login Failed',
message: "Couldn't log in with Google, please try again",
})
})
})
}
@@ -167,7 +178,10 @@ const LoginView = () => {
}
const handleMFAError = errorMessage => {
setError(errorMessage)
showError({
title: 'Two-Factor Authentication Failed',
message: errorMessage,
})
}
const handleMFAClose = () => {
@@ -380,7 +394,11 @@ const LoginView = () => {
loggedWithProvider(provider, data)
}}
onReject={() => {
setError("Couldn't log in with Google, please try again")
showError({
title: 'Google Login Failed',
message:
"Couldn't log in with Google, please try again",
})
}}
>
<Button
@@ -466,14 +484,6 @@ const LoginView = () => {
</Button>
</Sheet>
</Box>
<Snackbar
open={error !== null}
onClose={() => setError(null)}
autoHideDuration={3000}
message={error}
>
{error}
</Snackbar>
<MFAVerificationModal
open={mfaModalOpen}

View File

@@ -7,12 +7,12 @@ import {
FormHelperText,
Input,
Sheet,
Snackbar,
Typography,
} from '@mui/joy'
import React from 'react'
import { useNavigate } from 'react-router-dom'
import Logo from '../../Logo'
import { useNotification } from '../../service/NotificationProvider'
import { login, signUp } from '../../utils/Fetcher'
const SignupView = () => {
@@ -25,9 +25,7 @@ const SignupView = () => {
const [passwordError, setPasswordError] = React.useState('')
const [emailError, setEmailError] = React.useState('')
const [displayNameError, setDisplayNameError] = React.useState('')
const [error, setError] = React.useState(null)
const [snackbarOpen, setSnackbarOpen] = React.useState(false)
const [snackbarMessage, setSnackbarMessage] = React.useState('')
const { showError } = useNotification()
const handleLogin = (username, password) => {
login(username, password).then(response => {
if (response.status === 200) {
@@ -104,11 +102,17 @@ const SignupView = () => {
if (response.status === 201) {
handleLogin(username, password)
} else if (response.status === 403) {
setError('Signup disabled, please contact admin')
showError({
title: 'Signup Failed',
message: 'Signup disabled, please contact admin',
})
} else {
console.log('Signup failed')
response.json().then(res => {
setError(res.error)
showError({
title: 'Signup Failed',
message: res.error || 'An error occurred during signup',
})
})
}
})
@@ -264,14 +268,6 @@ const SignupView = () => {
</Button>
</Sheet>
</Box>
<Snackbar
open={error !== null}
onClose={() => setError(null)}
autoHideDuration={5000}
message={error}
>
{error}
</Snackbar>
</Container>
)
}

View File

@@ -7,13 +7,13 @@ import {
FormHelperText,
Input,
Sheet,
Snackbar,
Typography,
} from '@mui/joy'
import { useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import Logo from '../../Logo'
import { useNotification } from '../../service/NotificationProvider'
import { ChangePassword } from '../../utils/Fetcher'
const UpdatePasswordView = () => {
@@ -24,8 +24,7 @@ const UpdatePasswordView = () => {
const [passworConfirmationError, setPasswordConfirmationError] =
useState(null)
const [searchParams] = useSearchParams()
const [updateStatusOk, setUpdateStatusOk] = useState(null)
const { showError, showNotification } = useNotification()
const verifiticationCode = searchParams.get('c')
@@ -55,16 +54,27 @@ const UpdatePasswordView = () => {
const response = await ChangePassword(verifiticationCode, password)
if (response.ok) {
setUpdateStatusOk(true)
showNotification({
type: 'success',
title: 'Password Updated',
message:
'Your password has been updated successfully. Redirecting to login...',
})
// wait 3 seconds and then redirect to login:
setTimeout(() => {
navigate('/login')
}, 3000)
} else {
setUpdateStatusOk(false)
showError({
title: 'Password Update Failed',
message: 'Failed to update password, please try again later',
})
}
} catch (error) {
setUpdateStatusOk(false)
showError({
title: 'Password Update Failed',
message: 'Failed to update password, please try again later',
})
}
}
return (
@@ -169,15 +179,6 @@ const UpdatePasswordView = () => {
</Button>
</Sheet>
</Box>
<Snackbar
open={updateStatusOk === false}
autoHideDuration={6000}
onClose={() => {
setUpdateStatusOk(null)
}}
>
Password update failed, try again later
</Snackbar>
</Container>
)
}

View File

@@ -18,8 +18,6 @@ import {
RadioGroup,
Select,
Sheet,
Snackbar,
Stack,
Switch,
Typography,
} from '@mui/joy'
@@ -33,6 +31,7 @@ import {
useUpdateChore,
} from '../../queries/ChoreQueries.jsx'
import { useUserProfile } from '../../queries/UserQueries.jsx'
import { useNotification } from '../../service/NotificationProvider'
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
import {
DeleteChore,
@@ -101,9 +100,6 @@ const ChoreEdit = () => {
const [createdBy, setCreatedBy] = useState(0)
const [errors, setErrors] = useState({})
const [attemptToSave, setAttemptToSave] = useState(false)
const [isSnackbarOpen, setIsSnackbarOpen] = useState(false)
const [snackbarMessage, setSnackbarMessage] = useState('')
const [snackbarColor, setSnackbarColor] = useState('warning')
const [addLabelModalOpen, setAddLabelModalOpen] = useState(false)
const { data: userLabelsRaw, isLoading: isUserLabelsLoading } = useLabels()
const updateChoreMutation = useUpdateChore()
@@ -113,6 +109,7 @@ const ChoreEdit = () => {
isLoading: isChoreLoading,
refetch: refetchChore,
} = useChore(choreId)
const { showSuccess, showError } = useNotification()
const [userLabels, setUserLabels] = useState([])
@@ -178,16 +175,10 @@ const ChoreEdit = () => {
const errorList = Object.keys(errors).map(key => (
<ListItem key={key}>{errors[key]}</ListItem>
))
setSnackbarMessage(
<Stack spacing={0.5}>
<Typography level='title-md'>
Please resolve the following errors:
</Typography>
<List>{errorList}</List>
</Stack>,
)
setSnackbarColor('danger')
setIsSnackbarOpen(true)
showError({
title: 'Please resolve the following errors:',
message: <List>{errorList}</List>,
})
return false
}
@@ -240,16 +231,18 @@ const ChoreEdit = () => {
SaveFunction(chore)
.then(() => {
setSnackbarColor('success')
setSnackbarMessage('Chore saved successfully!')
setIsSnackbarOpen(true)
showSuccess({
title: 'Chore Saved',
message: 'Your task has been saved successfully!',
})
Navigate('/my/chores/')
})
.catch(error => {
console.error('Failed to save chore:', error)
setSnackbarColor('danger')
setSnackbarMessage('Failed to save chore, please try again.')
setIsSnackbarOpen(true)
showError({
title: 'Save Failed',
message: 'Failed to save chore, please try again.',
})
})
}
useEffect(() => {
@@ -1099,20 +1092,6 @@ const ChoreEdit = () => {
/>
)}
{/* <ChoreHistory ChoreHistory={choresHistory} UsersData={performers} /> */}
<Snackbar
open={isSnackbarOpen}
onClose={() => {
setIsSnackbarOpen(false)
setSnackbarMessage(null)
}}
color={snackbarColor}
autoHideDuration={4000}
sx={{ bottom: 70 }}
invertedColors={true}
variant='soft'
>
{snackbarMessage}
</Snackbar>
</Container>
)
}

View File

@@ -33,6 +33,7 @@ import {
Typography,
} from '@mui/joy'
import { Divider } from '@mui/material'
import { useQueryClient } from '@tanstack/react-query'
import moment from 'moment'
import { useEffect, useState } from 'react'
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
@@ -61,6 +62,7 @@ const ChoreView = () => {
const [infoCards, setInfoCards] = useState([])
const { choreId } = useParams()
const [note, setNote] = useState(null)
const queryClient = useQueryClient()
const [searchParams] = useSearchParams()
@@ -71,18 +73,12 @@ const ChoreView = () => {
const [confirmModelConfig, setConfirmModelConfig] = useState({})
const [chorePriority, setChorePriority] = useState(null)
const [isDescriptionOpen, setIsDescriptionOpen] = useState(false)
const {
data: circleMembersData,
isLoading: isCircleMembersLoading,
handleRefetch: handleCircleMembersRefetch,
} = useCircleMembers()
const { data: circleMembersData, isLoading: isCircleMembersLoading } =
useCircleMembers()
const { impersonatedUser } = useImpersonateUser()
const {
data: choreData,
isLoading: isChoreLoading,
refetch: refetchChore,
} = useChoreDetails(choreId)
const { data: choreData, isLoading: isChoreLoading } =
useChoreDetails(choreId)
useEffect(() => {
if (!choreData || !choreData.res || !circleMembersData) {
@@ -107,8 +103,10 @@ const ChoreView = () => {
const handleUpdatePriority = priority => {
UpdateChorePriority(choreId, priority.value).then(response => {
if (response.ok) {
response.json().then(data => {
response.json().then(() => {
setChorePriority(priority)
// Invalidate chores cache to refetch data
queryClient.invalidateQueries(['chores'])
})
}
})
@@ -195,6 +193,8 @@ const ChoreView = () => {
clearInterval(countdownInterval) // Ensure to clear this interval as well
setTimeoutId(null)
setSecondsLeftToCancel(null)
// Invalidate chores cache to refetch data
queryClient.invalidateQueries(['chores'])
})
.then(() => {
// refetch the chore details
@@ -216,6 +216,8 @@ const ChoreView = () => {
response.json().then(data => {
const newChore = data.res
setChore(newChore)
// Invalidate chores cache to refetch data
queryClient.invalidateQueries(['chores'])
})
}
})

View File

@@ -7,9 +7,6 @@ import {
Chip,
FormControl,
Input,
ListItem,
ListItemContent,
ListItemDecorator,
Option,
Select,
TextField,
@@ -113,7 +110,7 @@ const ThingTriggerSection = ({
onChange={(e, newValue) => setSelectedThing(newValue)}
getOptionLabel={option => option.name}
renderOption={(props, option) => (
<ListItem {...props}>
<Box {...props}>
<Box
sx={{
display: 'flex',
@@ -123,19 +120,19 @@ const ThingTriggerSection = ({
p: 1,
}}
>
<ListItemDecorator sx={{ alignSelf: 'flex-start' }}>
<Box sx={{ alignSelf: 'flex-start' }}>
<Typography level='body-lg' textColor='primary'>
{option.name}
</Typography>
</ListItemDecorator>
<ListItemContent>
</Box>
<Box>
<Typography level='body2' textColor='text.secondary'>
<Chip>type: {option.type}</Chip>{' '}
<Chip>state: {option.state}</Chip>
</Typography>
</ListItemContent>
</Box>
</Box>
</ListItem>
</Box>
)}
renderInput={params => (
<TextField {...params} label='Select a thing' />

View File

@@ -11,6 +11,7 @@ import {
Box,
Button,
Card,
Checkbox,
Chip,
CircularProgress,
Grid,
@@ -23,7 +24,7 @@ import React from 'react'
import { useNavigate } from 'react-router-dom'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useUserProfile } from '../../queries/UserQueries.jsx'
import { useError } from '../../service/ErrorProvider'
import { useNotification } from '../../service/NotificationProvider'
import { notInCompletionWindow } from '../../utils/Chores.jsx'
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
import {
@@ -47,6 +48,10 @@ const ChoreCard = ({
sx,
viewOnly,
onChipClick,
// Multi-select props
isMultiSelectMode = false,
isSelected = false,
onSelectionToggle,
}) => {
const [isChangeDueDateModalOpen, setIsChangeDueDateModalOpen] =
React.useState(false)
@@ -67,7 +72,7 @@ const ChoreCard = ({
const { impersonatedUser } = useImpersonateUser()
const { showError } = useError()
const { showError } = useNotification()
const handleDelete = () => {
setConfirmModelConfig({
@@ -392,19 +397,71 @@ const ChoreCard = ({
flexDirection: 'column',
justifyContent: 'space-between',
p: 2,
// backgroundColor: 'white',
boxShadow: 'sm',
borderRadius: 20,
key: `${chore.id}-card`,
// mb: 2,
position: 'relative',
backgroundColor: 'background.surface',
border: '1px solid',
borderColor: 'divider',
transition: 'all 0.2s ease-in-out',
cursor: isMultiSelectMode ? 'pointer' : 'default',
'&:hover': {
boxShadow: 'md',
borderColor: isMultiSelectMode ? 'primary.500' : 'primary.300',
},
// Add padding when in multi-select mode to account for checkbox
pl: isMultiSelectMode ? 6 : 2,
// Visual feedback when selected
...(isMultiSelectMode &&
isSelected && {
borderColor: 'primary.500',
backgroundColor: 'primary.softBg',
boxShadow: 'sm',
}),
}}
>
{/* Multi-select checkbox */}
{isMultiSelectMode && (
<Checkbox
checked={isSelected}
onChange={onSelectionToggle}
sx={{
position: 'absolute',
top: '50%',
left: 12,
transform: 'translateY(-50%)',
zIndex: 2,
bgcolor: 'background.surface',
borderRadius: 'md',
borderColor: 'divider',
'&:hover': {
bgcolor: 'background.level1',
borderColor: 'primary.300',
},
'&.Mui-checked': {
bgcolor: 'primary.500',
borderColor: 'primary.500',
color: 'primary.solidColor',
'&:hover': {
bgcolor: 'primary.600',
borderColor: 'primary.600',
},
},
}}
onClick={e => e.stopPropagation()}
/>
)}
<Grid container>
<Grid
xs={9}
sx={{ cursor: 'pointer' }}
onClick={() => {
navigate(`/chores/${chore.id}`)
if (isMultiSelectMode) {
onSelectionToggle()
} else {
navigate(`/chores/${chore.id}`)
}
}}
>
{/* Box in top right with Chip showing next due date */}

View File

@@ -8,6 +8,7 @@ import {
import {
Box,
Button,
Checkbox,
Chip,
CircularProgress,
IconButton,
@@ -19,16 +20,18 @@ import React from 'react'
import { useNavigate } from 'react-router-dom'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useUserProfile } from '../../queries/UserQueries.jsx'
import { useError } from '../../service/ErrorProvider'
import { useNotification } from '../../service/NotificationProvider'
import { notInCompletionWindow } from '../../utils/Chores.jsx'
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
import {
getTextColorFromBackgroundColor,
TASK_COLOR,
} from '../../utils/Colors.jsx'
import {
DeleteChore,
MarkChoreComplete,
UpdateChoreAssignee,
UpdateDueDate,
} from '../../utils/Fetcher'
import Priorities from '../../utils/Priorities'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import DateModal from '../Modals/Inputs/DateModal'
import SelectModal from '../Modals/Inputs/SelectModal'
@@ -44,6 +47,10 @@ const CompactChoreCard = ({
sx,
viewOnly,
onChipClick,
// Multi-select props
isMultiSelectMode = false,
isSelected = false,
onSelectionToggle,
}) => {
const [isChangeDueDateModalOpen, setIsChangeDueDateModalOpen] =
React.useState(false)
@@ -64,7 +71,7 @@ const CompactChoreCard = ({
const { impersonatedUser } = useImpersonateUser()
const { showError } = useError()
const { showError } = useNotification()
// All the existing handler methods (same as original ChoreCard)
const handleDelete = () => {
@@ -364,6 +371,21 @@ const CompactChoreCard = ({
return parts.join(' • ')
}
const getPriorityColor = priority => {
switch (priority) {
case 1:
return TASK_COLOR.PRIORITY_1
case 2:
return TASK_COLOR.PRIORITY_2
case 3:
return TASK_COLOR.PRIORITY_3
case 4:
return TASK_COLOR.PRIORITY_4
default:
return TASK_COLOR.NO_PRIORITY
}
}
return (
<Box key={chore.id + '-compact-box'}>
<Box
@@ -372,23 +394,173 @@ const CompactChoreCard = ({
...sx,
display: 'flex',
alignItems: 'center',
// px: 1,
// py: 0.75,
minHeight: 56, // More compact height
cursor: 'pointer',
borderBottom: '1px solid',
borderColor: 'divider',
position: 'relative',
pl: '16px', // Consistent padding since both elements are in the same position
// backgroundColor: 'background.surface',
transition: 'all 0.2s ease-in-out',
'&:hover': {
bgcolor: 'background.level1',
boxShadow: 'sm',
},
'&:last-child': {
borderBottom: 'none',
},
'&::before': {
content: '""',
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: '3px',
backgroundColor: getPriorityColor(chore.priority),
borderRadius: '16px',
},
}}
onClick={() => {
if (isMultiSelectMode) {
onSelectionToggle()
} else {
navigate(`/chores/${chore.id}`)
}
}}
onClick={() => navigate(`/chores/${chore.id}`)}
>
{/* Left side - Content */}
{/* Priority bar clickable area */}
{chore.priority > 0 && (
<Box
sx={{
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: '12px',
cursor: 'pointer',
zIndex: 1,
}}
onClick={e => {
e.stopPropagation()
onChipClick({ priority: chore.priority })
}}
/>
)}
{/* Animated transition container for Complete Button / Multi-select checkbox */}
<Box
sx={{
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 40,
height: 40,
mr: 1.5,
flexShrink: 0,
}}
>
{/* Complete Button */}
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition:
'opacity 0.3s ease-in-out, transform 0.3s ease-in-out',
opacity: isMultiSelectMode ? 0 : 1,
transform: isMultiSelectMode
? 'scale(0.8) rotate(45deg)'
: 'scale(1) rotate(0deg)',
pointerEvents: isMultiSelectMode ? 'none' : 'auto',
}}
>
<IconButton
variant='solid'
color='success'
size='sm'
onClick={e => {
e.stopPropagation()
handleTaskCompletion()
}}
disabled={isPendingCompletion || notInCompletionWindow(chore)}
sx={{
width: 32,
height: 32,
borderRadius: '50%',
transition: 'all 0.2s ease',
'&:active': {
transform: 'scale(0.95)',
},
'&:disabled': {
opacity: 0.5,
transform: 'none',
},
}}
>
{isPendingCompletion ? (
<CircularProgress size='sm' />
) : (
<Check sx={{ fontSize: 16 }} />
)}
</IconButton>
</Box>
{/* Multi-select Checkbox */}
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition:
'opacity 0.3s ease-in-out, transform 0.3s ease-in-out',
opacity: isMultiSelectMode ? 1 : 0,
transform: isMultiSelectMode
? 'scale(1) rotate(0deg)'
: 'scale(0.8) rotate(-45deg)',
pointerEvents: isMultiSelectMode ? 'auto' : 'none',
}}
>
<Checkbox
checked={isSelected}
onChange={onSelectionToggle}
sx={{
bgcolor: 'background.surface',
borderRadius: 'md',
boxShadow: 'sm',
border: '2px solid',
borderColor: 'divider',
'&:hover': {
bgcolor: 'background.level1',
borderColor: 'primary.300',
},
'&.Mui-checked': {
bgcolor: 'primary.500',
borderColor: 'primary.500',
color: 'primary.solidColor',
'&:hover': {
bgcolor: 'primary.600',
borderColor: 'primary.600',
},
},
}}
onClick={e => e.stopPropagation()}
/>
</Box>
</Box>
{/* Content - Center */}
<Box
sx={{
flex: 1,
@@ -398,7 +570,7 @@ const CompactChoreCard = ({
flexDirection: 'column',
}}
>
{/* Line 1: Name + Due Date + Frequency */}
{/* Line 1: Name + Due Date */}
<Box
sx={{
display: 'flex',
@@ -407,36 +579,35 @@ const CompactChoreCard = ({
mb: 0.25,
}}
>
<Box
{/* Chore Name */}
<Typography
level='title-sm'
sx={{
display: 'flex',
alignItems: 'center',
minWidth: 0,
fontWeight: 600,
fontSize: 14,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
mr: 1,
flex: 1,
minWidth: 0,
}}
>
{/* Chore Name */}
<Typography
level='title-sm'
sx={{
fontWeight: 600,
fontSize: 14,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
mr: 1,
}}
>
{chore.name}
</Typography>
</Box>
{chore.name}
</Typography>
{/* Due Date */}
{/* Due Date - Inline with name */}
<Chip
variant='soft'
size='sm'
color={getDueDateColor(chore.nextDueDate)}
sx={{ fontSize: 10, height: 20, flexShrink: 0 }}
sx={{
fontSize: 10,
height: 18,
px: 0.75,
flexShrink: 0,
ml: 1,
}}
>
{getDueDateText(chore.nextDueDate)}
</Chip>
@@ -458,35 +629,7 @@ const CompactChoreCard = ({
{formatMetadata()}
</Typography>
{/* Labels */}
{chore.priority > 0 && (
<Chip
variant='solid'
size='sm'
color={
chore.priority === 1
? 'danger'
: chore.priority === 2
? 'warning'
: 'neutral'
}
startDecorator={
Priorities.find(p => p.value === chore.priority)?.icon
}
onClick={e => {
e.stopPropagation()
onChipClick({ priority: chore.priority })
}}
sx={{
ml: 0.5,
// height: 16,
// fontSize: 9,
// px: 0.5,
}}
>
P{chore.priority}
</Chip>
)}
{/* Labels - Priority chip removed, now shown as vertical bar */}
{chore.labelsV2?.map(l => (
<div
role='none'
@@ -530,39 +673,21 @@ const CompactChoreCard = ({
</Box>
</Box>
{/* Right side - Actions */}
{/* Right side - Action Menu with animation */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.25,
flexShrink: 0,
transition:
'opacity 0.3s ease-in-out, transform 0.3s ease-in-out, width 0.3s ease-in-out, margin 0.3s ease-in-out',
opacity: isMultiSelectMode ? 0 : 1,
transform: isMultiSelectMode
? 'translateX(20px) scale(0.8)'
: 'translateX(0) scale(1)',
width: isMultiSelectMode ? 0 : 32,
marginRight: isMultiSelectMode ? 0 : undefined,
overflow: 'hidden',
pointerEvents: isMultiSelectMode ? 'none' : 'auto',
}}
>
{/* Complete Button */}
<IconButton
variant='solid'
color='success'
size='sm'
onClick={e => {
e.stopPropagation()
handleTaskCompletion()
}}
disabled={isPendingCompletion || notInCompletionWindow(chore)}
sx={{
width: 32,
height: 32,
borderRadius: '50%',
}}
>
{isPendingCompletion ? (
<CircularProgress size='sm' color='success' />
) : (
<Check sx={{ fontSize: 16 }} />
)}
</IconButton>
{/* Chore Action Menu */}
<ChoreActionMenu
variant='plain'
chore={chore}
@@ -577,12 +702,13 @@ const CompactChoreCard = ({
onWriteNFC={() => setIsNFCModalOpen(true)}
onDelete={handleDelete}
sx={{
width: 28,
marginRight: -3,
height: 28,
// opacity: 0.6,
width: 32,
height: 32,
color: 'text.tertiary',
flexShrink: 0,
'&:hover': {
opacity: 0,
color: 'text.secondary',
bgcolor: 'background.level1',
},
}}
/>

View File

@@ -0,0 +1,198 @@
import { Close, HelpOutline, Keyboard } from '@mui/icons-material'
import {
Box,
Button,
Card,
Divider,
IconButton,
Modal,
ModalDialog,
Typography,
} from '@mui/joy'
import { useState } from 'react'
const MultiSelectHelp = ({ isVisible = true }) => {
const [isHelpOpen, setIsHelpOpen] = useState(false)
if (!isVisible) return null
return (
<>
{/* Help Button */}
<IconButton
size='sm'
variant='soft'
color='neutral'
onClick={() => setIsHelpOpen(true)}
sx={{
position: 'fixed',
bottom: 24,
right: 24,
zIndex: 1000,
width: 48,
height: 48,
borderRadius: '50%',
boxShadow: 'lg',
}}
title='Show keyboard shortcuts'
>
<HelpOutline />
</IconButton>
{/* Help Modal */}
<Modal open={isHelpOpen} onClose={() => setIsHelpOpen(false)}>
<ModalDialog
variant='outlined'
size='md'
sx={{
maxWidth: 500,
p: 3,
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 2,
}}
>
<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 }}>
<Typography
level='title-sm'
sx={{ mb: 1.5, color: 'primary.600' }}
>
Selection
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<ShortcutItem
keys={['Ctrl', 'A']}
description='Select all visible tasks'
/>
<ShortcutItem
keys={['Esc']}
description='Clear selection or exit multi-select mode'
/>
</Box>
</Card>
{/* Action shortcuts */}
<Card variant='soft' sx={{ p: 2 }}>
<Typography
level='title-sm'
sx={{ mb: 1.5, color: 'success.600' }}
>
Actions
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<ShortcutItem
keys={['Enter']}
description='Mark selected tasks as completed'
/>
<ShortcutItem
keys={['Del', '⌫']}
description='Delete selected tasks'
/>
</Box>
</Card>
{/* Interface shortcuts */}
<Card variant='soft' sx={{ p: 2 }}>
<Typography
level='title-sm'
sx={{ mb: 1.5, color: 'warning.600' }}
>
Interface
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<ShortcutItem
keys={['Ctrl', 'K']}
description='Quick add new task'
/>
</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>
</ModalDialog>
</Modal>
</>
)
}
const ShortcutItem = ({ keys, description }) => (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
}}
>
<Typography level='body-sm' sx={{ flex: 1 }}>
{description}
</Typography>
<Box sx={{ display: 'flex', gap: 0.5 }}>
{keys.map((key, index) => (
<Box
key={index}
sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}
>
{index > 0 && (
<Typography level='body-xs' color='text.secondary'>
+
</Typography>
)}
<Box
sx={{
px: 1,
py: 0.25,
bgcolor: 'background.level2',
borderRadius: 'sm',
border: '1px solid',
borderColor: 'divider',
minWidth: 32,
textAlign: 'center',
}}
>
<Typography level='body-xs' fontWeight='bold'>
{key}
</Typography>
</Box>
</Box>
))}
</Box>
</Box>
)
export default MultiSelectHelp

View File

@@ -1,11 +1,19 @@
import {
Add,
Archive,
Bolt,
CancelRounded,
CheckBox,
CheckBoxOutlineBlank,
Close,
Delete,
Done,
EditCalendar,
ExpandCircleDown,
Grain,
PriorityHigh,
SelectAll,
SkipNext,
Sort,
Style,
Unarchive,
@@ -26,23 +34,27 @@ import {
List,
Menu,
MenuItem,
Snackbar,
Typography,
} from '@mui/joy'
import Fuse from 'fuse.js'
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useChores } from '../../queries/ChoreQueries'
import { GetArchivedChores } from '../../utils/Fetcher'
import { useNotification } from '../../service/NotificationProvider'
import { ArchiveChore, GetArchivedChores } from '../../utils/Fetcher'
import Priorities from '../../utils/Priorities'
import LoadingComponent from '../components/Loading'
import { useLabels } from '../Labels/LabelQueries'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import ChoreCard from './ChoreCard'
import CompactChoreCard from './CompactChoreCard'
import IconButtonWithMenu from './IconButtonWithMenu'
import MultiSelectHelp from './MultiSelectHelp'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import { ChoreFilters, ChoresGrouper, ChoreSorter } from '../../utils/Chores'
import { DeleteChore, MarkChoreComplete, SkipChore } from '../../utils/Fetcher'
import TaskInput from '../components/AddTaskModal'
import {
canScheduleNotification,
@@ -55,8 +67,8 @@ import SortAndGrouping from './SortAndGrouping'
const MyChores = () => {
const { data: userProfile, isLoading: isUserProfileLoading } =
useUserProfile()
const [isSnackbarOpen, setIsSnackbarOpen] = useState(false)
const [snackBarMessage, setSnackBarMessage] = useState(null)
const { showSuccess, showError } = useNotification()
const { impersonatedUser } = useImpersonateUser()
const [chores, setChores] = useState([])
const [archivedChores, setArchivedChores] = useState(null)
const [filteredChores, setFilteredChores] = useState([])
@@ -93,6 +105,11 @@ const MyChores = () => {
} = useChores()
const { data: membersData, isLoading: membersLoading } = useCircleMembers()
// Multi-select state
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false)
const [selectedChores, setSelectedChores] = useState(new Set())
const [confirmModelConfig, setConfirmModelConfig] = useState({})
useEffect(() => {
if (!choresLoading && !membersLoading && userProfile) {
setPerformers(membersData.res)
@@ -119,7 +136,14 @@ const MyChores = () => {
scheduleChoreNotification(choresData.res, userProfile, membersData.res)
}
}
}, [membersLoading, choresLoading, isUserProfileLoading])
}, [
membersLoading,
choresLoading,
isUserProfileLoading,
choresData,
membersData,
userProfile,
])
useEffect(() => {
document.addEventListener('mousedown', handleMenuOutsideClick)
@@ -137,20 +161,150 @@ const MyChores = () => {
}
}, [searchInputFocus])
// add listern to Control/Command + K to focus on search input
// Keyboard shortcuts for multi-select and other actions
useEffect(() => {
const handleKeyDown = event => {
// Ctrl/Cmd + K to open task modal
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
event.preventDefault()
setAddTaskModalOpen(true)
return
}
// Ctrl/Cmd + F to focus search input:
else if ((event.ctrlKey || event.metaKey) && event.key === 'f') {
event.preventDefault()
searchInputRef.current?.focus()
return
}
// Ctrl/Cmd + S Toggle Multi-select mode
else if ((event.ctrlKey || event.metaKey) && event.key === 's') {
event.preventDefault()
toggleMultiSelectMode()
return
}
// Ctrl/Cmd + A to select all - works both in and out of multi-select mode
else if (
(event.ctrlKey || event.metaKey) &&
event.key === 'a' &&
!['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)
) {
event.preventDefault()
if (!isMultiSelectMode) {
// Enable multi-select mode and select all visible tasks
setIsMultiSelectMode(true)
setTimeout(() => {
selectAllVisibleChores()
}, 0)
// showSuccess({
// title: '🎯 Multi-select Mode Active',
// message: 'Selected all visible tasks. Press Esc to exit.',
// })
} else {
// Already in multi-select mode, check if all visible tasks are already selected
let visibleChores = []
if (searchTerm?.length > 0 || searchFilter !== 'All') {
visibleChores = filteredChores
const allVisibleSelected =
visibleChores.length > 0 &&
visibleChores.every(chore => selectedChores.has(chore.id))
if (allVisibleSelected) {
showSuccess({
title: '✅ All Tasks Selected',
message: `All ${visibleChores.length} filtered task${visibleChores.length !== 1 ? 's are' : ' is'} already selected.`,
})
} else {
selectAllVisibleChores()
showSuccess({
title: '🎯 Tasks Selected',
message: `Selected ${visibleChores.length} filtered task${visibleChores.length !== 1 ? 's' : ''}.`,
})
}
} else {
// Check expanded sections first
const expandedChores = choreSections
.filter((section, index) => openChoreSections[index])
.flatMap(section => section.content || [])
const allExpandedSelected =
expandedChores.length > 0 &&
expandedChores.every(chore => selectedChores.has(chore.id))
// Get all chores (including collapsed sections)
const allChores = choreSections.flatMap(
section => section.content || [],
)
const allChoresSelected =
allChores.length > 0 &&
allChores.every(chore => selectedChores.has(chore.id))
if (allChoresSelected) {
// All chores (including collapsed) are already selected
showSuccess({
title: '✅ All Tasks Selected',
message: `All ${allChores.length} task${allChores.length !== 1 ? 's are' : ' is'} already selected (including collapsed sections).`,
})
} else if (allExpandedSelected) {
// All expanded are selected, now select ALL (including collapsed)
selectAllVisibleChores() // This will now select all chores
const collapsedCount = allChores.length - expandedChores.length
showSuccess({
title: '🎯 All Tasks Selected',
message: `Selected all ${allChores.length} tasks (including ${collapsedCount} from collapsed sections).`,
})
} else {
// Not all expanded are selected, select expanded only
selectAllVisibleChores() // This will select expanded only
showSuccess({
title: '🎯 Tasks Selected',
message: `Selected ${expandedChores.length} task${expandedChores.length !== 1 ? 's' : ''} from expanded sections.`,
})
}
}
}
}
// Multi-select keyboard shortcuts (only when in multi-select mode)
if (isMultiSelectMode) {
// Escape to clear selection or exit multi-select mode
if (event.key === 'Escape') {
event.preventDefault()
if (selectedChores.size > 0) {
clearSelection()
} else {
setIsMultiSelectMode(false)
}
return
}
// Delete/Backspace key for bulk delete (with confirmation)
if (
(event.key === 'Delete' || event.key === 'Backspace') &&
selectedChores.size > 0
) {
event.preventDefault()
handleBulkDelete()
return
}
// Enter key for bulk complete
if (event.key === 'Enter' && selectedChores.size > 0) {
event.preventDefault()
handleBulkComplete()
return
}
}
}
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [])
}, [isMultiSelectMode, selectedChores.size])
const setSelectedChoreSectionWithCache = value => {
setSelectedChoreSection(value)
localStorage.setItem('selectedChoreSection', value)
@@ -182,6 +336,10 @@ const MyChores = () => {
performers={performers}
userLabels={userLabels}
onChipClick={handleLabelFiltering}
// Multi-select props
isMultiSelectMode={isMultiSelectMode}
isSelected={selectedChores.has(chore.id)}
onSelectionToggle={() => toggleChoreSelection(chore.id)}
/>
)
}
@@ -283,24 +441,42 @@ const MyChores = () => {
switch (event) {
case 'completed':
setSnackBarMessage('Completed')
showSuccess({
title: 'Task Completed',
message: 'Great job! The task has been marked as completed.',
})
break
case 'skipped':
setSnackBarMessage('Skipped')
showSuccess({
title: 'Task Skipped',
message: 'The task has been moved to the next due date.',
})
break
case 'rescheduled':
setSnackBarMessage('Rescheduled')
showSuccess({
title: 'Task Rescheduled',
message: 'The task due date has been updated successfully.',
})
break
case 'unarchive':
setSnackBarMessage('Unarchive')
showSuccess({
title: 'Task Restored',
message: 'The task has been restored and is now active.',
})
break
case 'archive':
setSnackBarMessage('Archived')
showSuccess({
title: 'Task Archived',
message:
'The task has been archived and hidden from the active list.',
})
break
default:
setSnackBarMessage('Updated')
showSuccess({
title: 'Task Updated',
message: 'Your changes have been saved successfully.',
})
}
setIsSnackbarOpen(true)
}
const handleChoreDeleted = deletedChore => {
@@ -351,37 +527,310 @@ const MyChores = () => {
setFilteredChores(fuse.search(term).map(result => result.item))
}
// Multi-select helper functions
const toggleMultiSelectMode = () => {
const newMode = !isMultiSelectMode
setIsMultiSelectMode(newMode)
if (newMode) {
setSelectedChores(new Set()) // Clear selection when exiting multi-select
}
}
const toggleChoreSelection = choreId => {
const newSelection = new Set(selectedChores)
if (newSelection.has(choreId)) {
newSelection.delete(choreId)
} else {
newSelection.add(choreId)
}
setSelectedChores(newSelection)
}
const selectAllVisibleChores = () => {
let visibleChores = []
if (searchTerm?.length > 0 || searchFilter !== 'All') {
// If there's a search term or filter, all filtered chores are visible
visibleChores = filteredChores
} else {
// First, get chores from expanded sections only
const expandedChores = choreSections
.filter((section, index) => openChoreSections[index]) // Only expanded sections
.flatMap(section => section.content || []) // Get all chores from expanded sections
// Check if all expanded chores are already selected
const allExpandedSelected =
expandedChores.length > 0 &&
expandedChores.every(chore => selectedChores.has(chore.id))
if (allExpandedSelected) {
// If all expanded chores are already selected, select ALL chores (including collapsed sections)
visibleChores = choreSections.flatMap(section => section.content || [])
} else {
// Otherwise, just select expanded chores
visibleChores = expandedChores
}
}
if (visibleChores.length > 0) {
const allIds = new Set(visibleChores.map(chore => chore.id))
setSelectedChores(allIds)
}
}
const clearSelection = () => {
// if already empty, just exit multi-select mode:
if (selectedChores.size === 0) {
setIsMultiSelectMode(false)
return
}
setSelectedChores(new Set())
}
const getSelectedChoresData = () => {
const allChores = [...chores, ...(archivedChores || [])]
return Array.from(selectedChores)
.map(id => allChores.find(chore => chore.id === id))
.filter(Boolean)
}
// Bulk operations with improved UX and confirmation modal
const handleBulkComplete = async () => {
const selectedData = getSelectedChoresData()
if (selectedData.length === 0) return
setConfirmModelConfig({
isOpen: true,
title: 'Complete Tasks',
confirmText: 'Complete',
cancelText: 'Cancel',
message: `Mark ${selectedData.length} task${selectedData.length > 1 ? 's' : ''} as completed?`,
onClose: async isConfirmed => {
if (isConfirmed === true) {
try {
const completedTasks = []
const failedTasks = []
for (const chore of selectedData) {
try {
await MarkChoreComplete(
chore.id,
impersonatedUser
? { completedBy: impersonatedUser.userId }
: null,
null,
null,
)
completedTasks.push(chore)
} catch (error) {
failedTasks.push(chore)
}
}
if (completedTasks.length > 0) {
showSuccess({
title: '✅ Tasks Completed',
message: `Successfully completed ${completedTasks.length} task${completedTasks.length > 1 ? 's' : ''}.`,
})
}
if (failedTasks.length > 0) {
showError({
title: 'Some Tasks Failed',
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be completed.`,
})
}
refetchChores()
clearSelection()
} catch (error) {
showError({
title: 'Bulk Complete Failed',
message: 'An unexpected error occurred. Please try again.',
})
}
}
setConfirmModelConfig({})
},
})
}
const handleBulkArchive = async () => {
const selectedData = getSelectedChoresData()
if (selectedData.length === 0) return
setConfirmModelConfig({
isOpen: true,
title: 'Archive Tasks',
confirmText: 'Archive',
cancelText: 'Cancel',
message: `Archive ${selectedData.length} task${selectedData.length > 1 ? 's' : ''}?`,
onClose: async isConfirmed => {
if (isConfirmed === true) {
try {
const archivedTasks = []
const failedTasks = []
for (const chore of selectedData) {
try {
const archivedChore = await ArchiveChore(chore.id)
archivedTasks.push(archivedChore)
// Remove from chores and filteredChores
setChores(chores.filter(c => c.id !== chore.id))
setFilteredChores(filteredChores.filter(c => c.id !== chore.id))
} catch (error) {
failedTasks.push(chore)
}
}
if (archivedTasks.length > 0) {
showSuccess({
title: '📦 Tasks Archived',
message: `Successfully archived ${archivedTasks.length} task${archivedTasks.length > 1 ? 's' : ''}.`,
})
// Update archived chores state
setArchivedChores([
...(archivedChores || []),
...archivedTasks.map(c => ({
...c,
archived: true,
})),
])
}
if (failedTasks.length > 0) {
showError({
title: 'Some Tasks Failed',
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be archived.`,
})
}
clearSelection()
} catch (error) {
showError({
title: 'Bulk Archive Failed',
message: 'An unexpected error occurred. Please try again.',
})
}
}
setConfirmModelConfig({})
},
})
}
const handleBulkDelete = async () => {
const selectedData = getSelectedChoresData()
if (selectedData.length === 0) return
setConfirmModelConfig({
isOpen: true,
title: 'Delete Tasks',
confirmText: 'Delete',
cancelText: 'Cancel',
message: `Delete ${selectedData.length} task${selectedData.length > 1 ? 's' : ''}?\n\nThis action cannot be undone.`,
onClose: async isConfirmed => {
if (isConfirmed === true) {
try {
const deletedTasks = []
const failedTasks = []
for (const chore of selectedData) {
try {
await DeleteChore(chore.id)
deletedTasks.push(chore)
} catch (error) {
failedTasks.push(chore)
}
}
if (deletedTasks.length > 0) {
showSuccess({
title: '🗑️ Tasks Deleted',
message: `Successfully deleted ${deletedTasks.length} task${deletedTasks.length > 1 ? 's' : ''}.`,
})
const deletedIds = new Set(deletedTasks.map(c => c.id))
setChores(chores.filter(c => !deletedIds.has(c.id)))
setFilteredChores(
filteredChores.filter(c => !deletedIds.has(c.id)),
)
}
if (failedTasks.length > 0) {
showError({
title: 'Some Tasks Failed',
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be deleted.`,
})
}
clearSelection()
} catch (error) {
showError({
title: 'Bulk Delete Failed',
message: 'An unexpected error occurred. Please try again.',
})
}
}
setConfirmModelConfig({})
},
})
}
const handleBulkSkip = async () => {
const selectedData = getSelectedChoresData()
if (selectedData.length === 0) return
setConfirmModelConfig({
isOpen: true,
title: 'Skip Tasks',
confirmText: 'Skip',
cancelText: 'Cancel',
message: `Skip ${selectedData.length} task${selectedData.length > 1 ? 's' : ''} to next due date?`,
onClose: async isConfirmed => {
if (isConfirmed === true) {
try {
const skippedTasks = []
const failedTasks = []
for (const chore of selectedData) {
try {
await SkipChore(chore.id)
skippedTasks.push(chore)
} catch (error) {
failedTasks.push(chore)
}
}
if (skippedTasks.length > 0) {
showSuccess({
title: '⏭️ Tasks Skipped',
message: `Successfully skipped ${skippedTasks.length} task${skippedTasks.length > 1 ? 's' : ''}.`,
})
}
if (failedTasks.length > 0) {
showError({
title: 'Some Tasks Failed',
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be skipped.`,
})
}
refetchChores()
clearSelection()
} catch (error) {
showError({
title: 'Bulk Skip Failed',
message: 'An unexpected error occurred. Please try again.',
})
}
}
setConfirmModelConfig({})
},
})
}
if (
isUserProfileLoading ||
userLabelsLoading ||
performers.length === 0 ||
choresLoading
) {
console.log(
'userProfile:',
userProfile,
'userLabelsLoading:',
userLabelsLoading,
'performers:',
performers.length,
'choresLoading:',
choresLoading,
)
return (
<>
<Typography level='title-lg' sx={{ mt: 2, mb: 2 }}>
{JSON.stringify(userProfile) === 'null'}
</Typography>
<Typography level='title-lg' sx={{ mt: 2, mb: 2 }}>
{userLabelsLoading}
</Typography>
<Typography level='title-lg' sx={{ mt: 2, mb: 2 }}>
{performers.length === 0}
</Typography>
<Typography level='title-lg' sx={{ mt: 2, mb: 2 }}>
{choresLoading}
</Typography>
<LoadingComponent />
</>
)
@@ -405,7 +854,7 @@ const MyChores = () => {
}}
>
<Input
ref={searchInputRef}
slotProps={{ input: { ref: searchInputRef } }}
placeholder='Search'
value={searchTerm}
onFocus={() => {
@@ -512,8 +961,39 @@ const MyChores = () => {
>
{isCompactView ? <ViewModule /> : <ViewAgenda />}
</IconButton>
{/* Multi-select Toggle Button */}
<IconButton
variant={isMultiSelectMode ? 'solid' : 'outlined'}
color={isMultiSelectMode ? 'primary' : 'neutral'}
size='sm'
sx={{
height: 32,
width: 32,
borderRadius: '50%',
}}
onClick={toggleMultiSelectMode}
title={
isMultiSelectMode
? 'Exit Multi-select Mode'
: 'Enable Multi-select Mode'
}
>
{isMultiSelectMode ? <CheckBox /> : <CheckBoxOutlineBlank />}
</IconButton>
</Box>
{showSearchFilter && (
{/* Search Filter with animation */}
<Box
sx={{
overflow: 'hidden',
transition: 'all 0.3s ease-in-out',
maxHeight: showSearchFilter ? '150px' : '0',
opacity: showSearchFilter ? 1 : 0,
transform: showSearchFilter ? 'translateY(0)' : 'translateY(-10px)',
marginBottom: showSearchFilter ? 1 : 0,
}}
>
<div className='flex gap-4'>
<div className='grid flex-1 grid-cols-3 gap-4'>
<IconButtonWithMenu
@@ -632,7 +1112,217 @@ const MyChores = () => {
<CancelRounded />
</IconButton>
</div>
)}
</Box>
{/* Multi-select Toolbar with animation */}
<Box
sx={{
position: 'sticky',
top: 0,
zIndex: 1000,
overflow: 'hidden',
transition: 'all 0.3s ease-in-out',
maxHeight: isMultiSelectMode ? '200px' : '0',
opacity: isMultiSelectMode ? 1 : 0,
transform: isMultiSelectMode
? 'translateY(0)'
: 'translateY(-20px)',
marginBottom: isMultiSelectMode ? 2 : 0,
}}
>
<Box
sx={{
backgroundColor: 'background.surface',
backdropFilter: 'blur(8px)',
borderRadius: 'lg',
p: 2,
border: '1px solid',
borderColor: 'divider',
boxShadow: 'm',
gap: 2,
display: 'flex',
flexDirection: {
sm: 'column', // Stack vertically on mobile
md: 'row', // Horizontal on tablet and larger
},
alignItems: {
xs: 'stretch', // Full width on mobile
sm: 'center', // Center aligned on larger screens
},
justifyContent: {
xs: 'center',
sm: 'space-between',
},
}}
>
{/* Selection Info and Controls */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 2,
flexWrap: {
xs: 'wrap', // Allow wrapping on mobile if needed
sm: 'nowrap',
},
justifyContent: {
xs: 'center', // Center on mobile
sm: 'flex-start',
},
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckBox sx={{ color: 'primary.500' }} />
<Typography level='body-sm' fontWeight='md'>
{selectedChores.size} task
{selectedChores.size !== 1 ? 's' : ''} selected
</Typography>
</Box>
<Divider
orientation='vertical'
sx={{
display: { xs: 'none', sm: 'block' }, // Hide vertical divider on mobile
}}
/>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size='sm'
variant='outlined'
onClick={selectAllVisibleChores}
startDecorator={<SelectAll />}
disabled={
searchTerm?.length > 0 || searchFilter !== 'All'
? selectedChores.size === filteredChores.length
: selectedChores.size ===
choreSections.flatMap(s => s.content || []).length
}
sx={{
minWidth: 'auto',
'--Button-paddingInline': '0.75rem',
}}
>
All
</Button>
<Button
size='sm'
variant='outlined'
onClick={clearSelection}
startDecorator={
selectedChores.size === 0 ? (
<Close />
) : (
<CheckBoxOutlineBlank />
)
}
sx={{
minWidth: 'auto',
'--Button-paddingInline': '0.75rem',
}}
>
{selectedChores.size === 0 ? 'Close' : 'Clear'}
</Button>
</Box>
</Box>
{/* Action Buttons */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: {
xs: 'wrap', // Allow wrapping on mobile
sm: 'nowrap',
},
justifyContent: {
xs: 'center', // Center on mobile
sm: 'flex-end',
},
}}
>
<Button
size='sm'
variant='solid'
color='success'
onClick={handleBulkComplete}
startDecorator={<Done />}
disabled={selectedChores.size === 0}
sx={{
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
}}
>
Complete
</Button>
<Button
size='sm'
variant='soft'
color='warning'
onClick={handleBulkSkip}
startDecorator={<SkipNext />}
disabled={selectedChores.size === 0}
sx={{
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
}}
>
Skip
</Button>
<Button
size='sm'
variant='soft'
color='danger'
onClick={handleBulkArchive}
startDecorator={<Archive />}
disabled={selectedChores.size === 0}
sx={{
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
}}
>
Archive
</Button>
<Button
size='sm'
variant='soft'
color='danger'
onClick={handleBulkDelete}
startDecorator={<Delete />}
disabled={selectedChores.size === 0}
sx={{
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
}}
>
Delete
</Button>
{/*
<Divider
orientation='vertical'
sx={{
display: { xs: 'none', sm: 'block' }, // Hide vertical divider on mobile
}}
/>
<IconButton
size='sm'
variant='plain'
onClick={toggleMultiSelectMode}
color='neutral'
title='Exit multi-select mode (Esc)'
sx={{
'&:hover': {
bgcolor: 'danger.softBg',
color: 'danger.softColor',
},
}}
>
<CancelRounded />
</IconButton> */}
</Box>
</Box>
</Box>
{searchFilter !== 'All' && (
<Chip
level='title-md'
@@ -861,19 +1551,6 @@ const MyChores = () => {
/>
</IconButton>
</Box>
<Snackbar
open={isSnackbarOpen}
onClose={() => {
setIsSnackbarOpen(false)
}}
autoHideDuration={3000}
variant='soft'
color='success'
size='lg'
invertedColors
>
<Typography level='title-md'>{snackBarMessage}</Typography>
</Snackbar>
<NotificationAccessSnackbar />
{addTaskModalOpen && (
<TaskInput
@@ -891,6 +1568,14 @@ const MyChores = () => {
</Container>
<Sidepanel chores={chores} performers={performers} />
{/* Multi-select Help - only show when in multi-select mode */}
<MultiSelectHelp isVisible={isMultiSelectMode} />
{/* Confirmation Modal for bulk operations */}
{confirmModelConfig?.isOpen && (
<ConfirmationModal config={confirmModelConfig} />
)}
</div>
)
}
@@ -938,7 +1623,7 @@ const FILTERS = {
return chore.assignedTo === userID
})
},
'No Due Date': function (chores, userID) {
'No Due Date': function (chores) {
return chores.filter(chore => {
return chore.nextDueDate === null
})

View File

@@ -1,78 +1,81 @@
import { Capacitor } from '@capacitor/core';
import { Button, Snackbar, Stack, Typography } from '@mui/joy'
import { Preferences } from '@capacitor/preferences';
import { LocalNotifications } from '@capacitor/local-notifications';
import {React, useEffect, useState} from 'react';
import { Capacitor } from '@capacitor/core'
import { LocalNotifications } from '@capacitor/local-notifications'
import { Preferences } from '@capacitor/preferences'
import { Button, Stack, Typography } from '@mui/joy'
import { useEffect, useState } from 'react'
const NotificationAccessSnackbar = () => {
const [open, setOpen] = useState(false)
if (!Capacitor.isNativePlatform()) {
return null
}
const getNotificationPreferences = async () => {
const ret = await Preferences.get({ key: 'notificationPreferences' })
return JSON.parse(ret.value)
}
const [open, setOpen] = useState(false);
if (!Capacitor.isNativePlatform()) {
return null;
}
const getNotificationPreferences = async () => {
const ret = await Preferences.get({ key: 'notificationPreferences' });
return JSON.parse(ret.value);
};
useEffect(() => {
getNotificationPreferences().then((data) => {
// if optOut is true then don't show the snackbar
if(data?.optOut === true || data?.granted === true) {
return;
}
setOpen(true);
});
}
, []);
return (
useEffect(() => {
getNotificationPreferences().then(data => {
// if optOut is true then don't show the snackbar
if (data?.optOut === true || data?.granted === true) {
return
}
setOpen(true)
})
}, [])
return (
<Snackbar
// autoHideDuration={5000}
variant="solid"
color="primary"
size="lg"
variant='solid'
color='primary'
size='lg'
invertedColors
open={open}
onClose={() => setOpen(false)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
sx={(theme) => ({
sx={theme => ({
background: `linear-gradient(45deg, ${theme.palette.primary[600]} 30%, ${theme.palette.primary[500]} 90%})`,
maxWidth: 360,
})}
>
<div>
<Typography level="title-lg">Need Notification?</Typography>
<Typography level='title-lg'>Need Notification?</Typography>
<Typography sx={{ mt: 1, mb: 2 }}>
You need to enable permission to receive notifications, do you want to enable it?
You need to enable permission to receive notifications, do you want to
enable it?
</Typography>
<Stack direction="row" spacing={1}>
<Button variant="solid" color="primary" onClick={() => {
const notificationPreferences = { optOut: false };
LocalNotifications.requestPermissions().then((resp) => {
<Stack direction='row' spacing={1}>
<Button
variant='solid'
color='primary'
onClick={() => {
const notificationPreferences = { optOut: false }
LocalNotifications.requestPermissions().then(resp => {
if (resp.display === 'granted') {
notificationPreferences['granted'] = true;
notificationPreferences['granted'] = true
}
})
Preferences.set({ key: 'notificationPreferences', value: JSON.stringify(notificationPreferences) });
setOpen(false);
}}>
Yes
Preferences.set({
key: 'notificationPreferences',
value: JSON.stringify(notificationPreferences),
})
setOpen(false)
}}
>
Yes
</Button>
<Button
variant="outlined"
color="primary"
variant='outlined'
color='primary'
onClick={() => {
const notificationPreferences = { optOut: true };
Preferences.set({ key: 'notificationPreferences', value: JSON.stringify(notificationPreferences) });
setOpen(false);
const notificationPreferences = { optOut: true }
Preferences.set({
key: 'notificationPreferences',
value: JSON.stringify(notificationPreferences),
})
setOpen(false)
}}
>
No, Keep it Disabled
@@ -80,8 +83,7 @@ return (
</Stack>
</div>
</Snackbar>
)
)
}
export default NotificationAccessSnackbar;
export default NotificationAccessSnackbar

View File

@@ -1,39 +1,42 @@
import { Button, Snackbar } from '@mui/joy'
import { Button } from '@mui/joy'
import Cookies from 'js-cookie'
import { useEffect, useState } from 'react'
import { useEffect } from 'react'
import { useNotification } from '../../service/NotificationProvider'
const CookiePermissionSnackbar = () => {
const { showNotification } = useNotification()
useEffect(() => {
const cookiePermission = Cookies.get('cookies_permission')
if (cookiePermission !== 'true') {
setOpen(true)
showNotification({
type: 'custom',
component: <CookieAcceptComponent />,
snackbarProps: {
autoHideDuration: null,
},
anchorOrigin: { vertical: 'bottom', horizontal: 'center' },
})
}
}, [])
}, [showNotification])
const [open, setOpen] = useState(false)
const handleClose = () => {
return null
}
const CookieAcceptComponent = ({ onClose }) => {
const handleAccept = () => {
Cookies.set('cookies_permission', 'true')
setOpen(false)
onClose?.()
}
return (
<Snackbar
open={open}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
onClose={(event, reason) => {
if (reason === 'clickaway') {
return
}
// Cookies.set('cookies_permission', 'true')
handleClose()
}}
>
<div>
We use cookies to ensure you get the best experience on our website.
<Button variant='soft' onClick={handleClose}>
<Button variant='soft' onClick={handleAccept} sx={{ ml: 2 }}>
Accept
</Button>
</Snackbar>
</div>
)
}

View File

@@ -93,7 +93,7 @@ const DemoMyChore = () => {
// },
]
const users = [{ displayName: 'Me', id: 1 }]
const users = [{ displayName: 'Me', id: 1, userId: 1 }]
return (
<>
<Grid item xs={12} sm={5} data-aos-first-tasks-list>

View File

@@ -1,6 +1,6 @@
/* eslint-disable tailwindcss/no-custom-classname */
// import { StyledButton } from '@/components/styled-button'
import { Button } from '@mui/joy'
import { Button, IconButton, useColorScheme } from '@mui/joy'
import Typography from '@mui/joy/Typography'
import Box from '@mui/material/Box'
import Grid from '@mui/material/Grid'
@@ -8,17 +8,17 @@ import React, { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import Logo from '@/assets/logo.svg'
import screenShotMyChoreDark from '@/assets/screenshot-my-chore-dark.png'
import screenShotMyChore from '@/assets/screenshot-my-chore.png'
import { GitHub } from '@mui/icons-material'
import { DarkMode, GitHub, LightMode } from '@mui/icons-material'
import useWindowWidth from '../../hooks/useWindowWidth'
const HomeHero = () => {
const navigate = useNavigate()
const windowWidth = useWindowWidth()
const windowThreshold = 600
const { mode, setMode } = useColorScheme()
const HERO_TEXT_THAT = [
// 'Donetick simplifies the entire process, from scheduling and reminders to automatic task assignment and progress tracking.',
// 'Donetick is the intuitive task and chore management app designed for groups. Take charge of shared responsibilities, automate your workflow, and achieve more together.',
'An open-source, user-friendly app for managing tasks and chores, featuring customizable options to help you and others stay organized',
]
@@ -169,20 +169,60 @@ const HomeHero = () => {
<Grid item xs={12} md={5}>
<div className='flex justify-center'>
<img
src={screenShotMyChore}
src={mode === 'dark' ? screenShotMyChoreDark : screenShotMyChore}
width={'100%'}
style={{
maxWidth: 300,
}}
height={'auto'}
alt='Hero img'
data-aos-delay={100 * 2}
data-aos-anchor='[data-aos-id-hero]'
data-aos='fade-left'
style={{
width: '100%',
maxWidth: 300,
}}
onMouseEnter={e => {
e.target.style.transform = 'rotate(0deg) scale(1.05)'
}}
onMouseLeave={e => {
e.target.style.transform = 'rotate(5deg) scale(1)'
}}
/>
</div>
</Grid>
)}
<Grid
item
xs={12}
sx={{
display: 'flex',
justifyContent: 'center',
position: 'absolute',
top: -90,
right: 16,
}}
>
<IconButton
onClick={() => {
setMode(mode === 'dark' ? 'light' : 'dark')
}}
sx={{
backgroundColor: 'rgba(255, 255, 255, 0.8)',
borderRadius: '50%',
boxShadow: '0px 4px 8px rgba(0, 0, 0, 0.1)',
transition: 'background-color 0.3s',
}}
>
{mode === 'dark' ? (
<LightMode sx={{ color: '#333' }} />
) : (
<DarkMode
sx={{
color: '#333',
}}
/>
)}
</IconButton>
</Grid>
</Grid>
)
}

View File

@@ -12,7 +12,7 @@ import {
import { useEffect, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useError } from '../../../service/ErrorProvider.jsx'
import { useNotification } from '../../../service/NotificationProvider.jsx'
import LABEL_COLORS from '../../../utils/Colors.jsx'
import { CreateLabel, UpdateLabel } from '../../../utils/Fetcher'
import { useLabels } from '../../Labels/LabelQueries'
@@ -23,7 +23,7 @@ function LabelModal({ isOpen, onClose, label }) {
const [error, setError] = useState('')
const { data: userLabels = [] } = useLabels()
const queryClient = useQueryClient()
const { showError } = useError()
const { showError } = useNotification()
// Populate the form fields when editing
useEffect(() => {

View File

@@ -13,19 +13,47 @@ import moment from 'moment'
import { useEffect, useState } from 'react'
import { useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import {
CreateLongLiveToken,
DeleteLongLiveToken,
GetLongLiveTokens,
} from '../../utils/Fetcher'
import { isPlusAccount } from '../../utils/Helpers'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import TextModal from '../Modals/Inputs/TextModal'
const APITokenSettings = () => {
const { data: userProfile } = useUserProfile()
const { showNotification } = useNotification()
const [tokens, setTokens] = useState([])
const [isGetTokenNameModalOpen, setIsGetTokenNameModalOpen] = useState(false)
const [showTokenId, setShowTokenId] = useState(null)
const [confirmModalConfig, setConfirmModalConfig] = useState({})
const showConfirmation = (
message,
title,
onConfirm,
confirmText = 'Confirm',
cancelText = 'Cancel',
color = 'primary',
) => {
setConfirmModalConfig({
isOpen: true,
message,
title,
confirmText,
cancelText,
color,
onClose: isConfirmed => {
if (isConfirmed) {
onConfirm()
}
setConfirmModalConfig({})
},
})
}
useEffect(() => {
GetLongLiveTokens().then(resp => {
resp.json().then(data => {
@@ -100,18 +128,28 @@ const APITokenSettings = () => {
variant='outlined'
color='danger'
onClick={() => {
const confirmed = confirm(
`Are you sure you want to remove ${token.name} ?`,
showConfirmation(
`Are you sure you want to remove ${token.name}?`,
'Remove Token',
() => {
DeleteLongLiveToken(token.id).then(resp => {
if (resp.ok) {
showNotification({
type: 'success',
title: 'Removed',
message: 'API token has been removed',
})
const newTokens = tokens.filter(
t => t.id !== token.id,
)
setTokens(newTokens)
}
})
},
'Remove',
'Cancel',
'danger',
)
if (confirmed) {
DeleteLongLiveToken(token.id).then(resp => {
if (resp.ok) {
alert('Token removed')
const newTokens = tokens.filter(t => t.id !== token.id)
setTokens(newTokens)
}
})
}
}}
>
Remove
@@ -130,7 +168,10 @@ const APITokenSettings = () => {
color='primary'
onClick={() => {
navigator.clipboard.writeText(token.token)
alert('Token copied to clipboard')
showNotification({
type: 'success',
message: 'Token copied to clipboard',
})
setShowTokenId(null)
}}
>
@@ -166,6 +207,11 @@ const APITokenSettings = () => {
okText={'Generate Token'}
onSave={handleSaveToken}
/>
{/* Modals */}
{confirmModalConfig?.isOpen && (
<ConfirmationModal config={confirmModalConfig} />
)}
</div>
)
}

View File

@@ -323,9 +323,23 @@ const MFASettings = () => {
)}
</Box>
<Alert color='neutral' variant='soft'>
<Typography level='body-sm'>
<strong>Manual entry key:</strong> {setupData.secret}
<Alert
color='neutral'
variant='soft'
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
}}
>
<Typography level='title-sm'>
<strong>Manual entry key:</strong>
</Typography>
<Typography
level='body-sm'
sx={{ wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}
>
{setupData.secret}
</Typography>
</Alert>

View File

@@ -1,7 +1,6 @@
import { Capacitor } from '@capacitor/core'
import { LocalNotifications } from '@capacitor/local-notifications'
import { Preferences } from '@capacitor/preferences'
import { Close } from '@mui/icons-material'
import {
Box,
Button,
@@ -10,24 +9,23 @@ import {
FormControl,
FormHelperText,
FormLabel,
IconButton,
Input,
Option,
Select,
Snackbar,
Switch,
Typography,
} from '@mui/joy'
import { useEffect, useState } from 'react'
import { useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import {
UpdateNotificationTarget,
UpdateUserDetails,
} from '../../utils/Fetcher'
const NotificationSetting = () => {
const [isSnackbarOpen, setIsSnackbarOpen] = useState(false)
const { showWarning } = useNotification()
const { data: userProfile, refetch: refetchUserProfile } = useUserProfile()
const getNotificationPreferences = async () => {
@@ -70,13 +68,17 @@ const NotificationSetting = () => {
useEffect(() => {
getNotificationPreferences().then(resp => {
setDeviceNotification(resp.granted)
setDueNotification(resp.dueNotification)
setPreDueNotification(resp.preDueNotification)
setNaggingNotification(resp.naggingNotification)
if (resp) {
setDeviceNotification(Boolean(resp.granted))
setDueNotification(Boolean(resp.dueNotification ?? true))
setPreDueNotification(Boolean(resp.preDueNotification))
setNaggingNotification(Boolean(resp.naggingNotification))
}
})
getPushNotificationPreferences().then(resp => {
setPushNotification(resp.granted)
if (resp) {
setPushNotification(Boolean(resp.granted))
}
})
}, [])
@@ -87,7 +89,7 @@ const NotificationSetting = () => {
)
const [chatID, setChatID] = useState(
userProfile?.notification_target?.target_id,
userProfile?.notification_target?.target_id ?? 0,
)
const [error, setError] = useState('')
const SaveValidation = () => {
@@ -147,7 +149,11 @@ const NotificationSetting = () => {
setDeviceNotification(true)
setNotificationPreferences({ granted: true })
} else if (resp.display === 'denied') {
setIsSnackbarOpen(true)
showWarning({
title: 'Notification Permission Denied',
message:
'You have denied notification permissions. You can enable them later in your device settings.',
})
setDeviceNotification(false)
setNotificationPreferences({ granted: false })
}
@@ -251,12 +257,14 @@ const NotificationSetting = () => {
setPushNotification(true)
setPushNotificationPreferences({granted: true})
}
if (resp.receive!== 'granted') {
setIsSnackbarOpen(true)
if (resp.receive !== 'granted') {
showWarning({
title: 'Push Notification Permission Denied',
message: 'Push notifications have been disabled. You can enable them in your device settings if needed.',
})
setPushNotification(false)
setPushNotificationPreferences({granted: false})
console.log("User denied permission", resp)
}
})
}
@@ -313,7 +321,7 @@ const NotificationSetting = () => {
<FormControl orientation='horizontal'>
<Switch
checked={chatID !== 0}
checked={Boolean(chatID !== 0)}
onClick={event => {
event.preventDefault()
if (chatID !== 0) {
@@ -440,30 +448,6 @@ const NotificationSetting = () => {
</Button>
</Box>
)}
<Snackbar
open={isSnackbarOpen}
autoHideDuration={8000}
onClose={() => setIsSnackbarOpen(false)}
endDecorator={
<IconButton size='md' onClick={() => setIsSnackbarOpen(false)}>
<Close />
</IconButton>
}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Typography level='title-md'>Permission Denied</Typography>
<Typography level='body-md'>
You have denied the permission to receive notification on this
device. Please enable it in your device settings
</Typography>
</div>
</Snackbar>
</div>
)
}

View File

@@ -6,14 +6,15 @@ import {
Card,
Divider,
Input,
Snackbar,
Typography,
} from '@mui/joy'
import Modal from '@mui/joy/Modal'
import ModalDialog from '@mui/joy/ModalDialog'
import imageCompression from 'browser-image-compression'
import { useRef, useState } from 'react'
import Cropper from 'react-easy-crop'
import { useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { UpdateUserDetails } from '../../utils/Fetcher'
import { resolvePhotoURL } from '../../utils/Helpers'
import { getCroppedImg } from '../../utils/imageCropUtils'
@@ -21,6 +22,7 @@ import { UploadFile } from '../../utils/TokenManager'
const ProfileSettings = () => {
const { data: userProfile } = useUserProfile()
const { showSuccess, showError } = useNotification()
const [displayName, setDisplayName] = useState(userProfile?.displayName || '')
const [timezone, setTimezone] = useState(
userProfile?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
@@ -28,11 +30,6 @@ const ProfileSettings = () => {
const [photoURL, setPhotoURL] = useState(userProfile?.image || '')
const [isUploading, setIsUploading] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [snackbar, setSnackbar] = useState({
open: false,
message: '',
color: 'success',
})
const fileInputRef = useRef()
const [crop, setCrop] = useState({ x: 0, y: 0 })
const [zoom, setZoom] = useState(1)
@@ -60,12 +57,32 @@ const ProfileSettings = () => {
const croppedBlob = await getCroppedImg(
selectedFile,
croppedAreaPixels,
320,
320,
160,
160,
'image/jpeg',
)
// Compress the cropped image
const compressionOptions = {
maxSizeMB: 0.02, // Smaller size for profile images
maxWidthOrHeight: 160, // Match the cropped dimensions
useWebWorker: true,
fileType: 'image/jpeg',
initialQuality: 0.8,
}
const compressedFile = await imageCompression(
croppedBlob,
compressionOptions,
)
console.log(`Original size: ${(croppedBlob.size / 1024).toFixed(2)} KB`)
console.log(
`Compressed size: ${(compressedFile.size / 1024).toFixed(2)} KB`,
)
const formData = new FormData()
formData.append('file', croppedBlob, 'profile.jpg')
formData.append('file', compressedFile, 'profile.jpg')
const response = await UploadFile('/users/profile_photo', {
method: 'POST',
body: formData,
@@ -75,16 +92,14 @@ const ProfileSettings = () => {
const url = resolvePhotoURL(data.url || data.sign)
setPhotoURL(url)
setSnackbar({
open: true,
message: 'Profile photo updated!',
color: 'success',
showSuccess({
title: 'Photo Updated',
message: 'Your profile photo has been updated successfully!',
})
} catch (err) {
setSnackbar({
open: true,
message: 'Failed to upload photo.',
color: 'danger',
showError({
title: 'Upload Failed',
message: 'Failed to upload your photo. Please try again.',
})
} finally {
setIsUploading(false)
@@ -100,19 +115,18 @@ const ProfileSettings = () => {
const response = await UpdateUserDetails(userDetails)
if (response.ok) {
setSnackbar({
open: true,
message: 'Profile updated successfully!',
color: 'success',
showSuccess({
title: 'Profile Updated',
message: 'Your profile information has been saved successfully!',
})
} else {
throw new Error('Failed to update profile')
}
} catch (err) {
setSnackbar({
open: true,
message: 'Failed to update profile.',
color: 'danger',
showError({
title: 'Update Failed',
message:
'Unable to update your profile. Please check your connection and try again.',
})
} finally {
setIsSaving(false)
@@ -280,14 +294,6 @@ const ProfileSettings = () => {
Save
</Button>
</Box>
<Snackbar
open={snackbar.open}
color={snackbar.color}
autoHideDuration={3000}
onClose={() => setSnackbar({ ...snackbar, open: false })}
>
{snackbar.message}
</Snackbar>
</div>
)
}

View File

@@ -10,13 +10,13 @@ import {
FormControl,
FormHelperText,
Input,
ListItem,
Option,
Select,
Typography,
} from '@mui/joy'
import moment from 'moment'
import { useEffect, useState } from 'react'
import RealTimeSettings from '../../components/RealTimeSettings'
import Logo from '../../Logo'
import { useUserProfile } from '../../queries/UserQueries'
import {
@@ -34,6 +34,7 @@ import {
UpdatePassword,
} from '../../utils/Fetcher'
import { isPlusAccount } from '../../utils/Helpers'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import PassowrdChangeModal from '../Modals/Inputs/PasswordChangeModal'
import APITokenSettings from './APITokenSettings'
import MFASettings from './MFASettings'
@@ -41,9 +42,11 @@ import NotificationSetting from './NotificationSetting'
import ProfileSettings from './ProfileSettings'
import StorageSettings from './StorageSettings'
import ThemeToggle from './ThemeToggle'
import { useNotification } from '../../service/NotificationProvider'
const Settings = () => {
const { data: userProfile } = useUserProfile()
const { showNotification } = useNotification()
const [userCircles, setUserCircles] = useState([])
const [circleMemberRequests, setCircleMemberRequests] = useState([])
@@ -54,6 +57,31 @@ const Settings = () => {
const [isAdmin, setIsAdmin] = useState(false)
const [changePasswordModal, setChangePasswordModal] = useState(false)
const [confirmModalConfig, setConfirmModalConfig] = useState({})
const showConfirmation = (
message,
title,
onConfirm,
confirmText = 'Confirm',
cancelText = 'Cancel',
color = 'primary',
) => {
setConfirmModalConfig({
isOpen: true,
message,
title,
confirmText,
cancelText,
color,
onClose: isConfirmed => {
if (isConfirmed) {
onConfirm()
}
setConfirmModalConfig({})
},
})
}
useEffect(() => {
GetUserCircle().then(resp => {
resp.json().then(data => {
@@ -165,7 +193,10 @@ const Settings = () => {
variant='soft'
onClick={() => {
navigator.clipboard.writeText(userCircles[0]?.invite_code)
alert('Code Copied to clipboard')
showNotification({
type: 'success',
message: 'Code copied to clipboard',
})
}}
>
Copy Code
@@ -180,27 +211,42 @@ const Settings = () => {
window.location.host +
`/circle/join?code=${userCircles[0]?.invite_code}`,
)
alert('Link Copied to clipboard')
showNotification({
type: 'success',
message: 'Link copied to clipboard',
})
}}
>
Copy Link
</Button>
{userCircles.length > 0 && userCircles[0]?.userRole === 'member' && (
<Button
color='danger'
variant='outlined'
sx={{ ml: 1 }}
onClick={() => {
const confirmed = confirm(
`Are you sure you want to leave your circle?`,
showConfirmation(
'Are you sure you want to leave your circle?',
'Leave Circle',
() => {
LeaveCircle(userCircles[0]?.id).then(resp => {
if (resp.ok) {
showNotification({
type: 'success',
message: 'Left circle successfully',
})
} else {
showNotification({
type: 'error',
message: 'Failed to leave circle',
})
}
})
},
'Leave',
'Cancel',
'danger',
)
if (confirmed) {
LeaveCircle(userCircles[0]?.id).then(resp => {
if (resp.ok) {
alert('Left circle successfully.')
} else {
alert('Failed to leave circle.')
}
})
}
}}
>
Leave Circle
@@ -257,7 +303,10 @@ const Settings = () => {
})
setCircleMembers(newCircleMembers)
} else {
alert('Failed to update role')
showNotification({
type: 'error',
message: 'Failed to update role',
})
}
})
}}
@@ -278,7 +327,7 @@ const Settings = () => {
},
].map((option, index) => (
<Option value={option.value} key={index}>
<ListItem
<Box
sx={{
display: 'flex',
flexDirection: 'column',
@@ -301,7 +350,7 @@ const Settings = () => {
>
{option.description}
</Typography>
</ListItem>
</Box>
</Option>
))}
</Select>
@@ -318,19 +367,26 @@ const Settings = () => {
color='danger'
size='sm'
onClick={() => {
const confirmed = confirm(
showConfirmation(
`Are you sure you want to remove ${member.displayName} from your circle?`,
'Remove Member',
() => {
DeleteCircleMember(
member.circleId,
member.userId,
).then(resp => {
if (resp.ok) {
showNotification({
type: 'success',
message: 'Removed member successfully',
})
}
})
},
'Remove',
'Cancel',
'danger',
)
if (confirmed) {
DeleteCircleMember(
member.circleId,
member.userId,
).then(resp => {
if (resp.ok) {
alert('Removed member successfully.')
}
})
}
}}
>
Remove
@@ -353,18 +409,24 @@ const Settings = () => {
variant='soft'
color='success'
onClick={() => {
const confirmed = confirm(
`Are you sure you want to accept ${request.displayName}(username:${request.username}) to join your circle?`,
showConfirmation(
`Are you sure you want to accept ${request.displayName} (username: ${request.username}) to join your circle?`,
'Accept Member Request',
() => {
AcceptCircleMemberRequest(request.id).then(resp => {
if (resp.ok) {
showNotification({
type: 'success',
message: 'Accepted request successfully',
})
// reload the page
window.location.reload()
}
})
},
'Accept',
'Cancel',
)
if (confirmed) {
AcceptCircleMemberRequest(request.id).then(resp => {
if (resp.ok) {
alert('Accepted request successfully.')
// reload the page
window.location.reload()
}
})
}
}}
>
Accept
@@ -393,18 +455,23 @@ const Settings = () => {
<Button
variant='soft'
onClick={() => {
const confirmed = confirm(
`Are you sure you want to leave you circle and join '${circleInviteCode}'?`,
showConfirmation(
`Are you sure you want to leave your circle and join '${circleInviteCode}'?`,
'Join Circle',
() => {
JoinCircle(circleInviteCode).then(resp => {
if (resp.ok) {
showNotification({
type: 'success',
message:
'Joined circle successfully, wait for the circle owner to accept your request.',
})
}
})
},
'Join',
'Cancel',
)
if (confirmed) {
JoinCircle(circleInviteCode).then(resp => {
if (resp.ok) {
alert(
'Joined circle successfully, wait for the circle owner to accept your request.',
)
}
})
}
}}
>
Join Circle
@@ -479,9 +546,15 @@ const Settings = () => {
onClick={() => {
PutWebhookURL(webhookURL).then(resp => {
if (resp.ok) {
alert('Webhook URL updated successfully.')
showNotification({
type: 'success',
message: 'Webhook URL updated successfully',
})
} else {
alert('Failed to update webhook URL.')
showNotification({
type: 'error',
message: 'Failed to update webhook URL',
})
}
})
}}
@@ -493,6 +566,10 @@ const Settings = () => {
)}
</>
)}
{/* WebSocket Settings */}
{/* <WebSocketSettings /> */}
<RealTimeSettings />
</div>
<div className='grid gap-4 py-4' id='account'>
@@ -537,10 +614,14 @@ const Settings = () => {
ml: 1,
}}
variant='outlined'
color='danger'
onClick={() => {
CancelSubscription().then(resp => {
if (resp.ok) {
alert('Subscription cancelled.')
showNotification({
type: 'success',
message: 'Subscription cancelled',
})
window.location.reload()
}
})
@@ -571,9 +652,15 @@ const Settings = () => {
if (password) {
UpdatePassword(password).then(resp => {
if (resp.ok) {
alert('Password changed successfully')
showNotification({
type: 'success',
message: 'Password changed successfully',
})
} else {
alert('Password change failed')
showNotification({
type: 'error',
message: 'Password change failed',
})
}
})
}
@@ -597,6 +684,11 @@ const Settings = () => {
</Typography>
<ThemeToggle />
</div>
{/* Modals */}
{confirmModalConfig?.isOpen && (
<ConfirmationModal config={confirmModalConfig} />
)}
</Container>
)
}

View File

@@ -12,12 +12,38 @@ import { useNavigate } from 'react-router-dom'
import { useUserProfile } from '../../queries/UserQueries'
import { GetStorageUsage } from '../../utils/Fetcher'
import { isPlusAccount } from '../../utils/Helpers'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
const StorageSettings = () => {
const Navigate = useNavigate()
const { data: userProfile } = useUserProfile()
const [usage, setUsage] = useState({ used: 0, total: 0 })
const [loading, setLoading] = useState(true)
const [confirmModalConfig, setConfirmModalConfig] = useState({})
const showConfirmation = (
message,
title,
onConfirm,
confirmText = 'Confirm',
cancelText = 'Cancel',
color = 'primary',
) => {
setConfirmModalConfig({
isOpen: true,
message,
title,
confirmText,
cancelText,
color,
onClose: isConfirmed => {
if (isConfirmed) {
onConfirm()
}
setConfirmModalConfig({})
},
})
}
useEffect(() => {
if (isPlusAccount(userProfile)) {
@@ -101,13 +127,17 @@ const StorageSettings = () => {
variant='soft'
color='danger'
onClick={() => {
const confirmed = confirm(
`Are you sure you want to clear your local storage and cache? This will remove all your data from this browser and require login.`,
showConfirmation(
'Are you sure you want to clear your local storage and cache? This will remove all your data from this browser and require login.',
'Clear All Local Storage',
() => {
localStorage.clear()
Navigate('/login')
},
'Clear All',
'Cancel',
'danger',
)
if (confirmed) {
localStorage.clear()
Navigate('/login')
}
}}
>
Clear All Local Storage and Cache
@@ -116,20 +146,29 @@ const StorageSettings = () => {
variant='outlined'
color='danger'
onClick={() => {
const confirmed = confirm(
`Are you sure you want to clear only the offline cache and tasks?`,
showConfirmation(
'Are you sure you want to clear only the offline cache and tasks?',
'Clear Offline Cache',
() => {
localStorage.removeItem('offline_cache')
localStorage.removeItem('offline_request_queue')
localStorage.removeItem('offlineTasks')
},
'Clear Cache',
'Cancel',
'danger',
)
if (confirmed) {
localStorage.removeItem('offline_cache')
localStorage.removeItem('offline_request_queue')
localStorage.removeItem('offlineTasks')
}
}}
sx={{ mt: 1 }}
>
Clear Offline Cache and Offline Tasks
</Button>
</Card>
{/* Modals */}
{confirmModalConfig?.isOpen && (
<ConfirmationModal config={confirmModalConfig} />
)}
</div>
)
}

View File

@@ -15,12 +15,11 @@ import {
Container,
Grid,
IconButton,
Snackbar,
Typography,
} from '@mui/joy'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useError } from '../../service/ErrorProvider'
import { useNotification } from '../../service/NotificationProvider'
import {
CreateThing,
DeleteThing,
@@ -169,11 +168,7 @@ const ThingsView = () => {
const [isShowEditThingStateModal, setIsShowEditStateModal] = useState(false)
const [createModalThing, setCreateModalThing] = useState(null)
const [confirmModelConfig, setConfirmModelConfig] = useState({})
const [isSnackbarOpen, setIsSnackbarOpen] = useState(false)
const [snackbarMessage, setSnackbarMessage] = useState('')
const [snackbarColor, setSnackbarColor] = useState('success')
const { showError } = useError()
const { showError, showNotification } = useNotification()
useEffect(() => {
// fetch things
@@ -204,9 +199,11 @@ const ThingsView = () => {
currentThings.push(data.res)
setThings(currentThings)
}
setSnackbarMessage('Thing saved successfully')
setSnackbarColor('success')
setIsSnackbarOpen(true)
showNotification({
type: 'success',
title: 'Thing Saved',
message: 'Thing saved successfully',
})
})
})
.catch(error => {
@@ -246,11 +243,10 @@ const ThingsView = () => {
currentThings.splice(thingIndex, 1)
setThings(currentThings)
} else if (response.status === 405) {
setSnackbarMessage(
'Unable to delete thing with associated tasks',
)
setSnackbarColor('danger')
setIsSnackbarOpen(true)
showError({
title: 'Unable to Delete Thing',
message: 'Unable to delete thing with associated tasks',
})
}
// if method not allwo show snackbar:
})
@@ -293,8 +289,11 @@ const ThingsView = () => {
)
currentThings[thingIndex] = data.res
setThings(currentThings)
setSnackbarMessage('Thing state updated successfully')
setIsSnackbarOpen(true)
showNotification({
type: 'success',
title: 'Thing Updated',
message: 'Thing state updated successfully',
})
})
})
.catch(error => {
@@ -399,19 +398,6 @@ const ThingsView = () => {
<ConfirmationModal config={confirmModelConfig} />
</Box>
<Snackbar
open={isSnackbarOpen}
onClose={() => {
setIsSnackbarOpen(false)
}}
autoHideDuration={3000}
variant='soft'
color={snackbarColor}
size='lg'
invertedColors
>
<Typography level='title-md'>{snackbarMessage}</Typography>
</Snackbar>
</Container>
)
}

View File

@@ -384,7 +384,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
return (
<Modal open={isModalOpen} onClose={handleCloseModal}>
<ModalOverflow>
<ModalDialog size='lg' sx={{ minWidth: '80%' }}>
<ModalDialog size='lg' sx={{ minWidth: '100%' }}>
<Typography level='h4'>Create new task</Typography>
<Chip startDecorator='🚧' variant='soft' color='warning' size='sm'>
Experimental Feature

View File

@@ -165,7 +165,6 @@ const CalendarView = ({ chores }) => {
return legendItems.map((item, index) => (
<Grid
key={index}
item
xs={12}
sx={{
display: 'flex',

View File

@@ -17,7 +17,7 @@ import {
import { Divider, IconButton, Menu, MenuItem } from '@mui/joy'
import React, { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useError } from '../../service/ErrorProvider'
import { useNotification } from '../../service/NotificationProvider'
import {
ArchiveChore,
DeleteChore,
@@ -41,7 +41,7 @@ const ChoreActionMenu = ({
const [anchorEl, setAnchorEl] = React.useState(null)
const menuRef = React.useRef(null)
const navigate = useNavigate()
const { showError } = useError()
const { showError } = useNotification()
useEffect(() => {
const handleMenuOutsideClick = event => {

View File

@@ -2,9 +2,9 @@ import imageCompression from 'browser-image-compression'
import Quill from 'quill'
import 'quill/dist/quill.snow.css'
import QuillMarkdown from 'quilljs-markdown'
import { useCallback, useContext, useEffect, useRef } from 'react'
import { UserContext } from '../../contexts/UserContext'
import { useError } from '../../service/ErrorProvider'
import { useCallback, useEffect, useRef } from 'react'
import { useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers'
import { UploadFile } from '../../utils/TokenManager'
import './RichTextEditor.css'
@@ -18,8 +18,8 @@ const RichTextEditor = ({
entityId,
entityType,
}) => {
const { showError } = useError()
const { userProfile } = useContext(UserContext)
const { showError } = useNotification()
const { data: userProfile } = useUserProfile()
const quillRef = useRef(null)
const editorRef = useRef(null)