Merge branch 'dev'

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

View File

@@ -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",

View File

@@ -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>
)

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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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>

View 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

View 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
View 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'
}
},
}
}

View 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
View 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'
}
},
}
}

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -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

View File

@@ -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(),
}),
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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