Merge branch 'dev'
This commit is contained in:
@@ -50,6 +50,7 @@
|
||||
"chrono-node": "^2.7.7",
|
||||
"dotenv": "^16.4.5",
|
||||
"esm": "^3.2.25",
|
||||
"event-source-polyfill": "^1.0.31",
|
||||
"farmhash": "^4.0.1",
|
||||
"fuse.js": "^7.0.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
|
||||
99
src/App.jsx
99
src/App.jsx
@@ -1,15 +1,18 @@
|
||||
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 { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { Outlet, useNavigate } from 'react-router-dom'
|
||||
import { useRegisterSW } from 'virtual:pwa-register/react'
|
||||
import { registerCapacitorListeners } from './CapacitorListener'
|
||||
import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext'
|
||||
import { useResource } from './queries/ResourceQueries'
|
||||
import { AuthenticationProvider } from './service/AuthenticationService'
|
||||
import { ErrorProvider } from './service/ErrorProvider'
|
||||
import {
|
||||
NotificationProvider,
|
||||
useNotification,
|
||||
} from './service/NotificationProvider'
|
||||
import { apiManager } from './utils/TokenManager'
|
||||
import NetworkBanner from './views/components/NetworkBanner'
|
||||
const add = className => {
|
||||
@@ -22,22 +25,15 @@ const remove = className => {
|
||||
// TODO: Update the interval to at 60 minutes
|
||||
const intervalMS = 5 * 60 * 1000 // 5 minutes
|
||||
const queryClient = new QueryClient({})
|
||||
function App() {
|
||||
const resource = useResource()
|
||||
const navigate = useNavigate()
|
||||
startApiManager(navigate)
|
||||
startOpenReplay()
|
||||
|
||||
const { mode, systemMode } = useColorScheme()
|
||||
const [showUpdateSnackbar, setShowUpdateSnackbar] = useState(true)
|
||||
const AppContent = () => {
|
||||
const { showNotification } = useNotification()
|
||||
|
||||
const {
|
||||
offlineReady: [offlineReady, setOfflineReady],
|
||||
needRefresh: [needRefresh, setNeedRefresh],
|
||||
updateServiceWorker,
|
||||
} = useRegisterSW({
|
||||
onRegistered(r) {
|
||||
// eslint-disable-next-line prefer-template
|
||||
console.log('SW Registered: ' + r)
|
||||
r &&
|
||||
setInterval(() => {
|
||||
@@ -48,10 +44,53 @@ function App() {
|
||||
console.log('SW registration error', error)
|
||||
},
|
||||
})
|
||||
const close = () => {
|
||||
setOfflineReady(false)
|
||||
setNeedRefresh(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
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 value = JSON.parse(localStorage.getItem('themeMode')) || mode
|
||||
@@ -73,6 +112,7 @@ function App() {
|
||||
useEffect(() => {
|
||||
setThemeClass()
|
||||
}, [mode, systemMode])
|
||||
|
||||
useEffect(() => {
|
||||
registerCapacitorListeners()
|
||||
}, [])
|
||||
@@ -83,30 +123,9 @@ function App() {
|
||||
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthenticationProvider />
|
||||
<ErrorProvider>
|
||||
<ImpersonateUserProvider>
|
||||
<NavBar />
|
||||
<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>
|
||||
)}
|
||||
<NotificationProvider>
|
||||
<AppContent />
|
||||
</NotificationProvider>
|
||||
</QueryClientProvider>
|
||||
</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 RouterContext from './RouterContext'
|
||||
import SSEProvider from './SSEContext'
|
||||
import ThemeContext from './ThemeContext'
|
||||
import WebSocketProvider from './WebSocketContext'
|
||||
|
||||
const Contexts = () => {
|
||||
const contexts = [ThemeContext, QueryContext, RouterContext]
|
||||
const contexts = [
|
||||
ThemeContext,
|
||||
QueryContext,
|
||||
SSEProvider,
|
||||
WebSocketProvider,
|
||||
RouterContext,
|
||||
]
|
||||
|
||||
return contexts.reduceRight((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',
|
||||
|
||||
// FOR PRIORITY:
|
||||
PRIORITY_1: '#F03A47',
|
||||
PRIORITY_2: '#ffc107',
|
||||
PRIORITY_3: '#00bcd4',
|
||||
PRIORITY_4: '#7e57c2',
|
||||
NO_PRIORITY: '#90a4ae',
|
||||
// PRIORITY_1: '#F03A47',
|
||||
// PRIORITY_2: '#ffc107',
|
||||
// PRIORITY_3: '#00bcd4',
|
||||
// PRIORITY_4: '#7e57c2',
|
||||
// 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
|
||||
|
||||
|
||||
@@ -146,7 +146,10 @@ const UpdateChoreAssignee = (id, assignee) => {
|
||||
return Fetch(`/chores/${id}/assignee`, {
|
||||
method: 'PUT',
|
||||
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({
|
||||
dueDate: dueDate ? new Date(dueDate).toISOString() : null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,21 +8,19 @@ import {
|
||||
FormHelperText,
|
||||
Input,
|
||||
Sheet,
|
||||
Snackbar,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { API_URL } from './../../Config'
|
||||
import { ResetPassword } from '../../utils/Fetcher'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { ResetPassword } from '../../utils/Fetcher'
|
||||
|
||||
const ForgotPasswordView = () => {
|
||||
const navigate = useNavigate()
|
||||
// const [showLoginSnackbar, setShowLoginSnackbar] = useState(false)
|
||||
// const [snackbarMessage, setSnackbarMessage] = useState('')
|
||||
const [resetStatusOk, setResetStatusOk] = useState(null)
|
||||
const [email, setEmail] = useState('')
|
||||
const [emailError, setEmailError] = useState(null)
|
||||
const { showError, showNotification } = useNotification()
|
||||
|
||||
const validateEmail = email => {
|
||||
return !/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(email)
|
||||
@@ -48,12 +46,24 @@ const ForgotPasswordView = () => {
|
||||
|
||||
if (response.ok) {
|
||||
setResetStatusOk(true)
|
||||
// wait 3 seconds and then redirect to login:
|
||||
showNotification({
|
||||
type: 'success',
|
||||
title: 'Reset Email Sent',
|
||||
message: 'Check your email for password reset instructions',
|
||||
})
|
||||
} else {
|
||||
setResetStatusOk(false)
|
||||
showError({
|
||||
title: 'Reset Failed',
|
||||
message: 'Failed to send reset email, please try again later',
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
setResetStatusOk(false)
|
||||
showError({
|
||||
title: 'Reset Failed',
|
||||
message: 'Failed to send reset email, please try again later',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,19 +205,6 @@ const ForgotPasswordView = () => {
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Snackbar
|
||||
open={resetStatusOk ? resetStatusOk : resetStatusOk === false}
|
||||
autoHideDuration={5000}
|
||||
onClose={() => {
|
||||
if (resetStatusOk) {
|
||||
navigate('/login')
|
||||
}
|
||||
}}
|
||||
>
|
||||
{resetStatusOk
|
||||
? 'Reset email sent, check your email'
|
||||
: 'Reset email failed, try again later'}
|
||||
</Snackbar>
|
||||
</Sheet>
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
import { Preferences } from '@capacitor/preferences'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Input,
|
||||
Sheet,
|
||||
Snackbar,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { Box, Button, Container, Input, Sheet, Typography } from '@mui/joy'
|
||||
import React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { API_URL } from '../../Config'
|
||||
import Logo from '../../Logo'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { apiManager } from '../../utils/TokenManager'
|
||||
const LoginSettings = () => {
|
||||
const [error, setError] = React.useState(null)
|
||||
const Navigate = useNavigate()
|
||||
|
||||
const [serverURL, setServerURL] = React.useState('')
|
||||
const { showError } = useNotification()
|
||||
|
||||
React.useEffect(() => {
|
||||
Preferences.get({ key: 'customServerUrl' }).then(result => {
|
||||
@@ -112,7 +104,11 @@ const LoginSettings = () => {
|
||||
return
|
||||
}
|
||||
if (!isValidServerURL()) {
|
||||
setError('Invalid server URL')
|
||||
showError({
|
||||
title: 'Invalid Server URL',
|
||||
message:
|
||||
'Please enter a valid server URL with protocol (http:// or https://)',
|
||||
})
|
||||
return
|
||||
}
|
||||
Preferences.set({
|
||||
@@ -150,14 +146,6 @@ const LoginSettings = () => {
|
||||
</Button>
|
||||
</Sheet>
|
||||
</Box>
|
||||
<Snackbar
|
||||
open={error !== null}
|
||||
onClose={() => setError(null)}
|
||||
autoHideDuration={3000}
|
||||
message={error}
|
||||
>
|
||||
{error}
|
||||
</Snackbar>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
IconButton,
|
||||
Input,
|
||||
Sheet,
|
||||
Snackbar,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import Cookies from 'js-cookie'
|
||||
@@ -22,21 +21,21 @@ import { LoginSocialGoogle } from 'reactjs-social-login'
|
||||
import { GOOGLE_CLIENT_ID, REDIRECT_URL } from '../../Config'
|
||||
import Logo from '../../Logo'
|
||||
import { useResource } from '../../queries/ResourceQueries'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { login } from '../../utils/Fetcher'
|
||||
import { apiManager } from '../../utils/TokenManager'
|
||||
import MFAVerificationModal from './MFAVerificationModal'
|
||||
|
||||
const LoginView = () => {
|
||||
// Only fetch user profile if token is valid to prevent unnecessary queries
|
||||
const { data: userProfileData } = useUserProfile()
|
||||
// const { data: userProfileData } = useUserProfile()
|
||||
const [userProfile, setUserProfile] = useState(null)
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState(null)
|
||||
const [mfaModalOpen, setMfaModalOpen] = useState(false)
|
||||
const [mfaSessionToken, setMfaSessionToken] = useState('')
|
||||
const { data: resource } = useResource()
|
||||
const { showError } = useNotification()
|
||||
const Navigate = useNavigate()
|
||||
useEffect(() => {
|
||||
const initializeSocialLogin = async () => {
|
||||
@@ -76,14 +75,23 @@ const LoginView = () => {
|
||||
}
|
||||
})
|
||||
} else if (response.status === 401) {
|
||||
setError('Wrong username or password')
|
||||
showError({
|
||||
title: 'Login Failed',
|
||||
message: 'Wrong username or password',
|
||||
})
|
||||
} else {
|
||||
setError('An error occurred, please try again')
|
||||
showError({
|
||||
title: 'Login Failed',
|
||||
message: 'An error occurred, please try again',
|
||||
})
|
||||
console.log('Login failed')
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
setError('Unable to communicate with server, please try again')
|
||||
showError({
|
||||
title: 'Connection Error',
|
||||
message: 'Unable to communicate with server, please try again',
|
||||
})
|
||||
console.log('Login failed', err)
|
||||
})
|
||||
}
|
||||
@@ -133,7 +141,10 @@ const LoginView = () => {
|
||||
})
|
||||
}
|
||||
return response.json().then(() => {
|
||||
setError("Couldn't log in with Google, please try again")
|
||||
showError({
|
||||
title: 'Google Login Failed',
|
||||
message: "Couldn't log in with Google, please try again",
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -167,7 +178,10 @@ const LoginView = () => {
|
||||
}
|
||||
|
||||
const handleMFAError = errorMessage => {
|
||||
setError(errorMessage)
|
||||
showError({
|
||||
title: 'Two-Factor Authentication Failed',
|
||||
message: errorMessage,
|
||||
})
|
||||
}
|
||||
|
||||
const handleMFAClose = () => {
|
||||
@@ -380,7 +394,11 @@ const LoginView = () => {
|
||||
loggedWithProvider(provider, data)
|
||||
}}
|
||||
onReject={() => {
|
||||
setError("Couldn't log in with Google, please try again")
|
||||
showError({
|
||||
title: 'Google Login Failed',
|
||||
message:
|
||||
"Couldn't log in with Google, please try again",
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
@@ -466,14 +484,6 @@ const LoginView = () => {
|
||||
</Button>
|
||||
</Sheet>
|
||||
</Box>
|
||||
<Snackbar
|
||||
open={error !== null}
|
||||
onClose={() => setError(null)}
|
||||
autoHideDuration={3000}
|
||||
message={error}
|
||||
>
|
||||
{error}
|
||||
</Snackbar>
|
||||
|
||||
<MFAVerificationModal
|
||||
open={mfaModalOpen}
|
||||
|
||||
@@ -7,12 +7,12 @@ import {
|
||||
FormHelperText,
|
||||
Input,
|
||||
Sheet,
|
||||
Snackbar,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import Logo from '../../Logo'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { login, signUp } from '../../utils/Fetcher'
|
||||
|
||||
const SignupView = () => {
|
||||
@@ -25,9 +25,7 @@ const SignupView = () => {
|
||||
const [passwordError, setPasswordError] = React.useState('')
|
||||
const [emailError, setEmailError] = React.useState('')
|
||||
const [displayNameError, setDisplayNameError] = React.useState('')
|
||||
const [error, setError] = React.useState(null)
|
||||
const [snackbarOpen, setSnackbarOpen] = React.useState(false)
|
||||
const [snackbarMessage, setSnackbarMessage] = React.useState('')
|
||||
const { showError } = useNotification()
|
||||
const handleLogin = (username, password) => {
|
||||
login(username, password).then(response => {
|
||||
if (response.status === 200) {
|
||||
@@ -104,11 +102,17 @@ const SignupView = () => {
|
||||
if (response.status === 201) {
|
||||
handleLogin(username, password)
|
||||
} else if (response.status === 403) {
|
||||
setError('Signup disabled, please contact admin')
|
||||
showError({
|
||||
title: 'Signup Failed',
|
||||
message: 'Signup disabled, please contact admin',
|
||||
})
|
||||
} else {
|
||||
console.log('Signup failed')
|
||||
response.json().then(res => {
|
||||
setError(res.error)
|
||||
showError({
|
||||
title: 'Signup Failed',
|
||||
message: res.error || 'An error occurred during signup',
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -264,14 +268,6 @@ const SignupView = () => {
|
||||
</Button>
|
||||
</Sheet>
|
||||
</Box>
|
||||
<Snackbar
|
||||
open={error !== null}
|
||||
onClose={() => setError(null)}
|
||||
autoHideDuration={5000}
|
||||
message={error}
|
||||
>
|
||||
{error}
|
||||
</Snackbar>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,13 +7,13 @@ import {
|
||||
FormHelperText,
|
||||
Input,
|
||||
Sheet,
|
||||
Snackbar,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
|
||||
import Logo from '../../Logo'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { ChangePassword } from '../../utils/Fetcher'
|
||||
|
||||
const UpdatePasswordView = () => {
|
||||
@@ -24,8 +24,7 @@ const UpdatePasswordView = () => {
|
||||
const [passworConfirmationError, setPasswordConfirmationError] =
|
||||
useState(null)
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
const [updateStatusOk, setUpdateStatusOk] = useState(null)
|
||||
const { showError, showNotification } = useNotification()
|
||||
|
||||
const verifiticationCode = searchParams.get('c')
|
||||
|
||||
@@ -55,16 +54,27 @@ const UpdatePasswordView = () => {
|
||||
const response = await ChangePassword(verifiticationCode, password)
|
||||
|
||||
if (response.ok) {
|
||||
setUpdateStatusOk(true)
|
||||
showNotification({
|
||||
type: 'success',
|
||||
title: 'Password Updated',
|
||||
message:
|
||||
'Your password has been updated successfully. Redirecting to login...',
|
||||
})
|
||||
// wait 3 seconds and then redirect to login:
|
||||
setTimeout(() => {
|
||||
navigate('/login')
|
||||
}, 3000)
|
||||
} else {
|
||||
setUpdateStatusOk(false)
|
||||
showError({
|
||||
title: 'Password Update Failed',
|
||||
message: 'Failed to update password, please try again later',
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
setUpdateStatusOk(false)
|
||||
showError({
|
||||
title: 'Password Update Failed',
|
||||
message: 'Failed to update password, please try again later',
|
||||
})
|
||||
}
|
||||
}
|
||||
return (
|
||||
@@ -169,15 +179,6 @@ const UpdatePasswordView = () => {
|
||||
</Button>
|
||||
</Sheet>
|
||||
</Box>
|
||||
<Snackbar
|
||||
open={updateStatusOk === false}
|
||||
autoHideDuration={6000}
|
||||
onClose={() => {
|
||||
setUpdateStatusOk(null)
|
||||
}}
|
||||
>
|
||||
Password update failed, try again later
|
||||
</Snackbar>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,8 +18,6 @@ import {
|
||||
RadioGroup,
|
||||
Select,
|
||||
Sheet,
|
||||
Snackbar,
|
||||
Stack,
|
||||
Switch,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
@@ -33,6 +31,7 @@ import {
|
||||
useUpdateChore,
|
||||
} from '../../queries/ChoreQueries.jsx'
|
||||
import { useUserProfile } from '../../queries/UserQueries.jsx'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||
import {
|
||||
DeleteChore,
|
||||
@@ -101,9 +100,6 @@ const ChoreEdit = () => {
|
||||
const [createdBy, setCreatedBy] = useState(0)
|
||||
const [errors, setErrors] = useState({})
|
||||
const [attemptToSave, setAttemptToSave] = useState(false)
|
||||
const [isSnackbarOpen, setIsSnackbarOpen] = useState(false)
|
||||
const [snackbarMessage, setSnackbarMessage] = useState('')
|
||||
const [snackbarColor, setSnackbarColor] = useState('warning')
|
||||
const [addLabelModalOpen, setAddLabelModalOpen] = useState(false)
|
||||
const { data: userLabelsRaw, isLoading: isUserLabelsLoading } = useLabels()
|
||||
const updateChoreMutation = useUpdateChore()
|
||||
@@ -113,6 +109,7 @@ const ChoreEdit = () => {
|
||||
isLoading: isChoreLoading,
|
||||
refetch: refetchChore,
|
||||
} = useChore(choreId)
|
||||
const { showSuccess, showError } = useNotification()
|
||||
|
||||
const [userLabels, setUserLabels] = useState([])
|
||||
|
||||
@@ -178,16 +175,10 @@ const ChoreEdit = () => {
|
||||
const errorList = Object.keys(errors).map(key => (
|
||||
<ListItem key={key}>{errors[key]}</ListItem>
|
||||
))
|
||||
setSnackbarMessage(
|
||||
<Stack spacing={0.5}>
|
||||
<Typography level='title-md'>
|
||||
Please resolve the following errors:
|
||||
</Typography>
|
||||
<List>{errorList}</List>
|
||||
</Stack>,
|
||||
)
|
||||
setSnackbarColor('danger')
|
||||
setIsSnackbarOpen(true)
|
||||
showError({
|
||||
title: 'Please resolve the following errors:',
|
||||
message: <List>{errorList}</List>,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -240,16 +231,18 @@ const ChoreEdit = () => {
|
||||
|
||||
SaveFunction(chore)
|
||||
.then(() => {
|
||||
setSnackbarColor('success')
|
||||
setSnackbarMessage('Chore saved successfully!')
|
||||
setIsSnackbarOpen(true)
|
||||
showSuccess({
|
||||
title: 'Chore Saved',
|
||||
message: 'Your task has been saved successfully!',
|
||||
})
|
||||
Navigate('/my/chores/')
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Failed to save chore:', error)
|
||||
setSnackbarColor('danger')
|
||||
setSnackbarMessage('Failed to save chore, please try again.')
|
||||
setIsSnackbarOpen(true)
|
||||
showError({
|
||||
title: 'Save Failed',
|
||||
message: 'Failed to save chore, please try again.',
|
||||
})
|
||||
})
|
||||
}
|
||||
useEffect(() => {
|
||||
@@ -1099,20 +1092,6 @@ const ChoreEdit = () => {
|
||||
/>
|
||||
)}
|
||||
{/* <ChoreHistory ChoreHistory={choresHistory} UsersData={performers} /> */}
|
||||
<Snackbar
|
||||
open={isSnackbarOpen}
|
||||
onClose={() => {
|
||||
setIsSnackbarOpen(false)
|
||||
setSnackbarMessage(null)
|
||||
}}
|
||||
color={snackbarColor}
|
||||
autoHideDuration={4000}
|
||||
sx={{ bottom: 70 }}
|
||||
invertedColors={true}
|
||||
variant='soft'
|
||||
>
|
||||
{snackbarMessage}
|
||||
</Snackbar>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { Divider } from '@mui/material'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
@@ -61,6 +62,7 @@ const ChoreView = () => {
|
||||
const [infoCards, setInfoCards] = useState([])
|
||||
const { choreId } = useParams()
|
||||
const [note, setNote] = useState(null)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
@@ -71,18 +73,12 @@ const ChoreView = () => {
|
||||
const [confirmModelConfig, setConfirmModelConfig] = useState({})
|
||||
const [chorePriority, setChorePriority] = useState(null)
|
||||
const [isDescriptionOpen, setIsDescriptionOpen] = useState(false)
|
||||
const {
|
||||
data: circleMembersData,
|
||||
isLoading: isCircleMembersLoading,
|
||||
handleRefetch: handleCircleMembersRefetch,
|
||||
} = useCircleMembers()
|
||||
const { data: circleMembersData, isLoading: isCircleMembersLoading } =
|
||||
useCircleMembers()
|
||||
const { impersonatedUser } = useImpersonateUser()
|
||||
|
||||
const {
|
||||
data: choreData,
|
||||
isLoading: isChoreLoading,
|
||||
refetch: refetchChore,
|
||||
} = useChoreDetails(choreId)
|
||||
const { data: choreData, isLoading: isChoreLoading } =
|
||||
useChoreDetails(choreId)
|
||||
|
||||
useEffect(() => {
|
||||
if (!choreData || !choreData.res || !circleMembersData) {
|
||||
@@ -107,8 +103,10 @@ const ChoreView = () => {
|
||||
const handleUpdatePriority = priority => {
|
||||
UpdateChorePriority(choreId, priority.value).then(response => {
|
||||
if (response.ok) {
|
||||
response.json().then(data => {
|
||||
response.json().then(() => {
|
||||
setChorePriority(priority)
|
||||
// Invalidate chores cache to refetch data
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -195,6 +193,8 @@ const ChoreView = () => {
|
||||
clearInterval(countdownInterval) // Ensure to clear this interval as well
|
||||
setTimeoutId(null)
|
||||
setSecondsLeftToCancel(null)
|
||||
// Invalidate chores cache to refetch data
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
})
|
||||
.then(() => {
|
||||
// refetch the chore details
|
||||
@@ -216,6 +216,8 @@ const ChoreView = () => {
|
||||
response.json().then(data => {
|
||||
const newChore = data.res
|
||||
setChore(newChore)
|
||||
// Invalidate chores cache to refetch data
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -7,9 +7,6 @@ import {
|
||||
Chip,
|
||||
FormControl,
|
||||
Input,
|
||||
ListItem,
|
||||
ListItemContent,
|
||||
ListItemDecorator,
|
||||
Option,
|
||||
Select,
|
||||
TextField,
|
||||
@@ -113,7 +110,7 @@ const ThingTriggerSection = ({
|
||||
onChange={(e, newValue) => setSelectedThing(newValue)}
|
||||
getOptionLabel={option => option.name}
|
||||
renderOption={(props, option) => (
|
||||
<ListItem {...props}>
|
||||
<Box {...props}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
@@ -123,19 +120,19 @@ const ThingTriggerSection = ({
|
||||
p: 1,
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator sx={{ alignSelf: 'flex-start' }}>
|
||||
<Box sx={{ alignSelf: 'flex-start' }}>
|
||||
<Typography level='body-lg' textColor='primary'>
|
||||
{option.name}
|
||||
</Typography>
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography level='body2' textColor='text.secondary'>
|
||||
<Chip>type: {option.type}</Chip>{' '}
|
||||
<Chip>state: {option.state}</Chip>
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</Box>
|
||||
</Box>
|
||||
</ListItem>
|
||||
</Box>
|
||||
)}
|
||||
renderInput={params => (
|
||||
<TextField {...params} label='Select a thing' />
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Grid,
|
||||
@@ -23,7 +24,7 @@ import React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||
import { useUserProfile } from '../../queries/UserQueries.jsx'
|
||||
import { useError } from '../../service/ErrorProvider'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { notInCompletionWindow } from '../../utils/Chores.jsx'
|
||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||
import {
|
||||
@@ -47,6 +48,10 @@ const ChoreCard = ({
|
||||
sx,
|
||||
viewOnly,
|
||||
onChipClick,
|
||||
// Multi-select props
|
||||
isMultiSelectMode = false,
|
||||
isSelected = false,
|
||||
onSelectionToggle,
|
||||
}) => {
|
||||
const [isChangeDueDateModalOpen, setIsChangeDueDateModalOpen] =
|
||||
React.useState(false)
|
||||
@@ -67,7 +72,7 @@ const ChoreCard = ({
|
||||
|
||||
const { impersonatedUser } = useImpersonateUser()
|
||||
|
||||
const { showError } = useError()
|
||||
const { showError } = useNotification()
|
||||
|
||||
const handleDelete = () => {
|
||||
setConfirmModelConfig({
|
||||
@@ -392,19 +397,71 @@ const ChoreCard = ({
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
p: 2,
|
||||
// backgroundColor: 'white',
|
||||
boxShadow: 'sm',
|
||||
borderRadius: 20,
|
||||
key: `${chore.id}-card`,
|
||||
|
||||
// mb: 2,
|
||||
position: 'relative',
|
||||
backgroundColor: 'background.surface',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
cursor: isMultiSelectMode ? 'pointer' : 'default',
|
||||
'&:hover': {
|
||||
boxShadow: 'md',
|
||||
borderColor: isMultiSelectMode ? 'primary.500' : 'primary.300',
|
||||
},
|
||||
// Add padding when in multi-select mode to account for checkbox
|
||||
pl: isMultiSelectMode ? 6 : 2,
|
||||
// Visual feedback when selected
|
||||
...(isMultiSelectMode &&
|
||||
isSelected && {
|
||||
borderColor: 'primary.500',
|
||||
backgroundColor: 'primary.softBg',
|
||||
boxShadow: 'sm',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{/* Multi-select checkbox */}
|
||||
{isMultiSelectMode && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={onSelectionToggle}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: 12,
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 2,
|
||||
bgcolor: 'background.surface',
|
||||
borderRadius: 'md',
|
||||
borderColor: 'divider',
|
||||
'&:hover': {
|
||||
bgcolor: 'background.level1',
|
||||
borderColor: 'primary.300',
|
||||
},
|
||||
'&.Mui-checked': {
|
||||
bgcolor: 'primary.500',
|
||||
borderColor: 'primary.500',
|
||||
color: 'primary.solidColor',
|
||||
'&:hover': {
|
||||
bgcolor: 'primary.600',
|
||||
borderColor: 'primary.600',
|
||||
},
|
||||
},
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
<Grid container>
|
||||
<Grid
|
||||
xs={9}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
navigate(`/chores/${chore.id}`)
|
||||
if (isMultiSelectMode) {
|
||||
onSelectionToggle()
|
||||
} else {
|
||||
navigate(`/chores/${chore.id}`)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Box in top right with Chip showing next due date */}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
@@ -19,16 +20,18 @@ import React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||
import { useUserProfile } from '../../queries/UserQueries.jsx'
|
||||
import { useError } from '../../service/ErrorProvider'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { notInCompletionWindow } from '../../utils/Chores.jsx'
|
||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||
import {
|
||||
getTextColorFromBackgroundColor,
|
||||
TASK_COLOR,
|
||||
} from '../../utils/Colors.jsx'
|
||||
import {
|
||||
DeleteChore,
|
||||
MarkChoreComplete,
|
||||
UpdateChoreAssignee,
|
||||
UpdateDueDate,
|
||||
} from '../../utils/Fetcher'
|
||||
import Priorities from '../../utils/Priorities'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import DateModal from '../Modals/Inputs/DateModal'
|
||||
import SelectModal from '../Modals/Inputs/SelectModal'
|
||||
@@ -44,6 +47,10 @@ const CompactChoreCard = ({
|
||||
sx,
|
||||
viewOnly,
|
||||
onChipClick,
|
||||
// Multi-select props
|
||||
isMultiSelectMode = false,
|
||||
isSelected = false,
|
||||
onSelectionToggle,
|
||||
}) => {
|
||||
const [isChangeDueDateModalOpen, setIsChangeDueDateModalOpen] =
|
||||
React.useState(false)
|
||||
@@ -64,7 +71,7 @@ const CompactChoreCard = ({
|
||||
|
||||
const { impersonatedUser } = useImpersonateUser()
|
||||
|
||||
const { showError } = useError()
|
||||
const { showError } = useNotification()
|
||||
|
||||
// All the existing handler methods (same as original ChoreCard)
|
||||
const handleDelete = () => {
|
||||
@@ -364,6 +371,21 @@ const CompactChoreCard = ({
|
||||
return parts.join(' • ')
|
||||
}
|
||||
|
||||
const getPriorityColor = priority => {
|
||||
switch (priority) {
|
||||
case 1:
|
||||
return TASK_COLOR.PRIORITY_1
|
||||
case 2:
|
||||
return TASK_COLOR.PRIORITY_2
|
||||
case 3:
|
||||
return TASK_COLOR.PRIORITY_3
|
||||
case 4:
|
||||
return TASK_COLOR.PRIORITY_4
|
||||
default:
|
||||
return TASK_COLOR.NO_PRIORITY
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={chore.id + '-compact-box'}>
|
||||
<Box
|
||||
@@ -372,23 +394,173 @@ const CompactChoreCard = ({
|
||||
...sx,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
// px: 1,
|
||||
// py: 0.75,
|
||||
minHeight: 56, // More compact height
|
||||
cursor: 'pointer',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
position: 'relative',
|
||||
pl: '16px', // Consistent padding since both elements are in the same position
|
||||
// backgroundColor: 'background.surface',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
bgcolor: 'background.level1',
|
||||
boxShadow: 'sm',
|
||||
},
|
||||
'&:last-child': {
|
||||
borderBottom: 'none',
|
||||
},
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '3px',
|
||||
backgroundColor: getPriorityColor(chore.priority),
|
||||
borderRadius: '16px',
|
||||
},
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isMultiSelectMode) {
|
||||
onSelectionToggle()
|
||||
} else {
|
||||
navigate(`/chores/${chore.id}`)
|
||||
}
|
||||
}}
|
||||
onClick={() => navigate(`/chores/${chore.id}`)}
|
||||
>
|
||||
{/* Left side - Content */}
|
||||
{/* Priority bar clickable area */}
|
||||
{chore.priority > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '12px',
|
||||
cursor: 'pointer',
|
||||
zIndex: 1,
|
||||
}}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onChipClick({ priority: chore.priority })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Animated transition container for Complete Button / Multi-select checkbox */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 40,
|
||||
height: 40,
|
||||
mr: 1.5,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{/* Complete Button */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition:
|
||||
'opacity 0.3s ease-in-out, transform 0.3s ease-in-out',
|
||||
opacity: isMultiSelectMode ? 0 : 1,
|
||||
transform: isMultiSelectMode
|
||||
? 'scale(0.8) rotate(45deg)'
|
||||
: 'scale(1) rotate(0deg)',
|
||||
pointerEvents: isMultiSelectMode ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
variant='solid'
|
||||
color='success'
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleTaskCompletion()
|
||||
}}
|
||||
disabled={isPendingCompletion || notInCompletionWindow(chore)}
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '50%',
|
||||
transition: 'all 0.2s ease',
|
||||
|
||||
'&:active': {
|
||||
transform: 'scale(0.95)',
|
||||
},
|
||||
'&:disabled': {
|
||||
opacity: 0.5,
|
||||
transform: 'none',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isPendingCompletion ? (
|
||||
<CircularProgress size='sm' />
|
||||
) : (
|
||||
<Check sx={{ fontSize: 16 }} />
|
||||
)}
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Multi-select Checkbox */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition:
|
||||
'opacity 0.3s ease-in-out, transform 0.3s ease-in-out',
|
||||
opacity: isMultiSelectMode ? 1 : 0,
|
||||
transform: isMultiSelectMode
|
||||
? 'scale(1) rotate(0deg)'
|
||||
: 'scale(0.8) rotate(-45deg)',
|
||||
pointerEvents: isMultiSelectMode ? 'auto' : 'none',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={onSelectionToggle}
|
||||
sx={{
|
||||
bgcolor: 'background.surface',
|
||||
borderRadius: 'md',
|
||||
boxShadow: 'sm',
|
||||
border: '2px solid',
|
||||
borderColor: 'divider',
|
||||
'&:hover': {
|
||||
bgcolor: 'background.level1',
|
||||
borderColor: 'primary.300',
|
||||
},
|
||||
'&.Mui-checked': {
|
||||
bgcolor: 'primary.500',
|
||||
borderColor: 'primary.500',
|
||||
color: 'primary.solidColor',
|
||||
'&:hover': {
|
||||
bgcolor: 'primary.600',
|
||||
borderColor: 'primary.600',
|
||||
},
|
||||
},
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Content - Center */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
@@ -398,7 +570,7 @@ const CompactChoreCard = ({
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Line 1: Name + Due Date + Frequency */}
|
||||
{/* Line 1: Name + Due Date */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
@@ -407,36 +579,35 @@ const CompactChoreCard = ({
|
||||
mb: 0.25,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
{/* Chore Name */}
|
||||
<Typography
|
||||
level='title-sm'
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minWidth: 0,
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
mr: 1,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{/* Chore Name */}
|
||||
<Typography
|
||||
level='title-sm'
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
mr: 1,
|
||||
}}
|
||||
>
|
||||
{chore.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
{chore.name}
|
||||
</Typography>
|
||||
|
||||
{/* Due Date */}
|
||||
{/* Due Date - Inline with name */}
|
||||
<Chip
|
||||
variant='soft'
|
||||
size='sm'
|
||||
color={getDueDateColor(chore.nextDueDate)}
|
||||
sx={{ fontSize: 10, height: 20, flexShrink: 0 }}
|
||||
sx={{
|
||||
fontSize: 10,
|
||||
height: 18,
|
||||
px: 0.75,
|
||||
flexShrink: 0,
|
||||
ml: 1,
|
||||
}}
|
||||
>
|
||||
{getDueDateText(chore.nextDueDate)}
|
||||
</Chip>
|
||||
@@ -458,35 +629,7 @@ const CompactChoreCard = ({
|
||||
{formatMetadata()}
|
||||
</Typography>
|
||||
|
||||
{/* Labels */}
|
||||
{chore.priority > 0 && (
|
||||
<Chip
|
||||
variant='solid'
|
||||
size='sm'
|
||||
color={
|
||||
chore.priority === 1
|
||||
? 'danger'
|
||||
: chore.priority === 2
|
||||
? 'warning'
|
||||
: 'neutral'
|
||||
}
|
||||
startDecorator={
|
||||
Priorities.find(p => p.value === chore.priority)?.icon
|
||||
}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onChipClick({ priority: chore.priority })
|
||||
}}
|
||||
sx={{
|
||||
ml: 0.5,
|
||||
// height: 16,
|
||||
// fontSize: 9,
|
||||
// px: 0.5,
|
||||
}}
|
||||
>
|
||||
P{chore.priority}
|
||||
</Chip>
|
||||
)}
|
||||
{/* Labels - Priority chip removed, now shown as vertical bar */}
|
||||
{chore.labelsV2?.map(l => (
|
||||
<div
|
||||
role='none'
|
||||
@@ -530,39 +673,21 @@ const CompactChoreCard = ({
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Right side - Actions */}
|
||||
{/* Right side - Action Menu with animation */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.25,
|
||||
flexShrink: 0,
|
||||
transition:
|
||||
'opacity 0.3s ease-in-out, transform 0.3s ease-in-out, width 0.3s ease-in-out, margin 0.3s ease-in-out',
|
||||
opacity: isMultiSelectMode ? 0 : 1,
|
||||
transform: isMultiSelectMode
|
||||
? 'translateX(20px) scale(0.8)'
|
||||
: 'translateX(0) scale(1)',
|
||||
width: isMultiSelectMode ? 0 : 32,
|
||||
marginRight: isMultiSelectMode ? 0 : undefined,
|
||||
overflow: 'hidden',
|
||||
pointerEvents: isMultiSelectMode ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
{/* Complete Button */}
|
||||
<IconButton
|
||||
variant='solid'
|
||||
color='success'
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleTaskCompletion()
|
||||
}}
|
||||
disabled={isPendingCompletion || notInCompletionWindow(chore)}
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
>
|
||||
{isPendingCompletion ? (
|
||||
<CircularProgress size='sm' color='success' />
|
||||
) : (
|
||||
<Check sx={{ fontSize: 16 }} />
|
||||
)}
|
||||
</IconButton>
|
||||
|
||||
{/* Chore Action Menu */}
|
||||
<ChoreActionMenu
|
||||
variant='plain'
|
||||
chore={chore}
|
||||
@@ -577,12 +702,13 @@ const CompactChoreCard = ({
|
||||
onWriteNFC={() => setIsNFCModalOpen(true)}
|
||||
onDelete={handleDelete}
|
||||
sx={{
|
||||
width: 28,
|
||||
marginRight: -3,
|
||||
height: 28,
|
||||
// opacity: 0.6,
|
||||
width: 32,
|
||||
height: 32,
|
||||
color: 'text.tertiary',
|
||||
flexShrink: 0,
|
||||
'&:hover': {
|
||||
opacity: 0,
|
||||
color: 'text.secondary',
|
||||
bgcolor: 'background.level1',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
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 {
|
||||
Add,
|
||||
Archive,
|
||||
Bolt,
|
||||
CancelRounded,
|
||||
CheckBox,
|
||||
CheckBoxOutlineBlank,
|
||||
Close,
|
||||
Delete,
|
||||
Done,
|
||||
EditCalendar,
|
||||
ExpandCircleDown,
|
||||
Grain,
|
||||
PriorityHigh,
|
||||
SelectAll,
|
||||
SkipNext,
|
||||
Sort,
|
||||
Style,
|
||||
Unarchive,
|
||||
@@ -26,23 +34,27 @@ import {
|
||||
List,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Snackbar,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import Fuse from 'fuse.js'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useChores } from '../../queries/ChoreQueries'
|
||||
import { GetArchivedChores } from '../../utils/Fetcher'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { ArchiveChore, GetArchivedChores } from '../../utils/Fetcher'
|
||||
import Priorities from '../../utils/Priorities'
|
||||
import LoadingComponent from '../components/Loading'
|
||||
import { useLabels } from '../Labels/LabelQueries'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import ChoreCard from './ChoreCard'
|
||||
import CompactChoreCard from './CompactChoreCard'
|
||||
import IconButtonWithMenu from './IconButtonWithMenu'
|
||||
import MultiSelectHelp from './MultiSelectHelp'
|
||||
|
||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||
import { ChoreFilters, ChoresGrouper, ChoreSorter } from '../../utils/Chores'
|
||||
import { DeleteChore, MarkChoreComplete, SkipChore } from '../../utils/Fetcher'
|
||||
import TaskInput from '../components/AddTaskModal'
|
||||
import {
|
||||
canScheduleNotification,
|
||||
@@ -55,8 +67,8 @@ import SortAndGrouping from './SortAndGrouping'
|
||||
const MyChores = () => {
|
||||
const { data: userProfile, isLoading: isUserProfileLoading } =
|
||||
useUserProfile()
|
||||
const [isSnackbarOpen, setIsSnackbarOpen] = useState(false)
|
||||
const [snackBarMessage, setSnackBarMessage] = useState(null)
|
||||
const { showSuccess, showError } = useNotification()
|
||||
const { impersonatedUser } = useImpersonateUser()
|
||||
const [chores, setChores] = useState([])
|
||||
const [archivedChores, setArchivedChores] = useState(null)
|
||||
const [filteredChores, setFilteredChores] = useState([])
|
||||
@@ -93,6 +105,11 @@ const MyChores = () => {
|
||||
} = useChores()
|
||||
const { data: membersData, isLoading: membersLoading } = useCircleMembers()
|
||||
|
||||
// Multi-select state
|
||||
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false)
|
||||
const [selectedChores, setSelectedChores] = useState(new Set())
|
||||
const [confirmModelConfig, setConfirmModelConfig] = useState({})
|
||||
|
||||
useEffect(() => {
|
||||
if (!choresLoading && !membersLoading && userProfile) {
|
||||
setPerformers(membersData.res)
|
||||
@@ -119,7 +136,14 @@ const MyChores = () => {
|
||||
scheduleChoreNotification(choresData.res, userProfile, membersData.res)
|
||||
}
|
||||
}
|
||||
}, [membersLoading, choresLoading, isUserProfileLoading])
|
||||
}, [
|
||||
membersLoading,
|
||||
choresLoading,
|
||||
isUserProfileLoading,
|
||||
choresData,
|
||||
membersData,
|
||||
userProfile,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('mousedown', handleMenuOutsideClick)
|
||||
@@ -137,20 +161,150 @@ const MyChores = () => {
|
||||
}
|
||||
}, [searchInputFocus])
|
||||
|
||||
// add listern to Control/Command + K to focus on search input
|
||||
// Keyboard shortcuts for multi-select and other actions
|
||||
useEffect(() => {
|
||||
const handleKeyDown = event => {
|
||||
// Ctrl/Cmd + K to open task modal
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
|
||||
event.preventDefault()
|
||||
setAddTaskModalOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + F to focus search input:
|
||||
else if ((event.ctrlKey || event.metaKey) && event.key === 'f') {
|
||||
event.preventDefault()
|
||||
searchInputRef.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + S Toggle Multi-select mode
|
||||
else if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
||||
event.preventDefault()
|
||||
toggleMultiSelectMode()
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + A to select all - works both in and out of multi-select mode
|
||||
else if (
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
event.key === 'a' &&
|
||||
!['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)
|
||||
) {
|
||||
event.preventDefault()
|
||||
if (!isMultiSelectMode) {
|
||||
// Enable multi-select mode and select all visible tasks
|
||||
setIsMultiSelectMode(true)
|
||||
setTimeout(() => {
|
||||
selectAllVisibleChores()
|
||||
}, 0)
|
||||
// showSuccess({
|
||||
// title: '🎯 Multi-select Mode Active',
|
||||
// message: 'Selected all visible tasks. Press Esc to exit.',
|
||||
// })
|
||||
} else {
|
||||
// Already in multi-select mode, check if all visible tasks are already selected
|
||||
let visibleChores = []
|
||||
|
||||
if (searchTerm?.length > 0 || searchFilter !== 'All') {
|
||||
visibleChores = filteredChores
|
||||
const allVisibleSelected =
|
||||
visibleChores.length > 0 &&
|
||||
visibleChores.every(chore => selectedChores.has(chore.id))
|
||||
|
||||
if (allVisibleSelected) {
|
||||
showSuccess({
|
||||
title: '✅ All Tasks Selected',
|
||||
message: `All ${visibleChores.length} filtered task${visibleChores.length !== 1 ? 's are' : ' is'} already selected.`,
|
||||
})
|
||||
} else {
|
||||
selectAllVisibleChores()
|
||||
showSuccess({
|
||||
title: '🎯 Tasks Selected',
|
||||
message: `Selected ${visibleChores.length} filtered task${visibleChores.length !== 1 ? 's' : ''}.`,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Check expanded sections first
|
||||
const expandedChores = choreSections
|
||||
.filter((section, index) => openChoreSections[index])
|
||||
.flatMap(section => section.content || [])
|
||||
|
||||
const allExpandedSelected =
|
||||
expandedChores.length > 0 &&
|
||||
expandedChores.every(chore => selectedChores.has(chore.id))
|
||||
|
||||
// Get all chores (including collapsed sections)
|
||||
const allChores = choreSections.flatMap(
|
||||
section => section.content || [],
|
||||
)
|
||||
const allChoresSelected =
|
||||
allChores.length > 0 &&
|
||||
allChores.every(chore => selectedChores.has(chore.id))
|
||||
|
||||
if (allChoresSelected) {
|
||||
// All chores (including collapsed) are already selected
|
||||
showSuccess({
|
||||
title: '✅ All Tasks Selected',
|
||||
message: `All ${allChores.length} task${allChores.length !== 1 ? 's are' : ' is'} already selected (including collapsed sections).`,
|
||||
})
|
||||
} else if (allExpandedSelected) {
|
||||
// All expanded are selected, now select ALL (including collapsed)
|
||||
selectAllVisibleChores() // This will now select all chores
|
||||
const collapsedCount = allChores.length - expandedChores.length
|
||||
showSuccess({
|
||||
title: '🎯 All Tasks Selected',
|
||||
message: `Selected all ${allChores.length} tasks (including ${collapsedCount} from collapsed sections).`,
|
||||
})
|
||||
} else {
|
||||
// Not all expanded are selected, select expanded only
|
||||
selectAllVisibleChores() // This will select expanded only
|
||||
showSuccess({
|
||||
title: '🎯 Tasks Selected',
|
||||
message: `Selected ${expandedChores.length} task${expandedChores.length !== 1 ? 's' : ''} from expanded sections.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-select keyboard shortcuts (only when in multi-select mode)
|
||||
if (isMultiSelectMode) {
|
||||
// Escape to clear selection or exit multi-select mode
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
if (selectedChores.size > 0) {
|
||||
clearSelection()
|
||||
} else {
|
||||
setIsMultiSelectMode(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Delete/Backspace key for bulk delete (with confirmation)
|
||||
if (
|
||||
(event.key === 'Delete' || event.key === 'Backspace') &&
|
||||
selectedChores.size > 0
|
||||
) {
|
||||
event.preventDefault()
|
||||
handleBulkDelete()
|
||||
return
|
||||
}
|
||||
|
||||
// Enter key for bulk complete
|
||||
if (event.key === 'Enter' && selectedChores.size > 0) {
|
||||
event.preventDefault()
|
||||
handleBulkComplete()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [])
|
||||
}, [isMultiSelectMode, selectedChores.size])
|
||||
const setSelectedChoreSectionWithCache = value => {
|
||||
setSelectedChoreSection(value)
|
||||
localStorage.setItem('selectedChoreSection', value)
|
||||
@@ -182,6 +336,10 @@ const MyChores = () => {
|
||||
performers={performers}
|
||||
userLabels={userLabels}
|
||||
onChipClick={handleLabelFiltering}
|
||||
// Multi-select props
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
isSelected={selectedChores.has(chore.id)}
|
||||
onSelectionToggle={() => toggleChoreSelection(chore.id)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -283,24 +441,42 @@ const MyChores = () => {
|
||||
|
||||
switch (event) {
|
||||
case 'completed':
|
||||
setSnackBarMessage('Completed')
|
||||
showSuccess({
|
||||
title: 'Task Completed',
|
||||
message: 'Great job! The task has been marked as completed.',
|
||||
})
|
||||
break
|
||||
case 'skipped':
|
||||
setSnackBarMessage('Skipped')
|
||||
showSuccess({
|
||||
title: 'Task Skipped',
|
||||
message: 'The task has been moved to the next due date.',
|
||||
})
|
||||
break
|
||||
case 'rescheduled':
|
||||
setSnackBarMessage('Rescheduled')
|
||||
showSuccess({
|
||||
title: 'Task Rescheduled',
|
||||
message: 'The task due date has been updated successfully.',
|
||||
})
|
||||
break
|
||||
case 'unarchive':
|
||||
setSnackBarMessage('Unarchive')
|
||||
showSuccess({
|
||||
title: 'Task Restored',
|
||||
message: 'The task has been restored and is now active.',
|
||||
})
|
||||
break
|
||||
case 'archive':
|
||||
setSnackBarMessage('Archived')
|
||||
showSuccess({
|
||||
title: 'Task Archived',
|
||||
message:
|
||||
'The task has been archived and hidden from the active list.',
|
||||
})
|
||||
break
|
||||
default:
|
||||
setSnackBarMessage('Updated')
|
||||
showSuccess({
|
||||
title: 'Task Updated',
|
||||
message: 'Your changes have been saved successfully.',
|
||||
})
|
||||
}
|
||||
setIsSnackbarOpen(true)
|
||||
}
|
||||
|
||||
const handleChoreDeleted = deletedChore => {
|
||||
@@ -351,37 +527,310 @@ const MyChores = () => {
|
||||
setFilteredChores(fuse.search(term).map(result => result.item))
|
||||
}
|
||||
|
||||
// Multi-select helper functions
|
||||
const toggleMultiSelectMode = () => {
|
||||
const newMode = !isMultiSelectMode
|
||||
setIsMultiSelectMode(newMode)
|
||||
|
||||
if (newMode) {
|
||||
setSelectedChores(new Set()) // Clear selection when exiting multi-select
|
||||
}
|
||||
}
|
||||
|
||||
const toggleChoreSelection = choreId => {
|
||||
const newSelection = new Set(selectedChores)
|
||||
if (newSelection.has(choreId)) {
|
||||
newSelection.delete(choreId)
|
||||
} else {
|
||||
newSelection.add(choreId)
|
||||
}
|
||||
setSelectedChores(newSelection)
|
||||
}
|
||||
|
||||
const selectAllVisibleChores = () => {
|
||||
let visibleChores = []
|
||||
|
||||
if (searchTerm?.length > 0 || searchFilter !== 'All') {
|
||||
// If there's a search term or filter, all filtered chores are visible
|
||||
visibleChores = filteredChores
|
||||
} else {
|
||||
// First, get chores from expanded sections only
|
||||
const expandedChores = choreSections
|
||||
.filter((section, index) => openChoreSections[index]) // Only expanded sections
|
||||
.flatMap(section => section.content || []) // Get all chores from expanded sections
|
||||
|
||||
// Check if all expanded chores are already selected
|
||||
const allExpandedSelected =
|
||||
expandedChores.length > 0 &&
|
||||
expandedChores.every(chore => selectedChores.has(chore.id))
|
||||
|
||||
if (allExpandedSelected) {
|
||||
// If all expanded chores are already selected, select ALL chores (including collapsed sections)
|
||||
visibleChores = choreSections.flatMap(section => section.content || [])
|
||||
} else {
|
||||
// Otherwise, just select expanded chores
|
||||
visibleChores = expandedChores
|
||||
}
|
||||
}
|
||||
|
||||
if (visibleChores.length > 0) {
|
||||
const allIds = new Set(visibleChores.map(chore => chore.id))
|
||||
setSelectedChores(allIds)
|
||||
}
|
||||
}
|
||||
|
||||
const clearSelection = () => {
|
||||
// if already empty, just exit multi-select mode:
|
||||
if (selectedChores.size === 0) {
|
||||
setIsMultiSelectMode(false)
|
||||
return
|
||||
}
|
||||
setSelectedChores(new Set())
|
||||
}
|
||||
|
||||
const getSelectedChoresData = () => {
|
||||
const allChores = [...chores, ...(archivedChores || [])]
|
||||
return Array.from(selectedChores)
|
||||
.map(id => allChores.find(chore => chore.id === id))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
// Bulk operations with improved UX and confirmation modal
|
||||
const handleBulkComplete = async () => {
|
||||
const selectedData = getSelectedChoresData()
|
||||
if (selectedData.length === 0) return
|
||||
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
title: 'Complete Tasks',
|
||||
confirmText: 'Complete',
|
||||
cancelText: 'Cancel',
|
||||
message: `Mark ${selectedData.length} task${selectedData.length > 1 ? 's' : ''} as completed?`,
|
||||
onClose: async isConfirmed => {
|
||||
if (isConfirmed === true) {
|
||||
try {
|
||||
const completedTasks = []
|
||||
const failedTasks = []
|
||||
|
||||
for (const chore of selectedData) {
|
||||
try {
|
||||
await MarkChoreComplete(
|
||||
chore.id,
|
||||
impersonatedUser
|
||||
? { completedBy: impersonatedUser.userId }
|
||||
: null,
|
||||
null,
|
||||
null,
|
||||
)
|
||||
completedTasks.push(chore)
|
||||
} catch (error) {
|
||||
failedTasks.push(chore)
|
||||
}
|
||||
}
|
||||
|
||||
if (completedTasks.length > 0) {
|
||||
showSuccess({
|
||||
title: '✅ Tasks Completed',
|
||||
message: `Successfully completed ${completedTasks.length} task${completedTasks.length > 1 ? 's' : ''}.`,
|
||||
})
|
||||
}
|
||||
|
||||
if (failedTasks.length > 0) {
|
||||
showError({
|
||||
title: 'Some Tasks Failed',
|
||||
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be completed.`,
|
||||
})
|
||||
}
|
||||
|
||||
refetchChores()
|
||||
clearSelection()
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Bulk Complete Failed',
|
||||
message: 'An unexpected error occurred. Please try again.',
|
||||
})
|
||||
}
|
||||
}
|
||||
setConfirmModelConfig({})
|
||||
},
|
||||
})
|
||||
}
|
||||
const handleBulkArchive = async () => {
|
||||
const selectedData = getSelectedChoresData()
|
||||
if (selectedData.length === 0) return
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
title: 'Archive Tasks',
|
||||
confirmText: 'Archive',
|
||||
cancelText: 'Cancel',
|
||||
message: `Archive ${selectedData.length} task${selectedData.length > 1 ? 's' : ''}?`,
|
||||
onClose: async isConfirmed => {
|
||||
if (isConfirmed === true) {
|
||||
try {
|
||||
const archivedTasks = []
|
||||
const failedTasks = []
|
||||
for (const chore of selectedData) {
|
||||
try {
|
||||
const archivedChore = await ArchiveChore(chore.id)
|
||||
archivedTasks.push(archivedChore)
|
||||
// Remove from chores and filteredChores
|
||||
setChores(chores.filter(c => c.id !== chore.id))
|
||||
setFilteredChores(filteredChores.filter(c => c.id !== chore.id))
|
||||
} catch (error) {
|
||||
failedTasks.push(chore)
|
||||
}
|
||||
}
|
||||
if (archivedTasks.length > 0) {
|
||||
showSuccess({
|
||||
title: '📦 Tasks Archived',
|
||||
message: `Successfully archived ${archivedTasks.length} task${archivedTasks.length > 1 ? 's' : ''}.`,
|
||||
})
|
||||
// Update archived chores state
|
||||
setArchivedChores([
|
||||
...(archivedChores || []),
|
||||
...archivedTasks.map(c => ({
|
||||
...c,
|
||||
archived: true,
|
||||
})),
|
||||
])
|
||||
}
|
||||
if (failedTasks.length > 0) {
|
||||
showError({
|
||||
title: 'Some Tasks Failed',
|
||||
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be archived.`,
|
||||
})
|
||||
}
|
||||
clearSelection()
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Bulk Archive Failed',
|
||||
message: 'An unexpected error occurred. Please try again.',
|
||||
})
|
||||
}
|
||||
}
|
||||
setConfirmModelConfig({})
|
||||
},
|
||||
})
|
||||
}
|
||||
const handleBulkDelete = async () => {
|
||||
const selectedData = getSelectedChoresData()
|
||||
if (selectedData.length === 0) return
|
||||
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
title: 'Delete Tasks',
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
message: `Delete ${selectedData.length} task${selectedData.length > 1 ? 's' : ''}?\n\nThis action cannot be undone.`,
|
||||
onClose: async isConfirmed => {
|
||||
if (isConfirmed === true) {
|
||||
try {
|
||||
const deletedTasks = []
|
||||
const failedTasks = []
|
||||
|
||||
for (const chore of selectedData) {
|
||||
try {
|
||||
await DeleteChore(chore.id)
|
||||
deletedTasks.push(chore)
|
||||
} catch (error) {
|
||||
failedTasks.push(chore)
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedTasks.length > 0) {
|
||||
showSuccess({
|
||||
title: '🗑️ Tasks Deleted',
|
||||
message: `Successfully deleted ${deletedTasks.length} task${deletedTasks.length > 1 ? 's' : ''}.`,
|
||||
})
|
||||
|
||||
const deletedIds = new Set(deletedTasks.map(c => c.id))
|
||||
setChores(chores.filter(c => !deletedIds.has(c.id)))
|
||||
setFilteredChores(
|
||||
filteredChores.filter(c => !deletedIds.has(c.id)),
|
||||
)
|
||||
}
|
||||
|
||||
if (failedTasks.length > 0) {
|
||||
showError({
|
||||
title: 'Some Tasks Failed',
|
||||
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be deleted.`,
|
||||
})
|
||||
}
|
||||
|
||||
clearSelection()
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Bulk Delete Failed',
|
||||
message: 'An unexpected error occurred. Please try again.',
|
||||
})
|
||||
}
|
||||
}
|
||||
setConfirmModelConfig({})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleBulkSkip = async () => {
|
||||
const selectedData = getSelectedChoresData()
|
||||
if (selectedData.length === 0) return
|
||||
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
title: 'Skip Tasks',
|
||||
confirmText: 'Skip',
|
||||
cancelText: 'Cancel',
|
||||
message: `Skip ${selectedData.length} task${selectedData.length > 1 ? 's' : ''} to next due date?`,
|
||||
onClose: async isConfirmed => {
|
||||
if (isConfirmed === true) {
|
||||
try {
|
||||
const skippedTasks = []
|
||||
const failedTasks = []
|
||||
|
||||
for (const chore of selectedData) {
|
||||
try {
|
||||
await SkipChore(chore.id)
|
||||
skippedTasks.push(chore)
|
||||
} catch (error) {
|
||||
failedTasks.push(chore)
|
||||
}
|
||||
}
|
||||
|
||||
if (skippedTasks.length > 0) {
|
||||
showSuccess({
|
||||
title: '⏭️ Tasks Skipped',
|
||||
message: `Successfully skipped ${skippedTasks.length} task${skippedTasks.length > 1 ? 's' : ''}.`,
|
||||
})
|
||||
}
|
||||
|
||||
if (failedTasks.length > 0) {
|
||||
showError({
|
||||
title: 'Some Tasks Failed',
|
||||
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be skipped.`,
|
||||
})
|
||||
}
|
||||
|
||||
refetchChores()
|
||||
clearSelection()
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Bulk Skip Failed',
|
||||
message: 'An unexpected error occurred. Please try again.',
|
||||
})
|
||||
}
|
||||
}
|
||||
setConfirmModelConfig({})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
isUserProfileLoading ||
|
||||
userLabelsLoading ||
|
||||
performers.length === 0 ||
|
||||
choresLoading
|
||||
) {
|
||||
console.log(
|
||||
'userProfile:',
|
||||
userProfile,
|
||||
'userLabelsLoading:',
|
||||
userLabelsLoading,
|
||||
'performers:',
|
||||
performers.length,
|
||||
'choresLoading:',
|
||||
choresLoading,
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography level='title-lg' sx={{ mt: 2, mb: 2 }}>
|
||||
{JSON.stringify(userProfile) === 'null'}
|
||||
</Typography>
|
||||
<Typography level='title-lg' sx={{ mt: 2, mb: 2 }}>
|
||||
{userLabelsLoading}
|
||||
</Typography>
|
||||
<Typography level='title-lg' sx={{ mt: 2, mb: 2 }}>
|
||||
{performers.length === 0}
|
||||
</Typography>
|
||||
<Typography level='title-lg' sx={{ mt: 2, mb: 2 }}>
|
||||
{choresLoading}
|
||||
</Typography>
|
||||
<LoadingComponent />
|
||||
</>
|
||||
)
|
||||
@@ -405,7 +854,7 @@ const MyChores = () => {
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
slotProps={{ input: { ref: searchInputRef } }}
|
||||
placeholder='Search'
|
||||
value={searchTerm}
|
||||
onFocus={() => {
|
||||
@@ -512,8 +961,39 @@ const MyChores = () => {
|
||||
>
|
||||
{isCompactView ? <ViewModule /> : <ViewAgenda />}
|
||||
</IconButton>
|
||||
|
||||
{/* Multi-select Toggle Button */}
|
||||
<IconButton
|
||||
variant={isMultiSelectMode ? 'solid' : 'outlined'}
|
||||
color={isMultiSelectMode ? 'primary' : 'neutral'}
|
||||
size='sm'
|
||||
sx={{
|
||||
height: 32,
|
||||
width: 32,
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
onClick={toggleMultiSelectMode}
|
||||
title={
|
||||
isMultiSelectMode
|
||||
? 'Exit Multi-select Mode'
|
||||
: 'Enable Multi-select Mode'
|
||||
}
|
||||
>
|
||||
{isMultiSelectMode ? <CheckBox /> : <CheckBoxOutlineBlank />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
{showSearchFilter && (
|
||||
|
||||
{/* Search Filter with animation */}
|
||||
<Box
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.3s ease-in-out',
|
||||
maxHeight: showSearchFilter ? '150px' : '0',
|
||||
opacity: showSearchFilter ? 1 : 0,
|
||||
transform: showSearchFilter ? 'translateY(0)' : 'translateY(-10px)',
|
||||
marginBottom: showSearchFilter ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<div className='flex gap-4'>
|
||||
<div className='grid flex-1 grid-cols-3 gap-4'>
|
||||
<IconButtonWithMenu
|
||||
@@ -632,7 +1112,217 @@ const MyChores = () => {
|
||||
<CancelRounded />
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Multi-select Toolbar with animation */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 1000,
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.3s ease-in-out',
|
||||
maxHeight: isMultiSelectMode ? '200px' : '0',
|
||||
opacity: isMultiSelectMode ? 1 : 0,
|
||||
transform: isMultiSelectMode
|
||||
? 'translateY(0)'
|
||||
: 'translateY(-20px)',
|
||||
marginBottom: isMultiSelectMode ? 2 : 0,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: 'background.surface',
|
||||
backdropFilter: 'blur(8px)',
|
||||
borderRadius: 'lg',
|
||||
p: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
boxShadow: 'm',
|
||||
gap: 2,
|
||||
display: 'flex',
|
||||
flexDirection: {
|
||||
sm: 'column', // Stack vertically on mobile
|
||||
md: 'row', // Horizontal on tablet and larger
|
||||
},
|
||||
alignItems: {
|
||||
xs: 'stretch', // Full width on mobile
|
||||
sm: 'center', // Center aligned on larger screens
|
||||
},
|
||||
justifyContent: {
|
||||
xs: 'center',
|
||||
sm: 'space-between',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Selection Info and Controls */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
flexWrap: {
|
||||
xs: 'wrap', // Allow wrapping on mobile if needed
|
||||
sm: 'nowrap',
|
||||
},
|
||||
justifyContent: {
|
||||
xs: 'center', // Center on mobile
|
||||
sm: 'flex-start',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CheckBox sx={{ color: 'primary.500' }} />
|
||||
<Typography level='body-sm' fontWeight='md'>
|
||||
{selectedChores.size} task
|
||||
{selectedChores.size !== 1 ? 's' : ''} selected
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider
|
||||
orientation='vertical'
|
||||
sx={{
|
||||
display: { xs: 'none', sm: 'block' }, // Hide vertical divider on mobile
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
onClick={selectAllVisibleChores}
|
||||
startDecorator={<SelectAll />}
|
||||
disabled={
|
||||
searchTerm?.length > 0 || searchFilter !== 'All'
|
||||
? selectedChores.size === filteredChores.length
|
||||
: selectedChores.size ===
|
||||
choreSections.flatMap(s => s.content || []).length
|
||||
}
|
||||
sx={{
|
||||
minWidth: 'auto',
|
||||
'--Button-paddingInline': '0.75rem',
|
||||
}}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
onClick={clearSelection}
|
||||
startDecorator={
|
||||
selectedChores.size === 0 ? (
|
||||
<Close />
|
||||
) : (
|
||||
<CheckBoxOutlineBlank />
|
||||
)
|
||||
}
|
||||
sx={{
|
||||
minWidth: 'auto',
|
||||
'--Button-paddingInline': '0.75rem',
|
||||
}}
|
||||
>
|
||||
{selectedChores.size === 0 ? 'Close' : 'Clear'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flexWrap: {
|
||||
xs: 'wrap', // Allow wrapping on mobile
|
||||
sm: 'nowrap',
|
||||
},
|
||||
justifyContent: {
|
||||
xs: 'center', // Center on mobile
|
||||
sm: 'flex-end',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='success'
|
||||
onClick={handleBulkComplete}
|
||||
startDecorator={<Done />}
|
||||
disabled={selectedChores.size === 0}
|
||||
sx={{
|
||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||
}}
|
||||
>
|
||||
Complete
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='warning'
|
||||
onClick={handleBulkSkip}
|
||||
startDecorator={<SkipNext />}
|
||||
disabled={selectedChores.size === 0}
|
||||
sx={{
|
||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||
}}
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={handleBulkArchive}
|
||||
startDecorator={<Archive />}
|
||||
disabled={selectedChores.size === 0}
|
||||
sx={{
|
||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||
}}
|
||||
>
|
||||
Archive
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={handleBulkDelete}
|
||||
startDecorator={<Delete />}
|
||||
disabled={selectedChores.size === 0}
|
||||
sx={{
|
||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
|
||||
{/*
|
||||
<Divider
|
||||
orientation='vertical'
|
||||
sx={{
|
||||
display: { xs: 'none', sm: 'block' }, // Hide vertical divider on mobile
|
||||
}}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='plain'
|
||||
onClick={toggleMultiSelectMode}
|
||||
color='neutral'
|
||||
title='Exit multi-select mode (Esc)'
|
||||
sx={{
|
||||
'&:hover': {
|
||||
bgcolor: 'danger.softBg',
|
||||
color: 'danger.softColor',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CancelRounded />
|
||||
</IconButton> */}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{searchFilter !== 'All' && (
|
||||
<Chip
|
||||
level='title-md'
|
||||
@@ -861,19 +1551,6 @@ const MyChores = () => {
|
||||
/>
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Snackbar
|
||||
open={isSnackbarOpen}
|
||||
onClose={() => {
|
||||
setIsSnackbarOpen(false)
|
||||
}}
|
||||
autoHideDuration={3000}
|
||||
variant='soft'
|
||||
color='success'
|
||||
size='lg'
|
||||
invertedColors
|
||||
>
|
||||
<Typography level='title-md'>{snackBarMessage}</Typography>
|
||||
</Snackbar>
|
||||
<NotificationAccessSnackbar />
|
||||
{addTaskModalOpen && (
|
||||
<TaskInput
|
||||
@@ -891,6 +1568,14 @@ const MyChores = () => {
|
||||
</Container>
|
||||
|
||||
<Sidepanel chores={chores} performers={performers} />
|
||||
|
||||
{/* Multi-select Help - only show when in multi-select mode */}
|
||||
<MultiSelectHelp isVisible={isMultiSelectMode} />
|
||||
|
||||
{/* Confirmation Modal for bulk operations */}
|
||||
{confirmModelConfig?.isOpen && (
|
||||
<ConfirmationModal config={confirmModelConfig} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -938,7 +1623,7 @@ const FILTERS = {
|
||||
return chore.assignedTo === userID
|
||||
})
|
||||
},
|
||||
'No Due Date': function (chores, userID) {
|
||||
'No Due Date': function (chores) {
|
||||
return chores.filter(chore => {
|
||||
return chore.nextDueDate === null
|
||||
})
|
||||
|
||||
@@ -1,78 +1,81 @@
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { Button, Snackbar, Stack, Typography } from '@mui/joy'
|
||||
import { Preferences } from '@capacitor/preferences';
|
||||
import { LocalNotifications } from '@capacitor/local-notifications';
|
||||
|
||||
import {React, useEffect, useState} from 'react';
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { LocalNotifications } from '@capacitor/local-notifications'
|
||||
import { Preferences } from '@capacitor/preferences'
|
||||
import { Button, Stack, Typography } from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const NotificationAccessSnackbar = () => {
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
return null
|
||||
}
|
||||
const getNotificationPreferences = async () => {
|
||||
const ret = await Preferences.get({ key: 'notificationPreferences' })
|
||||
return JSON.parse(ret.value)
|
||||
}
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
return null;
|
||||
}
|
||||
const getNotificationPreferences = async () => {
|
||||
const ret = await Preferences.get({ key: 'notificationPreferences' });
|
||||
return JSON.parse(ret.value);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getNotificationPreferences().then((data) => {
|
||||
// if optOut is true then don't show the snackbar
|
||||
if(data?.optOut === true || data?.granted === true) {
|
||||
return;
|
||||
}
|
||||
setOpen(true);
|
||||
});
|
||||
}
|
||||
, []);
|
||||
|
||||
|
||||
|
||||
return (
|
||||
useEffect(() => {
|
||||
getNotificationPreferences().then(data => {
|
||||
// if optOut is true then don't show the snackbar
|
||||
if (data?.optOut === true || data?.granted === true) {
|
||||
return
|
||||
}
|
||||
setOpen(true)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
// autoHideDuration={5000}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="lg"
|
||||
variant='solid'
|
||||
color='primary'
|
||||
size='lg'
|
||||
invertedColors
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
sx={(theme) => ({
|
||||
sx={theme => ({
|
||||
background: `linear-gradient(45deg, ${theme.palette.primary[600]} 30%, ${theme.palette.primary[500]} 90%})`,
|
||||
maxWidth: 360,
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
<Typography level="title-lg">Need Notification?</Typography>
|
||||
<Typography level='title-lg'>Need Notification?</Typography>
|
||||
<Typography sx={{ mt: 1, mb: 2 }}>
|
||||
You need to enable permission to receive notifications, do you want to enable it?
|
||||
You need to enable permission to receive notifications, do you want to
|
||||
enable it?
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button variant="solid" color="primary" onClick={() => {
|
||||
const notificationPreferences = { optOut: false };
|
||||
LocalNotifications.requestPermissions().then((resp) => {
|
||||
<Stack direction='row' spacing={1}>
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
onClick={() => {
|
||||
const notificationPreferences = { optOut: false }
|
||||
LocalNotifications.requestPermissions().then(resp => {
|
||||
if (resp.display === 'granted') {
|
||||
notificationPreferences['granted'] = true;
|
||||
notificationPreferences['granted'] = true
|
||||
}
|
||||
})
|
||||
Preferences.set({ key: 'notificationPreferences', value: JSON.stringify(notificationPreferences) });
|
||||
setOpen(false);
|
||||
}}>
|
||||
Yes
|
||||
Preferences.set({
|
||||
key: 'notificationPreferences',
|
||||
value: JSON.stringify(notificationPreferences),
|
||||
})
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
Yes
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
variant='outlined'
|
||||
color='primary'
|
||||
onClick={() => {
|
||||
const notificationPreferences = { optOut: true };
|
||||
Preferences.set({ key: 'notificationPreferences', value: JSON.stringify(notificationPreferences) });
|
||||
setOpen(false);
|
||||
const notificationPreferences = { optOut: true }
|
||||
Preferences.set({
|
||||
key: 'notificationPreferences',
|
||||
value: JSON.stringify(notificationPreferences),
|
||||
})
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
No, Keep it Disabled
|
||||
@@ -80,8 +83,7 @@ return (
|
||||
</Stack>
|
||||
</div>
|
||||
</Snackbar>
|
||||
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export default NotificationAccessSnackbar;
|
||||
export default NotificationAccessSnackbar
|
||||
|
||||
@@ -1,39 +1,42 @@
|
||||
import { Button, Snackbar } from '@mui/joy'
|
||||
import { Button } from '@mui/joy'
|
||||
import Cookies from 'js-cookie'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
|
||||
const CookiePermissionSnackbar = () => {
|
||||
const { showNotification } = useNotification()
|
||||
|
||||
useEffect(() => {
|
||||
const cookiePermission = Cookies.get('cookies_permission')
|
||||
|
||||
if (cookiePermission !== 'true') {
|
||||
setOpen(true)
|
||||
showNotification({
|
||||
type: 'custom',
|
||||
component: <CookieAcceptComponent />,
|
||||
snackbarProps: {
|
||||
autoHideDuration: null,
|
||||
},
|
||||
anchorOrigin: { vertical: 'bottom', horizontal: 'center' },
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
}, [showNotification])
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const handleClose = () => {
|
||||
return null
|
||||
}
|
||||
|
||||
const CookieAcceptComponent = ({ onClose }) => {
|
||||
const handleAccept = () => {
|
||||
Cookies.set('cookies_permission', 'true')
|
||||
setOpen(false)
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
open={open}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
onClose={(event, reason) => {
|
||||
if (reason === 'clickaway') {
|
||||
return
|
||||
}
|
||||
// Cookies.set('cookies_permission', 'true')
|
||||
handleClose()
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
We use cookies to ensure you get the best experience on our website.
|
||||
<Button variant='soft' onClick={handleClose}>
|
||||
<Button variant='soft' onClick={handleAccept} sx={{ ml: 2 }}>
|
||||
Accept
|
||||
</Button>
|
||||
</Snackbar>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ const DemoMyChore = () => {
|
||||
// },
|
||||
]
|
||||
|
||||
const users = [{ displayName: 'Me', id: 1 }]
|
||||
const users = [{ displayName: 'Me', id: 1, userId: 1 }]
|
||||
return (
|
||||
<>
|
||||
<Grid item xs={12} sm={5} data-aos-first-tasks-list>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable tailwindcss/no-custom-classname */
|
||||
// import { StyledButton } from '@/components/styled-button'
|
||||
import { Button } from '@mui/joy'
|
||||
import { Button, IconButton, useColorScheme } from '@mui/joy'
|
||||
import Typography from '@mui/joy/Typography'
|
||||
import Box from '@mui/material/Box'
|
||||
import Grid from '@mui/material/Grid'
|
||||
@@ -8,17 +8,17 @@ import React, { useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import Logo from '@/assets/logo.svg'
|
||||
import screenShotMyChoreDark from '@/assets/screenshot-my-chore-dark.png'
|
||||
import screenShotMyChore from '@/assets/screenshot-my-chore.png'
|
||||
import { GitHub } from '@mui/icons-material'
|
||||
import { DarkMode, GitHub, LightMode } from '@mui/icons-material'
|
||||
import useWindowWidth from '../../hooks/useWindowWidth'
|
||||
|
||||
const HomeHero = () => {
|
||||
const navigate = useNavigate()
|
||||
const windowWidth = useWindowWidth()
|
||||
const windowThreshold = 600
|
||||
const { mode, setMode } = useColorScheme()
|
||||
const HERO_TEXT_THAT = [
|
||||
// 'Donetick simplifies the entire process, from scheduling and reminders to automatic task assignment and progress tracking.',
|
||||
// 'Donetick is the intuitive task and chore management app designed for groups. Take charge of shared responsibilities, automate your workflow, and achieve more together.',
|
||||
'An open-source, user-friendly app for managing tasks and chores, featuring customizable options to help you and others stay organized',
|
||||
]
|
||||
|
||||
@@ -169,20 +169,60 @@ const HomeHero = () => {
|
||||
<Grid item xs={12} md={5}>
|
||||
<div className='flex justify-center'>
|
||||
<img
|
||||
src={screenShotMyChore}
|
||||
src={mode === 'dark' ? screenShotMyChoreDark : screenShotMyChore}
|
||||
width={'100%'}
|
||||
style={{
|
||||
maxWidth: 300,
|
||||
}}
|
||||
height={'auto'}
|
||||
alt='Hero img'
|
||||
data-aos-delay={100 * 2}
|
||||
data-aos-anchor='[data-aos-id-hero]'
|
||||
data-aos='fade-left'
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 300,
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
e.target.style.transform = 'rotate(0deg) scale(1.05)'
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.target.style.transform = 'rotate(5deg) scale(1)'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
position: 'absolute',
|
||||
top: -90,
|
||||
right: 16,
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
setMode(mode === 'dark' ? 'light' : 'dark')
|
||||
}}
|
||||
sx={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.8)',
|
||||
borderRadius: '50%',
|
||||
boxShadow: '0px 4px 8px rgba(0, 0, 0, 0.1)',
|
||||
transition: 'background-color 0.3s',
|
||||
}}
|
||||
>
|
||||
{mode === 'dark' ? (
|
||||
<LightMode sx={{ color: '#333' }} />
|
||||
) : (
|
||||
<DarkMode
|
||||
sx={{
|
||||
color: '#333',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</IconButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useError } from '../../../service/ErrorProvider.jsx'
|
||||
import { useNotification } from '../../../service/NotificationProvider.jsx'
|
||||
import LABEL_COLORS from '../../../utils/Colors.jsx'
|
||||
import { CreateLabel, UpdateLabel } from '../../../utils/Fetcher'
|
||||
import { useLabels } from '../../Labels/LabelQueries'
|
||||
@@ -23,7 +23,7 @@ function LabelModal({ isOpen, onClose, label }) {
|
||||
const [error, setError] = useState('')
|
||||
const { data: userLabels = [] } = useLabels()
|
||||
const queryClient = useQueryClient()
|
||||
const { showError } = useError()
|
||||
const { showError } = useNotification()
|
||||
|
||||
// Populate the form fields when editing
|
||||
useEffect(() => {
|
||||
|
||||
@@ -13,19 +13,47 @@ import moment from 'moment'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import {
|
||||
CreateLongLiveToken,
|
||||
DeleteLongLiveToken,
|
||||
GetLongLiveTokens,
|
||||
} from '../../utils/Fetcher'
|
||||
import { isPlusAccount } from '../../utils/Helpers'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import TextModal from '../Modals/Inputs/TextModal'
|
||||
|
||||
const APITokenSettings = () => {
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const { showNotification } = useNotification()
|
||||
const [tokens, setTokens] = useState([])
|
||||
const [isGetTokenNameModalOpen, setIsGetTokenNameModalOpen] = useState(false)
|
||||
const [showTokenId, setShowTokenId] = useState(null)
|
||||
const [confirmModalConfig, setConfirmModalConfig] = useState({})
|
||||
|
||||
const showConfirmation = (
|
||||
message,
|
||||
title,
|
||||
onConfirm,
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
color = 'primary',
|
||||
) => {
|
||||
setConfirmModalConfig({
|
||||
isOpen: true,
|
||||
message,
|
||||
title,
|
||||
confirmText,
|
||||
cancelText,
|
||||
color,
|
||||
onClose: isConfirmed => {
|
||||
if (isConfirmed) {
|
||||
onConfirm()
|
||||
}
|
||||
setConfirmModalConfig({})
|
||||
},
|
||||
})
|
||||
}
|
||||
useEffect(() => {
|
||||
GetLongLiveTokens().then(resp => {
|
||||
resp.json().then(data => {
|
||||
@@ -100,18 +128,28 @@ const APITokenSettings = () => {
|
||||
variant='outlined'
|
||||
color='danger'
|
||||
onClick={() => {
|
||||
const confirmed = confirm(
|
||||
`Are you sure you want to remove ${token.name} ?`,
|
||||
showConfirmation(
|
||||
`Are you sure you want to remove ${token.name}?`,
|
||||
'Remove Token',
|
||||
() => {
|
||||
DeleteLongLiveToken(token.id).then(resp => {
|
||||
if (resp.ok) {
|
||||
showNotification({
|
||||
type: 'success',
|
||||
title: 'Removed',
|
||||
message: 'API token has been removed',
|
||||
})
|
||||
const newTokens = tokens.filter(
|
||||
t => t.id !== token.id,
|
||||
)
|
||||
setTokens(newTokens)
|
||||
}
|
||||
})
|
||||
},
|
||||
'Remove',
|
||||
'Cancel',
|
||||
'danger',
|
||||
)
|
||||
if (confirmed) {
|
||||
DeleteLongLiveToken(token.id).then(resp => {
|
||||
if (resp.ok) {
|
||||
alert('Token removed')
|
||||
const newTokens = tokens.filter(t => t.id !== token.id)
|
||||
setTokens(newTokens)
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
@@ -130,7 +168,10 @@ const APITokenSettings = () => {
|
||||
color='primary'
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(token.token)
|
||||
alert('Token copied to clipboard')
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Token copied to clipboard',
|
||||
})
|
||||
setShowTokenId(null)
|
||||
}}
|
||||
>
|
||||
@@ -166,6 +207,11 @@ const APITokenSettings = () => {
|
||||
okText={'Generate Token'}
|
||||
onSave={handleSaveToken}
|
||||
/>
|
||||
|
||||
{/* Modals */}
|
||||
{confirmModalConfig?.isOpen && (
|
||||
<ConfirmationModal config={confirmModalConfig} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -323,9 +323,23 @@ const MFASettings = () => {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Alert color='neutral' variant='soft'>
|
||||
<Typography level='body-sm'>
|
||||
<strong>Manual entry key:</strong> {setupData.secret}
|
||||
<Alert
|
||||
color='neutral'
|
||||
variant='soft'
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<Typography level='title-sm'>
|
||||
<strong>Manual entry key:</strong>
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}
|
||||
>
|
||||
{setupData.secret}
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { LocalNotifications } from '@capacitor/local-notifications'
|
||||
import { Preferences } from '@capacitor/preferences'
|
||||
import { Close } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -10,24 +9,23 @@ import {
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
Input,
|
||||
Option,
|
||||
Select,
|
||||
Snackbar,
|
||||
Switch,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import {
|
||||
UpdateNotificationTarget,
|
||||
UpdateUserDetails,
|
||||
} from '../../utils/Fetcher'
|
||||
|
||||
const NotificationSetting = () => {
|
||||
const [isSnackbarOpen, setIsSnackbarOpen] = useState(false)
|
||||
const { showWarning } = useNotification()
|
||||
const { data: userProfile, refetch: refetchUserProfile } = useUserProfile()
|
||||
|
||||
const getNotificationPreferences = async () => {
|
||||
@@ -70,13 +68,17 @@ const NotificationSetting = () => {
|
||||
|
||||
useEffect(() => {
|
||||
getNotificationPreferences().then(resp => {
|
||||
setDeviceNotification(resp.granted)
|
||||
setDueNotification(resp.dueNotification)
|
||||
setPreDueNotification(resp.preDueNotification)
|
||||
setNaggingNotification(resp.naggingNotification)
|
||||
if (resp) {
|
||||
setDeviceNotification(Boolean(resp.granted))
|
||||
setDueNotification(Boolean(resp.dueNotification ?? true))
|
||||
setPreDueNotification(Boolean(resp.preDueNotification))
|
||||
setNaggingNotification(Boolean(resp.naggingNotification))
|
||||
}
|
||||
})
|
||||
getPushNotificationPreferences().then(resp => {
|
||||
setPushNotification(resp.granted)
|
||||
if (resp) {
|
||||
setPushNotification(Boolean(resp.granted))
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
@@ -87,7 +89,7 @@ const NotificationSetting = () => {
|
||||
)
|
||||
|
||||
const [chatID, setChatID] = useState(
|
||||
userProfile?.notification_target?.target_id,
|
||||
userProfile?.notification_target?.target_id ?? 0,
|
||||
)
|
||||
const [error, setError] = useState('')
|
||||
const SaveValidation = () => {
|
||||
@@ -147,7 +149,11 @@ const NotificationSetting = () => {
|
||||
setDeviceNotification(true)
|
||||
setNotificationPreferences({ granted: true })
|
||||
} else if (resp.display === 'denied') {
|
||||
setIsSnackbarOpen(true)
|
||||
showWarning({
|
||||
title: 'Notification Permission Denied',
|
||||
message:
|
||||
'You have denied notification permissions. You can enable them later in your device settings.',
|
||||
})
|
||||
setDeviceNotification(false)
|
||||
setNotificationPreferences({ granted: false })
|
||||
}
|
||||
@@ -251,12 +257,14 @@ const NotificationSetting = () => {
|
||||
setPushNotification(true)
|
||||
setPushNotificationPreferences({granted: true})
|
||||
}
|
||||
if (resp.receive!== 'granted') {
|
||||
setIsSnackbarOpen(true)
|
||||
if (resp.receive !== 'granted') {
|
||||
showWarning({
|
||||
title: 'Push Notification Permission Denied',
|
||||
message: 'Push notifications have been disabled. You can enable them in your device settings if needed.',
|
||||
})
|
||||
setPushNotification(false)
|
||||
setPushNotificationPreferences({granted: false})
|
||||
console.log("User denied permission", resp)
|
||||
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -313,7 +321,7 @@ const NotificationSetting = () => {
|
||||
|
||||
<FormControl orientation='horizontal'>
|
||||
<Switch
|
||||
checked={chatID !== 0}
|
||||
checked={Boolean(chatID !== 0)}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
if (chatID !== 0) {
|
||||
@@ -440,30 +448,6 @@ const NotificationSetting = () => {
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
<Snackbar
|
||||
open={isSnackbarOpen}
|
||||
autoHideDuration={8000}
|
||||
onClose={() => setIsSnackbarOpen(false)}
|
||||
endDecorator={
|
||||
<IconButton size='md' onClick={() => setIsSnackbarOpen(false)}>
|
||||
<Close />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography level='title-md'>Permission Denied</Typography>
|
||||
<Typography level='body-md'>
|
||||
You have denied the permission to receive notification on this
|
||||
device. Please enable it in your device settings
|
||||
</Typography>
|
||||
</div>
|
||||
</Snackbar>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,14 +6,15 @@ import {
|
||||
Card,
|
||||
Divider,
|
||||
Input,
|
||||
Snackbar,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import Modal from '@mui/joy/Modal'
|
||||
import ModalDialog from '@mui/joy/ModalDialog'
|
||||
import imageCompression from 'browser-image-compression'
|
||||
import { useRef, useState } from 'react'
|
||||
import Cropper from 'react-easy-crop'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { UpdateUserDetails } from '../../utils/Fetcher'
|
||||
import { resolvePhotoURL } from '../../utils/Helpers'
|
||||
import { getCroppedImg } from '../../utils/imageCropUtils'
|
||||
@@ -21,6 +22,7 @@ import { UploadFile } from '../../utils/TokenManager'
|
||||
|
||||
const ProfileSettings = () => {
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const { showSuccess, showError } = useNotification()
|
||||
const [displayName, setDisplayName] = useState(userProfile?.displayName || '')
|
||||
const [timezone, setTimezone] = useState(
|
||||
userProfile?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
@@ -28,11 +30,6 @@ const ProfileSettings = () => {
|
||||
const [photoURL, setPhotoURL] = useState(userProfile?.image || '')
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [snackbar, setSnackbar] = useState({
|
||||
open: false,
|
||||
message: '',
|
||||
color: 'success',
|
||||
})
|
||||
const fileInputRef = useRef()
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 })
|
||||
const [zoom, setZoom] = useState(1)
|
||||
@@ -60,12 +57,32 @@ const ProfileSettings = () => {
|
||||
const croppedBlob = await getCroppedImg(
|
||||
selectedFile,
|
||||
croppedAreaPixels,
|
||||
320,
|
||||
320,
|
||||
160,
|
||||
160,
|
||||
'image/jpeg',
|
||||
)
|
||||
|
||||
// Compress the cropped image
|
||||
const compressionOptions = {
|
||||
maxSizeMB: 0.02, // Smaller size for profile images
|
||||
maxWidthOrHeight: 160, // Match the cropped dimensions
|
||||
useWebWorker: true,
|
||||
fileType: 'image/jpeg',
|
||||
initialQuality: 0.8,
|
||||
}
|
||||
|
||||
const compressedFile = await imageCompression(
|
||||
croppedBlob,
|
||||
compressionOptions,
|
||||
)
|
||||
|
||||
console.log(`Original size: ${(croppedBlob.size / 1024).toFixed(2)} KB`)
|
||||
console.log(
|
||||
`Compressed size: ${(compressedFile.size / 1024).toFixed(2)} KB`,
|
||||
)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', croppedBlob, 'profile.jpg')
|
||||
formData.append('file', compressedFile, 'profile.jpg')
|
||||
const response = await UploadFile('/users/profile_photo', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
@@ -75,16 +92,14 @@ const ProfileSettings = () => {
|
||||
const url = resolvePhotoURL(data.url || data.sign)
|
||||
|
||||
setPhotoURL(url)
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: 'Profile photo updated!',
|
||||
color: 'success',
|
||||
showSuccess({
|
||||
title: 'Photo Updated',
|
||||
message: 'Your profile photo has been updated successfully!',
|
||||
})
|
||||
} catch (err) {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: 'Failed to upload photo.',
|
||||
color: 'danger',
|
||||
showError({
|
||||
title: 'Upload Failed',
|
||||
message: 'Failed to upload your photo. Please try again.',
|
||||
})
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
@@ -100,19 +115,18 @@ const ProfileSettings = () => {
|
||||
const response = await UpdateUserDetails(userDetails)
|
||||
|
||||
if (response.ok) {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: 'Profile updated successfully!',
|
||||
color: 'success',
|
||||
showSuccess({
|
||||
title: 'Profile Updated',
|
||||
message: 'Your profile information has been saved successfully!',
|
||||
})
|
||||
} else {
|
||||
throw new Error('Failed to update profile')
|
||||
}
|
||||
} catch (err) {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: 'Failed to update profile.',
|
||||
color: 'danger',
|
||||
showError({
|
||||
title: 'Update Failed',
|
||||
message:
|
||||
'Unable to update your profile. Please check your connection and try again.',
|
||||
})
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
@@ -280,14 +294,6 @@ const ProfileSettings = () => {
|
||||
Save
|
||||
</Button>
|
||||
</Box>
|
||||
<Snackbar
|
||||
open={snackbar.open}
|
||||
color={snackbar.color}
|
||||
autoHideDuration={3000}
|
||||
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
||||
>
|
||||
{snackbar.message}
|
||||
</Snackbar>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,13 +10,13 @@ import {
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
Input,
|
||||
ListItem,
|
||||
Option,
|
||||
Select,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useState } from 'react'
|
||||
import RealTimeSettings from '../../components/RealTimeSettings'
|
||||
import Logo from '../../Logo'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import {
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
UpdatePassword,
|
||||
} from '../../utils/Fetcher'
|
||||
import { isPlusAccount } from '../../utils/Helpers'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import PassowrdChangeModal from '../Modals/Inputs/PasswordChangeModal'
|
||||
import APITokenSettings from './APITokenSettings'
|
||||
import MFASettings from './MFASettings'
|
||||
@@ -41,9 +42,11 @@ import NotificationSetting from './NotificationSetting'
|
||||
import ProfileSettings from './ProfileSettings'
|
||||
import StorageSettings from './StorageSettings'
|
||||
import ThemeToggle from './ThemeToggle'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
|
||||
const Settings = () => {
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const { showNotification } = useNotification()
|
||||
|
||||
const [userCircles, setUserCircles] = useState([])
|
||||
const [circleMemberRequests, setCircleMemberRequests] = useState([])
|
||||
@@ -54,6 +57,31 @@ const Settings = () => {
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
|
||||
const [changePasswordModal, setChangePasswordModal] = useState(false)
|
||||
const [confirmModalConfig, setConfirmModalConfig] = useState({})
|
||||
|
||||
const showConfirmation = (
|
||||
message,
|
||||
title,
|
||||
onConfirm,
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
color = 'primary',
|
||||
) => {
|
||||
setConfirmModalConfig({
|
||||
isOpen: true,
|
||||
message,
|
||||
title,
|
||||
confirmText,
|
||||
cancelText,
|
||||
color,
|
||||
onClose: isConfirmed => {
|
||||
if (isConfirmed) {
|
||||
onConfirm()
|
||||
}
|
||||
setConfirmModalConfig({})
|
||||
},
|
||||
})
|
||||
}
|
||||
useEffect(() => {
|
||||
GetUserCircle().then(resp => {
|
||||
resp.json().then(data => {
|
||||
@@ -165,7 +193,10 @@ const Settings = () => {
|
||||
variant='soft'
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(userCircles[0]?.invite_code)
|
||||
alert('Code Copied to clipboard')
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Code copied to clipboard',
|
||||
})
|
||||
}}
|
||||
>
|
||||
Copy Code
|
||||
@@ -180,27 +211,42 @@ const Settings = () => {
|
||||
window.location.host +
|
||||
`/circle/join?code=${userCircles[0]?.invite_code}`,
|
||||
)
|
||||
alert('Link Copied to clipboard')
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Link copied to clipboard',
|
||||
})
|
||||
}}
|
||||
>
|
||||
Copy Link
|
||||
</Button>
|
||||
{userCircles.length > 0 && userCircles[0]?.userRole === 'member' && (
|
||||
<Button
|
||||
color='danger'
|
||||
variant='outlined'
|
||||
sx={{ ml: 1 }}
|
||||
onClick={() => {
|
||||
const confirmed = confirm(
|
||||
`Are you sure you want to leave your circle?`,
|
||||
showConfirmation(
|
||||
'Are you sure you want to leave your circle?',
|
||||
'Leave Circle',
|
||||
() => {
|
||||
LeaveCircle(userCircles[0]?.id).then(resp => {
|
||||
if (resp.ok) {
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Left circle successfully',
|
||||
})
|
||||
} else {
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message: 'Failed to leave circle',
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
'Leave',
|
||||
'Cancel',
|
||||
'danger',
|
||||
)
|
||||
if (confirmed) {
|
||||
LeaveCircle(userCircles[0]?.id).then(resp => {
|
||||
if (resp.ok) {
|
||||
alert('Left circle successfully.')
|
||||
} else {
|
||||
alert('Failed to leave circle.')
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
Leave Circle
|
||||
@@ -257,7 +303,10 @@ const Settings = () => {
|
||||
})
|
||||
setCircleMembers(newCircleMembers)
|
||||
} else {
|
||||
alert('Failed to update role')
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message: 'Failed to update role',
|
||||
})
|
||||
}
|
||||
})
|
||||
}}
|
||||
@@ -278,7 +327,7 @@ const Settings = () => {
|
||||
},
|
||||
].map((option, index) => (
|
||||
<Option value={option.value} key={index}>
|
||||
<ListItem
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -301,7 +350,7 @@ const Settings = () => {
|
||||
>
|
||||
{option.description}
|
||||
</Typography>
|
||||
</ListItem>
|
||||
</Box>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
@@ -318,19 +367,26 @@ const Settings = () => {
|
||||
color='danger'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
const confirmed = confirm(
|
||||
showConfirmation(
|
||||
`Are you sure you want to remove ${member.displayName} from your circle?`,
|
||||
'Remove Member',
|
||||
() => {
|
||||
DeleteCircleMember(
|
||||
member.circleId,
|
||||
member.userId,
|
||||
).then(resp => {
|
||||
if (resp.ok) {
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Removed member successfully',
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
'Remove',
|
||||
'Cancel',
|
||||
'danger',
|
||||
)
|
||||
if (confirmed) {
|
||||
DeleteCircleMember(
|
||||
member.circleId,
|
||||
member.userId,
|
||||
).then(resp => {
|
||||
if (resp.ok) {
|
||||
alert('Removed member successfully.')
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
@@ -353,18 +409,24 @@ const Settings = () => {
|
||||
variant='soft'
|
||||
color='success'
|
||||
onClick={() => {
|
||||
const confirmed = confirm(
|
||||
`Are you sure you want to accept ${request.displayName}(username:${request.username}) to join your circle?`,
|
||||
showConfirmation(
|
||||
`Are you sure you want to accept ${request.displayName} (username: ${request.username}) to join your circle?`,
|
||||
'Accept Member Request',
|
||||
() => {
|
||||
AcceptCircleMemberRequest(request.id).then(resp => {
|
||||
if (resp.ok) {
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Accepted request successfully',
|
||||
})
|
||||
// reload the page
|
||||
window.location.reload()
|
||||
}
|
||||
})
|
||||
},
|
||||
'Accept',
|
||||
'Cancel',
|
||||
)
|
||||
if (confirmed) {
|
||||
AcceptCircleMemberRequest(request.id).then(resp => {
|
||||
if (resp.ok) {
|
||||
alert('Accepted request successfully.')
|
||||
// reload the page
|
||||
window.location.reload()
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
Accept
|
||||
@@ -393,18 +455,23 @@ const Settings = () => {
|
||||
<Button
|
||||
variant='soft'
|
||||
onClick={() => {
|
||||
const confirmed = confirm(
|
||||
`Are you sure you want to leave you circle and join '${circleInviteCode}'?`,
|
||||
showConfirmation(
|
||||
`Are you sure you want to leave your circle and join '${circleInviteCode}'?`,
|
||||
'Join Circle',
|
||||
() => {
|
||||
JoinCircle(circleInviteCode).then(resp => {
|
||||
if (resp.ok) {
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message:
|
||||
'Joined circle successfully, wait for the circle owner to accept your request.',
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
'Join',
|
||||
'Cancel',
|
||||
)
|
||||
if (confirmed) {
|
||||
JoinCircle(circleInviteCode).then(resp => {
|
||||
if (resp.ok) {
|
||||
alert(
|
||||
'Joined circle successfully, wait for the circle owner to accept your request.',
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
Join Circle
|
||||
@@ -479,9 +546,15 @@ const Settings = () => {
|
||||
onClick={() => {
|
||||
PutWebhookURL(webhookURL).then(resp => {
|
||||
if (resp.ok) {
|
||||
alert('Webhook URL updated successfully.')
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Webhook URL updated successfully',
|
||||
})
|
||||
} else {
|
||||
alert('Failed to update webhook URL.')
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message: 'Failed to update webhook URL',
|
||||
})
|
||||
}
|
||||
})
|
||||
}}
|
||||
@@ -493,6 +566,10 @@ const Settings = () => {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* WebSocket Settings */}
|
||||
{/* <WebSocketSettings /> */}
|
||||
<RealTimeSettings />
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 py-4' id='account'>
|
||||
@@ -537,10 +614,14 @@ const Settings = () => {
|
||||
ml: 1,
|
||||
}}
|
||||
variant='outlined'
|
||||
color='danger'
|
||||
onClick={() => {
|
||||
CancelSubscription().then(resp => {
|
||||
if (resp.ok) {
|
||||
alert('Subscription cancelled.')
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Subscription cancelled',
|
||||
})
|
||||
window.location.reload()
|
||||
}
|
||||
})
|
||||
@@ -571,9 +652,15 @@ const Settings = () => {
|
||||
if (password) {
|
||||
UpdatePassword(password).then(resp => {
|
||||
if (resp.ok) {
|
||||
alert('Password changed successfully')
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Password changed successfully',
|
||||
})
|
||||
} else {
|
||||
alert('Password change failed')
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message: 'Password change failed',
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -597,6 +684,11 @@ const Settings = () => {
|
||||
</Typography>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
{confirmModalConfig?.isOpen && (
|
||||
<ConfirmationModal config={confirmModalConfig} />
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,12 +12,38 @@ import { useNavigate } from 'react-router-dom'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { GetStorageUsage } from '../../utils/Fetcher'
|
||||
import { isPlusAccount } from '../../utils/Helpers'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
|
||||
const StorageSettings = () => {
|
||||
const Navigate = useNavigate()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const [usage, setUsage] = useState({ used: 0, total: 0 })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [confirmModalConfig, setConfirmModalConfig] = useState({})
|
||||
|
||||
const showConfirmation = (
|
||||
message,
|
||||
title,
|
||||
onConfirm,
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
color = 'primary',
|
||||
) => {
|
||||
setConfirmModalConfig({
|
||||
isOpen: true,
|
||||
message,
|
||||
title,
|
||||
confirmText,
|
||||
cancelText,
|
||||
color,
|
||||
onClose: isConfirmed => {
|
||||
if (isConfirmed) {
|
||||
onConfirm()
|
||||
}
|
||||
setConfirmModalConfig({})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isPlusAccount(userProfile)) {
|
||||
@@ -101,13 +127,17 @@ const StorageSettings = () => {
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={() => {
|
||||
const confirmed = confirm(
|
||||
`Are you sure you want to clear your local storage and cache? This will remove all your data from this browser and require login.`,
|
||||
showConfirmation(
|
||||
'Are you sure you want to clear your local storage and cache? This will remove all your data from this browser and require login.',
|
||||
'Clear All Local Storage',
|
||||
() => {
|
||||
localStorage.clear()
|
||||
Navigate('/login')
|
||||
},
|
||||
'Clear All',
|
||||
'Cancel',
|
||||
'danger',
|
||||
)
|
||||
if (confirmed) {
|
||||
localStorage.clear()
|
||||
Navigate('/login')
|
||||
}
|
||||
}}
|
||||
>
|
||||
Clear All Local Storage and Cache
|
||||
@@ -116,20 +146,29 @@ const StorageSettings = () => {
|
||||
variant='outlined'
|
||||
color='danger'
|
||||
onClick={() => {
|
||||
const confirmed = confirm(
|
||||
`Are you sure you want to clear only the offline cache and tasks?`,
|
||||
showConfirmation(
|
||||
'Are you sure you want to clear only the offline cache and tasks?',
|
||||
'Clear Offline Cache',
|
||||
() => {
|
||||
localStorage.removeItem('offline_cache')
|
||||
localStorage.removeItem('offline_request_queue')
|
||||
localStorage.removeItem('offlineTasks')
|
||||
},
|
||||
'Clear Cache',
|
||||
'Cancel',
|
||||
'danger',
|
||||
)
|
||||
if (confirmed) {
|
||||
localStorage.removeItem('offline_cache')
|
||||
localStorage.removeItem('offline_request_queue')
|
||||
localStorage.removeItem('offlineTasks')
|
||||
}
|
||||
}}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
Clear Offline Cache and Offline Tasks
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
{/* Modals */}
|
||||
{confirmModalConfig?.isOpen && (
|
||||
<ConfirmationModal config={confirmModalConfig} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,12 +15,11 @@ import {
|
||||
Container,
|
||||
Grid,
|
||||
IconButton,
|
||||
Snackbar,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useError } from '../../service/ErrorProvider'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import {
|
||||
CreateThing,
|
||||
DeleteThing,
|
||||
@@ -169,11 +168,7 @@ const ThingsView = () => {
|
||||
const [isShowEditThingStateModal, setIsShowEditStateModal] = useState(false)
|
||||
const [createModalThing, setCreateModalThing] = useState(null)
|
||||
const [confirmModelConfig, setConfirmModelConfig] = useState({})
|
||||
|
||||
const [isSnackbarOpen, setIsSnackbarOpen] = useState(false)
|
||||
const [snackbarMessage, setSnackbarMessage] = useState('')
|
||||
const [snackbarColor, setSnackbarColor] = useState('success')
|
||||
const { showError } = useError()
|
||||
const { showError, showNotification } = useNotification()
|
||||
|
||||
useEffect(() => {
|
||||
// fetch things
|
||||
@@ -204,9 +199,11 @@ const ThingsView = () => {
|
||||
currentThings.push(data.res)
|
||||
setThings(currentThings)
|
||||
}
|
||||
setSnackbarMessage('Thing saved successfully')
|
||||
setSnackbarColor('success')
|
||||
setIsSnackbarOpen(true)
|
||||
showNotification({
|
||||
type: 'success',
|
||||
title: 'Thing Saved',
|
||||
message: 'Thing saved successfully',
|
||||
})
|
||||
})
|
||||
})
|
||||
.catch(error => {
|
||||
@@ -246,11 +243,10 @@ const ThingsView = () => {
|
||||
currentThings.splice(thingIndex, 1)
|
||||
setThings(currentThings)
|
||||
} else if (response.status === 405) {
|
||||
setSnackbarMessage(
|
||||
'Unable to delete thing with associated tasks',
|
||||
)
|
||||
setSnackbarColor('danger')
|
||||
setIsSnackbarOpen(true)
|
||||
showError({
|
||||
title: 'Unable to Delete Thing',
|
||||
message: 'Unable to delete thing with associated tasks',
|
||||
})
|
||||
}
|
||||
// if method not allwo show snackbar:
|
||||
})
|
||||
@@ -293,8 +289,11 @@ const ThingsView = () => {
|
||||
)
|
||||
currentThings[thingIndex] = data.res
|
||||
setThings(currentThings)
|
||||
setSnackbarMessage('Thing state updated successfully')
|
||||
setIsSnackbarOpen(true)
|
||||
showNotification({
|
||||
type: 'success',
|
||||
title: 'Thing Updated',
|
||||
message: 'Thing state updated successfully',
|
||||
})
|
||||
})
|
||||
})
|
||||
.catch(error => {
|
||||
@@ -399,19 +398,6 @@ const ThingsView = () => {
|
||||
|
||||
<ConfirmationModal config={confirmModelConfig} />
|
||||
</Box>
|
||||
<Snackbar
|
||||
open={isSnackbarOpen}
|
||||
onClose={() => {
|
||||
setIsSnackbarOpen(false)
|
||||
}}
|
||||
autoHideDuration={3000}
|
||||
variant='soft'
|
||||
color={snackbarColor}
|
||||
size='lg'
|
||||
invertedColors
|
||||
>
|
||||
<Typography level='title-md'>{snackbarMessage}</Typography>
|
||||
</Snackbar>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -384,7 +384,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
return (
|
||||
<Modal open={isModalOpen} onClose={handleCloseModal}>
|
||||
<ModalOverflow>
|
||||
<ModalDialog size='lg' sx={{ minWidth: '80%' }}>
|
||||
<ModalDialog size='lg' sx={{ minWidth: '100%' }}>
|
||||
<Typography level='h4'>Create new task</Typography>
|
||||
<Chip startDecorator='🚧' variant='soft' color='warning' size='sm'>
|
||||
Experimental Feature
|
||||
|
||||
@@ -165,7 +165,6 @@ const CalendarView = ({ chores }) => {
|
||||
return legendItems.map((item, index) => (
|
||||
<Grid
|
||||
key={index}
|
||||
item
|
||||
xs={12}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { Divider, IconButton, Menu, MenuItem } from '@mui/joy'
|
||||
import React, { useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useError } from '../../service/ErrorProvider'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import {
|
||||
ArchiveChore,
|
||||
DeleteChore,
|
||||
@@ -41,7 +41,7 @@ const ChoreActionMenu = ({
|
||||
const [anchorEl, setAnchorEl] = React.useState(null)
|
||||
const menuRef = React.useRef(null)
|
||||
const navigate = useNavigate()
|
||||
const { showError } = useError()
|
||||
const { showError } = useNotification()
|
||||
|
||||
useEffect(() => {
|
||||
const handleMenuOutsideClick = event => {
|
||||
|
||||
@@ -2,9 +2,9 @@ import imageCompression from 'browser-image-compression'
|
||||
import Quill from 'quill'
|
||||
import 'quill/dist/quill.snow.css'
|
||||
import QuillMarkdown from 'quilljs-markdown'
|
||||
import { useCallback, useContext, useEffect, useRef } from 'react'
|
||||
import { UserContext } from '../../contexts/UserContext'
|
||||
import { useError } from '../../service/ErrorProvider'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers'
|
||||
import { UploadFile } from '../../utils/TokenManager'
|
||||
import './RichTextEditor.css'
|
||||
@@ -18,8 +18,8 @@ const RichTextEditor = ({
|
||||
entityId,
|
||||
entityType,
|
||||
}) => {
|
||||
const { showError } = useError()
|
||||
const { userProfile } = useContext(UserContext)
|
||||
const { showError } = useNotification()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const quillRef = useRef(null)
|
||||
const editorRef = useRef(null)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user