Merge branch 'dev'
This commit is contained in:
@@ -50,6 +50,7 @@
|
|||||||
"chrono-node": "^2.7.7",
|
"chrono-node": "^2.7.7",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"esm": "^3.2.25",
|
"esm": "^3.2.25",
|
||||||
|
"event-source-polyfill": "^1.0.31",
|
||||||
"farmhash": "^4.0.1",
|
"farmhash": "^4.0.1",
|
||||||
"fuse.js": "^7.0.0",
|
"fuse.js": "^7.0.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
|
|||||||
99
src/App.jsx
99
src/App.jsx
@@ -1,15 +1,18 @@
|
|||||||
import NavBar from '@/views/components/NavBar'
|
import NavBar from '@/views/components/NavBar'
|
||||||
import { Button, Snackbar, Typography, useColorScheme } from '@mui/joy'
|
import { Button, Typography, useColorScheme } from '@mui/joy'
|
||||||
import Tracker from '@openreplay/tracker'
|
import Tracker from '@openreplay/tracker'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { Outlet, useNavigate } from 'react-router-dom'
|
import { Outlet, useNavigate } from 'react-router-dom'
|
||||||
import { useRegisterSW } from 'virtual:pwa-register/react'
|
import { useRegisterSW } from 'virtual:pwa-register/react'
|
||||||
import { registerCapacitorListeners } from './CapacitorListener'
|
import { registerCapacitorListeners } from './CapacitorListener'
|
||||||
import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext'
|
import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext'
|
||||||
import { useResource } from './queries/ResourceQueries'
|
import { useResource } from './queries/ResourceQueries'
|
||||||
import { AuthenticationProvider } from './service/AuthenticationService'
|
import { AuthenticationProvider } from './service/AuthenticationService'
|
||||||
import { ErrorProvider } from './service/ErrorProvider'
|
import {
|
||||||
|
NotificationProvider,
|
||||||
|
useNotification,
|
||||||
|
} from './service/NotificationProvider'
|
||||||
import { apiManager } from './utils/TokenManager'
|
import { apiManager } from './utils/TokenManager'
|
||||||
import NetworkBanner from './views/components/NetworkBanner'
|
import NetworkBanner from './views/components/NetworkBanner'
|
||||||
const add = className => {
|
const add = className => {
|
||||||
@@ -22,22 +25,15 @@ const remove = className => {
|
|||||||
// TODO: Update the interval to at 60 minutes
|
// TODO: Update the interval to at 60 minutes
|
||||||
const intervalMS = 5 * 60 * 1000 // 5 minutes
|
const intervalMS = 5 * 60 * 1000 // 5 minutes
|
||||||
const queryClient = new QueryClient({})
|
const queryClient = new QueryClient({})
|
||||||
function App() {
|
|
||||||
const resource = useResource()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
startApiManager(navigate)
|
|
||||||
startOpenReplay()
|
|
||||||
|
|
||||||
const { mode, systemMode } = useColorScheme()
|
const AppContent = () => {
|
||||||
const [showUpdateSnackbar, setShowUpdateSnackbar] = useState(true)
|
const { showNotification } = useNotification()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
offlineReady: [offlineReady, setOfflineReady],
|
|
||||||
needRefresh: [needRefresh, setNeedRefresh],
|
needRefresh: [needRefresh, setNeedRefresh],
|
||||||
updateServiceWorker,
|
updateServiceWorker,
|
||||||
} = useRegisterSW({
|
} = useRegisterSW({
|
||||||
onRegistered(r) {
|
onRegistered(r) {
|
||||||
// eslint-disable-next-line prefer-template
|
|
||||||
console.log('SW Registered: ' + r)
|
console.log('SW Registered: ' + r)
|
||||||
r &&
|
r &&
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
@@ -48,10 +44,53 @@ function App() {
|
|||||||
console.log('SW registration error', error)
|
console.log('SW registration error', error)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const close = () => {
|
|
||||||
setOfflineReady(false)
|
useEffect(() => {
|
||||||
setNeedRefresh(false)
|
if (needRefresh) {
|
||||||
}
|
showNotification({
|
||||||
|
type: 'custom',
|
||||||
|
component: (
|
||||||
|
<div>
|
||||||
|
<Typography level='body-md'>
|
||||||
|
A new version is now available. Click on reload button to update.
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
color='secondary'
|
||||||
|
size='small'
|
||||||
|
onClick={() => {
|
||||||
|
updateServiceWorker(true)
|
||||||
|
setNeedRefresh(false)
|
||||||
|
}}
|
||||||
|
sx={{ ml: 2 }}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
snackbarProps: {
|
||||||
|
autoHideDuration: null, // Persistent until user action
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [needRefresh, showNotification, updateServiceWorker, setNeedRefresh])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ImpersonateUserProvider>
|
||||||
|
<NavBar />
|
||||||
|
<Outlet />
|
||||||
|
</ImpersonateUserProvider>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const resource = useResource()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
startApiManager(navigate)
|
||||||
|
startOpenReplay()
|
||||||
|
|
||||||
|
const { mode, systemMode } = useColorScheme()
|
||||||
|
|
||||||
const setThemeClass = () => {
|
const setThemeClass = () => {
|
||||||
const value = JSON.parse(localStorage.getItem('themeMode')) || mode
|
const value = JSON.parse(localStorage.getItem('themeMode')) || mode
|
||||||
@@ -73,6 +112,7 @@ function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setThemeClass()
|
setThemeClass()
|
||||||
}, [mode, systemMode])
|
}, [mode, systemMode])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
registerCapacitorListeners()
|
registerCapacitorListeners()
|
||||||
}, [])
|
}, [])
|
||||||
@@ -83,30 +123,9 @@ function App() {
|
|||||||
|
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<AuthenticationProvider />
|
<AuthenticationProvider />
|
||||||
<ErrorProvider>
|
<NotificationProvider>
|
||||||
<ImpersonateUserProvider>
|
<AppContent />
|
||||||
<NavBar />
|
</NotificationProvider>
|
||||||
<Outlet />
|
|
||||||
</ImpersonateUserProvider>
|
|
||||||
</ErrorProvider>
|
|
||||||
|
|
||||||
{needRefresh && (
|
|
||||||
<Snackbar open={showUpdateSnackbar}>
|
|
||||||
<Typography level='body-md'>
|
|
||||||
A new version is now available.Click on reload button to update.
|
|
||||||
</Typography>
|
|
||||||
<Button
|
|
||||||
color='secondary'
|
|
||||||
size='small'
|
|
||||||
onClick={() => {
|
|
||||||
updateServiceWorker(true)
|
|
||||||
setShowUpdateSnackbar(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</Snackbar>
|
|
||||||
)}
|
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
BIN
src/assets/screenshot-my-chore-dark.png
Normal file
BIN
src/assets/screenshot-my-chore-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 145 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 406 KiB After Width: | Height: | Size: 147 KiB |
190
src/components/RealTimeSettings.jsx
Normal file
190
src/components/RealTimeSettings.jsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { Sync, SyncDisabled } from '@mui/icons-material'
|
||||||
|
import { Box, Card, Chip, FormHelperText, Switch, Typography } from '@mui/joy'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useSSEContext } from '../hooks/useSSEContext'
|
||||||
|
import { useUserProfile } from '../queries/UserQueries'
|
||||||
|
import { isPlusAccount } from '../utils/Helpers'
|
||||||
|
import SSEConnectionStatus from './SSEConnectionStatus'
|
||||||
|
|
||||||
|
const REALTIME_TYPES = {
|
||||||
|
DISABLED: 'disabled',
|
||||||
|
SSE: 'sse',
|
||||||
|
}
|
||||||
|
|
||||||
|
const RealTimeSettings = () => {
|
||||||
|
const { data: userProfile } = useUserProfile()
|
||||||
|
|
||||||
|
// SSE context
|
||||||
|
const sseContext = useSSEContext()
|
||||||
|
|
||||||
|
// Get current realtime type from localStorage
|
||||||
|
const getCurrentRealtimeType = () => {
|
||||||
|
const sseEnabled = localStorage.getItem('sse_enabled') === 'true'
|
||||||
|
return sseEnabled ? REALTIME_TYPES.SSE : REALTIME_TYPES.DISABLED
|
||||||
|
}
|
||||||
|
|
||||||
|
const [realtimeType, setRealtimeType] = useState(getCurrentRealtimeType())
|
||||||
|
|
||||||
|
const handleRealtimeTypeChange = (event, newValue) => {
|
||||||
|
if (!isPlusAccount(userProfile)) {
|
||||||
|
return // Don't allow changes for non-Plus users
|
||||||
|
}
|
||||||
|
|
||||||
|
setRealtimeType(newValue)
|
||||||
|
|
||||||
|
// Update localStorage and toggle connections
|
||||||
|
switch (newValue) {
|
||||||
|
case REALTIME_TYPES.DISABLED:
|
||||||
|
localStorage.setItem('sse_enabled', 'false')
|
||||||
|
sseContext.disconnect()
|
||||||
|
break
|
||||||
|
case REALTIME_TYPES.SSE:
|
||||||
|
localStorage.setItem('sse_enabled', 'true')
|
||||||
|
sseContext.connect()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCurrentContext = () => {
|
||||||
|
switch (realtimeType) {
|
||||||
|
case REALTIME_TYPES.SSE:
|
||||||
|
return sseContext
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
isConnected: false,
|
||||||
|
isConnecting: false,
|
||||||
|
error: null,
|
||||||
|
getConnectionStatus: () => 'disabled',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = getCurrentContext()
|
||||||
|
|
||||||
|
const getStatusDescription = () => {
|
||||||
|
if (!isPlusAccount(userProfile)) {
|
||||||
|
return 'Real-time updates are not available in the Basic plan. Upgrade to Plus to receive instant notifications when tasks are updated.'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (realtimeType === REALTIME_TYPES.DISABLED) {
|
||||||
|
return 'Real-time updates are disabled. Enable them to see live changes when you or other circle members complete, skip, or modify tasks.'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.isConnected) {
|
||||||
|
return "Real-time updates are working. You'll see live changes when you or other circle members complete, skip, or modify tasks."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.isConnecting) {
|
||||||
|
return 'Connecting to real-time updates...'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.error) {
|
||||||
|
return `Real-time updates are enabled but not working: ${context.error}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Real-time updates are enabled but not currently connected.'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getConnectionStatusComponent = () => {
|
||||||
|
switch (realtimeType) {
|
||||||
|
case REALTIME_TYPES.SSE:
|
||||||
|
return <SSEConnectionStatus variant='chip' />
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card sx={{ mt: 2, p: 3 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2, mb: 2 }}>
|
||||||
|
<Switch
|
||||||
|
color='success'
|
||||||
|
checked={realtimeType !== REALTIME_TYPES.DISABLED}
|
||||||
|
onChange={e => {
|
||||||
|
handleRealtimeTypeChange(
|
||||||
|
null,
|
||||||
|
e.target.checked ? REALTIME_TYPES.SSE : REALTIME_TYPES.DISABLED,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
disabled={!isPlusAccount(userProfile)}
|
||||||
|
inputProps={{ 'aria-label': 'Enable Real-time Updates' }}
|
||||||
|
/>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
mb: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography level='title-md'>
|
||||||
|
Real-time Updates
|
||||||
|
{!isPlusAccount(userProfile) && (
|
||||||
|
<Chip variant='soft' color='warning' sx={{ ml: 1 }}>
|
||||||
|
Plus Feature
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{realtimeType !== REALTIME_TYPES.DISABLED &&
|
||||||
|
isPlusAccount(userProfile) ? (
|
||||||
|
<Sync color={context.isConnected ? 'success' : 'disabled'} />
|
||||||
|
) : (
|
||||||
|
<SyncDisabled color='disabled' />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Typography level='body-sm' color='neutral'>
|
||||||
|
Get instant notifications when tasks are updated
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* <FormControl orientation='horizontal' sx={{ mb: 2 }}>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<FormLabel>Real-time Connection Type</FormLabel>
|
||||||
|
<FormHelperText sx={{ mt: 0 }}>
|
||||||
|
Choose how to receive real-time updates
|
||||||
|
</FormHelperText>
|
||||||
|
</Box>
|
||||||
|
<Select
|
||||||
|
value={realtimeType}
|
||||||
|
onChange={handleRealtimeTypeChange}
|
||||||
|
disabled={!isPlusAccount(userProfile)}
|
||||||
|
sx={{ minWidth: 140 }}
|
||||||
|
>
|
||||||
|
<Option value={REALTIME_TYPES.DISABLED}>Disabled</Option>
|
||||||
|
<Option value={REALTIME_TYPES.WEBSOCKET}>WebSocket</Option>
|
||||||
|
<Option value={REALTIME_TYPES.SSE}>SSE</Option>
|
||||||
|
</Select>
|
||||||
|
</FormControl> */}
|
||||||
|
|
||||||
|
<FormHelperText sx={{ mb: 2 }}>{getStatusDescription()}</FormHelperText>
|
||||||
|
|
||||||
|
{realtimeType !== REALTIME_TYPES.DISABLED &&
|
||||||
|
isPlusAccount(userProfile) && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
|
||||||
|
<Typography level='body-xs' color='neutral'>
|
||||||
|
Status:
|
||||||
|
</Typography>
|
||||||
|
{getConnectionStatusComponent()}
|
||||||
|
{context.error && (
|
||||||
|
<Typography level='body-xs' color='danger'>
|
||||||
|
{context.error}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isPlusAccount(userProfile) && (
|
||||||
|
<Typography level='body-sm' color='warning' sx={{ mt: 1 }}>
|
||||||
|
Real-time updates are not available in the Basic plan. Upgrade to Plus
|
||||||
|
to receive instant notifications when you or other circle members
|
||||||
|
complete, skip, or modify tasks.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RealTimeSettings
|
||||||
106
src/components/SSEConnectionStatus.jsx
Normal file
106
src/components/SSEConnectionStatus.jsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { Circle, SignalWifi4Bar, SignalWifiOff } from '@mui/icons-material'
|
||||||
|
import { Box, Chip, Tooltip, Typography } from '@mui/joy'
|
||||||
|
import { useSSEContext } from '../hooks/useSSEContext'
|
||||||
|
|
||||||
|
const SSEConnectionStatus = ({
|
||||||
|
variant = 'minimal',
|
||||||
|
showError = false,
|
||||||
|
sx = {},
|
||||||
|
}) => {
|
||||||
|
const { isConnected, isConnecting, error, getConnectionStatus } =
|
||||||
|
useSSEContext()
|
||||||
|
|
||||||
|
const getStatusColor = () => {
|
||||||
|
if (isConnected) return 'success'
|
||||||
|
if (isConnecting) return 'warning'
|
||||||
|
return 'danger'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusIcon = () => {
|
||||||
|
if (isConnected) return <SignalWifi4Bar />
|
||||||
|
if (isConnecting) return <Circle />
|
||||||
|
return <SignalWifiOff />
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusText = () => {
|
||||||
|
if (isConnected) return 'Connected'
|
||||||
|
if (isConnecting) return 'Connecting...'
|
||||||
|
return 'Disconnected'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTooltipText = () => {
|
||||||
|
const status = getConnectionStatus()
|
||||||
|
if (error) return `Real-time updates (SSE): ${status} - ${error}`
|
||||||
|
if (!isConnected && !isConnecting) {
|
||||||
|
return `Real-time updates (SSE): ${status} - Join a circle to enable real-time updates`
|
||||||
|
}
|
||||||
|
return `Real-time updates (SSE): ${status}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === 'minimal') {
|
||||||
|
return (
|
||||||
|
<Tooltip title={getTooltipText()} size='sm'>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.5,
|
||||||
|
...sx,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Circle
|
||||||
|
sx={{
|
||||||
|
fontSize: 8,
|
||||||
|
color:
|
||||||
|
getStatusColor() === 'success'
|
||||||
|
? 'success.main'
|
||||||
|
: getStatusColor() === 'warning'
|
||||||
|
? 'warning.main'
|
||||||
|
: 'danger.main',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{showError && error && (
|
||||||
|
<Typography level='body-xs' color='danger'>
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === 'chip') {
|
||||||
|
return (
|
||||||
|
<Tooltip title={getTooltipText()} size='sm'>
|
||||||
|
<Chip
|
||||||
|
color={getStatusColor()}
|
||||||
|
size='sm'
|
||||||
|
variant='soft'
|
||||||
|
startDecorator={getStatusIcon()}
|
||||||
|
sx={sx}
|
||||||
|
>
|
||||||
|
{getStatusText()}
|
||||||
|
</Chip>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full variant
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, ...sx }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
{getStatusIcon()}
|
||||||
|
<Typography level='body-sm' color={getStatusColor()}>
|
||||||
|
{getStatusText()}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{showError && error && (
|
||||||
|
<Typography level='body-xs' color='danger'>
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SSEConnectionStatus
|
||||||
149
src/components/SSESettings.jsx
Normal file
149
src/components/SSESettings.jsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { Sync, SyncDisabled } from '@mui/icons-material'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
Chip,
|
||||||
|
FormControl,
|
||||||
|
FormHelperText,
|
||||||
|
FormLabel,
|
||||||
|
Switch,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/joy'
|
||||||
|
import { useSSEContext } from '../hooks/useSSEContext'
|
||||||
|
import { useUserProfile } from '../queries/UserQueries'
|
||||||
|
import { isPlusAccount } from '../utils/Helpers'
|
||||||
|
import SSEConnectionStatus from './SSEConnectionStatus'
|
||||||
|
|
||||||
|
const SSESettings = () => {
|
||||||
|
const { data: userProfile } = useUserProfile()
|
||||||
|
const {
|
||||||
|
isConnected,
|
||||||
|
isConnecting,
|
||||||
|
error,
|
||||||
|
getConnectionStatus,
|
||||||
|
toggleSSEEnabled,
|
||||||
|
isSSEEnabled,
|
||||||
|
} = useSSEContext()
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
console.log('=== TOGGLE CLICKED ===')
|
||||||
|
if (!isPlusAccount(userProfile)) {
|
||||||
|
console.log('Not a Plus account, returning early')
|
||||||
|
return // Don't allow toggle for non-Plus users
|
||||||
|
}
|
||||||
|
const currentlyEnabled = isSSEEnabled()
|
||||||
|
console.log('SSE Settings - Toggle clicked:', {
|
||||||
|
currentlyEnabled,
|
||||||
|
newState: !currentlyEnabled,
|
||||||
|
userProfile,
|
||||||
|
isPlusAccount: isPlusAccount(userProfile),
|
||||||
|
})
|
||||||
|
toggleSSEEnabled(!currentlyEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusDescription = () => {
|
||||||
|
if (!isPlusAccount(userProfile)) {
|
||||||
|
return 'Real-time updates (SSE) are not available in the Basic plan. Upgrade to Plus to receive instant notifications when chores are updated.'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSSEEnabled()) {
|
||||||
|
return 'Real-time updates (SSE) are disabled. Enable to see live changes when you or other circle members complete, skip, or modify chores.'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isConnected) {
|
||||||
|
return "Real-time updates (SSE) are working. You'll see live changes when you or other circle members complete, skip, or modify chores."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isConnecting) {
|
||||||
|
return 'Connecting to real-time updates (SSE)...'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return `Real-time updates (SSE) are enabled but not working: ${error}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Real-time updates (SSE) are enabled but not currently connected.'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card sx={{ mt: 2, p: 3 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||||
|
{isSSEEnabled() && isPlusAccount(userProfile) ? (
|
||||||
|
<Sync color={isConnected ? 'success' : 'disabled'} />
|
||||||
|
) : (
|
||||||
|
<SyncDisabled color='disabled' />
|
||||||
|
)}
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography level='title-md'>
|
||||||
|
Real-time Updates (SSE)
|
||||||
|
{!isPlusAccount(userProfile) && (
|
||||||
|
<Chip variant='soft' color='warning' sx={{ ml: 1 }}>
|
||||||
|
Plus Feature
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
<Typography level='body-sm' color='neutral'>
|
||||||
|
Get instant notifications via Server-Sent Events
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{isSSEEnabled() && isPlusAccount(userProfile) && (
|
||||||
|
<SSEConnectionStatus variant='chip' />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<FormControl orientation='horizontal' sx={{ mb: 2 }}>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<FormLabel>Enable Real-time Updates (SSE)</FormLabel>
|
||||||
|
<FormHelperText sx={{ mt: 0 }}>
|
||||||
|
{getStatusDescription()}
|
||||||
|
</FormHelperText>
|
||||||
|
</Box>
|
||||||
|
<Switch
|
||||||
|
checked={isSSEEnabled() && isPlusAccount(userProfile)}
|
||||||
|
onChange={handleToggle}
|
||||||
|
disabled={!isPlusAccount(userProfile)}
|
||||||
|
color={
|
||||||
|
isSSEEnabled() && isPlusAccount(userProfile) ? 'success' : 'neutral'
|
||||||
|
}
|
||||||
|
variant='solid'
|
||||||
|
endDecorator={
|
||||||
|
isSSEEnabled() && isPlusAccount(userProfile) ? 'On' : 'Off'
|
||||||
|
}
|
||||||
|
slotProps={{ endDecorator: { sx: { minWidth: 24 } } }}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{isSSEEnabled() && isPlusAccount(userProfile) && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
|
||||||
|
<Typography level='body-xs' color='neutral'>
|
||||||
|
Status:
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
size='sm'
|
||||||
|
variant='soft'
|
||||||
|
color={
|
||||||
|
isConnected ? 'success' : isConnecting ? 'warning' : 'danger'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{getConnectionStatus()}
|
||||||
|
</Chip>
|
||||||
|
{error && (
|
||||||
|
<Typography level='body-xs' color='danger'>
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isPlusAccount(userProfile) && (
|
||||||
|
<Typography level='body-sm' color='warning' sx={{ mt: 1 }}>
|
||||||
|
Real-time updates (SSE) are not available in the Basic plan. Upgrade
|
||||||
|
to Plus to receive instant notifications when you or other circle
|
||||||
|
members complete, skip, or modify chores.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SSESettings
|
||||||
106
src/components/WebSocketConnectionStatus.jsx
Normal file
106
src/components/WebSocketConnectionStatus.jsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { Circle, SignalWifi4Bar, SignalWifiOff } from '@mui/icons-material'
|
||||||
|
import { Box, Chip, Tooltip, Typography } from '@mui/joy'
|
||||||
|
import { useWebSocketContext } from '../contexts/WebSocketContext'
|
||||||
|
|
||||||
|
const WebSocketConnectionStatus = ({
|
||||||
|
variant = 'minimal',
|
||||||
|
showError = false,
|
||||||
|
sx = {},
|
||||||
|
}) => {
|
||||||
|
const { isConnected, isConnecting, error, getConnectionStatus } =
|
||||||
|
useWebSocketContext()
|
||||||
|
|
||||||
|
const getStatusColor = () => {
|
||||||
|
if (isConnected) return 'success'
|
||||||
|
if (isConnecting) return 'warning'
|
||||||
|
return 'danger'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusIcon = () => {
|
||||||
|
if (isConnected) return <SignalWifi4Bar />
|
||||||
|
if (isConnecting) return <Circle />
|
||||||
|
return <SignalWifiOff />
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusText = () => {
|
||||||
|
if (isConnected) return 'Connected'
|
||||||
|
if (isConnecting) return 'Connecting...'
|
||||||
|
return 'Disconnected'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTooltipText = () => {
|
||||||
|
const status = getConnectionStatus()
|
||||||
|
if (error) return `Real-time updates: ${status} - ${error}`
|
||||||
|
if (!isConnected && !isConnecting) {
|
||||||
|
return `Real-time updates: ${status} - Join a circle to enable real-time updates`
|
||||||
|
}
|
||||||
|
return `Real-time updates: ${status}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === 'minimal') {
|
||||||
|
return (
|
||||||
|
<Tooltip title={getTooltipText()} size='sm'>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.5,
|
||||||
|
...sx,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Circle
|
||||||
|
sx={{
|
||||||
|
fontSize: 8,
|
||||||
|
color:
|
||||||
|
getStatusColor() === 'success'
|
||||||
|
? 'success.main'
|
||||||
|
: getStatusColor() === 'warning'
|
||||||
|
? 'warning.main'
|
||||||
|
: 'danger.main',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{showError && error && (
|
||||||
|
<Typography level='body-xs' color='danger'>
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === 'chip') {
|
||||||
|
return (
|
||||||
|
<Tooltip title={getTooltipText()} size='sm'>
|
||||||
|
<Chip
|
||||||
|
color={getStatusColor()}
|
||||||
|
size='sm'
|
||||||
|
variant='soft'
|
||||||
|
startDecorator={getStatusIcon()}
|
||||||
|
sx={sx}
|
||||||
|
>
|
||||||
|
{getStatusText()}
|
||||||
|
</Chip>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full variant
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, ...sx }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
{getStatusIcon()}
|
||||||
|
<Typography level='body-sm' color={getStatusColor()}>
|
||||||
|
{getStatusText()}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{showError && error && (
|
||||||
|
<Typography level='body-xs' color='danger'>
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WebSocketConnectionStatus
|
||||||
143
src/components/WebSocketSettings.jsx
Normal file
143
src/components/WebSocketSettings.jsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { Sync, SyncDisabled } from '@mui/icons-material'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
Chip,
|
||||||
|
FormControl,
|
||||||
|
FormHelperText,
|
||||||
|
FormLabel,
|
||||||
|
Switch,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/joy'
|
||||||
|
import { useWebSocketContext } from '../contexts/WebSocketContext'
|
||||||
|
import { useUserProfile } from '../queries/UserQueries'
|
||||||
|
import { isPlusAccount } from '../utils/Helpers'
|
||||||
|
import WebSocketConnectionStatus from './WebSocketConnectionStatus'
|
||||||
|
|
||||||
|
const WebSocketSettings = () => {
|
||||||
|
const { data: userProfile } = useUserProfile()
|
||||||
|
const {
|
||||||
|
isConnected,
|
||||||
|
isConnecting,
|
||||||
|
error,
|
||||||
|
getConnectionStatus,
|
||||||
|
toggleWebSocketEnabled,
|
||||||
|
isWebSocketEnabled,
|
||||||
|
} = useWebSocketContext()
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
if (!isPlusAccount(userProfile)) {
|
||||||
|
return // Don't allow toggle for non-Plus users
|
||||||
|
}
|
||||||
|
const currentlyEnabled = isWebSocketEnabled()
|
||||||
|
toggleWebSocketEnabled(!currentlyEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusDescription = () => {
|
||||||
|
if (!isPlusAccount(userProfile)) {
|
||||||
|
return 'Real-time updates are not available in the Basic plan. Upgrade to Plus to receive instant notifications when chores are updated.'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isWebSocketEnabled()) {
|
||||||
|
return 'Real-time updates are disabled. Enable to see live changes when you or other circle members complete, skip, or modify chores.'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isConnected) {
|
||||||
|
return "Real-time updates are working. You'll see live changes when you or other circle members complete, skip, or modify chores."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isConnecting) {
|
||||||
|
return 'Connecting to real-time updates...'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return `Real-time updates are enabled but not working: ${error}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Real-time updates are enabled but not currently connected.'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card sx={{ mt: 2, p: 3 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||||
|
{isWebSocketEnabled() && isPlusAccount(userProfile) ? (
|
||||||
|
<Sync color={isConnected ? 'success' : 'disabled'} />
|
||||||
|
) : (
|
||||||
|
<SyncDisabled color='disabled' />
|
||||||
|
)}
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography level='title-md'>
|
||||||
|
Real-time Updates
|
||||||
|
{!isPlusAccount(userProfile) && (
|
||||||
|
<Chip variant='soft' color='warning' sx={{ ml: 1 }}>
|
||||||
|
Plus Feature
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
<Typography level='body-sm' color='neutral'>
|
||||||
|
Get instant notifications when chores are updated
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{isWebSocketEnabled() && isPlusAccount(userProfile) && (
|
||||||
|
<WebSocketConnectionStatus variant='chip' />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<FormControl orientation='horizontal' sx={{ mb: 2 }}>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<FormLabel>Enable Real-time Updates</FormLabel>
|
||||||
|
<FormHelperText sx={{ mt: 0 }}>
|
||||||
|
{getStatusDescription()}
|
||||||
|
</FormHelperText>
|
||||||
|
</Box>
|
||||||
|
<Switch
|
||||||
|
checked={Boolean(isWebSocketEnabled() && isPlusAccount(userProfile))}
|
||||||
|
onChange={handleToggle}
|
||||||
|
disabled={!isPlusAccount(userProfile)}
|
||||||
|
color={
|
||||||
|
isWebSocketEnabled() && isPlusAccount(userProfile)
|
||||||
|
? 'success'
|
||||||
|
: 'neutral'
|
||||||
|
}
|
||||||
|
variant='solid'
|
||||||
|
endDecorator={
|
||||||
|
isWebSocketEnabled() && isPlusAccount(userProfile) ? 'On' : 'Off'
|
||||||
|
}
|
||||||
|
slotProps={{ endDecorator: { sx: { minWidth: 24 } } }}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{isWebSocketEnabled() && isPlusAccount(userProfile) && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
|
||||||
|
<Typography level='body-xs' color='neutral'>
|
||||||
|
Status:
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
size='sm'
|
||||||
|
variant='soft'
|
||||||
|
color={
|
||||||
|
isConnected ? 'success' : isConnecting ? 'warning' : 'danger'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{getConnectionStatus()}
|
||||||
|
</Chip>
|
||||||
|
{error && (
|
||||||
|
<Typography level='body-xs' color='danger'>
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isPlusAccount(userProfile) && (
|
||||||
|
<Typography level='body-sm' color='warning' sx={{ mt: 1 }}>
|
||||||
|
Real-time updates are not available in the Basic plan. Upgrade to Plus
|
||||||
|
to receive instant notifications when you or other circle members
|
||||||
|
complete, skip, or modify chores.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WebSocketSettings
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
import QueryContext from './QueryContext'
|
import QueryContext from './QueryContext'
|
||||||
import RouterContext from './RouterContext'
|
import RouterContext from './RouterContext'
|
||||||
|
import SSEProvider from './SSEContext'
|
||||||
import ThemeContext from './ThemeContext'
|
import ThemeContext from './ThemeContext'
|
||||||
|
import WebSocketProvider from './WebSocketContext'
|
||||||
|
|
||||||
const Contexts = () => {
|
const Contexts = () => {
|
||||||
const contexts = [ThemeContext, QueryContext, RouterContext]
|
const contexts = [
|
||||||
|
ThemeContext,
|
||||||
|
QueryContext,
|
||||||
|
SSEProvider,
|
||||||
|
WebSocketProvider,
|
||||||
|
RouterContext,
|
||||||
|
]
|
||||||
|
|
||||||
return contexts.reduceRight((acc, Context) => {
|
return contexts.reduceRight((acc, Context) => {
|
||||||
return <Context>{acc}</Context>
|
return <Context>{acc}</Context>
|
||||||
|
|||||||
25
src/contexts/SSEContext.jsx
Normal file
25
src/contexts/SSEContext.jsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { createContext, useContext } from 'react'
|
||||||
|
import { useSSE } from '../hooks/useSSE'
|
||||||
|
|
||||||
|
export const SSEContext = createContext({
|
||||||
|
connectionState: 2, // CLOSED
|
||||||
|
isConnected: false,
|
||||||
|
isConnecting: false,
|
||||||
|
lastEvent: null,
|
||||||
|
error: null,
|
||||||
|
connect: () => {},
|
||||||
|
disconnect: () => {},
|
||||||
|
getConnectionStatus: () => 'disconnected',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useSSEContext = () => {
|
||||||
|
return useContext(SSEContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SSEProvider = ({ children }) => {
|
||||||
|
const sseState = useSSE()
|
||||||
|
|
||||||
|
return <SSEContext.Provider value={sseState}>{children}</SSEContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SSEProvider
|
||||||
29
src/contexts/WebSocketContext.jsx
Normal file
29
src/contexts/WebSocketContext.jsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { createContext, useContext } from 'react'
|
||||||
|
import { useWebSocket } from '../hooks/useWebSocket'
|
||||||
|
|
||||||
|
const WebSocketContext = createContext({
|
||||||
|
connectionState: 3, // CLOSED
|
||||||
|
isConnected: false,
|
||||||
|
isConnecting: false,
|
||||||
|
lastEvent: null,
|
||||||
|
error: null,
|
||||||
|
connect: () => {},
|
||||||
|
disconnect: () => {},
|
||||||
|
getConnectionStatus: () => 'disconnected',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useWebSocketContext = () => {
|
||||||
|
return useContext(WebSocketContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WebSocketProvider = ({ children }) => {
|
||||||
|
const webSocketState = useWebSocket()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WebSocketContext.Provider value={webSocketState}>
|
||||||
|
{children}
|
||||||
|
</WebSocketContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WebSocketProvider
|
||||||
421
src/hooks/useSSE.js
Normal file
421
src/hooks/useSSE.js
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { EventSourcePolyfill } from 'event-source-polyfill'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { apiManager, isTokenValid } from '../utils/TokenManager'
|
||||||
|
|
||||||
|
const SSE_STATES = {
|
||||||
|
CONNECTING: 0,
|
||||||
|
OPEN: 1,
|
||||||
|
CLOSED: 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECONNECT_INTERVALS = [1000, 2000, 5000, 10000, 30000] // Progressive backoff
|
||||||
|
const MAX_RECONNECT_ATTEMPTS = 10 // Circuit breaker limit
|
||||||
|
const CIRCUIT_BREAKER_RESET_TIME = 300000 // 5 minutes
|
||||||
|
|
||||||
|
export const useSSE = () => {
|
||||||
|
const [connectionState, setConnectionState] = useState(SSE_STATES.CLOSED)
|
||||||
|
const [lastEvent, setLastEvent] = useState(null)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [isCircuitBreakerOpen, setIsCircuitBreakerOpen] = useState(false)
|
||||||
|
|
||||||
|
const eventSourceRef = useRef(null)
|
||||||
|
const reconnectTimeoutRef = useRef(null)
|
||||||
|
const reconnectAttemptsRef = useRef(0)
|
||||||
|
const isManuallyClosedRef = useRef(false)
|
||||||
|
const lastHeartbeatRef = useRef(Date.now())
|
||||||
|
const heartbeatMonitorRef = useRef(null)
|
||||||
|
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const getSSEUrl = useCallback(() => {
|
||||||
|
const token = localStorage.getItem('ca_token')
|
||||||
|
if (!token || !isTokenValid()) {
|
||||||
|
console.log('SSE: No valid authentication token')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the API URL from apiManager
|
||||||
|
const apiUrl = apiManager.getApiURL() // e.g., "http://localhost:8080/api/v1"
|
||||||
|
|
||||||
|
// Build SSE URL - let backend determine circle from authenticated user
|
||||||
|
const sseUrl = `${apiUrl}/realtime/sse`
|
||||||
|
|
||||||
|
return { url: sseUrl, token }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSSEMessage = useCallback(
|
||||||
|
event => {
|
||||||
|
try {
|
||||||
|
const eventData = JSON.parse(event.data)
|
||||||
|
setLastEvent(eventData)
|
||||||
|
|
||||||
|
// Update heartbeat timestamp
|
||||||
|
if (eventData.type === 'heartbeat') {
|
||||||
|
lastHeartbeatRef.current = Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle different event types and update React Query cache accordingly
|
||||||
|
switch (eventData.type) {
|
||||||
|
case 'chore.created':
|
||||||
|
case 'chore.updated':
|
||||||
|
case 'chore.completed':
|
||||||
|
case 'chore.skipped':
|
||||||
|
queryClient.invalidateQueries(['choresHistory', 7])
|
||||||
|
queryClient.invalidateQueries(['chores'])
|
||||||
|
|
||||||
|
// If it's a specific chore event, also invalidate that chore's details
|
||||||
|
if (eventData.data.chore?.id) {
|
||||||
|
queryClient.invalidateQueries(['chore', eventData.data.chore.id])
|
||||||
|
queryClient.invalidateQueries([
|
||||||
|
'choreDetails',
|
||||||
|
eventData.data.chore.id,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'chore.deleted':
|
||||||
|
// Invalidate chores queries to refetch data
|
||||||
|
queryClient.invalidateQueries(['chores'])
|
||||||
|
|
||||||
|
// If it's a specific chore event, also invalidate that chore's details
|
||||||
|
if (eventData.data.chore?.id) {
|
||||||
|
queryClient.invalidateQueries(['chore', eventData.data.chore.id])
|
||||||
|
queryClient.invalidateQueries([
|
||||||
|
'choreDetails',
|
||||||
|
eventData.data.chore.id,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'subtask.updated':
|
||||||
|
case 'subtask.completed':
|
||||||
|
// Invalidate the specific chore that contains this subtask
|
||||||
|
if (eventData.data.choreId) {
|
||||||
|
queryClient.invalidateQueries(['chore', eventData.data.choreId])
|
||||||
|
queryClient.invalidateQueries([
|
||||||
|
'choreDetails',
|
||||||
|
eventData.data.choreId,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
// Also invalidate general chores list
|
||||||
|
queryClient.invalidateQueries(['chores'])
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'heartbeat':
|
||||||
|
// Heartbeat events don't need cache invalidation
|
||||||
|
console.debug('SSE Heartbeat received')
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'connection.established':
|
||||||
|
console.log('SSE connection established')
|
||||||
|
setError(null)
|
||||||
|
lastHeartbeatRef.current = Date.now()
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
console.error('SSE error event:', eventData.data)
|
||||||
|
setError(eventData.data.message || 'SSE error occurred')
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log('Unknown SSE event type:', eventData.type)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to parse SSE message:', err)
|
||||||
|
setError('Failed to parse server message')
|
||||||
|
return // Stop processing if JSON parsing fails
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[queryClient],
|
||||||
|
)
|
||||||
|
|
||||||
|
const stopHeartbeatMonitor = useCallback(() => {
|
||||||
|
if (heartbeatMonitorRef.current) {
|
||||||
|
clearInterval(heartbeatMonitorRef.current)
|
||||||
|
heartbeatMonitorRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Create connect function that can be called from anywhere
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
if (isCircuitBreakerOpen) {
|
||||||
|
console.log('SSE: Circuit breaker is open, preventing connection attempt')
|
||||||
|
setError(
|
||||||
|
'Connection blocked due to repeated failures. Please try again later.',
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reconnectAttemptsRef.current >= MAX_RECONNECT_ATTEMPTS) {
|
||||||
|
console.error(
|
||||||
|
'SSE: Maximum reconnection attempts reached, opening circuit breaker',
|
||||||
|
)
|
||||||
|
setIsCircuitBreakerOpen(true)
|
||||||
|
setError(
|
||||||
|
'Maximum connection attempts reached. SSE disabled for 5 minutes.',
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reset circuit breaker after timeout
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('SSE: Resetting circuit breaker')
|
||||||
|
setIsCircuitBreakerOpen(false)
|
||||||
|
reconnectAttemptsRef.current = 0
|
||||||
|
}, CIRCUIT_BREAKER_RESET_TIME)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent race conditions by checking if already connecting or connected
|
||||||
|
if (eventSourceRef.current?.readyState === SSE_STATES.OPEN) {
|
||||||
|
console.log('SSE: Already connected')
|
||||||
|
return // Already connected
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventSourceRef.current?.readyState === SSE_STATES.CONNECTING) {
|
||||||
|
console.log('SSE: Connection already in progress')
|
||||||
|
return // Already connecting
|
||||||
|
}
|
||||||
|
|
||||||
|
const sseConfig = getSSEUrl()
|
||||||
|
console.log('SSE connect - Config:', sseConfig)
|
||||||
|
|
||||||
|
if (!sseConfig) {
|
||||||
|
console.log('Cannot connect to SSE: missing URL, token, or user profile')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create connection logic inline to avoid circular dependency
|
||||||
|
try {
|
||||||
|
console.log('Connecting to SSE:', sseConfig.url)
|
||||||
|
setConnectionState(SSE_STATES.CONNECTING)
|
||||||
|
isManuallyClosedRef.current = false
|
||||||
|
|
||||||
|
// here use EventSource polyfill with Authorization header as the native EventSource does not support headers
|
||||||
|
// the other option was to pass via query param which is less secure and also there.
|
||||||
|
// TODO: use cookie-based once/if at all i move from local storage to httpOnly cookies.
|
||||||
|
eventSourceRef.current = new EventSourcePolyfill(sseConfig.url, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${sseConfig.token}`,
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Accept: 'text/event-stream',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
eventSourceRef.current.onopen = () => {
|
||||||
|
console.log('SSE connection opened')
|
||||||
|
setConnectionState(SSE_STATES.OPEN)
|
||||||
|
setError(null)
|
||||||
|
reconnectAttemptsRef.current = 0
|
||||||
|
lastHeartbeatRef.current = Date.now()
|
||||||
|
|
||||||
|
// Start heartbeat monitor
|
||||||
|
if (heartbeatMonitorRef.current) {
|
||||||
|
clearInterval(heartbeatMonitorRef.current)
|
||||||
|
}
|
||||||
|
heartbeatMonitorRef.current = setInterval(() => {
|
||||||
|
const timeSinceLastHeartbeat = Date.now() - lastHeartbeatRef.current
|
||||||
|
const heartbeatTimeout = 90000 // 90 seconds
|
||||||
|
|
||||||
|
if (timeSinceLastHeartbeat > heartbeatTimeout) {
|
||||||
|
console.warn(
|
||||||
|
'SSE: No heartbeat received, connection may be stale. Reconnecting...',
|
||||||
|
)
|
||||||
|
if (!isManuallyClosedRef.current) {
|
||||||
|
// Clear current heartbeat monitor before reconnecting
|
||||||
|
stopHeartbeatMonitor()
|
||||||
|
|
||||||
|
// Schedule reconnect
|
||||||
|
if (reconnectTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectTimeoutRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
const attemptIndex = Math.min(
|
||||||
|
reconnectAttemptsRef.current,
|
||||||
|
RECONNECT_INTERVALS.length - 1,
|
||||||
|
)
|
||||||
|
const delay = RECONNECT_INTERVALS[attemptIndex]
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Scheduling SSE reconnect in ${delay}ms (attempt ${
|
||||||
|
reconnectAttemptsRef.current + 1
|
||||||
|
})`,
|
||||||
|
)
|
||||||
|
|
||||||
|
reconnectTimeoutRef.current = setTimeout(() => {
|
||||||
|
reconnectAttemptsRef.current++
|
||||||
|
connect()
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 30000) // Check every 30 seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
eventSourceRef.current.onmessage = handleSSEMessage
|
||||||
|
|
||||||
|
eventSourceRef.current.onerror = error => {
|
||||||
|
console.error('SSE error:', error)
|
||||||
|
setConnectionState(SSE_STATES.CLOSED)
|
||||||
|
stopHeartbeatMonitor()
|
||||||
|
|
||||||
|
if (!isManuallyClosedRef.current) {
|
||||||
|
setError('Connection error occurred')
|
||||||
|
|
||||||
|
// Schedule reconnect
|
||||||
|
if (reconnectTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectTimeoutRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
const attemptIndex = Math.min(
|
||||||
|
reconnectAttemptsRef.current,
|
||||||
|
RECONNECT_INTERVALS.length - 1,
|
||||||
|
)
|
||||||
|
const delay = RECONNECT_INTERVALS[attemptIndex]
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Scheduling SSE reconnect in ${delay}ms (attempt ${
|
||||||
|
reconnectAttemptsRef.current + 1
|
||||||
|
})`,
|
||||||
|
)
|
||||||
|
|
||||||
|
reconnectTimeoutRef.current = setTimeout(() => {
|
||||||
|
reconnectAttemptsRef.current++
|
||||||
|
connect()
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create SSE connection:', err)
|
||||||
|
setError('Failed to establish connection')
|
||||||
|
setConnectionState(SSE_STATES.CLOSED)
|
||||||
|
}
|
||||||
|
}, [getSSEUrl, handleSSEMessage, stopHeartbeatMonitor, isCircuitBreakerOpen])
|
||||||
|
|
||||||
|
const disconnect = useCallback(() => {
|
||||||
|
isManuallyClosedRef.current = true
|
||||||
|
|
||||||
|
if (reconnectTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectTimeoutRef.current)
|
||||||
|
reconnectTimeoutRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
stopHeartbeatMonitor()
|
||||||
|
|
||||||
|
if (eventSourceRef.current) {
|
||||||
|
eventSourceRef.current.close()
|
||||||
|
eventSourceRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
setConnectionState(SSE_STATES.CLOSED)
|
||||||
|
}, [stopHeartbeatMonitor])
|
||||||
|
|
||||||
|
const toggleSSEEnabled = useCallback(
|
||||||
|
enabled => {
|
||||||
|
console.log('SSE toggleSSEEnabled called:', {
|
||||||
|
enabled,
|
||||||
|
isTokenValid: isTokenValid(),
|
||||||
|
})
|
||||||
|
localStorage.setItem('sse_enabled', enabled.toString())
|
||||||
|
if (enabled && isTokenValid()) {
|
||||||
|
console.log('SSE toggleSSEEnabled: Calling connect()')
|
||||||
|
connect()
|
||||||
|
} else {
|
||||||
|
console.log('SSE toggleSSEEnabled: Calling disconnect()')
|
||||||
|
disconnect()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[connect, disconnect],
|
||||||
|
)
|
||||||
|
|
||||||
|
const isSSEEnabled = useCallback(() => {
|
||||||
|
return localStorage.getItem('sse_enabled') === 'true'
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Auto-connect when SSE is enabled and token is valid
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('SSE auto-connect effect triggered')
|
||||||
|
console.log('Token valid:', isTokenValid())
|
||||||
|
|
||||||
|
// Check if SSE is enabled in settings
|
||||||
|
const isSSEEnabledSetting = localStorage.getItem('sse_enabled') === 'true'
|
||||||
|
console.log('SSE enabled in settings:', isSSEEnabledSetting)
|
||||||
|
|
||||||
|
if (isTokenValid() && isSSEEnabledSetting) {
|
||||||
|
console.log('SSE: Conditions met, attempting to connect')
|
||||||
|
connect()
|
||||||
|
} else {
|
||||||
|
console.log('SSE: Conditions not met, disconnecting')
|
||||||
|
disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
return () => {
|
||||||
|
disconnect()
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []) // Only run once on mount
|
||||||
|
|
||||||
|
// Cleanup timeouts on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (reconnectTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectTimeoutRef.current)
|
||||||
|
}
|
||||||
|
stopHeartbeatMonitor()
|
||||||
|
}
|
||||||
|
}, [stopHeartbeatMonitor])
|
||||||
|
|
||||||
|
// Handle visibility changes for better performance
|
||||||
|
useEffect(() => {
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
// App went to background, maintain connection but log the state
|
||||||
|
console.log(
|
||||||
|
'SSE: App backgrounded, maintaining connection but reducing activity',
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// App came to foreground, ensure connection is active
|
||||||
|
console.log('SSE: App foregrounded, ensuring connection is active')
|
||||||
|
|
||||||
|
const isSSEEnabledSetting =
|
||||||
|
localStorage.getItem('sse_enabled') === 'true'
|
||||||
|
if (
|
||||||
|
isTokenValid() &&
|
||||||
|
isSSEEnabledSetting &&
|
||||||
|
connectionState !== SSE_STATES.OPEN
|
||||||
|
) {
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
}
|
||||||
|
}, [connectionState, connect])
|
||||||
|
|
||||||
|
return {
|
||||||
|
connectionState,
|
||||||
|
isConnected: connectionState === SSE_STATES.OPEN,
|
||||||
|
isConnecting: connectionState === SSE_STATES.CONNECTING,
|
||||||
|
lastEvent,
|
||||||
|
error,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
toggleSSEEnabled,
|
||||||
|
isSSEEnabled,
|
||||||
|
// Helper function to check connection status
|
||||||
|
getConnectionStatus: () => {
|
||||||
|
switch (connectionState) {
|
||||||
|
case SSE_STATES.CONNECTING:
|
||||||
|
return 'connecting'
|
||||||
|
case SSE_STATES.OPEN:
|
||||||
|
return 'connected'
|
||||||
|
case SSE_STATES.CLOSED:
|
||||||
|
default:
|
||||||
|
return 'disconnected'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/hooks/useSSEContext.js
Normal file
6
src/hooks/useSSEContext.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { useContext } from 'react'
|
||||||
|
import { SSEContext } from '../contexts/SSEContext'
|
||||||
|
|
||||||
|
export const useSSEContext = () => {
|
||||||
|
return useContext(SSEContext)
|
||||||
|
}
|
||||||
313
src/hooks/useWebSocket.js
Normal file
313
src/hooks/useWebSocket.js
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { apiManager, isTokenValid } from '../utils/TokenManager'
|
||||||
|
|
||||||
|
const WEBSOCKET_STATES = {
|
||||||
|
CONNECTING: 0,
|
||||||
|
OPEN: 1,
|
||||||
|
CLOSING: 2,
|
||||||
|
CLOSED: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECONNECT_INTERVALS = [1000, 2000, 5000, 10000, 30000] // Progressive backoff
|
||||||
|
|
||||||
|
export const useWebSocket = () => {
|
||||||
|
const [connectionState, setConnectionState] = useState(
|
||||||
|
WEBSOCKET_STATES.CLOSED,
|
||||||
|
)
|
||||||
|
const [lastEvent, setLastEvent] = useState(null)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
const wsRef = useRef(null)
|
||||||
|
const reconnectTimeoutRef = useRef(null)
|
||||||
|
const reconnectAttemptsRef = useRef(0)
|
||||||
|
const isManuallyClosedRef = useRef(false)
|
||||||
|
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const getWebSocketUrl = useCallback(() => {
|
||||||
|
const token = localStorage.getItem('ca_token')
|
||||||
|
if (!token || !isTokenValid()) {
|
||||||
|
console.log('WebSocket: No valid authentication token')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiUrl = apiManager.getApiURL()
|
||||||
|
|
||||||
|
// Convert HTTP/HTTPS to WebSocket protocol and remove /api/v1 suffix
|
||||||
|
let wsUrl = apiUrl.replace(/\/api\/v1$/, '')
|
||||||
|
if (wsUrl.startsWith('http://')) {
|
||||||
|
wsUrl = wsUrl.replace('http://', 'ws://')
|
||||||
|
} else if (wsUrl.startsWith('https://')) {
|
||||||
|
wsUrl = wsUrl.replace('https://', 'wss://')
|
||||||
|
} else {
|
||||||
|
const isHttps = window.location.protocol === 'https:'
|
||||||
|
wsUrl = `${isHttps ? 'wss:' : 'ws:'}//${wsUrl}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let backend determine circle from authenticated user
|
||||||
|
wsUrl = `${wsUrl}/api/v1/realtime/ws?token=${token}`
|
||||||
|
|
||||||
|
return wsUrl
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleWebSocketMessage = useCallback(
|
||||||
|
event => {
|
||||||
|
try {
|
||||||
|
const eventData = JSON.parse(event.data)
|
||||||
|
setLastEvent(eventData)
|
||||||
|
|
||||||
|
console.debug('WebSocket event received:', eventData.type, eventData)
|
||||||
|
|
||||||
|
// Handle different event types and update React Query cache accordingly
|
||||||
|
switch (eventData.type) {
|
||||||
|
case 'chore.created':
|
||||||
|
case 'chore.updated':
|
||||||
|
case 'chore.completed':
|
||||||
|
case 'chore.skipped':
|
||||||
|
case 'chore.deleted':
|
||||||
|
// Invalidate chores queries to refetch data
|
||||||
|
queryClient.invalidateQueries(['chores'])
|
||||||
|
|
||||||
|
// If it's a specific chore event, also invalidate that chore's details
|
||||||
|
if (eventData.data.chore?.id) {
|
||||||
|
queryClient.invalidateQueries(['chore', eventData.data.chore.id])
|
||||||
|
queryClient.invalidateQueries([
|
||||||
|
'choreDetails',
|
||||||
|
eventData.data.chore.id,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
// expire the history so feed on dashboard gert updated :
|
||||||
|
// need to find a better way to do this as we don't need to do it with every single update for anything
|
||||||
|
// but not sure if i can do it with the fall-through switch case in javascript :)
|
||||||
|
queryClient.invalidateQueries(['choresHistory', 7])
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'subtask.updated':
|
||||||
|
case 'subtask.completed':
|
||||||
|
// Invalidate the specific chore that contains this subtask
|
||||||
|
if (eventData.data.choreId) {
|
||||||
|
queryClient.invalidateQueries(['chore', eventData.data.choreId])
|
||||||
|
queryClient.invalidateQueries([
|
||||||
|
'choreDetails',
|
||||||
|
eventData.data.choreId,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
// Also invalidate general chores list
|
||||||
|
queryClient.invalidateQueries(['chores'])
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'heartbeat':
|
||||||
|
// Heartbeat events don't need cache invalidation
|
||||||
|
console.debug('Heartbeat!')
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'connection.established':
|
||||||
|
console.log('WebSocket connection established')
|
||||||
|
setError(null)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
console.error('WebSocket error event:', eventData.data)
|
||||||
|
setError(eventData.data.message || 'WebSocket error occurred')
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log('Unknown WebSocket event type:', eventData.type)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to parse WebSocket message:', err)
|
||||||
|
setError('Failed to parse server message')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[queryClient],
|
||||||
|
)
|
||||||
|
|
||||||
|
const createWebSocketConnection = useCallback(
|
||||||
|
wsUrl => {
|
||||||
|
try {
|
||||||
|
setConnectionState(WEBSOCKET_STATES.CONNECTING)
|
||||||
|
isManuallyClosedRef.current = false
|
||||||
|
|
||||||
|
// Use query parameter authentication (token already included in URL)
|
||||||
|
wsRef.current = new WebSocket(wsUrl)
|
||||||
|
|
||||||
|
wsRef.current.onopen = () => {
|
||||||
|
setConnectionState(WEBSOCKET_STATES.OPEN)
|
||||||
|
setError(null)
|
||||||
|
reconnectAttemptsRef.current = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
wsRef.current.onmessage = handleWebSocketMessage
|
||||||
|
|
||||||
|
wsRef.current.onerror = error => {
|
||||||
|
console.error('WebSocket error:', error)
|
||||||
|
setError('Connection error occurred')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create WebSocket connection:', err)
|
||||||
|
setError('Failed to establish connection')
|
||||||
|
setConnectionState(WEBSOCKET_STATES.CLOSED)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleWebSocketMessage],
|
||||||
|
)
|
||||||
|
|
||||||
|
const scheduleReconnect = useCallback(() => {
|
||||||
|
if (reconnectTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectTimeoutRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
const attemptIndex = Math.min(
|
||||||
|
reconnectAttemptsRef.current,
|
||||||
|
RECONNECT_INTERVALS.length - 1,
|
||||||
|
)
|
||||||
|
const delay = RECONNECT_INTERVALS[attemptIndex]
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Scheduling WebSocket reconnect in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`,
|
||||||
|
)
|
||||||
|
|
||||||
|
reconnectTimeoutRef.current = setTimeout(() => {
|
||||||
|
reconnectAttemptsRef.current++
|
||||||
|
// Trigger reconnection
|
||||||
|
const wsUrl = getWebSocketUrl()
|
||||||
|
if (wsUrl && wsRef.current?.readyState !== WEBSOCKET_STATES.OPEN) {
|
||||||
|
createWebSocketConnection(wsUrl)
|
||||||
|
}
|
||||||
|
}, delay)
|
||||||
|
}, [getWebSocketUrl, createWebSocketConnection])
|
||||||
|
|
||||||
|
// Set up the onclose handler separately to avoid circular dependency
|
||||||
|
useEffect(() => {
|
||||||
|
if (wsRef.current) {
|
||||||
|
wsRef.current.onclose = event => {
|
||||||
|
console.log('WebSocket connection closed:', event.code, event.reason)
|
||||||
|
setConnectionState(WEBSOCKET_STATES.CLOSED)
|
||||||
|
|
||||||
|
// Handle different close codes
|
||||||
|
if (event.code === 4000) {
|
||||||
|
setError('Authentication failed - please refresh the page')
|
||||||
|
return // Don't attempt to reconnect for auth failures
|
||||||
|
} else if (event.code === 4001) {
|
||||||
|
setError('Authorization failed - check circle access')
|
||||||
|
return // Don't attempt to reconnect for auth failures
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to reconnect if not manually closed
|
||||||
|
if (!isManuallyClosedRef.current && event.code !== 1000) {
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [scheduleReconnect])
|
||||||
|
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
if (wsRef.current?.readyState === WEBSOCKET_STATES.OPEN) {
|
||||||
|
console.log('WebSocket: Already connected')
|
||||||
|
return // Already connected
|
||||||
|
}
|
||||||
|
|
||||||
|
const wsUrl = getWebSocketUrl()
|
||||||
|
console.log('WebSocket connect - URL:', wsUrl)
|
||||||
|
|
||||||
|
if (!wsUrl) {
|
||||||
|
console.log(
|
||||||
|
'Cannot connect to WebSocket: missing URL, token, or user profile',
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
createWebSocketConnection(wsUrl)
|
||||||
|
}, [getWebSocketUrl, createWebSocketConnection])
|
||||||
|
|
||||||
|
const disconnect = useCallback(() => {
|
||||||
|
isManuallyClosedRef.current = true
|
||||||
|
|
||||||
|
if (reconnectTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectTimeoutRef.current)
|
||||||
|
reconnectTimeoutRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wsRef.current) {
|
||||||
|
wsRef.current.close(1000, 'Manual disconnect')
|
||||||
|
wsRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
setConnectionState(WEBSOCKET_STATES.CLOSED)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleWebSocketEnabled = useCallback(
|
||||||
|
enabled => {
|
||||||
|
localStorage.setItem('websocket_enabled', enabled.toString())
|
||||||
|
if (enabled && isTokenValid()) {
|
||||||
|
connect()
|
||||||
|
} else {
|
||||||
|
disconnect()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[connect, disconnect],
|
||||||
|
)
|
||||||
|
|
||||||
|
const isWebSocketEnabled = useCallback(() => {
|
||||||
|
return localStorage.getItem('websocket_enabled') !== 'false'
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Auto-connect when WebSocket is enabled and token is valid
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if WebSocket is enabled in settings
|
||||||
|
const isWebSocketEnabledSetting =
|
||||||
|
localStorage.getItem('websocket_enabled') !== 'false'
|
||||||
|
console.log('WebSocket enabled in settings:', isWebSocketEnabledSetting)
|
||||||
|
|
||||||
|
if (isTokenValid() && isWebSocketEnabledSetting) {
|
||||||
|
console.log('WebSocket: Conditions met, attempting to connect')
|
||||||
|
connect()
|
||||||
|
} else {
|
||||||
|
console.log('WebSocket: Conditions not met, disconnecting')
|
||||||
|
disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
return () => {
|
||||||
|
disconnect()
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []) // Only run once on mount
|
||||||
|
|
||||||
|
// Cleanup timeouts on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (reconnectTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectTimeoutRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
connectionState,
|
||||||
|
isConnected: connectionState === WEBSOCKET_STATES.OPEN,
|
||||||
|
isConnecting: connectionState === WEBSOCKET_STATES.CONNECTING,
|
||||||
|
lastEvent,
|
||||||
|
error,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
toggleWebSocketEnabled,
|
||||||
|
isWebSocketEnabled,
|
||||||
|
// Helper function to check connection status
|
||||||
|
getConnectionStatus: () => {
|
||||||
|
switch (connectionState) {
|
||||||
|
case WEBSOCKET_STATES.CONNECTING:
|
||||||
|
return 'connecting'
|
||||||
|
case WEBSOCKET_STATES.OPEN:
|
||||||
|
return 'connected'
|
||||||
|
case WEBSOCKET_STATES.CLOSING:
|
||||||
|
return 'disconnecting'
|
||||||
|
case WEBSOCKET_STATES.CLOSED:
|
||||||
|
default:
|
||||||
|
return 'disconnected'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { Error } from '@mui/icons-material'
|
|
||||||
import { Box, Button, Snackbar, Typography } from '@mui/joy'
|
|
||||||
import React, { createContext, useContext, useState } from 'react'
|
|
||||||
|
|
||||||
const ErrorContext = createContext()
|
|
||||||
|
|
||||||
export const useError = () => useContext(ErrorContext)
|
|
||||||
|
|
||||||
export const ErrorProvider = ({ children }) => {
|
|
||||||
const [error, setError] = useState(null)
|
|
||||||
|
|
||||||
const showError = error => {
|
|
||||||
setError(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ErrorContext.Provider value={{ showError }}>
|
|
||||||
{children}
|
|
||||||
<Snackbar
|
|
||||||
open={Boolean(error)}
|
|
||||||
autoHideDuration={6000}
|
|
||||||
onClose={() => setError(null)}
|
|
||||||
startDecorator={<Error color='danger' />}
|
|
||||||
endDecorator={
|
|
||||||
<Button
|
|
||||||
variant='outlined'
|
|
||||||
color='danger'
|
|
||||||
onClick={() => setError(null)}
|
|
||||||
>
|
|
||||||
Dismiss
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{typeof error === 'string' ? (
|
|
||||||
<Typography color='danger' level='body-md'>
|
|
||||||
{error}
|
|
||||||
</Typography>
|
|
||||||
) : (
|
|
||||||
<Box>
|
|
||||||
<Typography color='danger' level='title-sm'>
|
|
||||||
{error?.title}
|
|
||||||
</Typography>
|
|
||||||
<Typography color='danger' level='body-sm'>
|
|
||||||
{error?.message}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Snackbar>
|
|
||||||
</ErrorContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
246
src/service/NotificationProvider.jsx
Normal file
246
src/service/NotificationProvider.jsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import { CheckCircle, Error, Info, Warning } from '@mui/icons-material'
|
||||||
|
import { Box, Button, Snackbar, Typography } from '@mui/joy'
|
||||||
|
import React, { createContext, useContext, useState } from 'react'
|
||||||
|
|
||||||
|
const NotificationContext = createContext()
|
||||||
|
|
||||||
|
export const useNotification = () => useContext(NotificationContext)
|
||||||
|
|
||||||
|
// For backward compatibility
|
||||||
|
export const useError = () => {
|
||||||
|
const { showError } = useNotification()
|
||||||
|
return { showError }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notification types configuration with default titles
|
||||||
|
const NOTIFICATION_TYPES = {
|
||||||
|
error: {
|
||||||
|
color: 'danger',
|
||||||
|
icon: <Error color='danger' />,
|
||||||
|
autoHideDuration: 6000,
|
||||||
|
showDismissButton: true,
|
||||||
|
defaultTitle: 'Error',
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
color: 'success',
|
||||||
|
icon: <CheckCircle color='success' />,
|
||||||
|
autoHideDuration: 3000,
|
||||||
|
showDismissButton: false,
|
||||||
|
defaultTitle: 'Success',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
color: 'warning',
|
||||||
|
icon: <Warning color='warning' />,
|
||||||
|
autoHideDuration: 4000,
|
||||||
|
showDismissButton: false,
|
||||||
|
defaultTitle: 'Warning',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
color: 'primary',
|
||||||
|
icon: <Info color='primary' />,
|
||||||
|
autoHideDuration: 4000,
|
||||||
|
showDismissButton: false,
|
||||||
|
defaultTitle: 'Information',
|
||||||
|
},
|
||||||
|
custom: {
|
||||||
|
color: 'neutral',
|
||||||
|
icon: null,
|
||||||
|
autoHideDuration: null,
|
||||||
|
showDismissButton: false,
|
||||||
|
defaultTitle: 'Notification',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NotificationProvider = ({ children }) => {
|
||||||
|
const [notifications, setNotifications] = useState([])
|
||||||
|
|
||||||
|
const addNotification = notification => {
|
||||||
|
const id = Date.now() + Math.random()
|
||||||
|
const newNotification = {
|
||||||
|
id,
|
||||||
|
...notification,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
setNotifications(prev => [...prev, newNotification])
|
||||||
|
|
||||||
|
// Auto-remove notification if it has a duration
|
||||||
|
const config =
|
||||||
|
NOTIFICATION_TYPES[notification.type] || NOTIFICATION_TYPES.info
|
||||||
|
if (config.autoHideDuration) {
|
||||||
|
setTimeout(() => {
|
||||||
|
removeNotification(id)
|
||||||
|
}, config.autoHideDuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeNotification = id => {
|
||||||
|
setNotifications(prev => prev.filter(n => n.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearAllNotifications = () => {
|
||||||
|
setNotifications([])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to normalize notification input
|
||||||
|
const normalizeNotification = (input, type) => {
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
message: input,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof input === 'object' && input !== null) {
|
||||||
|
// If it's already a properly structured notification
|
||||||
|
if (input.title || input.message) {
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
...input,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a simple object with just message content
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
message: input.message || input.toString(),
|
||||||
|
title: input.title,
|
||||||
|
...input,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
message: input?.toString() || 'Unknown notification',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unified notification method
|
||||||
|
const showNotification = notification => {
|
||||||
|
// Handle different input formats
|
||||||
|
if (typeof notification === 'string') {
|
||||||
|
return addNotification(normalizeNotification(notification, 'info'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return addNotification(
|
||||||
|
normalizeNotification(notification, notification.type || 'info'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Specific notification methods with enhanced language
|
||||||
|
const showError = error => {
|
||||||
|
return addNotification(normalizeNotification(error, 'error'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const showSuccess = message => {
|
||||||
|
return addNotification(normalizeNotification(message, 'success'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const showWarning = message => {
|
||||||
|
return addNotification(normalizeNotification(message, 'warning'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const showInfo = message => {
|
||||||
|
return addNotification(normalizeNotification(message, 'info'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderNotification = notification => {
|
||||||
|
const config =
|
||||||
|
NOTIFICATION_TYPES[notification.type] || NOTIFICATION_TYPES.info
|
||||||
|
|
||||||
|
// Handle custom notifications with components
|
||||||
|
if (notification.type === 'custom' && notification.component) {
|
||||||
|
return (
|
||||||
|
<Snackbar
|
||||||
|
key={notification.id}
|
||||||
|
open={true}
|
||||||
|
onClose={() => removeNotification(notification.id)}
|
||||||
|
anchorOrigin={
|
||||||
|
notification.anchorOrigin || {
|
||||||
|
vertical: 'bottom',
|
||||||
|
horizontal: 'right',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{...(notification.snackbarProps || {})}
|
||||||
|
>
|
||||||
|
{React.cloneElement(notification.component, {
|
||||||
|
onClose: () => removeNotification(notification.id),
|
||||||
|
...notification.componentProps,
|
||||||
|
})}
|
||||||
|
</Snackbar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle standard notifications
|
||||||
|
// Determine the icon to use
|
||||||
|
const notificationIcon = notification.icon || config.icon
|
||||||
|
|
||||||
|
// Determine title and message
|
||||||
|
const title = notification.title || config.defaultTitle
|
||||||
|
const message = notification.message
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Snackbar
|
||||||
|
key={notification.id}
|
||||||
|
open={true}
|
||||||
|
autoHideDuration={config.autoHideDuration}
|
||||||
|
onClose={() => removeNotification(notification.id)}
|
||||||
|
startDecorator={notificationIcon}
|
||||||
|
endDecorator={
|
||||||
|
config.showDismissButton ? (
|
||||||
|
<Button
|
||||||
|
variant='outlined'
|
||||||
|
color={config.color}
|
||||||
|
onClick={() => removeNotification(notification.id)}
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
anchorOrigin={
|
||||||
|
notification.anchorOrigin || {
|
||||||
|
vertical: 'bottom',
|
||||||
|
horizontal: 'right',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{...(notification.snackbarProps || {})}
|
||||||
|
>
|
||||||
|
{/* Enhanced structure like ErrorProvider - always show title and message for consistency */}
|
||||||
|
{title && message ? (
|
||||||
|
<Box>
|
||||||
|
<Typography color={config.color} level='title-sm'>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
<Typography color={config.color} level='body-sm'>
|
||||||
|
{message}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Typography color={config.color} level='body-md'>
|
||||||
|
{message || title || 'Notification'}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Snackbar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NotificationContext.Provider
|
||||||
|
value={{
|
||||||
|
showNotification,
|
||||||
|
showError,
|
||||||
|
showSuccess,
|
||||||
|
showWarning,
|
||||||
|
showInfo,
|
||||||
|
removeNotification,
|
||||||
|
clearAllNotifications,
|
||||||
|
notifications,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{notifications.map(renderNotification)}
|
||||||
|
</NotificationContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -80,11 +80,22 @@ export const TASK_COLOR = {
|
|||||||
ASSIGNED_TO_OTHER: '#b39ddb',
|
ASSIGNED_TO_OTHER: '#b39ddb',
|
||||||
|
|
||||||
// FOR PRIORITY:
|
// FOR PRIORITY:
|
||||||
PRIORITY_1: '#F03A47',
|
// PRIORITY_1: '#F03A47',
|
||||||
PRIORITY_2: '#ffc107',
|
// PRIORITY_2: '#ffc107',
|
||||||
PRIORITY_3: '#00bcd4',
|
// PRIORITY_3: '#00bcd4',
|
||||||
PRIORITY_4: '#7e57c2',
|
// PRIORITY_4: '#7e57c2',
|
||||||
NO_PRIORITY: '#90a4ae',
|
// NO_PRIORITY: '#90a4ae',
|
||||||
|
// FOR PRIORITY:
|
||||||
|
// PRIORITY_1: '#F03A4780',
|
||||||
|
// PRIORITY_2: '#ffc10780',
|
||||||
|
// PRIORITY_3: '#00bcd480',
|
||||||
|
// PRIORITY_4: '#7e57c280',
|
||||||
|
PRIORITY_1: '#d32f2f',
|
||||||
|
PRIORITY_2: '#ed6c02',
|
||||||
|
PRIORITY_3: '#0288d1',
|
||||||
|
// PRIORITY_4: '#388e3c',
|
||||||
|
PRIORITY_4: '#90a4ae',
|
||||||
|
// NO_PRIORITY: '#90a4ae80',
|
||||||
}
|
}
|
||||||
export default LABEL_COLORS
|
export default LABEL_COLORS
|
||||||
|
|
||||||
|
|||||||
@@ -146,7 +146,10 @@ const UpdateChoreAssignee = (id, assignee) => {
|
|||||||
return Fetch(`/chores/${id}/assignee`, {
|
return Fetch(`/chores/${id}/assignee`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: HEADERS(),
|
headers: HEADERS(),
|
||||||
body: JSON.stringify({ assignee: Number(assignee) }),
|
body: JSON.stringify({
|
||||||
|
assignee: Number(assignee),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,6 +502,7 @@ const UpdateDueDate = (id, dueDate) => {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
dueDate: dueDate ? new Date(dueDate).toISOString() : null,
|
dueDate: dueDate ? new Date(dueDate).toISOString() : null,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,21 +8,19 @@ import {
|
|||||||
FormHelperText,
|
FormHelperText,
|
||||||
Input,
|
Input,
|
||||||
Sheet,
|
Sheet,
|
||||||
Snackbar,
|
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { API_URL } from './../../Config'
|
import { useNotification } from '../../service/NotificationProvider'
|
||||||
import { ResetPassword } from '../../utils/Fetcher'
|
import { ResetPassword } from '../../utils/Fetcher'
|
||||||
|
|
||||||
const ForgotPasswordView = () => {
|
const ForgotPasswordView = () => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
// const [showLoginSnackbar, setShowLoginSnackbar] = useState(false)
|
|
||||||
// const [snackbarMessage, setSnackbarMessage] = useState('')
|
|
||||||
const [resetStatusOk, setResetStatusOk] = useState(null)
|
const [resetStatusOk, setResetStatusOk] = useState(null)
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [emailError, setEmailError] = useState(null)
|
const [emailError, setEmailError] = useState(null)
|
||||||
|
const { showError, showNotification } = useNotification()
|
||||||
|
|
||||||
const validateEmail = email => {
|
const validateEmail = email => {
|
||||||
return !/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(email)
|
return !/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(email)
|
||||||
@@ -48,12 +46,24 @@ const ForgotPasswordView = () => {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setResetStatusOk(true)
|
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 {
|
} else {
|
||||||
setResetStatusOk(false)
|
setResetStatusOk(false)
|
||||||
|
showError({
|
||||||
|
title: 'Reset Failed',
|
||||||
|
message: 'Failed to send reset email, please try again later',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setResetStatusOk(false)
|
setResetStatusOk(false)
|
||||||
|
showError({
|
||||||
|
title: 'Reset Failed',
|
||||||
|
message: 'Failed to send reset email, please try again later',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,19 +205,6 @@ const ForgotPasswordView = () => {
|
|||||||
</Button>
|
</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>
|
</Sheet>
|
||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -1,23 +1,15 @@
|
|||||||
import { Preferences } from '@capacitor/preferences'
|
import { Preferences } from '@capacitor/preferences'
|
||||||
import {
|
import { Box, Button, Container, Input, Sheet, Typography } from '@mui/joy'
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Container,
|
|
||||||
Input,
|
|
||||||
Sheet,
|
|
||||||
Snackbar,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/joy'
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { API_URL } from '../../Config'
|
import { API_URL } from '../../Config'
|
||||||
import Logo from '../../Logo'
|
import Logo from '../../Logo'
|
||||||
|
import { useNotification } from '../../service/NotificationProvider'
|
||||||
import { apiManager } from '../../utils/TokenManager'
|
import { apiManager } from '../../utils/TokenManager'
|
||||||
const LoginSettings = () => {
|
const LoginSettings = () => {
|
||||||
const [error, setError] = React.useState(null)
|
|
||||||
const Navigate = useNavigate()
|
const Navigate = useNavigate()
|
||||||
|
|
||||||
const [serverURL, setServerURL] = React.useState('')
|
const [serverURL, setServerURL] = React.useState('')
|
||||||
|
const { showError } = useNotification()
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
Preferences.get({ key: 'customServerUrl' }).then(result => {
|
Preferences.get({ key: 'customServerUrl' }).then(result => {
|
||||||
@@ -112,7 +104,11 @@ const LoginSettings = () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!isValidServerURL()) {
|
if (!isValidServerURL()) {
|
||||||
setError('Invalid server URL')
|
showError({
|
||||||
|
title: 'Invalid Server URL',
|
||||||
|
message:
|
||||||
|
'Please enter a valid server URL with protocol (http:// or https://)',
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Preferences.set({
|
Preferences.set({
|
||||||
@@ -150,14 +146,6 @@ const LoginSettings = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
</Box>
|
</Box>
|
||||||
<Snackbar
|
|
||||||
open={error !== null}
|
|
||||||
onClose={() => setError(null)}
|
|
||||||
autoHideDuration={3000}
|
|
||||||
message={error}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</Snackbar>
|
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
IconButton,
|
IconButton,
|
||||||
Input,
|
Input,
|
||||||
Sheet,
|
Sheet,
|
||||||
Snackbar,
|
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import Cookies from 'js-cookie'
|
import Cookies from 'js-cookie'
|
||||||
@@ -22,21 +21,21 @@ import { LoginSocialGoogle } from 'reactjs-social-login'
|
|||||||
import { GOOGLE_CLIENT_ID, REDIRECT_URL } from '../../Config'
|
import { GOOGLE_CLIENT_ID, REDIRECT_URL } from '../../Config'
|
||||||
import Logo from '../../Logo'
|
import Logo from '../../Logo'
|
||||||
import { useResource } from '../../queries/ResourceQueries'
|
import { useResource } from '../../queries/ResourceQueries'
|
||||||
import { useUserProfile } from '../../queries/UserQueries'
|
import { useNotification } from '../../service/NotificationProvider'
|
||||||
import { login } from '../../utils/Fetcher'
|
import { login } from '../../utils/Fetcher'
|
||||||
import { apiManager } from '../../utils/TokenManager'
|
import { apiManager } from '../../utils/TokenManager'
|
||||||
import MFAVerificationModal from './MFAVerificationModal'
|
import MFAVerificationModal from './MFAVerificationModal'
|
||||||
|
|
||||||
const LoginView = () => {
|
const LoginView = () => {
|
||||||
// Only fetch user profile if token is valid to prevent unnecessary queries
|
// 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 [userProfile, setUserProfile] = useState(null)
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [error, setError] = useState(null)
|
|
||||||
const [mfaModalOpen, setMfaModalOpen] = useState(false)
|
const [mfaModalOpen, setMfaModalOpen] = useState(false)
|
||||||
const [mfaSessionToken, setMfaSessionToken] = useState('')
|
const [mfaSessionToken, setMfaSessionToken] = useState('')
|
||||||
const { data: resource } = useResource()
|
const { data: resource } = useResource()
|
||||||
|
const { showError } = useNotification()
|
||||||
const Navigate = useNavigate()
|
const Navigate = useNavigate()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initializeSocialLogin = async () => {
|
const initializeSocialLogin = async () => {
|
||||||
@@ -76,14 +75,23 @@ const LoginView = () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else if (response.status === 401) {
|
} else if (response.status === 401) {
|
||||||
setError('Wrong username or password')
|
showError({
|
||||||
|
title: 'Login Failed',
|
||||||
|
message: 'Wrong username or password',
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
setError('An error occurred, please try again')
|
showError({
|
||||||
|
title: 'Login Failed',
|
||||||
|
message: 'An error occurred, please try again',
|
||||||
|
})
|
||||||
console.log('Login failed')
|
console.log('Login failed')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.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)
|
console.log('Login failed', err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -133,7 +141,10 @@ const LoginView = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
return response.json().then(() => {
|
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 => {
|
const handleMFAError = errorMessage => {
|
||||||
setError(errorMessage)
|
showError({
|
||||||
|
title: 'Two-Factor Authentication Failed',
|
||||||
|
message: errorMessage,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMFAClose = () => {
|
const handleMFAClose = () => {
|
||||||
@@ -380,7 +394,11 @@ const LoginView = () => {
|
|||||||
loggedWithProvider(provider, data)
|
loggedWithProvider(provider, data)
|
||||||
}}
|
}}
|
||||||
onReject={() => {
|
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
|
<Button
|
||||||
@@ -466,14 +484,6 @@ const LoginView = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
</Box>
|
</Box>
|
||||||
<Snackbar
|
|
||||||
open={error !== null}
|
|
||||||
onClose={() => setError(null)}
|
|
||||||
autoHideDuration={3000}
|
|
||||||
message={error}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</Snackbar>
|
|
||||||
|
|
||||||
<MFAVerificationModal
|
<MFAVerificationModal
|
||||||
open={mfaModalOpen}
|
open={mfaModalOpen}
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ import {
|
|||||||
FormHelperText,
|
FormHelperText,
|
||||||
Input,
|
Input,
|
||||||
Sheet,
|
Sheet,
|
||||||
Snackbar,
|
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import Logo from '../../Logo'
|
import Logo from '../../Logo'
|
||||||
|
import { useNotification } from '../../service/NotificationProvider'
|
||||||
import { login, signUp } from '../../utils/Fetcher'
|
import { login, signUp } from '../../utils/Fetcher'
|
||||||
|
|
||||||
const SignupView = () => {
|
const SignupView = () => {
|
||||||
@@ -25,9 +25,7 @@ const SignupView = () => {
|
|||||||
const [passwordError, setPasswordError] = React.useState('')
|
const [passwordError, setPasswordError] = React.useState('')
|
||||||
const [emailError, setEmailError] = React.useState('')
|
const [emailError, setEmailError] = React.useState('')
|
||||||
const [displayNameError, setDisplayNameError] = React.useState('')
|
const [displayNameError, setDisplayNameError] = React.useState('')
|
||||||
const [error, setError] = React.useState(null)
|
const { showError } = useNotification()
|
||||||
const [snackbarOpen, setSnackbarOpen] = React.useState(false)
|
|
||||||
const [snackbarMessage, setSnackbarMessage] = React.useState('')
|
|
||||||
const handleLogin = (username, password) => {
|
const handleLogin = (username, password) => {
|
||||||
login(username, password).then(response => {
|
login(username, password).then(response => {
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
@@ -104,11 +102,17 @@ const SignupView = () => {
|
|||||||
if (response.status === 201) {
|
if (response.status === 201) {
|
||||||
handleLogin(username, password)
|
handleLogin(username, password)
|
||||||
} else if (response.status === 403) {
|
} else if (response.status === 403) {
|
||||||
setError('Signup disabled, please contact admin')
|
showError({
|
||||||
|
title: 'Signup Failed',
|
||||||
|
message: 'Signup disabled, please contact admin',
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
console.log('Signup failed')
|
console.log('Signup failed')
|
||||||
response.json().then(res => {
|
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>
|
</Button>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
</Box>
|
</Box>
|
||||||
<Snackbar
|
|
||||||
open={error !== null}
|
|
||||||
onClose={() => setError(null)}
|
|
||||||
autoHideDuration={5000}
|
|
||||||
message={error}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</Snackbar>
|
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ import {
|
|||||||
FormHelperText,
|
FormHelperText,
|
||||||
Input,
|
Input,
|
||||||
Sheet,
|
Sheet,
|
||||||
Snackbar,
|
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
|
|
||||||
import Logo from '../../Logo'
|
import Logo from '../../Logo'
|
||||||
|
import { useNotification } from '../../service/NotificationProvider'
|
||||||
import { ChangePassword } from '../../utils/Fetcher'
|
import { ChangePassword } from '../../utils/Fetcher'
|
||||||
|
|
||||||
const UpdatePasswordView = () => {
|
const UpdatePasswordView = () => {
|
||||||
@@ -24,8 +24,7 @@ const UpdatePasswordView = () => {
|
|||||||
const [passworConfirmationError, setPasswordConfirmationError] =
|
const [passworConfirmationError, setPasswordConfirmationError] =
|
||||||
useState(null)
|
useState(null)
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
|
const { showError, showNotification } = useNotification()
|
||||||
const [updateStatusOk, setUpdateStatusOk] = useState(null)
|
|
||||||
|
|
||||||
const verifiticationCode = searchParams.get('c')
|
const verifiticationCode = searchParams.get('c')
|
||||||
|
|
||||||
@@ -55,16 +54,27 @@ const UpdatePasswordView = () => {
|
|||||||
const response = await ChangePassword(verifiticationCode, password)
|
const response = await ChangePassword(verifiticationCode, password)
|
||||||
|
|
||||||
if (response.ok) {
|
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:
|
// wait 3 seconds and then redirect to login:
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
navigate('/login')
|
navigate('/login')
|
||||||
}, 3000)
|
}, 3000)
|
||||||
} else {
|
} else {
|
||||||
setUpdateStatusOk(false)
|
showError({
|
||||||
|
title: 'Password Update Failed',
|
||||||
|
message: 'Failed to update password, please try again later',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setUpdateStatusOk(false)
|
showError({
|
||||||
|
title: 'Password Update Failed',
|
||||||
|
message: 'Failed to update password, please try again later',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
@@ -169,15 +179,6 @@ const UpdatePasswordView = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
</Box>
|
</Box>
|
||||||
<Snackbar
|
|
||||||
open={updateStatusOk === false}
|
|
||||||
autoHideDuration={6000}
|
|
||||||
onClose={() => {
|
|
||||||
setUpdateStatusOk(null)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Password update failed, try again later
|
|
||||||
</Snackbar>
|
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ import {
|
|||||||
RadioGroup,
|
RadioGroup,
|
||||||
Select,
|
Select,
|
||||||
Sheet,
|
Sheet,
|
||||||
Snackbar,
|
|
||||||
Stack,
|
|
||||||
Switch,
|
Switch,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
@@ -33,6 +31,7 @@ import {
|
|||||||
useUpdateChore,
|
useUpdateChore,
|
||||||
} from '../../queries/ChoreQueries.jsx'
|
} from '../../queries/ChoreQueries.jsx'
|
||||||
import { useUserProfile } from '../../queries/UserQueries.jsx'
|
import { useUserProfile } from '../../queries/UserQueries.jsx'
|
||||||
|
import { useNotification } from '../../service/NotificationProvider'
|
||||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||||
import {
|
import {
|
||||||
DeleteChore,
|
DeleteChore,
|
||||||
@@ -101,9 +100,6 @@ const ChoreEdit = () => {
|
|||||||
const [createdBy, setCreatedBy] = useState(0)
|
const [createdBy, setCreatedBy] = useState(0)
|
||||||
const [errors, setErrors] = useState({})
|
const [errors, setErrors] = useState({})
|
||||||
const [attemptToSave, setAttemptToSave] = useState(false)
|
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 [addLabelModalOpen, setAddLabelModalOpen] = useState(false)
|
||||||
const { data: userLabelsRaw, isLoading: isUserLabelsLoading } = useLabels()
|
const { data: userLabelsRaw, isLoading: isUserLabelsLoading } = useLabels()
|
||||||
const updateChoreMutation = useUpdateChore()
|
const updateChoreMutation = useUpdateChore()
|
||||||
@@ -113,6 +109,7 @@ const ChoreEdit = () => {
|
|||||||
isLoading: isChoreLoading,
|
isLoading: isChoreLoading,
|
||||||
refetch: refetchChore,
|
refetch: refetchChore,
|
||||||
} = useChore(choreId)
|
} = useChore(choreId)
|
||||||
|
const { showSuccess, showError } = useNotification()
|
||||||
|
|
||||||
const [userLabels, setUserLabels] = useState([])
|
const [userLabels, setUserLabels] = useState([])
|
||||||
|
|
||||||
@@ -178,16 +175,10 @@ const ChoreEdit = () => {
|
|||||||
const errorList = Object.keys(errors).map(key => (
|
const errorList = Object.keys(errors).map(key => (
|
||||||
<ListItem key={key}>{errors[key]}</ListItem>
|
<ListItem key={key}>{errors[key]}</ListItem>
|
||||||
))
|
))
|
||||||
setSnackbarMessage(
|
showError({
|
||||||
<Stack spacing={0.5}>
|
title: 'Please resolve the following errors:',
|
||||||
<Typography level='title-md'>
|
message: <List>{errorList}</List>,
|
||||||
Please resolve the following errors:
|
})
|
||||||
</Typography>
|
|
||||||
<List>{errorList}</List>
|
|
||||||
</Stack>,
|
|
||||||
)
|
|
||||||
setSnackbarColor('danger')
|
|
||||||
setIsSnackbarOpen(true)
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,16 +231,18 @@ const ChoreEdit = () => {
|
|||||||
|
|
||||||
SaveFunction(chore)
|
SaveFunction(chore)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setSnackbarColor('success')
|
showSuccess({
|
||||||
setSnackbarMessage('Chore saved successfully!')
|
title: 'Chore Saved',
|
||||||
setIsSnackbarOpen(true)
|
message: 'Your task has been saved successfully!',
|
||||||
|
})
|
||||||
Navigate('/my/chores/')
|
Navigate('/my/chores/')
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error('Failed to save chore:', error)
|
console.error('Failed to save chore:', error)
|
||||||
setSnackbarColor('danger')
|
showError({
|
||||||
setSnackbarMessage('Failed to save chore, please try again.')
|
title: 'Save Failed',
|
||||||
setIsSnackbarOpen(true)
|
message: 'Failed to save chore, please try again.',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1099,20 +1092,6 @@ const ChoreEdit = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* <ChoreHistory ChoreHistory={choresHistory} UsersData={performers} /> */}
|
{/* <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>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { Divider } from '@mui/material'
|
import { Divider } from '@mui/material'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||||
@@ -61,6 +62,7 @@ const ChoreView = () => {
|
|||||||
const [infoCards, setInfoCards] = useState([])
|
const [infoCards, setInfoCards] = useState([])
|
||||||
const { choreId } = useParams()
|
const { choreId } = useParams()
|
||||||
const [note, setNote] = useState(null)
|
const [note, setNote] = useState(null)
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
|
|
||||||
@@ -71,18 +73,12 @@ const ChoreView = () => {
|
|||||||
const [confirmModelConfig, setConfirmModelConfig] = useState({})
|
const [confirmModelConfig, setConfirmModelConfig] = useState({})
|
||||||
const [chorePriority, setChorePriority] = useState(null)
|
const [chorePriority, setChorePriority] = useState(null)
|
||||||
const [isDescriptionOpen, setIsDescriptionOpen] = useState(false)
|
const [isDescriptionOpen, setIsDescriptionOpen] = useState(false)
|
||||||
const {
|
const { data: circleMembersData, isLoading: isCircleMembersLoading } =
|
||||||
data: circleMembersData,
|
useCircleMembers()
|
||||||
isLoading: isCircleMembersLoading,
|
|
||||||
handleRefetch: handleCircleMembersRefetch,
|
|
||||||
} = useCircleMembers()
|
|
||||||
const { impersonatedUser } = useImpersonateUser()
|
const { impersonatedUser } = useImpersonateUser()
|
||||||
|
|
||||||
const {
|
const { data: choreData, isLoading: isChoreLoading } =
|
||||||
data: choreData,
|
useChoreDetails(choreId)
|
||||||
isLoading: isChoreLoading,
|
|
||||||
refetch: refetchChore,
|
|
||||||
} = useChoreDetails(choreId)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!choreData || !choreData.res || !circleMembersData) {
|
if (!choreData || !choreData.res || !circleMembersData) {
|
||||||
@@ -107,8 +103,10 @@ const ChoreView = () => {
|
|||||||
const handleUpdatePriority = priority => {
|
const handleUpdatePriority = priority => {
|
||||||
UpdateChorePriority(choreId, priority.value).then(response => {
|
UpdateChorePriority(choreId, priority.value).then(response => {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
response.json().then(data => {
|
response.json().then(() => {
|
||||||
setChorePriority(priority)
|
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
|
clearInterval(countdownInterval) // Ensure to clear this interval as well
|
||||||
setTimeoutId(null)
|
setTimeoutId(null)
|
||||||
setSecondsLeftToCancel(null)
|
setSecondsLeftToCancel(null)
|
||||||
|
// Invalidate chores cache to refetch data
|
||||||
|
queryClient.invalidateQueries(['chores'])
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// refetch the chore details
|
// refetch the chore details
|
||||||
@@ -216,6 +216,8 @@ const ChoreView = () => {
|
|||||||
response.json().then(data => {
|
response.json().then(data => {
|
||||||
const newChore = data.res
|
const newChore = data.res
|
||||||
setChore(newChore)
|
setChore(newChore)
|
||||||
|
// Invalidate chores cache to refetch data
|
||||||
|
queryClient.invalidateQueries(['chores'])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ import {
|
|||||||
Chip,
|
Chip,
|
||||||
FormControl,
|
FormControl,
|
||||||
Input,
|
Input,
|
||||||
ListItem,
|
|
||||||
ListItemContent,
|
|
||||||
ListItemDecorator,
|
|
||||||
Option,
|
Option,
|
||||||
Select,
|
Select,
|
||||||
TextField,
|
TextField,
|
||||||
@@ -113,7 +110,7 @@ const ThingTriggerSection = ({
|
|||||||
onChange={(e, newValue) => setSelectedThing(newValue)}
|
onChange={(e, newValue) => setSelectedThing(newValue)}
|
||||||
getOptionLabel={option => option.name}
|
getOptionLabel={option => option.name}
|
||||||
renderOption={(props, option) => (
|
renderOption={(props, option) => (
|
||||||
<ListItem {...props}>
|
<Box {...props}>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -123,19 +120,19 @@ const ThingTriggerSection = ({
|
|||||||
p: 1,
|
p: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ListItemDecorator sx={{ alignSelf: 'flex-start' }}>
|
<Box sx={{ alignSelf: 'flex-start' }}>
|
||||||
<Typography level='body-lg' textColor='primary'>
|
<Typography level='body-lg' textColor='primary'>
|
||||||
{option.name}
|
{option.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
</ListItemDecorator>
|
</Box>
|
||||||
<ListItemContent>
|
<Box>
|
||||||
<Typography level='body2' textColor='text.secondary'>
|
<Typography level='body2' textColor='text.secondary'>
|
||||||
<Chip>type: {option.type}</Chip>{' '}
|
<Chip>type: {option.type}</Chip>{' '}
|
||||||
<Chip>state: {option.state}</Chip>
|
<Chip>state: {option.state}</Chip>
|
||||||
</Typography>
|
</Typography>
|
||||||
</ListItemContent>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</ListItem>
|
</Box>
|
||||||
)}
|
)}
|
||||||
renderInput={params => (
|
renderInput={params => (
|
||||||
<TextField {...params} label='Select a thing' />
|
<TextField {...params} label='Select a thing' />
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
|
Checkbox,
|
||||||
Chip,
|
Chip,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Grid,
|
Grid,
|
||||||
@@ -23,7 +24,7 @@ import React from 'react'
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||||
import { useUserProfile } from '../../queries/UserQueries.jsx'
|
import { useUserProfile } from '../../queries/UserQueries.jsx'
|
||||||
import { useError } from '../../service/ErrorProvider'
|
import { useNotification } from '../../service/NotificationProvider'
|
||||||
import { notInCompletionWindow } from '../../utils/Chores.jsx'
|
import { notInCompletionWindow } from '../../utils/Chores.jsx'
|
||||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||||
import {
|
import {
|
||||||
@@ -47,6 +48,10 @@ const ChoreCard = ({
|
|||||||
sx,
|
sx,
|
||||||
viewOnly,
|
viewOnly,
|
||||||
onChipClick,
|
onChipClick,
|
||||||
|
// Multi-select props
|
||||||
|
isMultiSelectMode = false,
|
||||||
|
isSelected = false,
|
||||||
|
onSelectionToggle,
|
||||||
}) => {
|
}) => {
|
||||||
const [isChangeDueDateModalOpen, setIsChangeDueDateModalOpen] =
|
const [isChangeDueDateModalOpen, setIsChangeDueDateModalOpen] =
|
||||||
React.useState(false)
|
React.useState(false)
|
||||||
@@ -67,7 +72,7 @@ const ChoreCard = ({
|
|||||||
|
|
||||||
const { impersonatedUser } = useImpersonateUser()
|
const { impersonatedUser } = useImpersonateUser()
|
||||||
|
|
||||||
const { showError } = useError()
|
const { showError } = useNotification()
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
setConfirmModelConfig({
|
setConfirmModelConfig({
|
||||||
@@ -392,19 +397,71 @@ const ChoreCard = ({
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
p: 2,
|
p: 2,
|
||||||
// backgroundColor: 'white',
|
|
||||||
boxShadow: 'sm',
|
boxShadow: 'sm',
|
||||||
borderRadius: 20,
|
borderRadius: 20,
|
||||||
key: `${chore.id}-card`,
|
key: `${chore.id}-card`,
|
||||||
|
position: 'relative',
|
||||||
// mb: 2,
|
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 container>
|
||||||
<Grid
|
<Grid
|
||||||
xs={9}
|
xs={9}
|
||||||
|
sx={{ cursor: 'pointer' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate(`/chores/${chore.id}`)
|
if (isMultiSelectMode) {
|
||||||
|
onSelectionToggle()
|
||||||
|
} else {
|
||||||
|
navigate(`/chores/${chore.id}`)
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Box in top right with Chip showing next due date */}
|
{/* Box in top right with Chip showing next due date */}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
Checkbox,
|
||||||
Chip,
|
Chip,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
IconButton,
|
IconButton,
|
||||||
@@ -19,16 +20,18 @@ import React from 'react'
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||||
import { useUserProfile } from '../../queries/UserQueries.jsx'
|
import { useUserProfile } from '../../queries/UserQueries.jsx'
|
||||||
import { useError } from '../../service/ErrorProvider'
|
import { useNotification } from '../../service/NotificationProvider'
|
||||||
import { notInCompletionWindow } from '../../utils/Chores.jsx'
|
import { notInCompletionWindow } from '../../utils/Chores.jsx'
|
||||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
import {
|
||||||
|
getTextColorFromBackgroundColor,
|
||||||
|
TASK_COLOR,
|
||||||
|
} from '../../utils/Colors.jsx'
|
||||||
import {
|
import {
|
||||||
DeleteChore,
|
DeleteChore,
|
||||||
MarkChoreComplete,
|
MarkChoreComplete,
|
||||||
UpdateChoreAssignee,
|
UpdateChoreAssignee,
|
||||||
UpdateDueDate,
|
UpdateDueDate,
|
||||||
} from '../../utils/Fetcher'
|
} from '../../utils/Fetcher'
|
||||||
import Priorities from '../../utils/Priorities'
|
|
||||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||||
import DateModal from '../Modals/Inputs/DateModal'
|
import DateModal from '../Modals/Inputs/DateModal'
|
||||||
import SelectModal from '../Modals/Inputs/SelectModal'
|
import SelectModal from '../Modals/Inputs/SelectModal'
|
||||||
@@ -44,6 +47,10 @@ const CompactChoreCard = ({
|
|||||||
sx,
|
sx,
|
||||||
viewOnly,
|
viewOnly,
|
||||||
onChipClick,
|
onChipClick,
|
||||||
|
// Multi-select props
|
||||||
|
isMultiSelectMode = false,
|
||||||
|
isSelected = false,
|
||||||
|
onSelectionToggle,
|
||||||
}) => {
|
}) => {
|
||||||
const [isChangeDueDateModalOpen, setIsChangeDueDateModalOpen] =
|
const [isChangeDueDateModalOpen, setIsChangeDueDateModalOpen] =
|
||||||
React.useState(false)
|
React.useState(false)
|
||||||
@@ -64,7 +71,7 @@ const CompactChoreCard = ({
|
|||||||
|
|
||||||
const { impersonatedUser } = useImpersonateUser()
|
const { impersonatedUser } = useImpersonateUser()
|
||||||
|
|
||||||
const { showError } = useError()
|
const { showError } = useNotification()
|
||||||
|
|
||||||
// All the existing handler methods (same as original ChoreCard)
|
// All the existing handler methods (same as original ChoreCard)
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
@@ -364,6 +371,21 @@ const CompactChoreCard = ({
|
|||||||
return parts.join(' • ')
|
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 (
|
return (
|
||||||
<Box key={chore.id + '-compact-box'}>
|
<Box key={chore.id + '-compact-box'}>
|
||||||
<Box
|
<Box
|
||||||
@@ -372,23 +394,173 @@ const CompactChoreCard = ({
|
|||||||
...sx,
|
...sx,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
// px: 1,
|
|
||||||
// py: 0.75,
|
|
||||||
minHeight: 56, // More compact height
|
minHeight: 56, // More compact height
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
borderBottom: '1px solid',
|
borderBottom: '1px solid',
|
||||||
borderColor: 'divider',
|
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': {
|
'&:hover': {
|
||||||
bgcolor: 'background.level1',
|
bgcolor: 'background.level1',
|
||||||
|
boxShadow: 'sm',
|
||||||
},
|
},
|
||||||
'&:last-child': {
|
'&:last-child': {
|
||||||
borderBottom: 'none',
|
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
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -398,7 +570,7 @@ const CompactChoreCard = ({
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Line 1: Name + Due Date + Frequency */}
|
{/* Line 1: Name + Due Date */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -407,36 +579,35 @@ const CompactChoreCard = ({
|
|||||||
mb: 0.25,
|
mb: 0.25,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
{/* Chore Name */}
|
||||||
|
<Typography
|
||||||
|
level='title-sm'
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
fontWeight: 600,
|
||||||
alignItems: 'center',
|
fontSize: 14,
|
||||||
minWidth: 0,
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
mr: 1,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Chore Name */}
|
{chore.name}
|
||||||
<Typography
|
</Typography>
|
||||||
level='title-sm'
|
|
||||||
sx={{
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: 14,
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
mr: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{chore.name}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Due Date */}
|
{/* Due Date - Inline with name */}
|
||||||
<Chip
|
<Chip
|
||||||
variant='soft'
|
variant='soft'
|
||||||
size='sm'
|
size='sm'
|
||||||
color={getDueDateColor(chore.nextDueDate)}
|
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)}
|
{getDueDateText(chore.nextDueDate)}
|
||||||
</Chip>
|
</Chip>
|
||||||
@@ -458,35 +629,7 @@ const CompactChoreCard = ({
|
|||||||
{formatMetadata()}
|
{formatMetadata()}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{/* Labels */}
|
{/* Labels - Priority chip removed, now shown as vertical bar */}
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
{chore.labelsV2?.map(l => (
|
{chore.labelsV2?.map(l => (
|
||||||
<div
|
<div
|
||||||
role='none'
|
role='none'
|
||||||
@@ -530,39 +673,21 @@ const CompactChoreCard = ({
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Right side - Actions */}
|
{/* Right side - Action Menu with animation */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
transition:
|
||||||
alignItems: 'center',
|
'opacity 0.3s ease-in-out, transform 0.3s ease-in-out, width 0.3s ease-in-out, margin 0.3s ease-in-out',
|
||||||
gap: 0.25,
|
opacity: isMultiSelectMode ? 0 : 1,
|
||||||
flexShrink: 0,
|
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
|
<ChoreActionMenu
|
||||||
variant='plain'
|
variant='plain'
|
||||||
chore={chore}
|
chore={chore}
|
||||||
@@ -577,12 +702,13 @@ const CompactChoreCard = ({
|
|||||||
onWriteNFC={() => setIsNFCModalOpen(true)}
|
onWriteNFC={() => setIsNFCModalOpen(true)}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
sx={{
|
sx={{
|
||||||
width: 28,
|
width: 32,
|
||||||
marginRight: -3,
|
height: 32,
|
||||||
height: 28,
|
color: 'text.tertiary',
|
||||||
// opacity: 0.6,
|
flexShrink: 0,
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
opacity: 0,
|
color: 'text.secondary',
|
||||||
|
bgcolor: 'background.level1',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
198
src/views/Chores/MultiSelectHelp.jsx
Normal file
198
src/views/Chores/MultiSelectHelp.jsx
Normal 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
|
||||||
@@ -1,11 +1,19 @@
|
|||||||
import {
|
import {
|
||||||
Add,
|
Add,
|
||||||
|
Archive,
|
||||||
Bolt,
|
Bolt,
|
||||||
CancelRounded,
|
CancelRounded,
|
||||||
|
CheckBox,
|
||||||
|
CheckBoxOutlineBlank,
|
||||||
|
Close,
|
||||||
|
Delete,
|
||||||
|
Done,
|
||||||
EditCalendar,
|
EditCalendar,
|
||||||
ExpandCircleDown,
|
ExpandCircleDown,
|
||||||
Grain,
|
Grain,
|
||||||
PriorityHigh,
|
PriorityHigh,
|
||||||
|
SelectAll,
|
||||||
|
SkipNext,
|
||||||
Sort,
|
Sort,
|
||||||
Style,
|
Style,
|
||||||
Unarchive,
|
Unarchive,
|
||||||
@@ -26,23 +34,27 @@ import {
|
|||||||
List,
|
List,
|
||||||
Menu,
|
Menu,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Snackbar,
|
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import Fuse from 'fuse.js'
|
import Fuse from 'fuse.js'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useChores } from '../../queries/ChoreQueries'
|
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 Priorities from '../../utils/Priorities'
|
||||||
import LoadingComponent from '../components/Loading'
|
import LoadingComponent from '../components/Loading'
|
||||||
import { useLabels } from '../Labels/LabelQueries'
|
import { useLabels } from '../Labels/LabelQueries'
|
||||||
|
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||||
import ChoreCard from './ChoreCard'
|
import ChoreCard from './ChoreCard'
|
||||||
import CompactChoreCard from './CompactChoreCard'
|
import CompactChoreCard from './CompactChoreCard'
|
||||||
import IconButtonWithMenu from './IconButtonWithMenu'
|
import IconButtonWithMenu from './IconButtonWithMenu'
|
||||||
|
import MultiSelectHelp from './MultiSelectHelp'
|
||||||
|
|
||||||
|
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||||
import { ChoreFilters, ChoresGrouper, ChoreSorter } from '../../utils/Chores'
|
import { ChoreFilters, ChoresGrouper, ChoreSorter } from '../../utils/Chores'
|
||||||
|
import { DeleteChore, MarkChoreComplete, SkipChore } from '../../utils/Fetcher'
|
||||||
import TaskInput from '../components/AddTaskModal'
|
import TaskInput from '../components/AddTaskModal'
|
||||||
import {
|
import {
|
||||||
canScheduleNotification,
|
canScheduleNotification,
|
||||||
@@ -55,8 +67,8 @@ import SortAndGrouping from './SortAndGrouping'
|
|||||||
const MyChores = () => {
|
const MyChores = () => {
|
||||||
const { data: userProfile, isLoading: isUserProfileLoading } =
|
const { data: userProfile, isLoading: isUserProfileLoading } =
|
||||||
useUserProfile()
|
useUserProfile()
|
||||||
const [isSnackbarOpen, setIsSnackbarOpen] = useState(false)
|
const { showSuccess, showError } = useNotification()
|
||||||
const [snackBarMessage, setSnackBarMessage] = useState(null)
|
const { impersonatedUser } = useImpersonateUser()
|
||||||
const [chores, setChores] = useState([])
|
const [chores, setChores] = useState([])
|
||||||
const [archivedChores, setArchivedChores] = useState(null)
|
const [archivedChores, setArchivedChores] = useState(null)
|
||||||
const [filteredChores, setFilteredChores] = useState([])
|
const [filteredChores, setFilteredChores] = useState([])
|
||||||
@@ -93,6 +105,11 @@ const MyChores = () => {
|
|||||||
} = useChores()
|
} = useChores()
|
||||||
const { data: membersData, isLoading: membersLoading } = useCircleMembers()
|
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(() => {
|
useEffect(() => {
|
||||||
if (!choresLoading && !membersLoading && userProfile) {
|
if (!choresLoading && !membersLoading && userProfile) {
|
||||||
setPerformers(membersData.res)
|
setPerformers(membersData.res)
|
||||||
@@ -119,7 +136,14 @@ const MyChores = () => {
|
|||||||
scheduleChoreNotification(choresData.res, userProfile, membersData.res)
|
scheduleChoreNotification(choresData.res, userProfile, membersData.res)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [membersLoading, choresLoading, isUserProfileLoading])
|
}, [
|
||||||
|
membersLoading,
|
||||||
|
choresLoading,
|
||||||
|
isUserProfileLoading,
|
||||||
|
choresData,
|
||||||
|
membersData,
|
||||||
|
userProfile,
|
||||||
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.addEventListener('mousedown', handleMenuOutsideClick)
|
document.addEventListener('mousedown', handleMenuOutsideClick)
|
||||||
@@ -137,20 +161,150 @@ const MyChores = () => {
|
|||||||
}
|
}
|
||||||
}, [searchInputFocus])
|
}, [searchInputFocus])
|
||||||
|
|
||||||
// add listern to Control/Command + K to focus on search input
|
// Keyboard shortcuts for multi-select and other actions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = event => {
|
const handleKeyDown = event => {
|
||||||
|
// Ctrl/Cmd + K to open task modal
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
|
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setAddTaskModalOpen(true)
|
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 () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', handleKeyDown)
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [isMultiSelectMode, selectedChores.size])
|
||||||
const setSelectedChoreSectionWithCache = value => {
|
const setSelectedChoreSectionWithCache = value => {
|
||||||
setSelectedChoreSection(value)
|
setSelectedChoreSection(value)
|
||||||
localStorage.setItem('selectedChoreSection', value)
|
localStorage.setItem('selectedChoreSection', value)
|
||||||
@@ -182,6 +336,10 @@ const MyChores = () => {
|
|||||||
performers={performers}
|
performers={performers}
|
||||||
userLabels={userLabels}
|
userLabels={userLabels}
|
||||||
onChipClick={handleLabelFiltering}
|
onChipClick={handleLabelFiltering}
|
||||||
|
// Multi-select props
|
||||||
|
isMultiSelectMode={isMultiSelectMode}
|
||||||
|
isSelected={selectedChores.has(chore.id)}
|
||||||
|
onSelectionToggle={() => toggleChoreSelection(chore.id)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -283,24 +441,42 @@ const MyChores = () => {
|
|||||||
|
|
||||||
switch (event) {
|
switch (event) {
|
||||||
case 'completed':
|
case 'completed':
|
||||||
setSnackBarMessage('Completed')
|
showSuccess({
|
||||||
|
title: 'Task Completed',
|
||||||
|
message: 'Great job! The task has been marked as completed.',
|
||||||
|
})
|
||||||
break
|
break
|
||||||
case 'skipped':
|
case 'skipped':
|
||||||
setSnackBarMessage('Skipped')
|
showSuccess({
|
||||||
|
title: 'Task Skipped',
|
||||||
|
message: 'The task has been moved to the next due date.',
|
||||||
|
})
|
||||||
break
|
break
|
||||||
case 'rescheduled':
|
case 'rescheduled':
|
||||||
setSnackBarMessage('Rescheduled')
|
showSuccess({
|
||||||
|
title: 'Task Rescheduled',
|
||||||
|
message: 'The task due date has been updated successfully.',
|
||||||
|
})
|
||||||
break
|
break
|
||||||
case 'unarchive':
|
case 'unarchive':
|
||||||
setSnackBarMessage('Unarchive')
|
showSuccess({
|
||||||
|
title: 'Task Restored',
|
||||||
|
message: 'The task has been restored and is now active.',
|
||||||
|
})
|
||||||
break
|
break
|
||||||
case 'archive':
|
case 'archive':
|
||||||
setSnackBarMessage('Archived')
|
showSuccess({
|
||||||
|
title: 'Task Archived',
|
||||||
|
message:
|
||||||
|
'The task has been archived and hidden from the active list.',
|
||||||
|
})
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
setSnackBarMessage('Updated')
|
showSuccess({
|
||||||
|
title: 'Task Updated',
|
||||||
|
message: 'Your changes have been saved successfully.',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
setIsSnackbarOpen(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleChoreDeleted = deletedChore => {
|
const handleChoreDeleted = deletedChore => {
|
||||||
@@ -351,37 +527,310 @@ const MyChores = () => {
|
|||||||
setFilteredChores(fuse.search(term).map(result => result.item))
|
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 (
|
if (
|
||||||
isUserProfileLoading ||
|
isUserProfileLoading ||
|
||||||
userLabelsLoading ||
|
userLabelsLoading ||
|
||||||
performers.length === 0 ||
|
performers.length === 0 ||
|
||||||
choresLoading
|
choresLoading
|
||||||
) {
|
) {
|
||||||
console.log(
|
|
||||||
'userProfile:',
|
|
||||||
userProfile,
|
|
||||||
'userLabelsLoading:',
|
|
||||||
userLabelsLoading,
|
|
||||||
'performers:',
|
|
||||||
performers.length,
|
|
||||||
'choresLoading:',
|
|
||||||
choresLoading,
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
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 />
|
<LoadingComponent />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -405,7 +854,7 @@ const MyChores = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
ref={searchInputRef}
|
slotProps={{ input: { ref: searchInputRef } }}
|
||||||
placeholder='Search'
|
placeholder='Search'
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
@@ -512,8 +961,39 @@ const MyChores = () => {
|
|||||||
>
|
>
|
||||||
{isCompactView ? <ViewModule /> : <ViewAgenda />}
|
{isCompactView ? <ViewModule /> : <ViewAgenda />}
|
||||||
</IconButton>
|
</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>
|
</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='flex gap-4'>
|
||||||
<div className='grid flex-1 grid-cols-3 gap-4'>
|
<div className='grid flex-1 grid-cols-3 gap-4'>
|
||||||
<IconButtonWithMenu
|
<IconButtonWithMenu
|
||||||
@@ -632,7 +1112,217 @@ const MyChores = () => {
|
|||||||
<CancelRounded />
|
<CancelRounded />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</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' && (
|
{searchFilter !== 'All' && (
|
||||||
<Chip
|
<Chip
|
||||||
level='title-md'
|
level='title-md'
|
||||||
@@ -861,19 +1551,6 @@ const MyChores = () => {
|
|||||||
/>
|
/>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
<Snackbar
|
|
||||||
open={isSnackbarOpen}
|
|
||||||
onClose={() => {
|
|
||||||
setIsSnackbarOpen(false)
|
|
||||||
}}
|
|
||||||
autoHideDuration={3000}
|
|
||||||
variant='soft'
|
|
||||||
color='success'
|
|
||||||
size='lg'
|
|
||||||
invertedColors
|
|
||||||
>
|
|
||||||
<Typography level='title-md'>{snackBarMessage}</Typography>
|
|
||||||
</Snackbar>
|
|
||||||
<NotificationAccessSnackbar />
|
<NotificationAccessSnackbar />
|
||||||
{addTaskModalOpen && (
|
{addTaskModalOpen && (
|
||||||
<TaskInput
|
<TaskInput
|
||||||
@@ -891,6 +1568,14 @@ const MyChores = () => {
|
|||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
<Sidepanel chores={chores} performers={performers} />
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -938,7 +1623,7 @@ const FILTERS = {
|
|||||||
return chore.assignedTo === userID
|
return chore.assignedTo === userID
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
'No Due Date': function (chores, userID) {
|
'No Due Date': function (chores) {
|
||||||
return chores.filter(chore => {
|
return chores.filter(chore => {
|
||||||
return chore.nextDueDate === null
|
return chore.nextDueDate === null
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,78 +1,81 @@
|
|||||||
import { Capacitor } from '@capacitor/core';
|
import { Capacitor } from '@capacitor/core'
|
||||||
import { Button, Snackbar, Stack, Typography } from '@mui/joy'
|
import { LocalNotifications } from '@capacitor/local-notifications'
|
||||||
import { Preferences } from '@capacitor/preferences';
|
import { Preferences } from '@capacitor/preferences'
|
||||||
import { LocalNotifications } from '@capacitor/local-notifications';
|
import { Button, Stack, Typography } from '@mui/joy'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
import {React, useEffect, useState} from 'react';
|
|
||||||
|
|
||||||
const NotificationAccessSnackbar = () => {
|
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);
|
useEffect(() => {
|
||||||
|
getNotificationPreferences().then(data => {
|
||||||
if (!Capacitor.isNativePlatform()) {
|
// if optOut is true then don't show the snackbar
|
||||||
return null;
|
if (data?.optOut === true || data?.granted === true) {
|
||||||
}
|
return
|
||||||
const getNotificationPreferences = async () => {
|
}
|
||||||
const ret = await Preferences.get({ key: 'notificationPreferences' });
|
setOpen(true)
|
||||||
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 (
|
|
||||||
|
|
||||||
|
return (
|
||||||
<Snackbar
|
<Snackbar
|
||||||
// autoHideDuration={5000}
|
// autoHideDuration={5000}
|
||||||
variant="solid"
|
variant='solid'
|
||||||
color="primary"
|
color='primary'
|
||||||
size="lg"
|
size='lg'
|
||||||
invertedColors
|
invertedColors
|
||||||
open={open}
|
open={open}
|
||||||
onClose={() => setOpen(false)}
|
onClose={() => setOpen(false)}
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||||
sx={(theme) => ({
|
sx={theme => ({
|
||||||
background: `linear-gradient(45deg, ${theme.palette.primary[600]} 30%, ${theme.palette.primary[500]} 90%})`,
|
background: `linear-gradient(45deg, ${theme.palette.primary[600]} 30%, ${theme.palette.primary[500]} 90%})`,
|
||||||
maxWidth: 360,
|
maxWidth: 360,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<Typography level="title-lg">Need Notification?</Typography>
|
<Typography level='title-lg'>Need Notification?</Typography>
|
||||||
<Typography sx={{ mt: 1, mb: 2 }}>
|
<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>
|
</Typography>
|
||||||
<Stack direction="row" spacing={1}>
|
<Stack direction='row' spacing={1}>
|
||||||
<Button variant="solid" color="primary" onClick={() => {
|
<Button
|
||||||
const notificationPreferences = { optOut: false };
|
variant='solid'
|
||||||
LocalNotifications.requestPermissions().then((resp) => {
|
color='primary'
|
||||||
|
onClick={() => {
|
||||||
|
const notificationPreferences = { optOut: false }
|
||||||
|
LocalNotifications.requestPermissions().then(resp => {
|
||||||
if (resp.display === 'granted') {
|
if (resp.display === 'granted') {
|
||||||
notificationPreferences['granted'] = true;
|
notificationPreferences['granted'] = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
Preferences.set({ key: 'notificationPreferences', value: JSON.stringify(notificationPreferences) });
|
Preferences.set({
|
||||||
setOpen(false);
|
key: 'notificationPreferences',
|
||||||
}}>
|
value: JSON.stringify(notificationPreferences),
|
||||||
Yes
|
})
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Yes
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant='outlined'
|
||||||
color="primary"
|
color='primary'
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const notificationPreferences = { optOut: true };
|
const notificationPreferences = { optOut: true }
|
||||||
Preferences.set({ key: 'notificationPreferences', value: JSON.stringify(notificationPreferences) });
|
Preferences.set({
|
||||||
setOpen(false);
|
key: 'notificationPreferences',
|
||||||
|
value: JSON.stringify(notificationPreferences),
|
||||||
|
})
|
||||||
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
No, Keep it Disabled
|
No, Keep it Disabled
|
||||||
@@ -80,8 +83,7 @@ return (
|
|||||||
</Stack>
|
</Stack>
|
||||||
</div>
|
</div>
|
||||||
</Snackbar>
|
</Snackbar>
|
||||||
|
)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default NotificationAccessSnackbar;
|
export default NotificationAccessSnackbar
|
||||||
|
|||||||
@@ -1,39 +1,42 @@
|
|||||||
import { Button, Snackbar } from '@mui/joy'
|
import { Button } from '@mui/joy'
|
||||||
import Cookies from 'js-cookie'
|
import Cookies from 'js-cookie'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect } from 'react'
|
||||||
|
import { useNotification } from '../../service/NotificationProvider'
|
||||||
|
|
||||||
const CookiePermissionSnackbar = () => {
|
const CookiePermissionSnackbar = () => {
|
||||||
|
const { showNotification } = useNotification()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cookiePermission = Cookies.get('cookies_permission')
|
const cookiePermission = Cookies.get('cookies_permission')
|
||||||
|
|
||||||
if (cookiePermission !== 'true') {
|
if (cookiePermission !== 'true') {
|
||||||
setOpen(true)
|
showNotification({
|
||||||
|
type: 'custom',
|
||||||
|
component: <CookieAcceptComponent />,
|
||||||
|
snackbarProps: {
|
||||||
|
autoHideDuration: null,
|
||||||
|
},
|
||||||
|
anchorOrigin: { vertical: 'bottom', horizontal: 'center' },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}, [])
|
}, [showNotification])
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
return null
|
||||||
const handleClose = () => {
|
}
|
||||||
|
|
||||||
|
const CookieAcceptComponent = ({ onClose }) => {
|
||||||
|
const handleAccept = () => {
|
||||||
Cookies.set('cookies_permission', 'true')
|
Cookies.set('cookies_permission', 'true')
|
||||||
setOpen(false)
|
onClose?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Snackbar
|
<div>
|
||||||
open={open}
|
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
|
||||||
onClose={(event, reason) => {
|
|
||||||
if (reason === 'clickaway') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Cookies.set('cookies_permission', 'true')
|
|
||||||
handleClose()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
We use cookies to ensure you get the best experience on our website.
|
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
|
Accept
|
||||||
</Button>
|
</Button>
|
||||||
</Snackbar>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ const DemoMyChore = () => {
|
|||||||
// },
|
// },
|
||||||
]
|
]
|
||||||
|
|
||||||
const users = [{ displayName: 'Me', id: 1 }]
|
const users = [{ displayName: 'Me', id: 1, userId: 1 }]
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Grid item xs={12} sm={5} data-aos-first-tasks-list>
|
<Grid item xs={12} sm={5} data-aos-first-tasks-list>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* eslint-disable tailwindcss/no-custom-classname */
|
/* eslint-disable tailwindcss/no-custom-classname */
|
||||||
// import { StyledButton } from '@/components/styled-button'
|
// 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 Typography from '@mui/joy/Typography'
|
||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
import Grid from '@mui/material/Grid'
|
import Grid from '@mui/material/Grid'
|
||||||
@@ -8,17 +8,17 @@ import React, { useEffect } from 'react'
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
import Logo from '@/assets/logo.svg'
|
import Logo from '@/assets/logo.svg'
|
||||||
|
import screenShotMyChoreDark from '@/assets/screenshot-my-chore-dark.png'
|
||||||
import screenShotMyChore from '@/assets/screenshot-my-chore.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'
|
import useWindowWidth from '../../hooks/useWindowWidth'
|
||||||
|
|
||||||
const HomeHero = () => {
|
const HomeHero = () => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const windowWidth = useWindowWidth()
|
const windowWidth = useWindowWidth()
|
||||||
const windowThreshold = 600
|
const windowThreshold = 600
|
||||||
|
const { mode, setMode } = useColorScheme()
|
||||||
const HERO_TEXT_THAT = [
|
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',
|
'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}>
|
<Grid item xs={12} md={5}>
|
||||||
<div className='flex justify-center'>
|
<div className='flex justify-center'>
|
||||||
<img
|
<img
|
||||||
src={screenShotMyChore}
|
src={mode === 'dark' ? screenShotMyChoreDark : screenShotMyChore}
|
||||||
width={'100%'}
|
width={'100%'}
|
||||||
style={{
|
|
||||||
maxWidth: 300,
|
|
||||||
}}
|
|
||||||
height={'auto'}
|
height={'auto'}
|
||||||
alt='Hero img'
|
alt='Hero img'
|
||||||
data-aos-delay={100 * 2}
|
data-aos-delay={100 * 2}
|
||||||
data-aos-anchor='[data-aos-id-hero]'
|
data-aos-anchor='[data-aos-id-hero]'
|
||||||
data-aos='fade-left'
|
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>
|
</div>
|
||||||
</Grid>
|
</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>
|
</Grid>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
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 LABEL_COLORS from '../../../utils/Colors.jsx'
|
||||||
import { CreateLabel, UpdateLabel } from '../../../utils/Fetcher'
|
import { CreateLabel, UpdateLabel } from '../../../utils/Fetcher'
|
||||||
import { useLabels } from '../../Labels/LabelQueries'
|
import { useLabels } from '../../Labels/LabelQueries'
|
||||||
@@ -23,7 +23,7 @@ function LabelModal({ isOpen, onClose, label }) {
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const { data: userLabels = [] } = useLabels()
|
const { data: userLabels = [] } = useLabels()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { showError } = useError()
|
const { showError } = useNotification()
|
||||||
|
|
||||||
// Populate the form fields when editing
|
// Populate the form fields when editing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -13,19 +13,47 @@ import moment from 'moment'
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useUserProfile } from '../../queries/UserQueries'
|
import { useUserProfile } from '../../queries/UserQueries'
|
||||||
|
import { useNotification } from '../../service/NotificationProvider'
|
||||||
import {
|
import {
|
||||||
CreateLongLiveToken,
|
CreateLongLiveToken,
|
||||||
DeleteLongLiveToken,
|
DeleteLongLiveToken,
|
||||||
GetLongLiveTokens,
|
GetLongLiveTokens,
|
||||||
} from '../../utils/Fetcher'
|
} from '../../utils/Fetcher'
|
||||||
import { isPlusAccount } from '../../utils/Helpers'
|
import { isPlusAccount } from '../../utils/Helpers'
|
||||||
|
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||||
import TextModal from '../Modals/Inputs/TextModal'
|
import TextModal from '../Modals/Inputs/TextModal'
|
||||||
|
|
||||||
const APITokenSettings = () => {
|
const APITokenSettings = () => {
|
||||||
const { data: userProfile } = useUserProfile()
|
const { data: userProfile } = useUserProfile()
|
||||||
|
const { showNotification } = useNotification()
|
||||||
const [tokens, setTokens] = useState([])
|
const [tokens, setTokens] = useState([])
|
||||||
const [isGetTokenNameModalOpen, setIsGetTokenNameModalOpen] = useState(false)
|
const [isGetTokenNameModalOpen, setIsGetTokenNameModalOpen] = useState(false)
|
||||||
const [showTokenId, setShowTokenId] = useState(null)
|
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(() => {
|
useEffect(() => {
|
||||||
GetLongLiveTokens().then(resp => {
|
GetLongLiveTokens().then(resp => {
|
||||||
resp.json().then(data => {
|
resp.json().then(data => {
|
||||||
@@ -100,18 +128,28 @@ const APITokenSettings = () => {
|
|||||||
variant='outlined'
|
variant='outlined'
|
||||||
color='danger'
|
color='danger'
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const confirmed = confirm(
|
showConfirmation(
|
||||||
`Are you sure you want to remove ${token.name} ?`,
|
`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
|
Remove
|
||||||
@@ -130,7 +168,10 @@ const APITokenSettings = () => {
|
|||||||
color='primary'
|
color='primary'
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(token.token)
|
navigator.clipboard.writeText(token.token)
|
||||||
alert('Token copied to clipboard')
|
showNotification({
|
||||||
|
type: 'success',
|
||||||
|
message: 'Token copied to clipboard',
|
||||||
|
})
|
||||||
setShowTokenId(null)
|
setShowTokenId(null)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -166,6 +207,11 @@ const APITokenSettings = () => {
|
|||||||
okText={'Generate Token'}
|
okText={'Generate Token'}
|
||||||
onSave={handleSaveToken}
|
onSave={handleSaveToken}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
{confirmModalConfig?.isOpen && (
|
||||||
|
<ConfirmationModal config={confirmModalConfig} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -323,9 +323,23 @@ const MFASettings = () => {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Alert color='neutral' variant='soft'>
|
<Alert
|
||||||
<Typography level='body-sm'>
|
color='neutral'
|
||||||
<strong>Manual entry key:</strong> {setupData.secret}
|
variant='soft'
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography level='title-sm'>
|
||||||
|
<strong>Manual entry key:</strong>
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
level='body-sm'
|
||||||
|
sx={{ wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}
|
||||||
|
>
|
||||||
|
{setupData.secret}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Capacitor } from '@capacitor/core'
|
import { Capacitor } from '@capacitor/core'
|
||||||
import { LocalNotifications } from '@capacitor/local-notifications'
|
import { LocalNotifications } from '@capacitor/local-notifications'
|
||||||
import { Preferences } from '@capacitor/preferences'
|
import { Preferences } from '@capacitor/preferences'
|
||||||
import { Close } from '@mui/icons-material'
|
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -10,24 +9,23 @@ import {
|
|||||||
FormControl,
|
FormControl,
|
||||||
FormHelperText,
|
FormHelperText,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
IconButton,
|
|
||||||
Input,
|
Input,
|
||||||
Option,
|
Option,
|
||||||
Select,
|
Select,
|
||||||
Snackbar,
|
|
||||||
Switch,
|
Switch,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { useUserProfile } from '../../queries/UserQueries'
|
import { useUserProfile } from '../../queries/UserQueries'
|
||||||
|
import { useNotification } from '../../service/NotificationProvider'
|
||||||
import {
|
import {
|
||||||
UpdateNotificationTarget,
|
UpdateNotificationTarget,
|
||||||
UpdateUserDetails,
|
UpdateUserDetails,
|
||||||
} from '../../utils/Fetcher'
|
} from '../../utils/Fetcher'
|
||||||
|
|
||||||
const NotificationSetting = () => {
|
const NotificationSetting = () => {
|
||||||
const [isSnackbarOpen, setIsSnackbarOpen] = useState(false)
|
const { showWarning } = useNotification()
|
||||||
const { data: userProfile, refetch: refetchUserProfile } = useUserProfile()
|
const { data: userProfile, refetch: refetchUserProfile } = useUserProfile()
|
||||||
|
|
||||||
const getNotificationPreferences = async () => {
|
const getNotificationPreferences = async () => {
|
||||||
@@ -70,13 +68,17 @@ const NotificationSetting = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getNotificationPreferences().then(resp => {
|
getNotificationPreferences().then(resp => {
|
||||||
setDeviceNotification(resp.granted)
|
if (resp) {
|
||||||
setDueNotification(resp.dueNotification)
|
setDeviceNotification(Boolean(resp.granted))
|
||||||
setPreDueNotification(resp.preDueNotification)
|
setDueNotification(Boolean(resp.dueNotification ?? true))
|
||||||
setNaggingNotification(resp.naggingNotification)
|
setPreDueNotification(Boolean(resp.preDueNotification))
|
||||||
|
setNaggingNotification(Boolean(resp.naggingNotification))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
getPushNotificationPreferences().then(resp => {
|
getPushNotificationPreferences().then(resp => {
|
||||||
setPushNotification(resp.granted)
|
if (resp) {
|
||||||
|
setPushNotification(Boolean(resp.granted))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -87,7 +89,7 @@ const NotificationSetting = () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const [chatID, setChatID] = useState(
|
const [chatID, setChatID] = useState(
|
||||||
userProfile?.notification_target?.target_id,
|
userProfile?.notification_target?.target_id ?? 0,
|
||||||
)
|
)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const SaveValidation = () => {
|
const SaveValidation = () => {
|
||||||
@@ -147,7 +149,11 @@ const NotificationSetting = () => {
|
|||||||
setDeviceNotification(true)
|
setDeviceNotification(true)
|
||||||
setNotificationPreferences({ granted: true })
|
setNotificationPreferences({ granted: true })
|
||||||
} else if (resp.display === 'denied') {
|
} 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)
|
setDeviceNotification(false)
|
||||||
setNotificationPreferences({ granted: false })
|
setNotificationPreferences({ granted: false })
|
||||||
}
|
}
|
||||||
@@ -251,12 +257,14 @@ const NotificationSetting = () => {
|
|||||||
setPushNotification(true)
|
setPushNotification(true)
|
||||||
setPushNotificationPreferences({granted: true})
|
setPushNotificationPreferences({granted: true})
|
||||||
}
|
}
|
||||||
if (resp.receive!== 'granted') {
|
if (resp.receive !== 'granted') {
|
||||||
setIsSnackbarOpen(true)
|
showWarning({
|
||||||
|
title: 'Push Notification Permission Denied',
|
||||||
|
message: 'Push notifications have been disabled. You can enable them in your device settings if needed.',
|
||||||
|
})
|
||||||
setPushNotification(false)
|
setPushNotification(false)
|
||||||
setPushNotificationPreferences({granted: false})
|
setPushNotificationPreferences({granted: false})
|
||||||
console.log("User denied permission", resp)
|
console.log("User denied permission", resp)
|
||||||
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -313,7 +321,7 @@ const NotificationSetting = () => {
|
|||||||
|
|
||||||
<FormControl orientation='horizontal'>
|
<FormControl orientation='horizontal'>
|
||||||
<Switch
|
<Switch
|
||||||
checked={chatID !== 0}
|
checked={Boolean(chatID !== 0)}
|
||||||
onClick={event => {
|
onClick={event => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (chatID !== 0) {
|
if (chatID !== 0) {
|
||||||
@@ -440,30 +448,6 @@ const NotificationSetting = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,15 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
Divider,
|
Divider,
|
||||||
Input,
|
Input,
|
||||||
Snackbar,
|
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import Modal from '@mui/joy/Modal'
|
import Modal from '@mui/joy/Modal'
|
||||||
import ModalDialog from '@mui/joy/ModalDialog'
|
import ModalDialog from '@mui/joy/ModalDialog'
|
||||||
|
import imageCompression from 'browser-image-compression'
|
||||||
import { useRef, useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import Cropper from 'react-easy-crop'
|
import Cropper from 'react-easy-crop'
|
||||||
import { useUserProfile } from '../../queries/UserQueries'
|
import { useUserProfile } from '../../queries/UserQueries'
|
||||||
|
import { useNotification } from '../../service/NotificationProvider'
|
||||||
import { UpdateUserDetails } from '../../utils/Fetcher'
|
import { UpdateUserDetails } from '../../utils/Fetcher'
|
||||||
import { resolvePhotoURL } from '../../utils/Helpers'
|
import { resolvePhotoURL } from '../../utils/Helpers'
|
||||||
import { getCroppedImg } from '../../utils/imageCropUtils'
|
import { getCroppedImg } from '../../utils/imageCropUtils'
|
||||||
@@ -21,6 +22,7 @@ import { UploadFile } from '../../utils/TokenManager'
|
|||||||
|
|
||||||
const ProfileSettings = () => {
|
const ProfileSettings = () => {
|
||||||
const { data: userProfile } = useUserProfile()
|
const { data: userProfile } = useUserProfile()
|
||||||
|
const { showSuccess, showError } = useNotification()
|
||||||
const [displayName, setDisplayName] = useState(userProfile?.displayName || '')
|
const [displayName, setDisplayName] = useState(userProfile?.displayName || '')
|
||||||
const [timezone, setTimezone] = useState(
|
const [timezone, setTimezone] = useState(
|
||||||
userProfile?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
userProfile?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
@@ -28,11 +30,6 @@ const ProfileSettings = () => {
|
|||||||
const [photoURL, setPhotoURL] = useState(userProfile?.image || '')
|
const [photoURL, setPhotoURL] = useState(userProfile?.image || '')
|
||||||
const [isUploading, setIsUploading] = useState(false)
|
const [isUploading, setIsUploading] = useState(false)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [snackbar, setSnackbar] = useState({
|
|
||||||
open: false,
|
|
||||||
message: '',
|
|
||||||
color: 'success',
|
|
||||||
})
|
|
||||||
const fileInputRef = useRef()
|
const fileInputRef = useRef()
|
||||||
const [crop, setCrop] = useState({ x: 0, y: 0 })
|
const [crop, setCrop] = useState({ x: 0, y: 0 })
|
||||||
const [zoom, setZoom] = useState(1)
|
const [zoom, setZoom] = useState(1)
|
||||||
@@ -60,12 +57,32 @@ const ProfileSettings = () => {
|
|||||||
const croppedBlob = await getCroppedImg(
|
const croppedBlob = await getCroppedImg(
|
||||||
selectedFile,
|
selectedFile,
|
||||||
croppedAreaPixels,
|
croppedAreaPixels,
|
||||||
320,
|
160,
|
||||||
320,
|
160,
|
||||||
'image/jpeg',
|
'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()
|
const formData = new FormData()
|
||||||
formData.append('file', croppedBlob, 'profile.jpg')
|
formData.append('file', compressedFile, 'profile.jpg')
|
||||||
const response = await UploadFile('/users/profile_photo', {
|
const response = await UploadFile('/users/profile_photo', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
@@ -75,16 +92,14 @@ const ProfileSettings = () => {
|
|||||||
const url = resolvePhotoURL(data.url || data.sign)
|
const url = resolvePhotoURL(data.url || data.sign)
|
||||||
|
|
||||||
setPhotoURL(url)
|
setPhotoURL(url)
|
||||||
setSnackbar({
|
showSuccess({
|
||||||
open: true,
|
title: 'Photo Updated',
|
||||||
message: 'Profile photo updated!',
|
message: 'Your profile photo has been updated successfully!',
|
||||||
color: 'success',
|
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSnackbar({
|
showError({
|
||||||
open: true,
|
title: 'Upload Failed',
|
||||||
message: 'Failed to upload photo.',
|
message: 'Failed to upload your photo. Please try again.',
|
||||||
color: 'danger',
|
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false)
|
setIsUploading(false)
|
||||||
@@ -100,19 +115,18 @@ const ProfileSettings = () => {
|
|||||||
const response = await UpdateUserDetails(userDetails)
|
const response = await UpdateUserDetails(userDetails)
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setSnackbar({
|
showSuccess({
|
||||||
open: true,
|
title: 'Profile Updated',
|
||||||
message: 'Profile updated successfully!',
|
message: 'Your profile information has been saved successfully!',
|
||||||
color: 'success',
|
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Failed to update profile')
|
throw new Error('Failed to update profile')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSnackbar({
|
showError({
|
||||||
open: true,
|
title: 'Update Failed',
|
||||||
message: 'Failed to update profile.',
|
message:
|
||||||
color: 'danger',
|
'Unable to update your profile. Please check your connection and try again.',
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
@@ -280,14 +294,6 @@ const ProfileSettings = () => {
|
|||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
<Snackbar
|
|
||||||
open={snackbar.open}
|
|
||||||
color={snackbar.color}
|
|
||||||
autoHideDuration={3000}
|
|
||||||
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
|
||||||
>
|
|
||||||
{snackbar.message}
|
|
||||||
</Snackbar>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ import {
|
|||||||
FormControl,
|
FormControl,
|
||||||
FormHelperText,
|
FormHelperText,
|
||||||
Input,
|
Input,
|
||||||
ListItem,
|
|
||||||
Option,
|
Option,
|
||||||
Select,
|
Select,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import RealTimeSettings from '../../components/RealTimeSettings'
|
||||||
import Logo from '../../Logo'
|
import Logo from '../../Logo'
|
||||||
import { useUserProfile } from '../../queries/UserQueries'
|
import { useUserProfile } from '../../queries/UserQueries'
|
||||||
import {
|
import {
|
||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
UpdatePassword,
|
UpdatePassword,
|
||||||
} from '../../utils/Fetcher'
|
} from '../../utils/Fetcher'
|
||||||
import { isPlusAccount } from '../../utils/Helpers'
|
import { isPlusAccount } from '../../utils/Helpers'
|
||||||
|
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||||
import PassowrdChangeModal from '../Modals/Inputs/PasswordChangeModal'
|
import PassowrdChangeModal from '../Modals/Inputs/PasswordChangeModal'
|
||||||
import APITokenSettings from './APITokenSettings'
|
import APITokenSettings from './APITokenSettings'
|
||||||
import MFASettings from './MFASettings'
|
import MFASettings from './MFASettings'
|
||||||
@@ -41,9 +42,11 @@ import NotificationSetting from './NotificationSetting'
|
|||||||
import ProfileSettings from './ProfileSettings'
|
import ProfileSettings from './ProfileSettings'
|
||||||
import StorageSettings from './StorageSettings'
|
import StorageSettings from './StorageSettings'
|
||||||
import ThemeToggle from './ThemeToggle'
|
import ThemeToggle from './ThemeToggle'
|
||||||
|
import { useNotification } from '../../service/NotificationProvider'
|
||||||
|
|
||||||
const Settings = () => {
|
const Settings = () => {
|
||||||
const { data: userProfile } = useUserProfile()
|
const { data: userProfile } = useUserProfile()
|
||||||
|
const { showNotification } = useNotification()
|
||||||
|
|
||||||
const [userCircles, setUserCircles] = useState([])
|
const [userCircles, setUserCircles] = useState([])
|
||||||
const [circleMemberRequests, setCircleMemberRequests] = useState([])
|
const [circleMemberRequests, setCircleMemberRequests] = useState([])
|
||||||
@@ -54,6 +57,31 @@ const Settings = () => {
|
|||||||
const [isAdmin, setIsAdmin] = useState(false)
|
const [isAdmin, setIsAdmin] = useState(false)
|
||||||
|
|
||||||
const [changePasswordModal, setChangePasswordModal] = 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(() => {
|
useEffect(() => {
|
||||||
GetUserCircle().then(resp => {
|
GetUserCircle().then(resp => {
|
||||||
resp.json().then(data => {
|
resp.json().then(data => {
|
||||||
@@ -165,7 +193,10 @@ const Settings = () => {
|
|||||||
variant='soft'
|
variant='soft'
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(userCircles[0]?.invite_code)
|
navigator.clipboard.writeText(userCircles[0]?.invite_code)
|
||||||
alert('Code Copied to clipboard')
|
showNotification({
|
||||||
|
type: 'success',
|
||||||
|
message: 'Code copied to clipboard',
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Copy Code
|
Copy Code
|
||||||
@@ -180,27 +211,42 @@ const Settings = () => {
|
|||||||
window.location.host +
|
window.location.host +
|
||||||
`/circle/join?code=${userCircles[0]?.invite_code}`,
|
`/circle/join?code=${userCircles[0]?.invite_code}`,
|
||||||
)
|
)
|
||||||
alert('Link Copied to clipboard')
|
showNotification({
|
||||||
|
type: 'success',
|
||||||
|
message: 'Link copied to clipboard',
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Copy Link
|
Copy Link
|
||||||
</Button>
|
</Button>
|
||||||
{userCircles.length > 0 && userCircles[0]?.userRole === 'member' && (
|
{userCircles.length > 0 && userCircles[0]?.userRole === 'member' && (
|
||||||
<Button
|
<Button
|
||||||
|
color='danger'
|
||||||
|
variant='outlined'
|
||||||
sx={{ ml: 1 }}
|
sx={{ ml: 1 }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const confirmed = confirm(
|
showConfirmation(
|
||||||
`Are you sure you want to leave your circle?`,
|
'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
|
Leave Circle
|
||||||
@@ -257,7 +303,10 @@ const Settings = () => {
|
|||||||
})
|
})
|
||||||
setCircleMembers(newCircleMembers)
|
setCircleMembers(newCircleMembers)
|
||||||
} else {
|
} else {
|
||||||
alert('Failed to update role')
|
showNotification({
|
||||||
|
type: 'error',
|
||||||
|
message: 'Failed to update role',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
@@ -278,7 +327,7 @@ const Settings = () => {
|
|||||||
},
|
},
|
||||||
].map((option, index) => (
|
].map((option, index) => (
|
||||||
<Option value={option.value} key={index}>
|
<Option value={option.value} key={index}>
|
||||||
<ListItem
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@@ -301,7 +350,7 @@ const Settings = () => {
|
|||||||
>
|
>
|
||||||
{option.description}
|
{option.description}
|
||||||
</Typography>
|
</Typography>
|
||||||
</ListItem>
|
</Box>
|
||||||
</Option>
|
</Option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
@@ -318,19 +367,26 @@ const Settings = () => {
|
|||||||
color='danger'
|
color='danger'
|
||||||
size='sm'
|
size='sm'
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const confirmed = confirm(
|
showConfirmation(
|
||||||
`Are you sure you want to remove ${member.displayName} from your circle?`,
|
`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
|
Remove
|
||||||
@@ -353,18 +409,24 @@ const Settings = () => {
|
|||||||
variant='soft'
|
variant='soft'
|
||||||
color='success'
|
color='success'
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const confirmed = confirm(
|
showConfirmation(
|
||||||
`Are you sure you want to accept ${request.displayName}(username:${request.username}) to join your circle?`,
|
`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
|
Accept
|
||||||
@@ -393,18 +455,23 @@ const Settings = () => {
|
|||||||
<Button
|
<Button
|
||||||
variant='soft'
|
variant='soft'
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const confirmed = confirm(
|
showConfirmation(
|
||||||
`Are you sure you want to leave you circle and join '${circleInviteCode}'?`,
|
`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
|
Join Circle
|
||||||
@@ -479,9 +546,15 @@ const Settings = () => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
PutWebhookURL(webhookURL).then(resp => {
|
PutWebhookURL(webhookURL).then(resp => {
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
alert('Webhook URL updated successfully.')
|
showNotification({
|
||||||
|
type: 'success',
|
||||||
|
message: 'Webhook URL updated successfully',
|
||||||
|
})
|
||||||
} else {
|
} 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>
|
||||||
|
|
||||||
<div className='grid gap-4 py-4' id='account'>
|
<div className='grid gap-4 py-4' id='account'>
|
||||||
@@ -537,10 +614,14 @@ const Settings = () => {
|
|||||||
ml: 1,
|
ml: 1,
|
||||||
}}
|
}}
|
||||||
variant='outlined'
|
variant='outlined'
|
||||||
|
color='danger'
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
CancelSubscription().then(resp => {
|
CancelSubscription().then(resp => {
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
alert('Subscription cancelled.')
|
showNotification({
|
||||||
|
type: 'success',
|
||||||
|
message: 'Subscription cancelled',
|
||||||
|
})
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -571,9 +652,15 @@ const Settings = () => {
|
|||||||
if (password) {
|
if (password) {
|
||||||
UpdatePassword(password).then(resp => {
|
UpdatePassword(password).then(resp => {
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
alert('Password changed successfully')
|
showNotification({
|
||||||
|
type: 'success',
|
||||||
|
message: 'Password changed successfully',
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
alert('Password change failed')
|
showNotification({
|
||||||
|
type: 'error',
|
||||||
|
message: 'Password change failed',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -597,6 +684,11 @@ const Settings = () => {
|
|||||||
</Typography>
|
</Typography>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
{confirmModalConfig?.isOpen && (
|
||||||
|
<ConfirmationModal config={confirmModalConfig} />
|
||||||
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,38 @@ import { useNavigate } from 'react-router-dom'
|
|||||||
import { useUserProfile } from '../../queries/UserQueries'
|
import { useUserProfile } from '../../queries/UserQueries'
|
||||||
import { GetStorageUsage } from '../../utils/Fetcher'
|
import { GetStorageUsage } from '../../utils/Fetcher'
|
||||||
import { isPlusAccount } from '../../utils/Helpers'
|
import { isPlusAccount } from '../../utils/Helpers'
|
||||||
|
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||||
|
|
||||||
const StorageSettings = () => {
|
const StorageSettings = () => {
|
||||||
const Navigate = useNavigate()
|
const Navigate = useNavigate()
|
||||||
const { data: userProfile } = useUserProfile()
|
const { data: userProfile } = useUserProfile()
|
||||||
const [usage, setUsage] = useState({ used: 0, total: 0 })
|
const [usage, setUsage] = useState({ used: 0, total: 0 })
|
||||||
const [loading, setLoading] = useState(true)
|
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(() => {
|
useEffect(() => {
|
||||||
if (isPlusAccount(userProfile)) {
|
if (isPlusAccount(userProfile)) {
|
||||||
@@ -101,13 +127,17 @@ const StorageSettings = () => {
|
|||||||
variant='soft'
|
variant='soft'
|
||||||
color='danger'
|
color='danger'
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const confirmed = confirm(
|
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.`,
|
'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
|
Clear All Local Storage and Cache
|
||||||
@@ -116,20 +146,29 @@ const StorageSettings = () => {
|
|||||||
variant='outlined'
|
variant='outlined'
|
||||||
color='danger'
|
color='danger'
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const confirmed = confirm(
|
showConfirmation(
|
||||||
`Are you sure you want to clear only the offline cache and tasks?`,
|
'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 }}
|
sx={{ mt: 1 }}
|
||||||
>
|
>
|
||||||
Clear Offline Cache and Offline Tasks
|
Clear Offline Cache and Offline Tasks
|
||||||
</Button>
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
{confirmModalConfig?.isOpen && (
|
||||||
|
<ConfirmationModal config={confirmModalConfig} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,12 +15,11 @@ import {
|
|||||||
Container,
|
Container,
|
||||||
Grid,
|
Grid,
|
||||||
IconButton,
|
IconButton,
|
||||||
Snackbar,
|
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useError } from '../../service/ErrorProvider'
|
import { useNotification } from '../../service/NotificationProvider'
|
||||||
import {
|
import {
|
||||||
CreateThing,
|
CreateThing,
|
||||||
DeleteThing,
|
DeleteThing,
|
||||||
@@ -169,11 +168,7 @@ const ThingsView = () => {
|
|||||||
const [isShowEditThingStateModal, setIsShowEditStateModal] = useState(false)
|
const [isShowEditThingStateModal, setIsShowEditStateModal] = useState(false)
|
||||||
const [createModalThing, setCreateModalThing] = useState(null)
|
const [createModalThing, setCreateModalThing] = useState(null)
|
||||||
const [confirmModelConfig, setConfirmModelConfig] = useState({})
|
const [confirmModelConfig, setConfirmModelConfig] = useState({})
|
||||||
|
const { showError, showNotification } = useNotification()
|
||||||
const [isSnackbarOpen, setIsSnackbarOpen] = useState(false)
|
|
||||||
const [snackbarMessage, setSnackbarMessage] = useState('')
|
|
||||||
const [snackbarColor, setSnackbarColor] = useState('success')
|
|
||||||
const { showError } = useError()
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// fetch things
|
// fetch things
|
||||||
@@ -204,9 +199,11 @@ const ThingsView = () => {
|
|||||||
currentThings.push(data.res)
|
currentThings.push(data.res)
|
||||||
setThings(currentThings)
|
setThings(currentThings)
|
||||||
}
|
}
|
||||||
setSnackbarMessage('Thing saved successfully')
|
showNotification({
|
||||||
setSnackbarColor('success')
|
type: 'success',
|
||||||
setIsSnackbarOpen(true)
|
title: 'Thing Saved',
|
||||||
|
message: 'Thing saved successfully',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
@@ -246,11 +243,10 @@ const ThingsView = () => {
|
|||||||
currentThings.splice(thingIndex, 1)
|
currentThings.splice(thingIndex, 1)
|
||||||
setThings(currentThings)
|
setThings(currentThings)
|
||||||
} else if (response.status === 405) {
|
} else if (response.status === 405) {
|
||||||
setSnackbarMessage(
|
showError({
|
||||||
'Unable to delete thing with associated tasks',
|
title: 'Unable to Delete Thing',
|
||||||
)
|
message: 'Unable to delete thing with associated tasks',
|
||||||
setSnackbarColor('danger')
|
})
|
||||||
setIsSnackbarOpen(true)
|
|
||||||
}
|
}
|
||||||
// if method not allwo show snackbar:
|
// if method not allwo show snackbar:
|
||||||
})
|
})
|
||||||
@@ -293,8 +289,11 @@ const ThingsView = () => {
|
|||||||
)
|
)
|
||||||
currentThings[thingIndex] = data.res
|
currentThings[thingIndex] = data.res
|
||||||
setThings(currentThings)
|
setThings(currentThings)
|
||||||
setSnackbarMessage('Thing state updated successfully')
|
showNotification({
|
||||||
setIsSnackbarOpen(true)
|
type: 'success',
|
||||||
|
title: 'Thing Updated',
|
||||||
|
message: 'Thing state updated successfully',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
@@ -399,19 +398,6 @@ const ThingsView = () => {
|
|||||||
|
|
||||||
<ConfirmationModal config={confirmModelConfig} />
|
<ConfirmationModal config={confirmModelConfig} />
|
||||||
</Box>
|
</Box>
|
||||||
<Snackbar
|
|
||||||
open={isSnackbarOpen}
|
|
||||||
onClose={() => {
|
|
||||||
setIsSnackbarOpen(false)
|
|
||||||
}}
|
|
||||||
autoHideDuration={3000}
|
|
||||||
variant='soft'
|
|
||||||
color={snackbarColor}
|
|
||||||
size='lg'
|
|
||||||
invertedColors
|
|
||||||
>
|
|
||||||
<Typography level='title-md'>{snackbarMessage}</Typography>
|
|
||||||
</Snackbar>
|
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -384,7 +384,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
|||||||
return (
|
return (
|
||||||
<Modal open={isModalOpen} onClose={handleCloseModal}>
|
<Modal open={isModalOpen} onClose={handleCloseModal}>
|
||||||
<ModalOverflow>
|
<ModalOverflow>
|
||||||
<ModalDialog size='lg' sx={{ minWidth: '80%' }}>
|
<ModalDialog size='lg' sx={{ minWidth: '100%' }}>
|
||||||
<Typography level='h4'>Create new task</Typography>
|
<Typography level='h4'>Create new task</Typography>
|
||||||
<Chip startDecorator='🚧' variant='soft' color='warning' size='sm'>
|
<Chip startDecorator='🚧' variant='soft' color='warning' size='sm'>
|
||||||
Experimental Feature
|
Experimental Feature
|
||||||
|
|||||||
@@ -165,7 +165,6 @@ const CalendarView = ({ chores }) => {
|
|||||||
return legendItems.map((item, index) => (
|
return legendItems.map((item, index) => (
|
||||||
<Grid
|
<Grid
|
||||||
key={index}
|
key={index}
|
||||||
item
|
|
||||||
xs={12}
|
xs={12}
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
import { Divider, IconButton, Menu, MenuItem } from '@mui/joy'
|
import { Divider, IconButton, Menu, MenuItem } from '@mui/joy'
|
||||||
import React, { useEffect } from 'react'
|
import React, { useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useError } from '../../service/ErrorProvider'
|
import { useNotification } from '../../service/NotificationProvider'
|
||||||
import {
|
import {
|
||||||
ArchiveChore,
|
ArchiveChore,
|
||||||
DeleteChore,
|
DeleteChore,
|
||||||
@@ -41,7 +41,7 @@ const ChoreActionMenu = ({
|
|||||||
const [anchorEl, setAnchorEl] = React.useState(null)
|
const [anchorEl, setAnchorEl] = React.useState(null)
|
||||||
const menuRef = React.useRef(null)
|
const menuRef = React.useRef(null)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { showError } = useError()
|
const { showError } = useNotification()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleMenuOutsideClick = event => {
|
const handleMenuOutsideClick = event => {
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import imageCompression from 'browser-image-compression'
|
|||||||
import Quill from 'quill'
|
import Quill from 'quill'
|
||||||
import 'quill/dist/quill.snow.css'
|
import 'quill/dist/quill.snow.css'
|
||||||
import QuillMarkdown from 'quilljs-markdown'
|
import QuillMarkdown from 'quilljs-markdown'
|
||||||
import { useCallback, useContext, useEffect, useRef } from 'react'
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
import { UserContext } from '../../contexts/UserContext'
|
import { useUserProfile } from '../../queries/UserQueries'
|
||||||
import { useError } from '../../service/ErrorProvider'
|
import { useNotification } from '../../service/NotificationProvider'
|
||||||
import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers'
|
import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers'
|
||||||
import { UploadFile } from '../../utils/TokenManager'
|
import { UploadFile } from '../../utils/TokenManager'
|
||||||
import './RichTextEditor.css'
|
import './RichTextEditor.css'
|
||||||
@@ -18,8 +18,8 @@ const RichTextEditor = ({
|
|||||||
entityId,
|
entityId,
|
||||||
entityType,
|
entityType,
|
||||||
}) => {
|
}) => {
|
||||||
const { showError } = useError()
|
const { showError } = useNotification()
|
||||||
const { userProfile } = useContext(UserContext)
|
const { data: userProfile } = useUserProfile()
|
||||||
const quillRef = useRef(null)
|
const quillRef = useRef(null)
|
||||||
const editorRef = useRef(null)
|
const editorRef = useRef(null)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user