diff --git a/package.json b/package.json index 253a62c..64307d3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App.jsx b/src/App.jsx index cbad980..314097a 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,15 +1,18 @@ import NavBar from '@/views/components/NavBar' -import { Button, Snackbar, Typography, useColorScheme } from '@mui/joy' +import { Button, Typography, useColorScheme } from '@mui/joy' import Tracker from '@openreplay/tracker' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { useEffect, useState } from 'react' +import { useEffect } from 'react' import { Outlet, useNavigate } from 'react-router-dom' import { useRegisterSW } from 'virtual:pwa-register/react' import { registerCapacitorListeners } from './CapacitorListener' import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext' import { useResource } from './queries/ResourceQueries' import { AuthenticationProvider } from './service/AuthenticationService' -import { ErrorProvider } from './service/ErrorProvider' +import { + NotificationProvider, + useNotification, +} from './service/NotificationProvider' import { apiManager } from './utils/TokenManager' import NetworkBanner from './views/components/NetworkBanner' const add = className => { @@ -22,22 +25,15 @@ const remove = className => { // TODO: Update the interval to at 60 minutes const intervalMS = 5 * 60 * 1000 // 5 minutes const queryClient = new QueryClient({}) -function App() { - const resource = useResource() - const navigate = useNavigate() - startApiManager(navigate) - startOpenReplay() - const { mode, systemMode } = useColorScheme() - const [showUpdateSnackbar, setShowUpdateSnackbar] = useState(true) +const AppContent = () => { + const { showNotification } = useNotification() const { - offlineReady: [offlineReady, setOfflineReady], needRefresh: [needRefresh, setNeedRefresh], updateServiceWorker, } = useRegisterSW({ onRegistered(r) { - // eslint-disable-next-line prefer-template console.log('SW Registered: ' + r) r && setInterval(() => { @@ -48,10 +44,53 @@ function App() { console.log('SW registration error', error) }, }) - const close = () => { - setOfflineReady(false) - setNeedRefresh(false) - } + + useEffect(() => { + if (needRefresh) { + showNotification({ + type: 'custom', + component: ( +
+ + A new version is now available. Click on reload button to update. + + +
+ ), + snackbarProps: { + autoHideDuration: null, // Persistent until user action + }, + }) + } + }, [needRefresh, showNotification, updateServiceWorker, setNeedRefresh]) + + return ( + <> + + + + + + ) +} + +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() { - - - - - - - - {needRefresh && ( - - - A new version is now available.Click on reload button to update. - - - - )} + + + ) diff --git a/src/assets/screenshot-my-chore-dark.png b/src/assets/screenshot-my-chore-dark.png new file mode 100644 index 0000000..a92c87c Binary files /dev/null and b/src/assets/screenshot-my-chore-dark.png differ diff --git a/src/assets/screenshot-my-chore.png b/src/assets/screenshot-my-chore.png index 62880cb..498d72a 100644 Binary files a/src/assets/screenshot-my-chore.png and b/src/assets/screenshot-my-chore.png differ diff --git a/src/components/RealTimeSettings.jsx b/src/components/RealTimeSettings.jsx new file mode 100644 index 0000000..1a24ad3 --- /dev/null +++ b/src/components/RealTimeSettings.jsx @@ -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 + default: + return null + } + } + + return ( + + + { + handleRealtimeTypeChange( + null, + e.target.checked ? REALTIME_TYPES.SSE : REALTIME_TYPES.DISABLED, + ) + }} + disabled={!isPlusAccount(userProfile)} + inputProps={{ 'aria-label': 'Enable Real-time Updates' }} + /> + + + + Real-time Updates + {!isPlusAccount(userProfile) && ( + + Plus Feature + + )} + + + {realtimeType !== REALTIME_TYPES.DISABLED && + isPlusAccount(userProfile) ? ( + + ) : ( + + )} + + + Get instant notifications when tasks are updated + + + + + {/* + + Real-time Connection Type + + Choose how to receive real-time updates + + + + */} + + {getStatusDescription()} + + {realtimeType !== REALTIME_TYPES.DISABLED && + isPlusAccount(userProfile) && ( + + + Status: + + {getConnectionStatusComponent()} + {context.error && ( + + {context.error} + + )} + + )} + + {!isPlusAccount(userProfile) && ( + + 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. + + )} + + ) +} + +export default RealTimeSettings diff --git a/src/components/SSEConnectionStatus.jsx b/src/components/SSEConnectionStatus.jsx new file mode 100644 index 0000000..718d355 --- /dev/null +++ b/src/components/SSEConnectionStatus.jsx @@ -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 + if (isConnecting) return + return + } + + 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 ( + + + + {showError && error && ( + + {error} + + )} + + + ) + } + + if (variant === 'chip') { + return ( + + + {getStatusText()} + + + ) + } + + // Full variant + return ( + + + {getStatusIcon()} + + {getStatusText()} + + + {showError && error && ( + + {error} + + )} + + ) +} + +export default SSEConnectionStatus diff --git a/src/components/SSESettings.jsx b/src/components/SSESettings.jsx new file mode 100644 index 0000000..d9623ad --- /dev/null +++ b/src/components/SSESettings.jsx @@ -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 ( + + + {isSSEEnabled() && isPlusAccount(userProfile) ? ( + + ) : ( + + )} + + + Real-time Updates (SSE) + {!isPlusAccount(userProfile) && ( + + Plus Feature + + )} + + + Get instant notifications via Server-Sent Events + + + {isSSEEnabled() && isPlusAccount(userProfile) && ( + + )} + + + + + Enable Real-time Updates (SSE) + + {getStatusDescription()} + + + + + + {isSSEEnabled() && isPlusAccount(userProfile) && ( + + + Status: + + + {getConnectionStatus()} + + {error && ( + + {error} + + )} + + )} + + {!isPlusAccount(userProfile) && ( + + 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. + + )} + + ) +} + +export default SSESettings diff --git a/src/components/WebSocketConnectionStatus.jsx b/src/components/WebSocketConnectionStatus.jsx new file mode 100644 index 0000000..cf44aea --- /dev/null +++ b/src/components/WebSocketConnectionStatus.jsx @@ -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 + if (isConnecting) return + return + } + + 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 ( + + + + {showError && error && ( + + {error} + + )} + + + ) + } + + if (variant === 'chip') { + return ( + + + {getStatusText()} + + + ) + } + + // Full variant + return ( + + + {getStatusIcon()} + + {getStatusText()} + + + {showError && error && ( + + {error} + + )} + + ) +} + +export default WebSocketConnectionStatus diff --git a/src/components/WebSocketSettings.jsx b/src/components/WebSocketSettings.jsx new file mode 100644 index 0000000..2c37cfa --- /dev/null +++ b/src/components/WebSocketSettings.jsx @@ -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 ( + + + {isWebSocketEnabled() && isPlusAccount(userProfile) ? ( + + ) : ( + + )} + + + Real-time Updates + {!isPlusAccount(userProfile) && ( + + Plus Feature + + )} + + + Get instant notifications when chores are updated + + + {isWebSocketEnabled() && isPlusAccount(userProfile) && ( + + )} + + + + + Enable Real-time Updates + + {getStatusDescription()} + + + + + + {isWebSocketEnabled() && isPlusAccount(userProfile) && ( + + + Status: + + + {getConnectionStatus()} + + {error && ( + + {error} + + )} + + )} + + {!isPlusAccount(userProfile) && ( + + 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. + + )} + + ) +} + +export default WebSocketSettings diff --git a/src/contexts/Contexts.jsx b/src/contexts/Contexts.jsx index 1269154..2b3472a 100644 --- a/src/contexts/Contexts.jsx +++ b/src/contexts/Contexts.jsx @@ -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 {acc} diff --git a/src/contexts/SSEContext.jsx b/src/contexts/SSEContext.jsx new file mode 100644 index 0000000..59f2195 --- /dev/null +++ b/src/contexts/SSEContext.jsx @@ -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 {children} +} + +export default SSEProvider diff --git a/src/contexts/WebSocketContext.jsx b/src/contexts/WebSocketContext.jsx new file mode 100644 index 0000000..4a9835e --- /dev/null +++ b/src/contexts/WebSocketContext.jsx @@ -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 ( + + {children} + + ) +} + +export default WebSocketProvider diff --git a/src/hooks/useSSE.js b/src/hooks/useSSE.js new file mode 100644 index 0000000..4b29207 --- /dev/null +++ b/src/hooks/useSSE.js @@ -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' + } + }, + } +} diff --git a/src/hooks/useSSEContext.js b/src/hooks/useSSEContext.js new file mode 100644 index 0000000..af15e58 --- /dev/null +++ b/src/hooks/useSSEContext.js @@ -0,0 +1,6 @@ +import { useContext } from 'react' +import { SSEContext } from '../contexts/SSEContext' + +export const useSSEContext = () => { + return useContext(SSEContext) +} diff --git a/src/hooks/useWebSocket.js b/src/hooks/useWebSocket.js new file mode 100644 index 0000000..2850258 --- /dev/null +++ b/src/hooks/useWebSocket.js @@ -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' + } + }, + } +} diff --git a/src/service/ErrorProvider.jsx b/src/service/ErrorProvider.jsx deleted file mode 100644 index 5cfc120..0000000 --- a/src/service/ErrorProvider.jsx +++ /dev/null @@ -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 ( - - {children} - setError(null)} - startDecorator={} - endDecorator={ - - } - > - {typeof error === 'string' ? ( - - {error} - - ) : ( - - - {error?.title} - - - {error?.message} - - - )} - - - ) -} diff --git a/src/service/NotificationProvider.jsx b/src/service/NotificationProvider.jsx new file mode 100644 index 0000000..9d6dc39 --- /dev/null +++ b/src/service/NotificationProvider.jsx @@ -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: , + autoHideDuration: 6000, + showDismissButton: true, + defaultTitle: 'Error', + }, + success: { + color: 'success', + icon: , + autoHideDuration: 3000, + showDismissButton: false, + defaultTitle: 'Success', + }, + warning: { + color: 'warning', + icon: , + autoHideDuration: 4000, + showDismissButton: false, + defaultTitle: 'Warning', + }, + info: { + color: 'primary', + icon: , + 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 ( + removeNotification(notification.id)} + anchorOrigin={ + notification.anchorOrigin || { + vertical: 'bottom', + horizontal: 'right', + } + } + {...(notification.snackbarProps || {})} + > + {React.cloneElement(notification.component, { + onClose: () => removeNotification(notification.id), + ...notification.componentProps, + })} + + ) + } + + // 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 ( + removeNotification(notification.id)} + startDecorator={notificationIcon} + endDecorator={ + config.showDismissButton ? ( + + ) : null + } + anchorOrigin={ + notification.anchorOrigin || { + vertical: 'bottom', + horizontal: 'right', + } + } + {...(notification.snackbarProps || {})} + > + {/* Enhanced structure like ErrorProvider - always show title and message for consistency */} + {title && message ? ( + + + {title} + + + {message} + + + ) : ( + + {message || title || 'Notification'} + + )} + + ) + } + + return ( + + {children} + {notifications.map(renderNotification)} + + ) +} diff --git a/src/utils/Colors.jsx b/src/utils/Colors.jsx index 61b01de..987d830 100644 --- a/src/utils/Colors.jsx +++ b/src/utils/Colors.jsx @@ -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 diff --git a/src/utils/Fetcher.jsx b/src/utils/Fetcher.jsx index d9594c4..bf6c92f 100644 --- a/src/utils/Fetcher.jsx +++ b/src/utils/Fetcher.jsx @@ -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(), }), }) } diff --git a/src/views/Authorization/ForgotPasswordView.jsx b/src/views/Authorization/ForgotPasswordView.jsx index b8eb6b0..904f14b 100644 --- a/src/views/Authorization/ForgotPasswordView.jsx +++ b/src/views/Authorization/ForgotPasswordView.jsx @@ -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 = () => { )} - { - if (resetStatusOk) { - navigate('/login') - } - }} - > - {resetStatusOk - ? 'Reset email sent, check your email' - : 'Reset email failed, try again later'} - diff --git a/src/views/Authorization/LoginSettings.jsx b/src/views/Authorization/LoginSettings.jsx index 22cde4c..8daee05 100644 --- a/src/views/Authorization/LoginSettings.jsx +++ b/src/views/Authorization/LoginSettings.jsx @@ -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 = () => { - setError(null)} - autoHideDuration={3000} - message={error} - > - {error} - ) } diff --git a/src/views/Authorization/LoginView.jsx b/src/views/Authorization/LoginView.jsx index 11261ef..3399497 100644 --- a/src/views/Authorization/LoginView.jsx +++ b/src/views/Authorization/LoginView.jsx @@ -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", + }) }} > - setError(null)} - autoHideDuration={3000} - message={error} - > - {error} - { @@ -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 = () => { - setError(null)} - autoHideDuration={5000} - message={error} - > - {error} - ) } diff --git a/src/views/Authorization/UpdatePasswordView.jsx b/src/views/Authorization/UpdatePasswordView.jsx index 8d82537..d891706 100644 --- a/src/views/Authorization/UpdatePasswordView.jsx +++ b/src/views/Authorization/UpdatePasswordView.jsx @@ -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 = () => { - { - setUpdateStatusOk(null) - }} - > - Password update failed, try again later - ) } diff --git a/src/views/ChoreEdit/ChoreEdit.jsx b/src/views/ChoreEdit/ChoreEdit.jsx index e2c8bc0..b8cb913 100644 --- a/src/views/ChoreEdit/ChoreEdit.jsx +++ b/src/views/ChoreEdit/ChoreEdit.jsx @@ -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 => ( {errors[key]} )) - setSnackbarMessage( - - - Please resolve the following errors: - - {errorList} - , - ) - setSnackbarColor('danger') - setIsSnackbarOpen(true) + showError({ + title: 'Please resolve the following errors:', + message: {errorList}, + }) 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 = () => { /> )} {/* */} - { - setIsSnackbarOpen(false) - setSnackbarMessage(null) - }} - color={snackbarColor} - autoHideDuration={4000} - sx={{ bottom: 70 }} - invertedColors={true} - variant='soft' - > - {snackbarMessage} - ) } diff --git a/src/views/ChoreEdit/ChoreView.jsx b/src/views/ChoreEdit/ChoreView.jsx index d00035a..52dda97 100644 --- a/src/views/ChoreEdit/ChoreView.jsx +++ b/src/views/ChoreEdit/ChoreView.jsx @@ -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']) }) } }) diff --git a/src/views/ChoreEdit/ThingTriggerSection.jsx b/src/views/ChoreEdit/ThingTriggerSection.jsx index 981f84b..e4c4d24 100644 --- a/src/views/ChoreEdit/ThingTriggerSection.jsx +++ b/src/views/ChoreEdit/ThingTriggerSection.jsx @@ -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) => ( - + - + {option.name} - - + + type: {option.type}{' '} state: {option.state} - + - + )} renderInput={params => ( diff --git a/src/views/Chores/ChoreCard.jsx b/src/views/Chores/ChoreCard.jsx index 2f806ee..058e11c 100644 --- a/src/views/Chores/ChoreCard.jsx +++ b/src/views/Chores/ChoreCard.jsx @@ -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 && ( + e.stopPropagation()} + /> + )} { - navigate(`/chores/${chore.id}`) + if (isMultiSelectMode) { + onSelectionToggle() + } else { + navigate(`/chores/${chore.id}`) + } }} > {/* Box in top right with Chip showing next due date */} diff --git a/src/views/Chores/CompactChoreCard.jsx b/src/views/Chores/CompactChoreCard.jsx index 7a18903..5bd53f4 100644 --- a/src/views/Chores/CompactChoreCard.jsx +++ b/src/views/Chores/CompactChoreCard.jsx @@ -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 ( { + if (isMultiSelectMode) { + onSelectionToggle() + } else { + navigate(`/chores/${chore.id}`) + } }} - onClick={() => navigate(`/chores/${chore.id}`)} > - {/* Left side - Content */} + {/* Priority bar clickable area */} + {chore.priority > 0 && ( + { + e.stopPropagation() + onChipClick({ priority: chore.priority }) + }} + /> + )} + {/* Animated transition container for Complete Button / Multi-select checkbox */} + + {/* Complete Button */} + + { + 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 ? ( + + ) : ( + + )} + + + + {/* Multi-select Checkbox */} + + e.stopPropagation()} + /> + + + + {/* Content - Center */} - {/* Line 1: Name + Due Date + Frequency */} + {/* Line 1: Name + Due Date */} - - {/* Chore Name */} - - {chore.name} - - + {chore.name} + - {/* Due Date */} + {/* Due Date - Inline with name */} {getDueDateText(chore.nextDueDate)} @@ -458,35 +629,7 @@ const CompactChoreCard = ({ {formatMetadata()} - {/* Labels */} - {chore.priority > 0 && ( - 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} - - )} + {/* Labels - Priority chip removed, now shown as vertical bar */} {chore.labelsV2?.map(l => (
- {/* Right side - Actions */} + {/* Right side - Action Menu with animation */} - {/* Complete Button */} - { - e.stopPropagation() - handleTaskCompletion() - }} - disabled={isPendingCompletion || notInCompletionWindow(chore)} - sx={{ - width: 32, - height: 32, - borderRadius: '50%', - }} - > - {isPendingCompletion ? ( - - ) : ( - - )} - - - {/* Chore Action Menu */} 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', }, }} /> diff --git a/src/views/Chores/MultiSelectHelp.jsx b/src/views/Chores/MultiSelectHelp.jsx new file mode 100644 index 0000000..11df46e --- /dev/null +++ b/src/views/Chores/MultiSelectHelp.jsx @@ -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 */} + setIsHelpOpen(true)} + sx={{ + position: 'fixed', + bottom: 24, + right: 24, + zIndex: 1000, + width: 48, + height: 48, + borderRadius: '50%', + boxShadow: 'lg', + }} + title='Show keyboard shortcuts' + > + + + + {/* Help Modal */} + setIsHelpOpen(false)}> + + + + + Multi-select Mode + + setIsHelpOpen(false)} + > + + + + + + Use these keyboard shortcuts to work more efficiently with multiple + tasks: + + + + {/* Selection shortcuts */} + + + Selection + + + + + + + + {/* Action shortcuts */} + + + Actions + + + + + + + + {/* Interface shortcuts */} + + + Interface + + + + + + + + + + + + + + + + ) +} + +const ShortcutItem = ({ keys, description }) => ( + + + {description} + + + {keys.map((key, index) => ( + + {index > 0 && ( + + + + + )} + + + {key} + + + + ))} + + +) + +export default MultiSelectHelp diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx index bb8f445..bf82031 100644 --- a/src/views/Chores/MyChores.jsx +++ b/src/views/Chores/MyChores.jsx @@ -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 ( <> - - {JSON.stringify(userProfile) === 'null'} - - - {userLabelsLoading} - - - {performers.length === 0} - - - {choresLoading} - ) @@ -405,7 +854,7 @@ const MyChores = () => { }} > { @@ -512,8 +961,39 @@ const MyChores = () => { > {isCompactView ? : } + + {/* Multi-select Toggle Button */} + + {isMultiSelectMode ? : } + - {showSearchFilter && ( + + {/* Search Filter with animation */} +
{
- )} + + + {/* Multi-select Toolbar with animation */} + + + {/* Selection Info and Controls */} + + + + + {selectedChores.size} task + {selectedChores.size !== 1 ? 's' : ''} selected + + + + + + + + + + + + {/* Action Buttons */} + + + + + + + + {/* + + + + + */} + + + + {searchFilter !== 'All' && ( { /> - { - setIsSnackbarOpen(false) - }} - autoHideDuration={3000} - variant='soft' - color='success' - size='lg' - invertedColors - > - {snackBarMessage} - {addTaskModalOpen && ( { + + {/* Multi-select Help - only show when in multi-select mode */} + + + {/* Confirmation Modal for bulk operations */} + {confirmModelConfig?.isOpen && ( + + )}
) } @@ -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 }) diff --git a/src/views/Chores/NotificationAccessSnackbar.jsx b/src/views/Chores/NotificationAccessSnackbar.jsx index 14bb692..c042806 100644 --- a/src/views/Chores/NotificationAccessSnackbar.jsx +++ b/src/views/Chores/NotificationAccessSnackbar.jsx @@ -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 ( 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, })} >
- Need Notification? + Need Notification? - 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? - -
- -) + ) } -export default NotificationAccessSnackbar; \ No newline at end of file +export default NotificationAccessSnackbar diff --git a/src/views/Landing/CookiePermissionSnackbar.jsx b/src/views/Landing/CookiePermissionSnackbar.jsx index 7fe3724..f72b922 100644 --- a/src/views/Landing/CookiePermissionSnackbar.jsx +++ b/src/views/Landing/CookiePermissionSnackbar.jsx @@ -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: , + 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 ( - { - if (reason === 'clickaway') { - return - } - // Cookies.set('cookies_permission', 'true') - handleClose() - }} - > +
We use cookies to ensure you get the best experience on our website. - - +
) } diff --git a/src/views/Landing/DemoMyChore.jsx b/src/views/Landing/DemoMyChore.jsx index b63688b..9a09954 100644 --- a/src/views/Landing/DemoMyChore.jsx +++ b/src/views/Landing/DemoMyChore.jsx @@ -93,7 +93,7 @@ const DemoMyChore = () => { // }, ] - const users = [{ displayName: 'Me', id: 1 }] + const users = [{ displayName: 'Me', id: 1, userId: 1 }] return ( <> diff --git a/src/views/Landing/HomeHero.jsx b/src/views/Landing/HomeHero.jsx index 35286ec..012ec67 100644 --- a/src/views/Landing/HomeHero.jsx +++ b/src/views/Landing/HomeHero.jsx @@ -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 = () => {
Hero img { + e.target.style.transform = 'rotate(0deg) scale(1.05)' + }} + onMouseLeave={e => { + e.target.style.transform = 'rotate(5deg) scale(1)' + }} />
)} + + { + 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' ? ( + + ) : ( + + )} + +
) } diff --git a/src/views/Modals/Inputs/LabelModal.jsx b/src/views/Modals/Inputs/LabelModal.jsx index 81118c4..9430209 100644 --- a/src/views/Modals/Inputs/LabelModal.jsx +++ b/src/views/Modals/Inputs/LabelModal.jsx @@ -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(() => { diff --git a/src/views/Settings/APITokenSettings.jsx b/src/views/Settings/APITokenSettings.jsx index 7addb98..899cc6b 100644 --- a/src/views/Settings/APITokenSettings.jsx +++ b/src/views/Settings/APITokenSettings.jsx @@ -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 && ( + + )}
) } diff --git a/src/views/Settings/MFASettings.jsx b/src/views/Settings/MFASettings.jsx index 31231fc..1e3c560 100644 --- a/src/views/Settings/MFASettings.jsx +++ b/src/views/Settings/MFASettings.jsx @@ -323,9 +323,23 @@ const MFASettings = () => { )}
- - - Manual entry key: {setupData.secret} + + + Manual entry key: + + + {setupData.secret} diff --git a/src/views/Settings/NotificationSetting.jsx b/src/views/Settings/NotificationSetting.jsx index 6e4d8fd..01d95df 100644 --- a/src/views/Settings/NotificationSetting.jsx +++ b/src/views/Settings/NotificationSetting.jsx @@ -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 = () => { { event.preventDefault() if (chatID !== 0) { @@ -440,30 +448,6 @@ const NotificationSetting = () => {
)} - setIsSnackbarOpen(false)} - endDecorator={ - setIsSnackbarOpen(false)}> - - - } - > -
- Permission Denied - - You have denied the permission to receive notification on this - device. Please enable it in your device settings - -
-
) } diff --git a/src/views/Settings/ProfileSettings.jsx b/src/views/Settings/ProfileSettings.jsx index 7644f4b..3975fe5 100644 --- a/src/views/Settings/ProfileSettings.jsx +++ b/src/views/Settings/ProfileSettings.jsx @@ -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
- setSnackbar({ ...snackbar, open: false })} - > - {snackbar.message} - ) } diff --git a/src/views/Settings/Settings.jsx b/src/views/Settings/Settings.jsx index baa22d2..6e1dbc7 100644 --- a/src/views/Settings/Settings.jsx +++ b/src/views/Settings/Settings.jsx @@ -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 {userCircles.length > 0 && userCircles[0]?.userRole === 'member' && ( + + {/* Modals */} + {confirmModalConfig?.isOpen && ( + + )} ) } diff --git a/src/views/Things/ThingsView.jsx b/src/views/Things/ThingsView.jsx index dbf3400..17e170a 100644 --- a/src/views/Things/ThingsView.jsx +++ b/src/views/Things/ThingsView.jsx @@ -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 = () => {
- { - setIsSnackbarOpen(false) - }} - autoHideDuration={3000} - variant='soft' - color={snackbarColor} - size='lg' - invertedColors - > - {snackbarMessage} - ) } diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx index 93a82fc..0c6d20c 100644 --- a/src/views/components/AddTaskModal.jsx +++ b/src/views/components/AddTaskModal.jsx @@ -384,7 +384,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { return ( - + Create new task Experimental Feature diff --git a/src/views/components/CalendarView.jsx b/src/views/components/CalendarView.jsx index 0595ddc..a296f2a 100644 --- a/src/views/components/CalendarView.jsx +++ b/src/views/components/CalendarView.jsx @@ -165,7 +165,6 @@ const CalendarView = ({ chores }) => { return legendItems.map((item, index) => ( { const handleMenuOutsideClick = event => { diff --git a/src/views/components/RichTextEditor.jsx b/src/views/components/RichTextEditor.jsx index 3a0eb40..4365ed6 100644 --- a/src/views/components/RichTextEditor.jsx +++ b/src/views/components/RichTextEditor.jsx @@ -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)