Merge branch 'main' into fix-notification-typo
This commit is contained in:
162
src/App.jsx
162
src/App.jsx
@@ -1,19 +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 { useCallback, useEffect } from 'react'
|
||||
import { Outlet, useNavigate } from 'react-router-dom'
|
||||
import { useRegisterSW } from 'virtual:pwa-register/react'
|
||||
import { registerCapacitorListeners } from './CapacitorListener'
|
||||
import PageTransition from './components/animations/PageTransition'
|
||||
import { AuthProvider } from './hooks/useAuth.jsx'
|
||||
import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext'
|
||||
import { UserContext } from './contexts/UserContext'
|
||||
import { useResource } from './queries/ResourceQueries'
|
||||
import { AuthenticationProvider } from './service/AuthenticationService'
|
||||
import { ErrorProvider } from './service/ErrorProvider'
|
||||
import { GetUserProfile } from './utils/Fetcher'
|
||||
import { apiManager, isTokenValid } from './utils/TokenManager'
|
||||
import SSEProvider from './contexts/SSEContext'
|
||||
import { useNotification } from './service/NotificationProvider'
|
||||
|
||||
import NetworkBanner from './views/components/NetworkBanner'
|
||||
|
||||
const add = className => {
|
||||
document.getElementById('root').classList.add(className)
|
||||
}
|
||||
@@ -21,26 +20,27 @@ const add = className => {
|
||||
const remove = className => {
|
||||
document.getElementById('root').classList.remove(className)
|
||||
}
|
||||
|
||||
// TODO: Update the interval to at 60 minutes
|
||||
const intervalMS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
function App() {
|
||||
const resource = useResource()
|
||||
const navigate = useNavigate()
|
||||
startApiManager(navigate)
|
||||
startOpenReplay()
|
||||
const queryClient = new QueryClient()
|
||||
const { mode, systemMode } = useColorScheme()
|
||||
const [userProfile, setUserProfile] = useState(null)
|
||||
const [showUpdateSnackbar, setShowUpdateSnackbar] = useState(true)
|
||||
const startOpenReplay = () => {
|
||||
if (!import.meta.env.VITE_OPENREPLAY_PROJECT_KEY) return
|
||||
const tracker = new Tracker({
|
||||
projectKey: import.meta.env.VITE_OPENREPLAY_PROJECT_KEY,
|
||||
})
|
||||
tracker.start()
|
||||
}
|
||||
|
||||
|
||||
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(() => {
|
||||
@@ -51,12 +51,54 @@ function App() {
|
||||
console.log('SW registration error', error)
|
||||
},
|
||||
})
|
||||
const close = () => {
|
||||
setOfflineReady(false)
|
||||
setNeedRefresh(false)
|
||||
}
|
||||
|
||||
const setThemeClass = () => {
|
||||
useEffect(() => {
|
||||
if (needRefresh) {
|
||||
showNotification({
|
||||
type: 'custom',
|
||||
component: (
|
||||
<div>
|
||||
<Typography level='body-md'>
|
||||
A new version is now available. Click on reload button to update.
|
||||
</Typography>
|
||||
<Button
|
||||
color='secondary'
|
||||
size='small'
|
||||
onClick={() => {
|
||||
updateServiceWorker(true)
|
||||
setNeedRefresh(false)
|
||||
}}
|
||||
sx={{ ml: 2 }}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
snackbarProps: {
|
||||
autoHideDuration: null, // Persistent until user action
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [needRefresh, showNotification, updateServiceWorker, setNeedRefresh])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ImpersonateUserProvider>
|
||||
<NavBar />
|
||||
<PageTransition>
|
||||
<Outlet />
|
||||
</PageTransition>
|
||||
</ImpersonateUserProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
// startOpenReplay()
|
||||
|
||||
const { mode, systemMode } = useColorScheme()
|
||||
|
||||
const setThemeClass = useCallback(() => {
|
||||
const value = JSON.parse(localStorage.getItem('themeMode')) || mode
|
||||
|
||||
if (value === 'system') {
|
||||
@@ -71,75 +113,27 @@ function App() {
|
||||
}
|
||||
|
||||
return remove('dark')
|
||||
}
|
||||
const getUserProfile = () => {
|
||||
GetUserProfile()
|
||||
.then(res => {
|
||||
res.json().then(data => {
|
||||
setUserProfile(data.res)
|
||||
})
|
||||
})
|
||||
.catch(error => {})
|
||||
}
|
||||
}, [mode, systemMode])
|
||||
|
||||
useEffect(() => {
|
||||
setThemeClass()
|
||||
}, [mode, systemMode])
|
||||
}, [setThemeClass])
|
||||
|
||||
useEffect(() => {
|
||||
registerCapacitorListeners()
|
||||
if (isTokenValid()) {
|
||||
if (!userProfile) getUserProfile()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='min-h-screen'>
|
||||
<>
|
||||
<NetworkBanner />
|
||||
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthenticationProvider />
|
||||
<ErrorProvider>
|
||||
<ImpersonateUserProvider>
|
||||
<UserContext.Provider value={{ userProfile, setUserProfile }}>
|
||||
<NavBar />
|
||||
<Outlet />
|
||||
</UserContext.Provider>
|
||||
</ImpersonateUserProvider>
|
||||
</ErrorProvider>
|
||||
|
||||
{needRefresh && (
|
||||
<Snackbar open={showUpdateSnackbar}>
|
||||
<Typography level='body-md'>
|
||||
A new version is now available.Click on reload button to update.
|
||||
</Typography>
|
||||
<Button
|
||||
color='secondary'
|
||||
size='small'
|
||||
onClick={() => {
|
||||
updateServiceWorker(true)
|
||||
setShowUpdateSnackbar(false)
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Snackbar>
|
||||
)}
|
||||
</QueryClientProvider>
|
||||
</div>
|
||||
<AuthProvider>
|
||||
<SSEProvider>
|
||||
<AppContent />
|
||||
</SSEProvider>
|
||||
</AuthProvider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const startOpenReplay = () => {
|
||||
if (!import.meta.env.VITE_OPENREPLAY_PROJECT_KEY) return
|
||||
const tracker = new Tracker({
|
||||
projectKey: import.meta.env.VITE_OPENREPLAY_PROJECT_KEY,
|
||||
})
|
||||
tracker.start()
|
||||
}
|
||||
export default App
|
||||
|
||||
const startApiManager = navigate => {
|
||||
apiManager.init()
|
||||
apiManager.setNavigateToLogin(() => {
|
||||
navigate('/login')
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,78 +1,195 @@
|
||||
import { LocalNotifications } from '@capacitor/local-notifications';
|
||||
import { App as mobileApp } from '@capacitor/app';
|
||||
import { App as mobileApp } from '@capacitor/app'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { Device } from '@capacitor/device'
|
||||
import { LocalNotifications } from '@capacitor/local-notifications'
|
||||
import { Preferences } from '@capacitor/preferences'
|
||||
import { PushNotifications } from '@capacitor/push-notifications'
|
||||
import { PutNotificationTarget } from './utils/Fetcher';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { RegisterDeviceToken } from './utils/Fetcher'
|
||||
const localNotificationListenerRegistration = () => {
|
||||
LocalNotifications.addListener('localNotificationReceived', (notification) => {
|
||||
console.log('Notification received', notification);
|
||||
});
|
||||
LocalNotifications.addListener('localNotificationActionPerformed', (event) => {
|
||||
|
||||
console.log('Notification action performed', event);
|
||||
if (event.actionId === 'tap') {
|
||||
console.log('Notification opened, navigate to chore', event.notification.extra.choreId);
|
||||
window.location.href = `/chores/${event.notification.extra.choreId}`
|
||||
}
|
||||
});
|
||||
}
|
||||
const pushNotificationListenerRegistration = () => {
|
||||
PushNotifications.register();
|
||||
PushNotifications.addListener('registration', (token) => {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
const type = Capacitor.getPlatform() === 'android' ? 1 : 2; // 1 for android, 2 for ios
|
||||
PutNotificationTarget(type, token.value).then((response) => {
|
||||
console.log('Notification target updated', response);
|
||||
}
|
||||
).catch((error) => {
|
||||
console.error('Error updating notification target', error);
|
||||
}
|
||||
);
|
||||
LocalNotifications.addListener('localNotificationReceived', notification => {
|
||||
console.log('Notification received', notification)
|
||||
})
|
||||
LocalNotifications.addListener('localNotificationActionPerformed', event => {
|
||||
console.log('Notification action performed', event)
|
||||
if (event.actionId === 'tap') {
|
||||
console.log(
|
||||
'Notification opened, navigate to chore',
|
||||
event.notification.extra.choreId,
|
||||
)
|
||||
window.location.href = `/chores/${event.notification.extra.choreId}`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TODO save the token in preferences and only send it if it has changed:
|
||||
console.log('Push registration success, token: ' + token.value);
|
||||
}
|
||||
const registerTokenIfNeeded = async (token, deviceInfo, deviceId, platform) => {
|
||||
try {
|
||||
// const stored = await Preferences.get({ key: 'deviceRegistration' })
|
||||
// const lastReg = stored.value ? JSON.parse(stored.value) : null
|
||||
|
||||
const current = {
|
||||
token: token.value,
|
||||
deviceId: deviceId.identifier,
|
||||
platform,
|
||||
appVersion: deviceInfo.appVersion,
|
||||
registeredAt: Date.now(),
|
||||
}
|
||||
);
|
||||
PushNotifications.addListener('registrationError', (error) => {
|
||||
console.error('Error on registration: ' + JSON.stringify(error));
|
||||
|
||||
// const shouldRegister =
|
||||
// !lastReg ||
|
||||
// lastReg.token !== current.token ||
|
||||
// lastReg.appVersion !== current.appVersion ||
|
||||
// Date.now() - lastReg.registeredAt > 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
// console.log('Registering device token:', {
|
||||
// reason: !lastReg
|
||||
// ? 'first_time'
|
||||
// : lastReg.token !== current.token
|
||||
// ? 'token_changed'
|
||||
// : lastReg.appVersion !== current.appVersion
|
||||
// ? 'app_updated'
|
||||
// : 'periodic_refresh',
|
||||
// })
|
||||
|
||||
const result = await RegisterDeviceToken(
|
||||
token.value,
|
||||
deviceId.identifier,
|
||||
platform,
|
||||
deviceInfo.appVersion,
|
||||
deviceInfo.model,
|
||||
)
|
||||
|
||||
if (result && result.ok) {
|
||||
await Preferences.set({
|
||||
key: 'deviceRegistration',
|
||||
value: JSON.stringify(current),
|
||||
})
|
||||
console.log('Device token registered successfully')
|
||||
|
||||
// Emit event to notify UI components of successful registration
|
||||
window.dispatchEvent(new CustomEvent('deviceTokenRegistered'))
|
||||
} else if (result) {
|
||||
// Handle registration errors
|
||||
console.error('Device registration failed:', result.status)
|
||||
|
||||
// Emit event with error details for UI to handle
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('deviceTokenRegistrationFailed', {
|
||||
detail: {
|
||||
status: result.status,
|
||||
error: await result.text().catch(() => 'Unknown error'),
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
);
|
||||
PushNotifications.addListener('pushNotificationActionPerformed', fcmEvent => {
|
||||
|
||||
if(fcmEvent.actionId === 'tap') {
|
||||
if (fcmEvent.notification.data.type === 'chore_due') {
|
||||
window.location.href = `/chores/${fcmEvent.notification.data.choreId}`
|
||||
}
|
||||
else {
|
||||
window.location.href = `/my/chores`
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Error in token registration check, registering anyway:',
|
||||
error,
|
||||
)
|
||||
const fallbackResult = await RegisterDeviceToken(
|
||||
token.value,
|
||||
deviceId.identifier,
|
||||
platform,
|
||||
deviceInfo.appVersion,
|
||||
deviceInfo.model,
|
||||
)
|
||||
|
||||
if (fallbackResult && fallbackResult.ok) {
|
||||
// Emit event to notify UI components of successful registration
|
||||
window.dispatchEvent(new CustomEvent('deviceTokenRegistered'))
|
||||
} else if (fallbackResult) {
|
||||
// Handle registration errors
|
||||
console.error(
|
||||
'Fallback device registration failed:',
|
||||
fallbackResult.status,
|
||||
)
|
||||
|
||||
// Emit event with error details for UI to handle
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('deviceTokenRegistrationFailed', {
|
||||
detail: {
|
||||
status: fallbackResult.status,
|
||||
error: await fallbackResult.text().catch(() => 'Unknown error'),
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
const pushNotificationListenerRegistration = async () => {
|
||||
// Check and request permissions for Android 13+
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
let permStatus = await PushNotifications.checkPermissions()
|
||||
|
||||
|
||||
|
||||
const registerCapacitorListeners = () => {
|
||||
if(!Capacitor.isNativePlatform()) {
|
||||
console.log('Not a native platform, skipping registration of native listeners');
|
||||
return
|
||||
if (permStatus.receive === 'prompt') {
|
||||
permStatus = await PushNotifications.requestPermissions()
|
||||
}
|
||||
localNotificationListenerRegistration();
|
||||
pushNotificationListenerRegistration();
|
||||
mobileApp.addListener('backButton', ({ canGoBack }) => {
|
||||
if (canGoBack) {
|
||||
window.history.back();
|
||||
} else {
|
||||
mobileApp.exitApp();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
if (permStatus.receive !== 'granted') {
|
||||
console.warn('Push notification permission not granted')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export { registerCapacitorListeners }
|
||||
await PushNotifications.register()
|
||||
|
||||
PushNotifications.addListener('registration', async token => {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
try {
|
||||
const deviceInfo = await Device.getInfo()
|
||||
const deviceId = await Device.getId()
|
||||
|
||||
const platform =
|
||||
Capacitor.getPlatform() === 'android' ? 'android' : 'ios'
|
||||
|
||||
await registerTokenIfNeeded(token, deviceInfo, deviceId, platform)
|
||||
} catch (error) {
|
||||
console.error('Error registering device token', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
PushNotifications.addListener('registrationError', error => {
|
||||
console.error('Error on registration: ' + JSON.stringify(error))
|
||||
})
|
||||
|
||||
PushNotifications.addListener('pushNotificationReceived', notification => {
|
||||
console.log('Push notification received: ', notification)
|
||||
})
|
||||
|
||||
PushNotifications.addListener('pushNotificationActionPerformed', fcmEvent => {
|
||||
if (fcmEvent.actionId === 'tap') {
|
||||
if (
|
||||
fcmEvent.notification.data.type === 'chore_due' ||
|
||||
fcmEvent.notification.data.type === 'nudge'
|
||||
) {
|
||||
window.location.href = `/chores/${fcmEvent.notification.data.choreId}`
|
||||
} else {
|
||||
window.location.href = `/chores`
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const registerCapacitorListeners = () => {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
console.log(
|
||||
'Not a native platform, skipping registration of native listeners',
|
||||
)
|
||||
return
|
||||
}
|
||||
localNotificationListenerRegistration()
|
||||
mobileApp.addListener('backButton', ({ canGoBack }) => {
|
||||
if (canGoBack) {
|
||||
window.history.back()
|
||||
} else {
|
||||
mobileApp.exitApp()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
registerCapacitorListeners,
|
||||
pushNotificationListenerRegistration as registerPushNotifications,
|
||||
}
|
||||
|
||||
BIN
src/assets/ipad_dashbard_calendar.png
Normal file
BIN
src/assets/ipad_dashbard_calendar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 419 KiB |
BIN
src/assets/screenshot-my-chore-dark.png
Normal file
BIN
src/assets/screenshot-my-chore-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 145 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 406 KiB After Width: | Height: | Size: 147 KiB |
744
src/components/NotificationTemplate.jsx
Normal file
744
src/components/NotificationTemplate.jsx
Normal file
@@ -0,0 +1,744 @@
|
||||
import { Save } from '@mui/icons-material'
|
||||
import AddIcon from '@mui/icons-material/Add'
|
||||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import InfoIcon from '@mui/icons-material/Info'
|
||||
import NotificationsIcon from '@mui/icons-material/Notifications'
|
||||
import Alert from '@mui/joy/Alert'
|
||||
import Badge from '@mui/joy/Badge'
|
||||
import Box from '@mui/joy/Box'
|
||||
import Button from '@mui/joy/Button'
|
||||
import IconButton from '@mui/joy/IconButton'
|
||||
import Input from '@mui/joy/Input'
|
||||
import Option from '@mui/joy/Option'
|
||||
import Select from '@mui/joy/Select'
|
||||
import Typography from '@mui/joy/Typography'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { NOTIFICATION_TYPE, TASK_COLOR } from '../utils/Colors'
|
||||
|
||||
const timeUnits = [
|
||||
{ label: 'Mins', value: 'm' },
|
||||
{ label: 'Hours', value: 'h' },
|
||||
{ label: 'Days', value: 'd' },
|
||||
]
|
||||
|
||||
const timingOptions = [
|
||||
{ label: 'Before', value: 'before' },
|
||||
{ label: 'On Due', value: 'ondue' },
|
||||
{ label: 'After', value: 'after' },
|
||||
]
|
||||
|
||||
function getRelativeLabel(notification) {
|
||||
const { value, unit } = notification
|
||||
const numericValue = Number(value)
|
||||
if (numericValue === 0) {
|
||||
return 'On due date'
|
||||
}
|
||||
const unitName = unit === 'm' ? 'minutes' : unit === 'h' ? 'hours' : 'days'
|
||||
const absValue = Math.abs(numericValue)
|
||||
return `${absValue} ${unitName} ${numericValue < 0 ? 'before' : 'after'} due`
|
||||
}
|
||||
|
||||
// Helper functions to convert between internal value and UI representation
|
||||
function getUIRepresentation(notification) {
|
||||
const numericValue = Number(notification.value)
|
||||
if (numericValue === 0) {
|
||||
return { timing: 'ondue', displayValue: 0, unit: notification.unit }
|
||||
} else if (numericValue < 0) {
|
||||
return {
|
||||
timing: 'before',
|
||||
displayValue: Math.abs(numericValue),
|
||||
unit: notification.unit,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
timing: 'after',
|
||||
displayValue: numericValue,
|
||||
unit: notification.unit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getInternalValue(timing, displayValue) {
|
||||
if (timing === 'ondue') return 0
|
||||
if (timing === 'before') return -Math.abs(displayValue)
|
||||
return Math.abs(displayValue) // 'after'
|
||||
}
|
||||
|
||||
const NotificationTemplate = ({
|
||||
maxNotifications = 5,
|
||||
onChange,
|
||||
value,
|
||||
showTimeline = true,
|
||||
}) => {
|
||||
const [notifications, setNotifications] = useState(
|
||||
value?.templates ||
|
||||
JSON.parse(localStorage.getItem('defaultNotificationTemplate')) ||
|
||||
[],
|
||||
)
|
||||
|
||||
const [error, setError] = useState(null)
|
||||
const [showSaveDefault, setShowSaveDefault] = useState(false)
|
||||
// Create a map of notification indices for timeline display
|
||||
const [notificationIndexMap, setNotificationIndexMap] = useState({})
|
||||
|
||||
const updateNotificationIndices = useCallback(() => {
|
||||
// Convert notifications to minutes for proper chronological sorting
|
||||
const convertToMinutes = (value, unit) => {
|
||||
const numericValue = Number(value)
|
||||
if (numericValue === 0) return 0
|
||||
let minutes = Math.abs(numericValue)
|
||||
if (unit === 'h') minutes *= 60
|
||||
if (unit === 'd') minutes *= 24 * 60
|
||||
return numericValue < 0 ? -minutes : minutes
|
||||
}
|
||||
|
||||
// Sort notifications for consistent ordering by actual time duration
|
||||
const sorted = [...notifications].sort((a, b) => {
|
||||
const aMinutes = convertToMinutes(a.value, a.unit)
|
||||
const bMinutes = convertToMinutes(b.value, b.unit)
|
||||
return aMinutes - bMinutes
|
||||
})
|
||||
|
||||
const indexMap = {}
|
||||
// Map original array indices to their chronological position numbers
|
||||
notifications.forEach((originalNotification, originalIdx) => {
|
||||
const chronologicalPosition = sorted.findIndex(
|
||||
sortedNotification =>
|
||||
Number(sortedNotification.value) ===
|
||||
Number(originalNotification.value) &&
|
||||
sortedNotification.unit === originalNotification.unit,
|
||||
)
|
||||
indexMap[originalIdx] = chronologicalPosition + 1
|
||||
})
|
||||
|
||||
setNotificationIndexMap(indexMap)
|
||||
}, [notifications])
|
||||
|
||||
// Sort notifications and update the index mapping
|
||||
useEffect(() => {
|
||||
updateNotificationIndices()
|
||||
setError(null)
|
||||
}, [updateNotificationIndices])
|
||||
|
||||
// Notify parent component of changes including the template name
|
||||
useEffect(() => {
|
||||
if (onChange) {
|
||||
onChange({ notifications })
|
||||
}
|
||||
}, [notifications, onChange])
|
||||
|
||||
// Validates if a notification configuration already exists
|
||||
const isDuplicate = (notification, currentIdx = -1) => {
|
||||
return notifications.some((n, idx) => {
|
||||
if (idx === currentIdx) return false
|
||||
|
||||
return (
|
||||
Number(n.value) === Number(notification.value) &&
|
||||
n.unit === notification.unit
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const handleChange = (idx, field, value) => {
|
||||
const currentNotification = notifications[idx]
|
||||
const uiRep = getUIRepresentation(currentNotification)
|
||||
|
||||
let updatedUIRep = { ...uiRep }
|
||||
let updatedNotification = { ...currentNotification }
|
||||
|
||||
// Update the UI representation based on the field being changed
|
||||
if (field === 'timing') {
|
||||
updatedUIRep.timing = value
|
||||
// Reset display value when switching to "On Due"
|
||||
if (value === 'ondue') {
|
||||
updatedUIRep.displayValue = 0
|
||||
}
|
||||
} else if (field === 'displayValue') {
|
||||
updatedUIRep.displayValue = Math.max(0, Number(value))
|
||||
} else if (field === 'unit') {
|
||||
updatedUIRep.unit = value
|
||||
updatedNotification.unit = value
|
||||
}
|
||||
|
||||
// Convert back to internal representation
|
||||
const newInternalValue = getInternalValue(
|
||||
updatedUIRep.timing,
|
||||
updatedUIRep.displayValue,
|
||||
)
|
||||
updatedNotification = {
|
||||
...updatedNotification,
|
||||
value: newInternalValue,
|
||||
unit: updatedUIRep.unit,
|
||||
}
|
||||
|
||||
// Check if another notification is already "On Due" (value = 0)
|
||||
if (newInternalValue === 0) {
|
||||
const existingOnDue = notifications.findIndex(
|
||||
(n, i) => i !== idx && Number(n.value) === 0,
|
||||
)
|
||||
|
||||
if (existingOnDue !== -1) {
|
||||
setError(
|
||||
'Only one notification can be set to "On Due". Please choose a different timing.',
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (isDuplicate(updatedNotification, idx)) {
|
||||
setError(
|
||||
'This notification setting already exists. Please use a different timing.',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const updated = notifications.map((n, i) =>
|
||||
i === idx ? updatedNotification : n,
|
||||
)
|
||||
setNotifications(updated)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const addSmartNotification = type => {
|
||||
if (notifications.length >= maxNotifications) return
|
||||
setShowSaveDefault(true)
|
||||
let newNotification
|
||||
let suggestions = []
|
||||
|
||||
switch (type) {
|
||||
case 'reminder':
|
||||
// Suggest common reminder times that don't exist
|
||||
suggestions = [
|
||||
{ value: -1, unit: 'd' }, // 1 day before
|
||||
{ value: -3, unit: 'h' }, // 3 hours before
|
||||
{ value: -30, unit: 'm' }, // 3 days before
|
||||
]
|
||||
break
|
||||
|
||||
case 'due':
|
||||
if (notifications.some(n => Number(n.value) === 0)) {
|
||||
setError('Only one "Due Alert" notification is allowed.')
|
||||
return
|
||||
}
|
||||
newNotification = { value: 0, unit: 'm' }
|
||||
break
|
||||
|
||||
case 'followup':
|
||||
suggestions = [
|
||||
{ value: 1, unit: 'd' }, // 1 day after
|
||||
{ value: 3, unit: 'd' }, // 3 days after
|
||||
{ value: 7, unit: 'd' }, // 1 week after
|
||||
]
|
||||
break
|
||||
}
|
||||
|
||||
// For reminder/followup, find first non-duplicate suggestion
|
||||
if (suggestions.length > 0) {
|
||||
newNotification = suggestions.find(suggestion => !isDuplicate(suggestion))
|
||||
|
||||
if (!newNotification) {
|
||||
setError(`All common ${type} times are already configured.`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Add the new notification to the end (don't sort, keep form order)
|
||||
const updatedNotifications = [...notifications, newNotification]
|
||||
|
||||
setNotifications(updatedNotifications)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const removeNotification = idx => {
|
||||
const updated = notifications.filter((_, i) => i !== idx)
|
||||
setNotifications(updated)
|
||||
onChange && onChange(updated)
|
||||
setShowSaveDefault(true)
|
||||
}
|
||||
const renderTimeline = () => {
|
||||
// Convert notifications to minutes for proper chronological sorting
|
||||
const convertToMinutes = (value, unit) => {
|
||||
const numericValue = Number(value)
|
||||
if (numericValue === 0) return 0
|
||||
let minutes = Math.abs(numericValue)
|
||||
if (unit === 'h') minutes *= 60
|
||||
if (unit === 'd') minutes *= 24 * 60
|
||||
return numericValue < 0 ? -minutes : minutes
|
||||
}
|
||||
|
||||
// Sort notifications chronologically by actual time (in minutes)
|
||||
const sorted = [...notifications].sort((a, b) => {
|
||||
const aMinutes = convertToMinutes(a.value, a.unit)
|
||||
const bMinutes = convertToMinutes(b.value, b.unit)
|
||||
return aMinutes - bMinutes
|
||||
})
|
||||
|
||||
// Get min and max notification times in minutes for dynamic scaling
|
||||
const minutesValues = sorted.map(n => convertToMinutes(n.value, n.unit))
|
||||
const minBefore = Math.min(0, ...minutesValues) // Default to 0 if no "before" notifications
|
||||
const maxAfter = Math.max(0, ...minutesValues) // Default to 0 if no "after" notifications
|
||||
|
||||
const getPositionPercent = (value, unit) => {
|
||||
const minutes = convertToMinutes(value, unit)
|
||||
|
||||
// Due date is always at center (50%)
|
||||
if (minutes === 0) return 50
|
||||
|
||||
// For notifications before due date (negative values)
|
||||
if (minutes < 0) {
|
||||
if (minBefore === 0) return 30 // Default position if no before notifications
|
||||
// Scale between 10% (furthest left) and 45% (closest to due)
|
||||
return 45 - (Math.abs(minutes) / Math.abs(minBefore)) * 35
|
||||
}
|
||||
|
||||
// For notifications after due date (positive values)
|
||||
if (maxAfter === 0) return 70 // Default position if no after notifications
|
||||
// Scale between 55% (closest to due) and 90% (furthest right)
|
||||
return 55 + (minutes / maxAfter) * 35
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 3, mb: 2 }}>
|
||||
<Typography level={'body-md'} sx={{ mb: 1 }}>
|
||||
Notification Timeline
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
height: 90,
|
||||
bgcolor: 'background.surface',
|
||||
borderRadius: 'md',
|
||||
border: '1px solid',
|
||||
borderColor: 'neutral.outlinedBorder',
|
||||
p: 2,
|
||||
transition: 'height 0.3s ease',
|
||||
// '&:hover': {
|
||||
// height: 130,
|
||||
// },
|
||||
}}
|
||||
>
|
||||
{/* Timeline line */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: 3,
|
||||
bgcolor: 'neutral.outlinedBorder',
|
||||
mt: 2,
|
||||
}}
|
||||
>
|
||||
{/* Due date marker */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
height: 16,
|
||||
width: 3,
|
||||
bgcolor: 'warning.500',
|
||||
top: -8,
|
||||
transform: 'translateX(-50%)',
|
||||
borderRadius: 'sm',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
level={'body-xs'}
|
||||
sx={{
|
||||
mt: 4,
|
||||
fontWeight: 'md',
|
||||
color: 'warning.700',
|
||||
fontSize: '0.6rem',
|
||||
}}
|
||||
>
|
||||
Due Date
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Notification markers */}
|
||||
{sorted.map((n, i) => {
|
||||
// Calculate position based on actual time duration
|
||||
const percent = getPositionPercent(n.value, n.unit)
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: `${percent}%`,
|
||||
transform: 'translateX(-50%)',
|
||||
color:
|
||||
convertToMinutes(n.value, n.unit) < 0
|
||||
? TASK_COLOR.SCHEDULED
|
||||
: convertToMinutes(n.value, n.unit) === 0
|
||||
? TASK_COLOR.TODAY
|
||||
: TASK_COLOR.LATE,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
transition: 'all 0.3s ease',
|
||||
cursor: 'pointer',
|
||||
opacity: 0.85,
|
||||
'&:hover': {
|
||||
opacity: 1,
|
||||
transform: 'translateX(-50%) scale(1.1)',
|
||||
zIndex: 10,
|
||||
},
|
||||
}}
|
||||
title={getRelativeLabel(n)}
|
||||
>
|
||||
<Badge
|
||||
badgeContent={
|
||||
notificationIndexMap[
|
||||
notifications.findIndex(
|
||||
original =>
|
||||
Number(original.value) === Number(n.value) &&
|
||||
original.unit === n.unit,
|
||||
)
|
||||
] || i + 1
|
||||
}
|
||||
size={'sm'}
|
||||
variant={'solid'}
|
||||
sx={{
|
||||
'--Badge-paddingX': '4px',
|
||||
'--Badge-minHeight': '16px',
|
||||
'--Badge-fontSize': '0.65rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
'& .MuiBadge-badge': {
|
||||
background:
|
||||
convertToMinutes(n.value, n.unit) < 0
|
||||
? NOTIFICATION_TYPE.PREDUE
|
||||
: convertToMinutes(n.value, n.unit) === 0
|
||||
? NOTIFICATION_TYPE.DUE_DATE
|
||||
: NOTIFICATION_TYPE.POSTDUE,
|
||||
color: 'white',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<NotificationsIcon
|
||||
fontSize={'small'}
|
||||
sx={{
|
||||
height: 18,
|
||||
width: 18,
|
||||
}}
|
||||
/>
|
||||
</Badge>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{error && (
|
||||
<Alert
|
||||
variant='soft'
|
||||
color='danger'
|
||||
sx={{ mb: 2 }}
|
||||
startDecorator={<InfoIcon />}
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{notifications
|
||||
.map((n, idx) => ({ notification: n, originalIndex: idx }))
|
||||
.sort((a, b) => {
|
||||
const aBadgeNumber = notificationIndexMap[a.originalIndex] || 0
|
||||
const bBadgeNumber = notificationIndexMap[b.originalIndex] || 0
|
||||
return aBadgeNumber - bBadgeNumber
|
||||
})
|
||||
.map(({ notification: n, originalIndex: idx }) => {
|
||||
// Get ordered badge number from timeline sorting
|
||||
const badgeNumber = notificationIndexMap[idx]
|
||||
const uiRep = getUIRepresentation(n)
|
||||
|
||||
const getNotificationColors = value => {
|
||||
if (Number(value) < 0) {
|
||||
return {
|
||||
bgColor: NOTIFICATION_TYPE.PREDUE,
|
||||
lightBg: `${NOTIFICATION_TYPE.PREDUE}20`,
|
||||
borderColor: `${NOTIFICATION_TYPE.PREDUE}40`,
|
||||
textColor: NOTIFICATION_TYPE.PREDUE,
|
||||
}
|
||||
} else if (Number(value) === 0) {
|
||||
return {
|
||||
bgColor: NOTIFICATION_TYPE.DUE_DATE,
|
||||
lightBg: `${NOTIFICATION_TYPE.DUE_DATE}20`,
|
||||
borderColor: `${NOTIFICATION_TYPE.DUE_DATE}40`,
|
||||
textColor: NOTIFICATION_TYPE.DUE_DATE,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
bgColor: NOTIFICATION_TYPE.POSTDUE,
|
||||
lightBg: `${NOTIFICATION_TYPE.POSTDUE}20`,
|
||||
borderColor: `${NOTIFICATION_TYPE.POSTDUE}40`,
|
||||
textColor: NOTIFICATION_TYPE.POSTDUE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const colors = getNotificationColors(n.value)
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={idx}
|
||||
sx={{
|
||||
mb: 1.5,
|
||||
p: 2,
|
||||
borderRadius: 8,
|
||||
border: '1px solid',
|
||||
borderColor: 'neutral.outlinedBorder',
|
||||
background: 'background.surface',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
className='notification-icon'
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 6,
|
||||
background: `${colors.bgColor}15`,
|
||||
color: colors.textColor,
|
||||
position: 'relative',
|
||||
flexShrink: 0,
|
||||
'& svg': {
|
||||
fontSize: 16,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Badge
|
||||
badgeContent={badgeNumber}
|
||||
size={'sm'}
|
||||
sx={{
|
||||
'--Badge-minHeight': '16px',
|
||||
'--Badge-fontSize': '0.7rem',
|
||||
'--Badge-paddingX': '5px',
|
||||
position: 'absolute',
|
||||
top: -6,
|
||||
right: -6,
|
||||
'& .MuiBadge-badge': {
|
||||
background: colors.bgColor,
|
||||
color: 'white',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<NotificationsIcon />
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
minWidth: 0,
|
||||
flex: '1',
|
||||
display: { xs: 'none', md: 'flex' }, // Show only on md and up
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: 'text.primary',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{getRelativeLabel(n)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Select
|
||||
value={uiRep.timing}
|
||||
onChange={(_, value) => handleChange(idx, 'timing', value)}
|
||||
sx={{ minWidth: 80 }}
|
||||
size={'sm'}
|
||||
>
|
||||
{timingOptions.map(opt => (
|
||||
<Option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
type={'number'}
|
||||
min={0}
|
||||
value={uiRep.displayValue}
|
||||
disabled={uiRep.timing === 'ondue'}
|
||||
onChange={e =>
|
||||
handleChange(idx, 'displayValue', e.target.value)
|
||||
}
|
||||
sx={{
|
||||
width: 60,
|
||||
opacity: uiRep.timing === 'ondue' ? 0.6 : 1,
|
||||
}}
|
||||
size={'sm'}
|
||||
placeholder='0'
|
||||
/>
|
||||
<Select
|
||||
value={n.unit}
|
||||
disabled={uiRep.timing === 'ondue'}
|
||||
onChange={(_, value) => handleChange(idx, 'unit', value)}
|
||||
sx={{
|
||||
minWidth: 70,
|
||||
opacity: uiRep.timing === 'ondue' ? 0.6 : 1,
|
||||
}}
|
||||
size={'sm'}
|
||||
>
|
||||
{timeUnits.map(opt => (
|
||||
<Option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<IconButton
|
||||
onClick={() => removeNotification(idx)}
|
||||
disabled={notifications.length === 1}
|
||||
color={'danger'}
|
||||
size={'sm'}
|
||||
variant={'soft'}
|
||||
sx={{
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.1)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DeleteIcon fontSize={'small'} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 1.5,
|
||||
mt: 1,
|
||||
mb: 2,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={() => addSmartNotification('reminder')}
|
||||
disabled={notifications.length >= maxNotifications}
|
||||
startDecorator={<AddIcon />}
|
||||
size={'sm'}
|
||||
variant={'outlined'}
|
||||
sx={{
|
||||
borderRadius: 6,
|
||||
fontWeight: 500,
|
||||
borderColor: `${TASK_COLOR.SCHEDULED}60`,
|
||||
color: TASK_COLOR.SCHEDULED,
|
||||
'&:hover': {
|
||||
borderColor: TASK_COLOR.SCHEDULED,
|
||||
background: `${TASK_COLOR.SCHEDULED}10`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Reminder
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => addSmartNotification('due')}
|
||||
disabled={
|
||||
notifications.length >= maxNotifications ||
|
||||
notifications.some(n => Number(n.value) === 0)
|
||||
}
|
||||
startDecorator={<AddIcon />}
|
||||
size={'sm'}
|
||||
variant={'outlined'}
|
||||
sx={{
|
||||
borderRadius: 6,
|
||||
fontWeight: 500,
|
||||
borderColor: `${NOTIFICATION_TYPE.DUE_DATE}60`,
|
||||
color: NOTIFICATION_TYPE.DUE_DATE,
|
||||
'&:hover': {
|
||||
borderColor: NOTIFICATION_TYPE.DUE_DATE,
|
||||
background: `${NOTIFICATION_TYPE.DUE_DATE}10`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Due Alert
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => addSmartNotification('followup')}
|
||||
disabled={notifications.length >= maxNotifications}
|
||||
startDecorator={<AddIcon />}
|
||||
size={'sm'}
|
||||
variant={'outlined'}
|
||||
sx={{
|
||||
borderRadius: 6,
|
||||
fontWeight: 500,
|
||||
borderColor: `${NOTIFICATION_TYPE.POSTDUE}60`,
|
||||
color: NOTIFICATION_TYPE.POSTDUE,
|
||||
'&:hover': {
|
||||
borderColor: NOTIFICATION_TYPE.POSTDUE,
|
||||
background: `${NOTIFICATION_TYPE.POSTDUE}10`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Follow-up
|
||||
</Button>
|
||||
</Box>
|
||||
{showSaveDefault && (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 1,
|
||||
display: 'flex',
|
||||
justifyContent: 'start',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant='outlined'
|
||||
size='sm'
|
||||
color='neutral'
|
||||
startDecorator={<Save />}
|
||||
sx={{
|
||||
borderRadius: 6,
|
||||
fontWeight: 500,
|
||||
'&:hover': {
|
||||
background: 'neutral.softHoverBg',
|
||||
},
|
||||
}}
|
||||
onClick={() => {
|
||||
localStorage.setItem(
|
||||
'defaultNotificationTemplate',
|
||||
JSON.stringify(notifications),
|
||||
)
|
||||
setShowSaveDefault(false)
|
||||
}}
|
||||
>
|
||||
Remember for Future Tasks
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{showTimeline && renderTimeline()}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotificationTemplate
|
||||
192
src/components/RealTimeSettings.jsx
Normal file
192
src/components/RealTimeSettings.jsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { Sync, SyncDisabled } from '@mui/icons-material'
|
||||
import { Box, Card, Chip, FormHelperText, Switch, Typography } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { useSSEContext } from '../hooks/useSSEContext'
|
||||
import { useUserProfile } from '../queries/UserQueries'
|
||||
import { isPlusAccount } from '../utils/Helpers'
|
||||
import SSEConnectionStatus from './SSEConnectionStatus'
|
||||
|
||||
const REALTIME_TYPES = {
|
||||
DISABLED: 'disabled',
|
||||
SSE: 'sse',
|
||||
}
|
||||
|
||||
const RealTimeSettings = () => {
|
||||
const { data: userProfile } = useUserProfile()
|
||||
|
||||
// SSE context
|
||||
const sseContext = useSSEContext()
|
||||
|
||||
// Get current realtime type from localStorage
|
||||
const getCurrentRealtimeType = () => {
|
||||
const sseEnabled = localStorage.getItem('sse_enabled') === 'true'
|
||||
return sseEnabled ? REALTIME_TYPES.SSE : REALTIME_TYPES.DISABLED
|
||||
}
|
||||
|
||||
const [realtimeType, setRealtimeType] = useState(getCurrentRealtimeType())
|
||||
|
||||
const handleRealtimeTypeChange = (event, newValue) => {
|
||||
if (!isPlusAccount(userProfile)) {
|
||||
return // Don't allow changes for non-Plus users
|
||||
}
|
||||
|
||||
setRealtimeType(newValue)
|
||||
|
||||
// Update localStorage and toggle connections
|
||||
switch (newValue) {
|
||||
case REALTIME_TYPES.DISABLED:
|
||||
localStorage.setItem('sse_enabled', 'false')
|
||||
sseContext.disconnect()
|
||||
break
|
||||
case REALTIME_TYPES.SSE:
|
||||
localStorage.setItem('sse_enabled', 'true')
|
||||
sseContext.connect()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const getCurrentContext = () => {
|
||||
switch (realtimeType) {
|
||||
case REALTIME_TYPES.SSE:
|
||||
return sseContext
|
||||
default:
|
||||
return {
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
error: null,
|
||||
getConnectionStatus: () => 'disabled',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const context = getCurrentContext()
|
||||
|
||||
const getStatusDescription = () => {
|
||||
if (!isPlusAccount(userProfile)) {
|
||||
return 'Real-time updates are not available in the Basic plan. Upgrade to Plus to receive instant notifications when tasks are updated.'
|
||||
}
|
||||
|
||||
if (realtimeType === REALTIME_TYPES.DISABLED) {
|
||||
return 'Real-time updates are disabled. Enable them to see live changes when you or other circle members complete, skip, or modify tasks.'
|
||||
}
|
||||
|
||||
if (context.isConnected) {
|
||||
return "Real-time updates are working. You'll see live changes when you or other circle members complete, skip, or modify tasks."
|
||||
}
|
||||
|
||||
if (context.isConnecting) {
|
||||
return 'Connecting to real-time updates...'
|
||||
}
|
||||
|
||||
if (context.error) {
|
||||
return `Real-time updates are enabled but not working: ${context.error}`
|
||||
}
|
||||
|
||||
return 'Real-time updates are enabled but not currently connected.'
|
||||
}
|
||||
|
||||
const getConnectionStatusComponent = () => {
|
||||
switch (realtimeType) {
|
||||
case REALTIME_TYPES.SSE:
|
||||
return <SSEConnectionStatus variant='chip' />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card sx={{ mt: 2, p: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2, mb: 2 }}>
|
||||
<Switch
|
||||
checked={realtimeType !== REALTIME_TYPES.DISABLED}
|
||||
onChange={e => {
|
||||
handleRealtimeTypeChange(
|
||||
null,
|
||||
e.target.checked ? REALTIME_TYPES.SSE : REALTIME_TYPES.DISABLED,
|
||||
)
|
||||
}}
|
||||
color={
|
||||
realtimeType !== REALTIME_TYPES.DISABLED ? 'success' : 'neutral'
|
||||
}
|
||||
disabled={!isPlusAccount(userProfile)}
|
||||
inputProps={{ 'aria-label': 'Enable Real-time Updates' }}
|
||||
/>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography level='title-md'>
|
||||
Real-time Updates
|
||||
{!isPlusAccount(userProfile) && (
|
||||
<Chip variant='soft' color='warning' sx={{ ml: 1 }}>
|
||||
Plus Feature
|
||||
</Chip>
|
||||
)}
|
||||
</Typography>
|
||||
|
||||
{realtimeType !== REALTIME_TYPES.DISABLED &&
|
||||
isPlusAccount(userProfile) ? (
|
||||
<Sync color={context.isConnected ? 'success' : 'disabled'} />
|
||||
) : (
|
||||
<SyncDisabled color='disabled' />
|
||||
)}
|
||||
</Box>
|
||||
<Typography level='body-sm' color='neutral'>
|
||||
Get instant notifications when tasks are updated
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* <FormControl orientation='horizontal' sx={{ mb: 2 }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<FormLabel>Real-time Connection Type</FormLabel>
|
||||
<FormHelperText sx={{ mt: 0 }}>
|
||||
Choose how to receive real-time updates
|
||||
</FormHelperText>
|
||||
</Box>
|
||||
<Select
|
||||
value={realtimeType}
|
||||
onChange={handleRealtimeTypeChange}
|
||||
disabled={!isPlusAccount(userProfile)}
|
||||
sx={{ minWidth: 140 }}
|
||||
>
|
||||
<Option value={REALTIME_TYPES.DISABLED}>Disabled</Option>
|
||||
<Option value={REALTIME_TYPES.WEBSOCKET}>WebSocket</Option>
|
||||
<Option value={REALTIME_TYPES.SSE}>SSE</Option>
|
||||
</Select>
|
||||
</FormControl> */}
|
||||
|
||||
<FormHelperText sx={{ mb: 2 }}>{getStatusDescription()}</FormHelperText>
|
||||
|
||||
{realtimeType !== REALTIME_TYPES.DISABLED &&
|
||||
isPlusAccount(userProfile) && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
|
||||
<Typography level='body-xs' color='neutral'>
|
||||
Status:
|
||||
</Typography>
|
||||
{getConnectionStatusComponent()}
|
||||
{context.error && (
|
||||
<Typography level='body-xs' color='danger'>
|
||||
{context.error}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!isPlusAccount(userProfile) && (
|
||||
<Typography level='body-sm' color='warning' sx={{ mt: 1 }}>
|
||||
Real-time updates are not available in the Basic plan. Upgrade to Plus
|
||||
to receive instant notifications when you or other circle members
|
||||
complete, skip, or modify tasks.
|
||||
</Typography>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default RealTimeSettings
|
||||
106
src/components/SSEConnectionStatus.jsx
Normal file
106
src/components/SSEConnectionStatus.jsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Circle, SignalWifi4Bar, SignalWifiOff } from '@mui/icons-material'
|
||||
import { Box, Chip, Tooltip, Typography } from '@mui/joy'
|
||||
import { useSSEContext } from '../hooks/useSSEContext'
|
||||
|
||||
const SSEConnectionStatus = ({
|
||||
variant = 'minimal',
|
||||
showError = false,
|
||||
sx = {},
|
||||
}) => {
|
||||
const { isConnected, isConnecting, error, getConnectionStatus } =
|
||||
useSSEContext()
|
||||
|
||||
const getStatusColor = () => {
|
||||
if (isConnected) return 'success'
|
||||
if (isConnecting) return 'warning'
|
||||
return 'danger'
|
||||
}
|
||||
|
||||
const getStatusIcon = () => {
|
||||
if (isConnected) return <SignalWifi4Bar />
|
||||
if (isConnecting) return <Circle />
|
||||
return <SignalWifiOff />
|
||||
}
|
||||
|
||||
const getStatusText = () => {
|
||||
if (isConnected) return 'Connected'
|
||||
if (isConnecting) return 'Connecting...'
|
||||
return 'Disconnected'
|
||||
}
|
||||
|
||||
const getTooltipText = () => {
|
||||
const status = getConnectionStatus()
|
||||
if (error) return `Real-time updates (SSE): ${status} - ${error}`
|
||||
if (!isConnected && !isConnecting) {
|
||||
return `Real-time updates (SSE): ${status} - Join a circle to enable real-time updates`
|
||||
}
|
||||
return `Real-time updates (SSE): ${status}`
|
||||
}
|
||||
|
||||
if (variant === 'minimal') {
|
||||
return (
|
||||
<Tooltip title={getTooltipText()} size='sm'>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
<Circle
|
||||
sx={{
|
||||
fontSize: 8,
|
||||
color:
|
||||
getStatusColor() === 'success'
|
||||
? 'success.main'
|
||||
: getStatusColor() === 'warning'
|
||||
? 'warning.main'
|
||||
: 'danger.main',
|
||||
}}
|
||||
/>
|
||||
{showError && error && (
|
||||
<Typography level='body-xs' color='danger'>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
if (variant === 'chip') {
|
||||
return (
|
||||
<Tooltip title={getTooltipText()} size='sm'>
|
||||
<Chip
|
||||
color={getStatusColor()}
|
||||
size='sm'
|
||||
variant='soft'
|
||||
startDecorator={getStatusIcon()}
|
||||
sx={sx}
|
||||
>
|
||||
{getStatusText()}
|
||||
</Chip>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
// Full variant
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, ...sx }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{getStatusIcon()}
|
||||
<Typography level='body-sm' color={getStatusColor()}>
|
||||
{getStatusText()}
|
||||
</Typography>
|
||||
</Box>
|
||||
{showError && error && (
|
||||
<Typography level='body-xs' color='danger'>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default SSEConnectionStatus
|
||||
149
src/components/SSESettings.jsx
Normal file
149
src/components/SSESettings.jsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { Sync, SyncDisabled } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
Chip,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
Switch,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useSSEContext } from '../hooks/useSSEContext'
|
||||
import { useUserProfile } from '../queries/UserQueries'
|
||||
import { isPlusAccount } from '../utils/Helpers'
|
||||
import SSEConnectionStatus from './SSEConnectionStatus'
|
||||
|
||||
const SSESettings = () => {
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const {
|
||||
isConnected,
|
||||
isConnecting,
|
||||
error,
|
||||
getConnectionStatus,
|
||||
toggleSSEEnabled,
|
||||
isSSEEnabled,
|
||||
} = useSSEContext()
|
||||
|
||||
const handleToggle = () => {
|
||||
console.log('=== TOGGLE CLICKED ===')
|
||||
if (!isPlusAccount(userProfile)) {
|
||||
console.log('Not a Plus account, returning early')
|
||||
return // Don't allow toggle for non-Plus users
|
||||
}
|
||||
const currentlyEnabled = isSSEEnabled()
|
||||
console.log('SSE Settings - Toggle clicked:', {
|
||||
currentlyEnabled,
|
||||
newState: !currentlyEnabled,
|
||||
userProfile,
|
||||
isPlusAccount: isPlusAccount(userProfile),
|
||||
})
|
||||
toggleSSEEnabled(!currentlyEnabled)
|
||||
}
|
||||
|
||||
const getStatusDescription = () => {
|
||||
if (!isPlusAccount(userProfile)) {
|
||||
return 'Real-time updates (SSE) are not available in the Basic plan. Upgrade to Plus to receive instant notifications when chores are updated.'
|
||||
}
|
||||
|
||||
if (!isSSEEnabled()) {
|
||||
return 'Real-time updates (SSE) are disabled. Enable to see live changes when you or other circle members complete, skip, or modify chores.'
|
||||
}
|
||||
|
||||
if (isConnected) {
|
||||
return "Real-time updates (SSE) are working. You'll see live changes when you or other circle members complete, skip, or modify chores."
|
||||
}
|
||||
|
||||
if (isConnecting) {
|
||||
return 'Connecting to real-time updates (SSE)...'
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return `Real-time updates (SSE) are enabled but not working: ${error}`
|
||||
}
|
||||
|
||||
return 'Real-time updates (SSE) are enabled but not currently connected.'
|
||||
}
|
||||
|
||||
return (
|
||||
<Card sx={{ mt: 2, p: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
{isSSEEnabled() && isPlusAccount(userProfile) ? (
|
||||
<Sync color={isConnected ? 'success' : 'disabled'} />
|
||||
) : (
|
||||
<SyncDisabled color='disabled' />
|
||||
)}
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography level='title-md'>
|
||||
Real-time Updates (SSE)
|
||||
{!isPlusAccount(userProfile) && (
|
||||
<Chip variant='soft' color='warning' sx={{ ml: 1 }}>
|
||||
Plus Feature
|
||||
</Chip>
|
||||
)}
|
||||
</Typography>
|
||||
<Typography level='body-sm' color='neutral'>
|
||||
Get instant notifications via Server-Sent Events
|
||||
</Typography>
|
||||
</Box>
|
||||
{isSSEEnabled() && isPlusAccount(userProfile) && (
|
||||
<SSEConnectionStatus variant='chip' />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<FormControl orientation='horizontal' sx={{ mb: 2 }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<FormLabel>Enable Real-time Updates (SSE)</FormLabel>
|
||||
<FormHelperText sx={{ mt: 0 }}>
|
||||
{getStatusDescription()}
|
||||
</FormHelperText>
|
||||
</Box>
|
||||
<Switch
|
||||
checked={isSSEEnabled() && isPlusAccount(userProfile)}
|
||||
onChange={handleToggle}
|
||||
disabled={!isPlusAccount(userProfile)}
|
||||
color={
|
||||
isSSEEnabled() && isPlusAccount(userProfile) ? 'success' : 'neutral'
|
||||
}
|
||||
variant='solid'
|
||||
endDecorator={
|
||||
isSSEEnabled() && isPlusAccount(userProfile) ? 'On' : 'Off'
|
||||
}
|
||||
slotProps={{ endDecorator: { sx: { minWidth: 24 } } }}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{isSSEEnabled() && isPlusAccount(userProfile) && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
|
||||
<Typography level='body-xs' color='neutral'>
|
||||
Status:
|
||||
</Typography>
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color={
|
||||
isConnected ? 'success' : isConnecting ? 'warning' : 'danger'
|
||||
}
|
||||
>
|
||||
{getConnectionStatus()}
|
||||
</Chip>
|
||||
{error && (
|
||||
<Typography level='body-xs' color='danger'>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!isPlusAccount(userProfile) && (
|
||||
<Typography level='body-sm' color='warning' sx={{ mt: 1 }}>
|
||||
Real-time updates (SSE) are not available in the Basic plan. Upgrade
|
||||
to Plus to receive instant notifications when you or other circle
|
||||
members complete, skip, or modify chores.
|
||||
</Typography>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default SSESettings
|
||||
259
src/components/SubscriptionModal.jsx
Normal file
259
src/components/SubscriptionModal.jsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import { Check, Star } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Chip,
|
||||
Divider,
|
||||
Modal,
|
||||
ModalDialog,
|
||||
Radio,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { useNotification } from '../service/NotificationProvider'
|
||||
import { GetSubscriptionSession } from '../utils/Fetcher'
|
||||
|
||||
const SubscriptionModal = ({ open, onClose }) => {
|
||||
const [selectedPlan, setSelectedPlan] = useState('yearly')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const { showError } = useNotification()
|
||||
|
||||
const plans = {
|
||||
yearly: {
|
||||
price: '$39.00',
|
||||
period: 'year',
|
||||
total: '$39.00/year',
|
||||
// savings: 'Save $20.88',
|
||||
// popular: true,
|
||||
},
|
||||
// monthly: {
|
||||
// price: '$4.99',
|
||||
// period: 'month',
|
||||
// total: '$4.99/month',
|
||||
// savings: null,
|
||||
// },
|
||||
}
|
||||
|
||||
const features = [
|
||||
'Task notifications and reminders',
|
||||
'Rich text descriptions with images uploads',
|
||||
'Thing-based task triggers',
|
||||
'API tokens for integrations',
|
||||
'Image uploads in descriptions',
|
||||
'Advanced task automation',
|
||||
// 'Unlimited task history',
|
||||
// 'Unlimited things history',
|
||||
]
|
||||
|
||||
const handleSubscribe = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// Call the backend with the selected plan
|
||||
const response = await GetSubscriptionSession()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create subscription session')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Redirect to Stripe
|
||||
if (data.sessionURL) {
|
||||
window.location.href = data.sessionURL
|
||||
} else {
|
||||
throw new Error('No session URL received')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Subscription error:', error)
|
||||
showError({
|
||||
title: 'Subscription Error',
|
||||
message: 'Failed to start subscription process. Please try again.',
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<ModalDialog
|
||||
layout='center'
|
||||
sx={{
|
||||
width: 600,
|
||||
maxWidth: '95vw',
|
||||
maxHeight: '95vh',
|
||||
overflow: 'auto',
|
||||
p: 0,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 4 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ textAlign: 'center', mb: 4 }}>
|
||||
<Typography level='h3' sx={{ mb: 1 }}>
|
||||
Upgrade to Plus
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Features List */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography level='title-lg' sx={{ mb: 2 }}>
|
||||
What's included:
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{features.map((feature, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 2 }}
|
||||
>
|
||||
<Check color='success' sx={{ fontSize: 20 }} />
|
||||
<Typography level='body-md'>{feature}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
{/* Plan Selection */}
|
||||
<Box
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 1.2, mb: 4 }}
|
||||
>
|
||||
{Object.entries(plans).map(([key, plan]) => (
|
||||
<Card
|
||||
key={key}
|
||||
color={selectedPlan === key ? 'primary' : 'neutral'}
|
||||
onClick={() => setSelectedPlan(key)}
|
||||
sx={{
|
||||
width: '100%',
|
||||
minHeight: 48,
|
||||
maxHeight: 64,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
mb: 0.2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 2.5,
|
||||
py: 1.2,
|
||||
position: 'relative',
|
||||
overflow: 'visible',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
justifyContent: 'flex-start',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Radio
|
||||
checked={selectedPlan === key}
|
||||
onChange={() => setSelectedPlan(key)}
|
||||
value={key}
|
||||
name='subscription-plan'
|
||||
color='primary'
|
||||
sx={{ mr: 1 }}
|
||||
/>
|
||||
<Typography level='body-md' sx={{ fontWeight: 600 }}>
|
||||
{key.charAt(0).toUpperCase() + key.slice(1)}
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 500, ml: 1 }}>
|
||||
{plan.price}
|
||||
<span style={{ color: '#888', fontWeight: 400 }}>
|
||||
{' '}
|
||||
/ {plan.period}
|
||||
</span>
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
position: 'absolute',
|
||||
right: 16,
|
||||
top: -18,
|
||||
}}
|
||||
>
|
||||
{plan.popular && (
|
||||
<Chip
|
||||
variant='solid'
|
||||
color='warning'
|
||||
size='sm'
|
||||
startDecorator={<Star />}
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
px: 1,
|
||||
py: 0.1,
|
||||
boxShadow: 2,
|
||||
mt: 0.8,
|
||||
}}
|
||||
>
|
||||
Most Popular
|
||||
</Chip>
|
||||
)}
|
||||
{plan.savings && (
|
||||
<Chip
|
||||
variant='soft'
|
||||
color='success'
|
||||
size='sm'
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
px: 1,
|
||||
py: 0.1,
|
||||
boxShadow: 2,
|
||||
mt: 0.8,
|
||||
}}
|
||||
>
|
||||
{plan.savings}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Box
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 1.2, mt: 2 }}
|
||||
>
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
onClick={handleSubscribe}
|
||||
loading={isLoading}
|
||||
fullWidth
|
||||
size='lg'
|
||||
sx={{ mb: 1 }}
|
||||
>
|
||||
Subscribe
|
||||
</Button>
|
||||
<Button
|
||||
variant='plain'
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
fullWidth
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Footer */}
|
||||
<Typography
|
||||
level='body-xs'
|
||||
color='neutral'
|
||||
sx={{ textAlign: 'center', mt: 3 }}
|
||||
>
|
||||
Cancel anytime. No hidden fees. Secure payment powered by Stripe.
|
||||
</Typography>
|
||||
</Box>
|
||||
</ModalDialog>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default SubscriptionModal
|
||||
539
src/components/UserProfileAvatar.jsx
Normal file
539
src/components/UserProfileAvatar.jsx
Normal file
@@ -0,0 +1,539 @@
|
||||
import {
|
||||
AdminPanelSettings,
|
||||
DarkModeOutlined,
|
||||
GroupAdd,
|
||||
LightModeOutlined,
|
||||
Logout,
|
||||
Person,
|
||||
Settings,
|
||||
SwapHoriz,
|
||||
Tune,
|
||||
WorkspacePremium,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Divider,
|
||||
Dropdown,
|
||||
ListItemContent,
|
||||
ListItemDecorator,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
Sheet,
|
||||
Typography,
|
||||
useColorScheme,
|
||||
} from '@mui/joy'
|
||||
import { useMediaQuery } from '@mui/material'
|
||||
import moment from 'moment'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useImpersonateUser } from '../contexts/ImpersonateUserContext'
|
||||
import useStickyState from '../hooks/useStickyState'
|
||||
import { useCircleMembers, useUserProfile } from '../queries/UserQueries'
|
||||
import { apiClient } from '../utils/ApiClient'
|
||||
import { isPlusAccount } from '../utils/Helpers'
|
||||
import UserModal from '../views/Modals/Inputs/UserModal'
|
||||
import SubscriptionModal from './SubscriptionModal'
|
||||
|
||||
const UserProfileAvatar = () => {
|
||||
const navigate = useNavigate()
|
||||
const { mode, setMode } = useColorScheme()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const {
|
||||
isImpersonating,
|
||||
startImpersonation,
|
||||
stopImpersonation,
|
||||
canImpersonate,
|
||||
getEffectiveUser,
|
||||
} = useImpersonateUser()
|
||||
const { data: circleMembersData } = useCircleMembers()
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [isSubscriptionModalOpen, setIsSubscriptionModalOpen] = useState(false)
|
||||
const [themeMode, setThemeMode] = useStickyState(mode, 'themeMode')
|
||||
const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('lg'))
|
||||
|
||||
if (!userProfile) return null
|
||||
|
||||
const currentUser = getEffectiveUser(userProfile)
|
||||
const isAdmin = canImpersonate(userProfile, circleMembersData?.res)
|
||||
const isPlusUser = isPlusAccount(userProfile)
|
||||
|
||||
const getSubscriptionStatus = () => {
|
||||
if (!userProfile) return 'Free'
|
||||
|
||||
if (userProfile.subscription === 'active') {
|
||||
return 'Plus'
|
||||
}
|
||||
|
||||
if (
|
||||
userProfile.subscription === 'cancelled' &&
|
||||
moment().isBefore(userProfile.expiration)
|
||||
) {
|
||||
return 'Plus (expires soon)'
|
||||
}
|
||||
|
||||
return 'Free'
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
apiClient.handleLogout()
|
||||
}
|
||||
|
||||
const handleSupportEmail = () => {
|
||||
window.location.href = 'mailto:support@donetick.com'
|
||||
}
|
||||
|
||||
const isDarkMode = themeMode === 'dark'
|
||||
|
||||
const handleThemeToggle = () => {
|
||||
const newThemeMode = isDarkMode ? 'light' : 'dark'
|
||||
setThemeMode(newThemeMode)
|
||||
setMode(newThemeMode)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropdown>
|
||||
<MenuButton
|
||||
variant='plain'
|
||||
sx={{
|
||||
p: 0,
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: '50%',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
|
||||
// transform: 'scale(1.05)',
|
||||
// transition: 'all 0.2s ease',
|
||||
},
|
||||
'&:active': {
|
||||
// transform: 'scale(0.95)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
{isImpersonating ? (
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<Avatar
|
||||
src={currentUser?.image || currentUser?.avatar}
|
||||
alt={currentUser?.displayName || currentUser?.name}
|
||||
size='md'
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
border: '2px solid var(--joy-palette-background-surface)',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
/>
|
||||
<Avatar
|
||||
src={userProfile?.image || userProfile?.avatar}
|
||||
alt={userProfile?.displayName || userProfile?.name}
|
||||
size='sm'
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: -2,
|
||||
left: -2,
|
||||
width: 18,
|
||||
height: 18,
|
||||
border: '2px solid var(--joy-palette-background-surface)',
|
||||
backgroundColor: 'var(--joy-palette-background-surface)',
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.2)',
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -2,
|
||||
right: -2,
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'var(--joy-palette-primary-500)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '1px solid var(--joy-palette-background-surface)',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.2)',
|
||||
}}
|
||||
>
|
||||
<SwapHoriz sx={{ fontSize: 8, color: 'white' }} />
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Avatar
|
||||
src={currentUser?.image || currentUser?.avatar}
|
||||
alt={currentUser?.displayName || currentUser?.name}
|
||||
size='md'
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
border: '2px solid var(--joy-palette-background-surface)',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</MenuButton>
|
||||
<Menu
|
||||
placement='bottom-end'
|
||||
sx={{
|
||||
minWidth: 280,
|
||||
p: 1,
|
||||
'--List-gap': '4px',
|
||||
boxShadow: 'var(--joy-shadow-lg)',
|
||||
border: '1px solid var(--joy-palette-divider)',
|
||||
borderRadius: 'var(--joy-radius-md)',
|
||||
}}
|
||||
>
|
||||
<Sheet sx={{ p: 2, borderRadius: 'var(--joy-radius-sm)', mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Avatar
|
||||
src={currentUser?.image || currentUser?.avatar}
|
||||
alt={currentUser?.displayName || currentUser?.name}
|
||||
size='lg'
|
||||
sx={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
border: '2px solid var(--joy-palette-background-surface)',
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography
|
||||
level='title-md'
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: 'var(--joy-palette-text-primary)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
mb: 0.25,
|
||||
}}
|
||||
>
|
||||
{currentUser?.displayName || currentUser?.name}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
color: 'var(--joy-palette-text-tertiary)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{currentUser?.email}
|
||||
</Typography>
|
||||
{isPlusUser && (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
color: 'var(--joy-palette-warning-600)',
|
||||
fontWeight: 500,
|
||||
fontSize: '11px',
|
||||
}}
|
||||
>
|
||||
{getSubscriptionStatus()}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{isImpersonating && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
mt: 0.5,
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
backgroundColor: 'var(--joy-palette-primary-softBg)',
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
width: 'fit-content',
|
||||
}}
|
||||
>
|
||||
<SwapHoriz
|
||||
sx={{
|
||||
fontSize: 12,
|
||||
color: 'var(--joy-palette-primary-600)',
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
color: 'var(--joy-palette-primary-600)',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Impersonating
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Sheet>
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<MenuItem
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
sx={{
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator
|
||||
sx={{ color: 'var(--joy-palette-primary-500)' }}
|
||||
>
|
||||
<AdminPanelSettings />
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
|
||||
{isImpersonating ? 'Switch User' : 'Impersonate User'}
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Act as another user
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
|
||||
{isImpersonating && (
|
||||
<MenuItem
|
||||
onClick={() => stopImpersonation()}
|
||||
sx={{
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator
|
||||
sx={{ color: 'var(--joy-palette-success-500)' }}
|
||||
>
|
||||
<Person />
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
|
||||
Stop Impersonating
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Return to your account
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 1 }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<MenuItem
|
||||
onClick={() => navigate('/settings')}
|
||||
sx={{
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator sx={{ color: 'var(--joy-palette-neutral-500)' }}>
|
||||
<Settings />
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
|
||||
Settings
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Account & preferences
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem
|
||||
onClick={() => navigate('/settings/circle')}
|
||||
sx={{
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator sx={{ color: 'var(--joy-palette-primary-500)' }}>
|
||||
<GroupAdd />
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
|
||||
Invite People
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Add members to your circle
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
{isLargeScreen && (
|
||||
<MenuItem
|
||||
onClick={() => navigate('/settings/detailed#sidepanel')}
|
||||
sx={{
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator
|
||||
sx={{ color: 'var(--joy-palette-neutral-500)' }}
|
||||
>
|
||||
<Tune />
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
|
||||
Side Panel Settings
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Customize layout & cards
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
<MenuItem
|
||||
onClick={handleThemeToggle}
|
||||
sx={{
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator sx={{ color: 'var(--joy-palette-neutral-500)' }}>
|
||||
{isDarkMode ? <LightModeOutlined /> : <DarkModeOutlined />}
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
|
||||
{isDarkMode ? 'Switch to Light' : 'Switch to Dark'}
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Toggle theme appearance
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
|
||||
{!isPlusUser && (
|
||||
<MenuItem
|
||||
onClick={() => setIsSubscriptionModalOpen(true)}
|
||||
sx={{
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--joy-palette-warning-softHoverBg)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator
|
||||
sx={{ color: 'var(--joy-palette-warning-500)' }}
|
||||
>
|
||||
<WorkspacePremium />
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Upgrade to Plus
|
||||
</Typography>
|
||||
<Typography level='body-xs'>Unlock premium features</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
{/* <MenuItem
|
||||
onClick={handleSupportEmail}
|
||||
sx={{
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator sx={{ color: 'var(--joy-palette-info-500)' }}>
|
||||
<Email />
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
|
||||
Support
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
support@donetick.com
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem> */}
|
||||
|
||||
<Divider sx={{ my: 1 }} />
|
||||
|
||||
<MenuItem
|
||||
onClick={handleLogout}
|
||||
sx={{
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--joy-palette-danger-softHoverBg)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator sx={{ color: 'var(--joy-palette-danger-500)' }}>
|
||||
<Logout />
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ fontWeight: 500, color: 'var(--joy-palette-danger-500)' }}
|
||||
>
|
||||
Logout
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Dropdown>
|
||||
|
||||
<UserModal
|
||||
isOpen={isModalOpen}
|
||||
performers={circleMembersData?.res}
|
||||
onSelect={user => {
|
||||
startImpersonation(user, userProfile)
|
||||
setIsModalOpen(false)
|
||||
}}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
/>
|
||||
|
||||
<SubscriptionModal
|
||||
open={isSubscriptionModalOpen}
|
||||
onClose={() => setIsSubscriptionModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserProfileAvatar
|
||||
88
src/components/animations/AnimatedList.jsx
Normal file
88
src/components/animations/AnimatedList.jsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React from 'react'
|
||||
import { Box } from '@mui/joy'
|
||||
import { CSSTransition, TransitionGroup } from 'react-transition-group'
|
||||
import { useStaggeredAnimation, useReducedMotion } from '../../hooks/useAnimations'
|
||||
import './PageTransition.css'
|
||||
|
||||
const AnimatedList = ({
|
||||
children,
|
||||
staggerDelay = 50,
|
||||
animationType = 'stagger', // 'stagger', 'fade', 'slide'
|
||||
direction = 'up', // 'up', 'down', 'left', 'right'
|
||||
renderItem,
|
||||
keyExtractor,
|
||||
items,
|
||||
...boxProps
|
||||
}) => {
|
||||
// Handle both children and items patterns
|
||||
let childrenArray
|
||||
if (items && renderItem) {
|
||||
childrenArray = items.map((item, index) =>
|
||||
React.cloneElement(renderItem(item, index), {
|
||||
key: keyExtractor ? keyExtractor(item, index) : index
|
||||
})
|
||||
)
|
||||
} else {
|
||||
childrenArray = React.Children.toArray(children)
|
||||
}
|
||||
|
||||
const visibleItems = useStaggeredAnimation(childrenArray.length, staggerDelay)
|
||||
const prefersReducedMotion = useReducedMotion()
|
||||
|
||||
// If user prefers reduced motion, render without animations
|
||||
if (prefersReducedMotion) {
|
||||
return (
|
||||
<Box {...boxProps}>
|
||||
{items && renderItem ? childrenArray : children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const getAnimationClass = () => {
|
||||
switch (animationType) {
|
||||
case 'fade':
|
||||
return 'fade'
|
||||
case 'slide':
|
||||
return direction === 'up' ? 'slide-up' : 'page'
|
||||
case 'stagger':
|
||||
default:
|
||||
return 'stagger'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box {...boxProps}>
|
||||
<TransitionGroup component={null}>
|
||||
{childrenArray.map((child, index) => {
|
||||
const isVisible = visibleItems.has(index)
|
||||
|
||||
return (
|
||||
<CSSTransition
|
||||
key={child.key || index}
|
||||
in={isVisible}
|
||||
timeout={{
|
||||
enter: 300,
|
||||
exit: 200,
|
||||
}}
|
||||
classNames={getAnimationClass()}
|
||||
unmountOnExit={false}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? 'none' : 'translateY(20px)',
|
||||
transition: 'opacity 0.3s ease, transform 0.3s ease',
|
||||
transitionDelay: `${index * staggerDelay}ms`,
|
||||
}}
|
||||
>
|
||||
{child}
|
||||
</Box>
|
||||
</CSSTransition>
|
||||
)
|
||||
})}
|
||||
</TransitionGroup>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default AnimatedList
|
||||
105
src/components/animations/LoadingScreen.jsx
Normal file
105
src/components/animations/LoadingScreen.jsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Box, CircularProgress, Typography } from '@mui/joy'
|
||||
import { styled } from '@mui/joy/styles'
|
||||
|
||||
// Styled components with keyframe animations
|
||||
const LoadingContainer = styled(Box)({
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
animation: 'fadeIn 0.3s ease-out',
|
||||
'@keyframes fadeIn': {
|
||||
from: {
|
||||
opacity: 0,
|
||||
},
|
||||
to: {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const LoadingContent = styled(Box)({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '24px',
|
||||
animation: 'slideUp 0.5s ease-out 0.2s both',
|
||||
'@keyframes slideUp': {
|
||||
from: {
|
||||
opacity: 0,
|
||||
transform: 'translateY(20px)',
|
||||
},
|
||||
to: {
|
||||
opacity: 1,
|
||||
transform: 'translateY(0)',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const PulsingText = styled(Typography)({
|
||||
animation: 'pulse 2s ease-in-out infinite',
|
||||
'@keyframes pulse': {
|
||||
'0%, 100%': {
|
||||
opacity: 1,
|
||||
},
|
||||
'50%': {
|
||||
opacity: 0.5,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const LogoContainer = styled(Box)({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
marginBottom: '24px',
|
||||
})
|
||||
|
||||
const LoadingScreen = ({
|
||||
message = 'Loading...',
|
||||
showLogo = true,
|
||||
size = 'lg',
|
||||
}) => {
|
||||
return (
|
||||
<LoadingContainer>
|
||||
<LoadingContent>
|
||||
{showLogo && (
|
||||
<LogoContainer>
|
||||
<Typography
|
||||
level='h1'
|
||||
sx={{
|
||||
fontSize: { xs: '2rem', sm: '2.5rem' },
|
||||
fontWeight: 700,
|
||||
color: 'primary.500',
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
Done
|
||||
<span style={{ color: '#06b6d4' }}>tick</span>
|
||||
</Typography>
|
||||
</LogoContainer>
|
||||
)}
|
||||
|
||||
<CircularProgress
|
||||
size={size}
|
||||
sx={{
|
||||
'--CircularProgress-size': size === 'lg' ? '60px' : '40px',
|
||||
mb: 2,
|
||||
}}
|
||||
/>
|
||||
|
||||
<PulsingText level='body-md'>{message}</PulsingText>
|
||||
</LoadingContent>
|
||||
</LoadingContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoadingScreen
|
||||
248
src/components/animations/PageTransition.css
Normal file
248
src/components/animations/PageTransition.css
Normal file
@@ -0,0 +1,248 @@
|
||||
/* Page Wrapper */
|
||||
.page-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* View Transitions API - Native browser page transitions */
|
||||
/* These run automatically when the browser supports view transitions */
|
||||
|
||||
/* Forward navigation (going deeper) */
|
||||
::view-transition-old(root):only-child {
|
||||
animation: slide-out-left 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
::view-transition-new(root):only-child {
|
||||
animation: slide-in-right 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Back navigation (going up) */
|
||||
html[data-transition='back']::view-transition-old(root):only-child {
|
||||
animation: slide-out-right 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
html[data-transition='back']::view-transition-new(root):only-child {
|
||||
animation: slide-in-left 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Fade for auth pages */
|
||||
html[data-transition='fade']::view-transition-old(root):only-child,
|
||||
html[data-transition='fade']::view-transition-new(root):only-child {
|
||||
animation: fade 200ms ease-in-out;
|
||||
}
|
||||
|
||||
/* Animation keyframes */
|
||||
@keyframes slide-out-left {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(-30%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-in-right {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-out-right {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-in-left {
|
||||
from {
|
||||
transform: translateX(-30%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Modal/overlay animations (still useful for non-route transitions) */
|
||||
.modal-enter {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
}
|
||||
|
||||
.modal-enter-active {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
transition:
|
||||
opacity 250ms ease-out,
|
||||
transform 250ms ease-out;
|
||||
}
|
||||
|
||||
.modal-exit {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
|
||||
.modal-exit-active {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
transition:
|
||||
opacity 200ms ease-in,
|
||||
transform 200ms ease-in;
|
||||
}
|
||||
|
||||
/* Slide up animation for bottom sheets/panels */
|
||||
.slide-up-enter {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
.slide-up-enter-active {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition:
|
||||
opacity 300ms ease-out,
|
||||
transform 300ms ease-out;
|
||||
}
|
||||
|
||||
.slide-up-exit {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.slide-up-exit-active {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
transition:
|
||||
opacity 250ms ease-in,
|
||||
transform 250ms ease-in;
|
||||
}
|
||||
|
||||
/* Loading spinner animation */
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* Skeleton loading animations */
|
||||
@keyframes skeleton-loading {
|
||||
0% {
|
||||
background-position: -200px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: calc(200px + 100%) 0;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200px 100%;
|
||||
animation: skeleton-loading 1.5s infinite;
|
||||
}
|
||||
|
||||
/* Micro-interactions */
|
||||
.interactive-element {
|
||||
transition: all 0.2s ease-out;
|
||||
transform: translateZ(0); /* Enable GPU acceleration */
|
||||
}
|
||||
|
||||
.interactive-element:hover {
|
||||
transform: translateY(-2px) translateZ(0);
|
||||
}
|
||||
|
||||
.interactive-element:active {
|
||||
transform: translateY(0) translateZ(0);
|
||||
transition: all 0.1s ease-out;
|
||||
}
|
||||
|
||||
/* Stagger animation for lists */
|
||||
.stagger-enter {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
.stagger-enter-active {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition:
|
||||
opacity 300ms ease-out,
|
||||
transform 300ms ease-out;
|
||||
}
|
||||
|
||||
/* Add animation delays for staggered effects */
|
||||
.stagger-enter-active:nth-child(1) {
|
||||
transition-delay: 0ms;
|
||||
}
|
||||
.stagger-enter-active:nth-child(2) {
|
||||
transition-delay: 50ms;
|
||||
}
|
||||
.stagger-enter-active:nth-child(3) {
|
||||
transition-delay: 100ms;
|
||||
}
|
||||
.stagger-enter-active:nth-child(4) {
|
||||
transition-delay: 150ms;
|
||||
}
|
||||
.stagger-enter-active:nth-child(5) {
|
||||
transition-delay: 200ms;
|
||||
}
|
||||
.stagger-enter-active:nth-child(6) {
|
||||
transition-delay: 250ms;
|
||||
}
|
||||
.stagger-enter-active:nth-child(7) {
|
||||
transition-delay: 300ms;
|
||||
}
|
||||
.stagger-enter-active:nth-child(8) {
|
||||
transition-delay: 350ms;
|
||||
}
|
||||
|
||||
/* Reduced motion for accessibility */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
/* Disable View Transitions for users who prefer reduced motion */
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.modal-enter,
|
||||
.modal-enter-active,
|
||||
.modal-exit,
|
||||
.modal-exit-active,
|
||||
.interactive-element,
|
||||
.stagger-enter,
|
||||
.stagger-enter-active {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
130
src/components/animations/PageTransition.jsx
Normal file
130
src/components/animations/PageTransition.jsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import './PageTransition.css'
|
||||
|
||||
// Route hierarchy for determining navigation direction
|
||||
const routeHierarchy = {
|
||||
'/': 0,
|
||||
'/my/chores': 0,
|
||||
'/chores': 1,
|
||||
'/chores/create': 2,
|
||||
'/settings': 1,
|
||||
'/things': 1,
|
||||
'/activities': 1,
|
||||
'/points': 1,
|
||||
'/labels': 1,
|
||||
'/projects': 1,
|
||||
'/login': 0,
|
||||
'/signup': 1,
|
||||
'/landing': 0,
|
||||
'/archived': 1,
|
||||
}
|
||||
|
||||
const getRouteLevel = pathname => {
|
||||
// Check for exact matches first
|
||||
if (routeHierarchy[pathname] !== undefined) {
|
||||
return routeHierarchy[pathname]
|
||||
}
|
||||
|
||||
// Check for dynamic routes (e.g., /chores/123/edit)
|
||||
if (pathname.includes('/chores/') && pathname.includes('/edit')) {
|
||||
return 3
|
||||
}
|
||||
if (pathname.includes('/chores/') && pathname.includes('/history')) {
|
||||
return 3
|
||||
}
|
||||
if (pathname.includes('/chores/') && pathname.includes('/timer')) {
|
||||
return 3
|
||||
}
|
||||
if (
|
||||
pathname.includes('/chores/') &&
|
||||
!pathname.includes('/edit') &&
|
||||
!pathname.includes('/history') &&
|
||||
!pathname.includes('/timer')
|
||||
) {
|
||||
return 2
|
||||
}
|
||||
if (pathname.includes('/things/')) {
|
||||
return 2
|
||||
}
|
||||
if (pathname.includes('/settings/')) {
|
||||
return 2
|
||||
}
|
||||
|
||||
// Default level
|
||||
return 1
|
||||
}
|
||||
|
||||
const PageTransition = ({ children }) => {
|
||||
const location = useLocation()
|
||||
const prevLevel = useRef(0)
|
||||
const [displayLocation, setDisplayLocation] = useState(location)
|
||||
const isFirstRender = useRef(true)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// Skip transition on first render
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false
|
||||
setDisplayLocation(location)
|
||||
prevLevel.current = getRouteLevel(location.pathname)
|
||||
return
|
||||
}
|
||||
|
||||
// Don't transition if location hasn't actually changed
|
||||
if (location.pathname === displayLocation.pathname) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentLevel = getRouteLevel(location.pathname)
|
||||
const previousLevel = prevLevel.current
|
||||
|
||||
// Determine navigation direction
|
||||
const isBack = currentLevel < previousLevel
|
||||
const isFade =
|
||||
location.pathname.includes('/login') ||
|
||||
location.pathname.includes('/signup') ||
|
||||
location.pathname.includes('/landing') ||
|
||||
location.pathname.includes('/auth/')
|
||||
|
||||
// Apply transition type as data attribute for CSS
|
||||
document.documentElement.dataset.transition = isFade
|
||||
? 'fade'
|
||||
: isBack
|
||||
? 'back'
|
||||
: 'forward'
|
||||
|
||||
// Trigger View Transition if supported
|
||||
if (document.startViewTransition) {
|
||||
document.startViewTransition(() => {
|
||||
// flushSync forces React to update the DOM synchronously
|
||||
// This ensures the View Transition captures the actual DOM change
|
||||
flushSync(() => {
|
||||
setDisplayLocation(location)
|
||||
})
|
||||
// Scroll to top after DOM update
|
||||
window.scrollTo({ top: 0, left: 0, behavior: 'instant' })
|
||||
})
|
||||
} else {
|
||||
// Fallback for browsers without View Transitions API
|
||||
setDisplayLocation(location)
|
||||
window.scrollTo({ top: 0, left: 0, behavior: 'instant' })
|
||||
}
|
||||
|
||||
// Update refs
|
||||
prevLevel.current = currentLevel
|
||||
}, [location, displayLocation.pathname])
|
||||
|
||||
return (
|
||||
<div
|
||||
className='page-wrapper'
|
||||
style={{
|
||||
paddingBottom: `var(--safe-area-inset-bottom, 0px)`,
|
||||
}}
|
||||
>
|
||||
{displayLocation.pathname === location.pathname ? children : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PageTransition
|
||||
138
src/components/animations/SkeletonLoader.jsx
Normal file
138
src/components/animations/SkeletonLoader.jsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { Box, Skeleton } from '@mui/joy'
|
||||
|
||||
const SkeletonLoader = ({
|
||||
type = 'card',
|
||||
count = 1,
|
||||
height = 100,
|
||||
width = '100%',
|
||||
variant = 'rectangular',
|
||||
...props
|
||||
}) => {
|
||||
const renderSkeleton = () => {
|
||||
switch (type) {
|
||||
case 'card':
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 'md',
|
||||
}}
|
||||
>
|
||||
<Skeleton variant='text' height={24} width='60%' sx={{ mb: 1 }} />
|
||||
<Skeleton
|
||||
variant='text'
|
||||
height={16}
|
||||
width='100%'
|
||||
sx={{ mb: 0.5 }}
|
||||
/>
|
||||
<Skeleton variant='text' height={16} width='80%' sx={{ mb: 2 }} />
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Skeleton variant='circular' width={32} height={32} />
|
||||
<Skeleton variant='rectangular' height={32} width={80} />
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
case 'list':
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, p: 1.5 }}>
|
||||
<Skeleton variant='circular' width={40} height={40} />
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Skeleton
|
||||
variant='text'
|
||||
height={20}
|
||||
width='70%'
|
||||
sx={{ mb: 0.5 }}
|
||||
/>
|
||||
<Skeleton variant='text' height={16} width='50%' />
|
||||
</Box>
|
||||
<Skeleton variant='rectangular' width={60} height={24} />
|
||||
</Box>
|
||||
)
|
||||
|
||||
case 'chore':
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 'md',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Skeleton variant='text' height={24} width='50%' />
|
||||
<Skeleton variant='circular' width={24} height={24} />
|
||||
</Box>
|
||||
<Skeleton variant='text' height={16} width='80%' sx={{ mb: 1 }} />
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
||||
<Skeleton variant='rectangular' height={20} width={60} />
|
||||
<Skeleton variant='rectangular' height={20} width={40} />
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Skeleton variant='circular' width={32} height={32} />
|
||||
<Skeleton variant='text' height={16} width='30%' />
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
case 'profile':
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
p: 3,
|
||||
}}
|
||||
>
|
||||
<Skeleton
|
||||
variant='circular'
|
||||
width={80}
|
||||
height={80}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<Skeleton variant='text' height={24} width={150} sx={{ mb: 1 }} />
|
||||
<Skeleton variant='text' height={16} width={100} />
|
||||
</Box>
|
||||
)
|
||||
|
||||
default:
|
||||
return (
|
||||
<Skeleton
|
||||
variant={variant}
|
||||
height={height}
|
||||
width={width}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: count }, (_, index) => (
|
||||
<Box key={index} sx={{ mb: type === 'list' ? 0 : 2 }}>
|
||||
{renderSkeleton()}
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SkeletonLoader
|
||||
107
src/components/animations/SmoothButton.jsx
Normal file
107
src/components/animations/SmoothButton.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Button } from '@mui/joy'
|
||||
import { styled } from '@mui/joy/styles'
|
||||
|
||||
const AnimatedButton = styled(Button)(({ theme }) => ({
|
||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
transform: 'translateZ(0)', // Enable GPU acceleration
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
|
||||
'&:hover:not(:disabled)': {
|
||||
transform: 'translateY(-2px) translateZ(0)',
|
||||
boxShadow: theme.shadow.lg,
|
||||
},
|
||||
|
||||
'&:active:not(:disabled)': {
|
||||
transform: 'translateY(0) translateZ(0)',
|
||||
transition: 'all 0.1s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
},
|
||||
|
||||
'&:focus-visible': {
|
||||
outline: '2px solid',
|
||||
outlineColor: theme.palette.primary[500],
|
||||
outlineOffset: '2px',
|
||||
},
|
||||
|
||||
// Ripple effect
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
width: '0',
|
||||
height: '0',
|
||||
borderRadius: '50%',
|
||||
background: 'currentColor',
|
||||
opacity: 0.1,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
transition: 'width 0.6s, height 0.6s',
|
||||
},
|
||||
|
||||
'&:active::before': {
|
||||
width: '300px',
|
||||
height: '300px',
|
||||
},
|
||||
|
||||
// Loading state
|
||||
'&[data-loading="true"]': {
|
||||
pointerEvents: 'none',
|
||||
position: 'relative',
|
||||
|
||||
'& > *': {
|
||||
opacity: 0.6,
|
||||
},
|
||||
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
border: '2px solid currentColor',
|
||||
borderTop: '2px solid transparent',
|
||||
borderRadius: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
animation: 'spin 1s linear infinite',
|
||||
},
|
||||
},
|
||||
|
||||
// Reduced motion support
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transition: 'none',
|
||||
transform: 'none !important',
|
||||
|
||||
'&:hover:not(:disabled)': {
|
||||
transform: 'none',
|
||||
},
|
||||
|
||||
'&:active:not(:disabled)': {
|
||||
transform: 'none',
|
||||
},
|
||||
|
||||
'&::before': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const SmoothButton = ({ children, loading = false, onClick, ...props }) => {
|
||||
const handleClick = event => {
|
||||
if (loading) return
|
||||
onClick?.(event)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatedButton
|
||||
{...props}
|
||||
onClick={handleClick}
|
||||
data-loading={loading}
|
||||
disabled={props.disabled || loading}
|
||||
>
|
||||
{children}
|
||||
</AnimatedButton>
|
||||
)
|
||||
}
|
||||
|
||||
export default SmoothButton
|
||||
88
src/components/animations/SmoothCard.jsx
Normal file
88
src/components/animations/SmoothCard.jsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React from 'react'
|
||||
import { Card } from '@mui/joy'
|
||||
import { styled } from '@mui/joy/styles'
|
||||
|
||||
const AnimatedCard = styled(Card)(({ theme }) => ({
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
transform: 'translateZ(0)', // Enable GPU acceleration
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px) translateZ(0)',
|
||||
boxShadow: theme.shadow.lg,
|
||||
},
|
||||
|
||||
'&:active': {
|
||||
transform: 'translateY(-2px) translateZ(0)',
|
||||
transition: 'all 0.1s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
},
|
||||
|
||||
// Subtle background animation on hover
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'linear-gradient(45deg, transparent, rgba(255,255,255,0.1), transparent)',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.3s ease',
|
||||
pointerEvents: 'none',
|
||||
borderRadius: 'inherit',
|
||||
},
|
||||
|
||||
'&:hover::before': {
|
||||
opacity: 1,
|
||||
},
|
||||
|
||||
// Focus states for accessibility
|
||||
'&:focus-visible': {
|
||||
outline: '2px solid',
|
||||
outlineColor: theme.palette.primary[500],
|
||||
outlineOffset: '2px',
|
||||
},
|
||||
|
||||
// Reduced motion support
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transition: 'none',
|
||||
transform: 'none !important',
|
||||
|
||||
'&:hover': {
|
||||
transform: 'none',
|
||||
boxShadow: theme.shadow.md, // Still provide visual feedback
|
||||
},
|
||||
|
||||
'&:active': {
|
||||
transform: 'none',
|
||||
},
|
||||
|
||||
'&::before': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const SmoothCard = ({
|
||||
children,
|
||||
onClick,
|
||||
animationDisabled = false,
|
||||
...props
|
||||
}) => {
|
||||
if (animationDisabled) {
|
||||
return (
|
||||
<Card {...props} onClick={onClick}>
|
||||
{children}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatedCard {...props} onClick={onClick}>
|
||||
{children}
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
||||
export default SmoothCard
|
||||
54
src/components/animations/StaggeredList.jsx
Normal file
54
src/components/animations/StaggeredList.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Box } from '@mui/joy'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { CSSTransition, TransitionGroup } from 'react-transition-group'
|
||||
import './PageTransition.css'
|
||||
|
||||
const StaggeredList = ({
|
||||
children,
|
||||
staggerDelay = 50,
|
||||
initialDelay = 0,
|
||||
animate = true,
|
||||
}) => {
|
||||
const [isVisible, setIsVisible] = useState(!animate)
|
||||
|
||||
useEffect(() => {
|
||||
if (animate) {
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(true)
|
||||
}, initialDelay)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [animate, initialDelay])
|
||||
|
||||
if (!animate) {
|
||||
return <Box>{children}</Box>
|
||||
}
|
||||
|
||||
const childrenArray = React.Children.toArray(children)
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<TransitionGroup component={null}>
|
||||
{isVisible &&
|
||||
childrenArray.map((child, index) => (
|
||||
<CSSTransition
|
||||
key={child.key || index}
|
||||
classNames='stagger'
|
||||
timeout={{
|
||||
enter: 300 + index * staggerDelay,
|
||||
exit: 200,
|
||||
}}
|
||||
style={{
|
||||
transitionDelay: `${index * staggerDelay}ms`,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ mb: 1 }}>{child}</Box>
|
||||
</CSSTransition>
|
||||
))}
|
||||
</TransitionGroup>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default StaggeredList
|
||||
9
src/components/animations/index.js
Normal file
9
src/components/animations/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
// Animation Components
|
||||
export { default as AnimatedList } from './AnimatedList'
|
||||
export { default as PageTransition } from './PageTransition'
|
||||
export { default as SmoothButton } from './SmoothButton'
|
||||
export { default as SmoothCard } from './SmoothCard'
|
||||
export { default as StaggeredList } from './StaggeredList'
|
||||
|
||||
// Animation Styles
|
||||
import './PageTransition.css'
|
||||
254
src/components/common/BottomSheetModal.jsx
Normal file
254
src/components/common/BottomSheetModal.jsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import { Close } from '@mui/icons-material'
|
||||
import { Divider, IconButton, Modal, Sheet, Typography } from '@mui/joy'
|
||||
import { forwardRef, useEffect, useState } from 'react'
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
|
||||
const BottomSheetModal = forwardRef(
|
||||
(
|
||||
{
|
||||
open,
|
||||
onClose,
|
||||
children,
|
||||
title,
|
||||
footer,
|
||||
height = 'auto',
|
||||
maxHeight = '90vh',
|
||||
expandedHeight = '95vh',
|
||||
backdropBlur = true,
|
||||
showHandle = true,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [isClosing, setIsClosing] = useState(false)
|
||||
const [internalOpen, setInternalOpen] = useState(open)
|
||||
|
||||
// Handle opening
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setInternalOpen(true)
|
||||
setIsClosing(false)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Handle closing with animation
|
||||
useEffect(() => {
|
||||
if (!open && internalOpen) {
|
||||
setIsClosing(true)
|
||||
// Wait for animation to complete before hiding modal
|
||||
const timer = setTimeout(() => {
|
||||
setInternalOpen(false)
|
||||
setIsClosing(false)
|
||||
setIsExpanded(false)
|
||||
}, 250) // Match transition duration
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [open, internalOpen])
|
||||
|
||||
// Handle toggle expansion
|
||||
const handleToggleExpansion = () => {
|
||||
setIsExpanded(prev => !prev)
|
||||
}
|
||||
|
||||
// Close on escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = event => {
|
||||
if (event.key === 'Escape' && internalOpen) {
|
||||
onClose?.()
|
||||
}
|
||||
}
|
||||
|
||||
if (internalOpen) {
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
// Prevent body scroll when modal is open
|
||||
// document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
// Restore scroll immediately when modal starts closing
|
||||
// document.body.style.overflow = 'unset'
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
document.body.style.overflow = 'unset'
|
||||
}
|
||||
}, [internalOpen, onClose])
|
||||
|
||||
// Calculate current height
|
||||
const currentHeight = isExpanded ? expandedHeight : height
|
||||
|
||||
// Filter out DOM props that shouldn't be passed to Modal
|
||||
const {
|
||||
fullWidth: _fullWidth,
|
||||
unmountDelay: _unmountDelay,
|
||||
...modalProps
|
||||
} = props
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={internalOpen}
|
||||
onClose={onClose}
|
||||
sx={{
|
||||
'& .MuiModal-backdrop': {
|
||||
backdropFilter: backdropBlur ? 'blur(3px)' : 'none',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||
},
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
keepMounted
|
||||
{...modalProps}
|
||||
>
|
||||
<Sheet
|
||||
ref={ref}
|
||||
sx={{
|
||||
zIndex: Z_INDEX.MODAL_CONTENT,
|
||||
minHeight: '20%',
|
||||
width: '100%',
|
||||
height: currentHeight,
|
||||
maxHeight: isExpanded ? expandedHeight : maxHeight,
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
p: 0,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition:
|
||||
'height 0.3s cubic-bezier(0.32, 0.72, 0, 1), max-height 0.3s cubic-bezier(0.32, 0.72, 0, 1), transform 0.3s cubic-bezier(0.32, 0.72, 0, 1)',
|
||||
transform:
|
||||
open && !isClosing ? 'translateY(0)' : 'translateY(100%)',
|
||||
// Handle safe area on mobile devices
|
||||
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||
}}
|
||||
>
|
||||
{/* Header Section with drag handle, title, and close button */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: 'inherit',
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Close button positioned absolutely in top-right */}
|
||||
{showCloseButton && (
|
||||
<IconButton
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
onClick={onClose}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 16,
|
||||
zIndex: 1,
|
||||
borderRadius: '50%',
|
||||
width: 32,
|
||||
height: 32,
|
||||
backgroundColor: 'neutral.softBg',
|
||||
color: 'neutral.softColor',
|
||||
'&:hover': {
|
||||
backgroundColor: 'neutral.softHoverBg',
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
transition: 'all 0.2s ease',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Close fontSize='small' />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{/* Drag Handle */}
|
||||
{showHandle && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
padding: '12px 0 8px 0',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
onClick={handleToggleExpansion}
|
||||
title={isExpanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 30,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: 'var(--joy-palette-neutral-300)',
|
||||
transition: 'background-color 0.2s ease',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title Row */}
|
||||
{title && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: showHandle
|
||||
? '0 20px 16px 20px'
|
||||
: '16px 20px 16px 20px',
|
||||
paddingRight: showCloseButton ? '60px' : '20px', // Add space for close button
|
||||
minHeight: 24,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
level='title-lg'
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Content area */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
padding: '0 20px 20px 20px',
|
||||
minHeight: 0, // Important for flex child with overflow
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{footer && (
|
||||
<>
|
||||
<Divider />
|
||||
<footer
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
// borderTop: '1px solid var(--joy-palette-divider)',
|
||||
padding: '16px 20px',
|
||||
}}
|
||||
>
|
||||
{footer}
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
</Sheet>
|
||||
</Modal>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
BottomSheetModal.displayName = 'BottomSheetModal'
|
||||
|
||||
export default BottomSheetModal
|
||||
86
src/components/common/FadeModal.jsx
Normal file
86
src/components/common/FadeModal.jsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Modal, ModalDialog, ModalOverflow } from '@mui/joy'
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
|
||||
/**
|
||||
* FadeModal component with consistent fade-in/out animations
|
||||
* Can be used as a drop-in replacement for Joy UI's Modal component
|
||||
*/
|
||||
const FadeModal = ({
|
||||
open,
|
||||
onClose,
|
||||
children,
|
||||
size = 'md',
|
||||
fullWidth = false,
|
||||
backdropBlur = true,
|
||||
...props
|
||||
}) => {
|
||||
// Filter out props that shouldn't be passed to Modal
|
||||
const { unmountDelay: _unmountDelay, ...modalProps } = props
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
sx={{
|
||||
'& .MuiModal-backdrop': {
|
||||
backdropFilter: backdropBlur ? 'blur(3px)' : 'none',
|
||||
},
|
||||
}}
|
||||
keepMounted
|
||||
// These transition properties create a smooth fade + slide effect
|
||||
transition={{
|
||||
mount: { opacity: 1, transform: 'translateY(0px)' },
|
||||
unmount: { opacity: 0, transform: 'translateY(20px)' },
|
||||
duration: 250, // Animation duration in ms
|
||||
easing: {
|
||||
enter: 'cubic-bezier(0.34, 1.56, 0.64, 1)', // Slight overshoot for natural feel
|
||||
exit: 'cubic-bezier(0.4, 0, 0.2, 1)', // Standard ease out
|
||||
},
|
||||
}}
|
||||
{...modalProps}
|
||||
>
|
||||
<ModalOverflow>
|
||||
<ModalDialog
|
||||
size={size}
|
||||
sx={{
|
||||
zIndex: Z_INDEX.MODAL_CONTENT,
|
||||
minWidth: fullWidth ? '90%' : 'auto',
|
||||
animation: open
|
||||
? 'modalFadeIn 0.35s forwards'
|
||||
: 'modalFadeOut 0.25s forwards',
|
||||
'@keyframes modalFadeIn': {
|
||||
from: { opacity: 0, transform: 'translateY(8px)' },
|
||||
to: { opacity: 1, transform: 'translateY(0)' },
|
||||
},
|
||||
'@keyframes modalFadeOut': {
|
||||
from: { opacity: 1, transform: 'translateY(0)' },
|
||||
to: { opacity: 0, transform: 'translateY(8px)' },
|
||||
},
|
||||
// Add staggered animation for child elements
|
||||
'& > *': {
|
||||
opacity: 0,
|
||||
animation: open
|
||||
? 'contentFadeIn 0.35s forwards'
|
||||
: 'contentFadeOut 0.2s forwards',
|
||||
},
|
||||
// Stagger child animations
|
||||
'& > *:nth-of-type(1)': { animationDelay: '0.05s' },
|
||||
'& > *:nth-of-type(2)': { animationDelay: '0.1s' },
|
||||
'& > *:nth-of-type(3)': { animationDelay: '0.15s' },
|
||||
'& > *:nth-of-type(4)': { animationDelay: '0.2s' },
|
||||
'& > *:nth-of-type(5)': { animationDelay: '0.25s' },
|
||||
'@keyframes contentFadeIn': {
|
||||
to: { opacity: 1 },
|
||||
},
|
||||
'@keyframes contentFadeOut': {
|
||||
to: { opacity: 0 },
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ModalDialog>
|
||||
</ModalOverflow>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default FadeModal
|
||||
75
src/components/common/KeyboardShortcutHint.jsx
Normal file
75
src/components/common/KeyboardShortcutHint.jsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Chip } from '@mui/joy'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
/**
|
||||
* A component that displays keyboard shortcut hints as small chips
|
||||
* Only visible on non-mobile devices
|
||||
* Supports platform-specific shortcuts (Cmd on Mac, Ctrl on Windows) and Shift key
|
||||
*/
|
||||
function KeyboardShortcutHint({
|
||||
shortcut,
|
||||
show = true,
|
||||
withCmd = true,
|
||||
withCtrl, // Legacy prop for backward compatibility
|
||||
withShift = false,
|
||||
sx = {},
|
||||
...props
|
||||
}) {
|
||||
if (!show) return null
|
||||
|
||||
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0
|
||||
const modifierKey = isMac ? '⌘' : 'Ctrl'
|
||||
|
||||
// Build the shortcut display string
|
||||
let displayShortcut = ''
|
||||
// Support both withCmd and withCtrl for backward compatibility
|
||||
const shouldShowModifier = withCmd || withCtrl
|
||||
if (shouldShowModifier) {
|
||||
displayShortcut += modifierKey
|
||||
}
|
||||
if (withShift) {
|
||||
displayShortcut += (displayShortcut ? ' + ' : '') + 'Shift'
|
||||
}
|
||||
if (shortcut) {
|
||||
displayShortcut += (displayShortcut ? ' + ' : '') + shortcut
|
||||
}
|
||||
|
||||
return (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
sx={{
|
||||
fontSize: '0.75rem',
|
||||
maxHeight: '1.5rem',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
fontWeight: '500',
|
||||
letterSpacing: '0.025em',
|
||||
border: '1px solid',
|
||||
borderColor: 'neutral.300',
|
||||
// backgroundColor: 'background.surface',
|
||||
color: 'text.secondary',
|
||||
borderRadius: '8px',
|
||||
px: 0.5,
|
||||
py: 0.125,
|
||||
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)',
|
||||
display: { xs: 'none', md: 'inline-flex' }, // Hide on mobile
|
||||
...sx,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{displayShortcut}
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
|
||||
KeyboardShortcutHint.propTypes = {
|
||||
shortcut: PropTypes.string.isRequired,
|
||||
show: PropTypes.bool,
|
||||
withCmd: PropTypes.bool,
|
||||
withCtrl: PropTypes.bool, // Legacy prop for backward compatibility
|
||||
withShift: PropTypes.bool,
|
||||
sx: PropTypes.object,
|
||||
}
|
||||
|
||||
export default KeyboardShortcutHint
|
||||
11
src/components/icons/DiscordIcon.jsx
Normal file
11
src/components/icons/DiscordIcon.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { SvgIcon } from '@mui/joy'
|
||||
|
||||
const DiscordIcon = props => {
|
||||
return (
|
||||
<SvgIcon {...props} viewBox='0 0 24 24'>
|
||||
<path d='M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419-.0190 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1568 2.4189Z' />
|
||||
</SvgIcon>
|
||||
)
|
||||
}
|
||||
|
||||
export default DiscordIcon
|
||||
11
src/components/icons/RedditIcon.jsx
Normal file
11
src/components/icons/RedditIcon.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { SvgIcon } from '@mui/joy'
|
||||
|
||||
const RedditIcon = props => {
|
||||
return (
|
||||
<SvgIcon {...props} viewBox='0 0 24 24'>
|
||||
<path d='M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z' />
|
||||
</SvgIcon>
|
||||
)
|
||||
}
|
||||
|
||||
export default RedditIcon
|
||||
36
src/constants/zIndex.js
Normal file
36
src/constants/zIndex.js
Normal file
@@ -0,0 +1,36 @@
|
||||
// Z-index constants for consistent layering
|
||||
// Lower values appear behind higher values
|
||||
|
||||
export const Z_INDEX = {
|
||||
// Base layer (0-99)
|
||||
BASE: 0,
|
||||
CARD_OVERLAY: 1,
|
||||
DROPDOWN_ITEM: 2,
|
||||
TOOLTIP: 3,
|
||||
|
||||
// UI Components (100-999)
|
||||
SAFE_AREA: 100,
|
||||
CALENDAR: 110,
|
||||
SMART_INPUT: 110,
|
||||
AUTOCOMPLETE: 200,
|
||||
|
||||
// Navigation (1000-1999)
|
||||
NAVBAR: 1000,
|
||||
DRAWER: 999,
|
||||
|
||||
// Modals and Overlays (2000-8999)
|
||||
MODAL_BACKDROP: 2000,
|
||||
MODAL_CONTENT: 2001,
|
||||
MODAL_CLOSE_BUTTON: 2002,
|
||||
TOAST: 3000,
|
||||
|
||||
// Critical System UI (9000-9999)
|
||||
LOADING_SCREEN: 9000,
|
||||
ALERTS: 9500,
|
||||
NETWORK_BANNER: 9600,
|
||||
|
||||
// Maximum (10000+) - Reserved for absolute emergencies
|
||||
EMERGENCY: 10000,
|
||||
}
|
||||
|
||||
export default Z_INDEX
|
||||
@@ -1,13 +1,21 @@
|
||||
import { AlertsProvider } from '../service/AlertsProvider'
|
||||
import { NotificationProvider } from '../service/NotificationProvider'
|
||||
import QueryContext from './QueryContext'
|
||||
import RouterContext from './RouterContext'
|
||||
import ThemeContext from './ThemeContext'
|
||||
|
||||
const Contexts = () => {
|
||||
const contexts = [ThemeContext, QueryContext, RouterContext]
|
||||
const Contexts = ({ children }) => {
|
||||
const contexts = [
|
||||
AlertsProvider,
|
||||
ThemeContext,
|
||||
QueryContext,
|
||||
NotificationProvider,
|
||||
RouterContext,
|
||||
]
|
||||
|
||||
return contexts.reduceRight((acc, Context) => {
|
||||
return <Context>{acc}</Context>
|
||||
}, {})
|
||||
}, children)
|
||||
}
|
||||
|
||||
export default Contexts
|
||||
|
||||
@@ -1,15 +1,150 @@
|
||||
import { createContext, useContext, useState } from 'react'
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
const ImpersonateUserContext = createContext()
|
||||
|
||||
export const useImpersonateUser = () => useContext(ImpersonateUserContext)
|
||||
|
||||
export const ImpersonateUserProvider = ({ children }) => {
|
||||
const [impersonatedUser, setImpersonatedUser] = useState(null)
|
||||
const [impersonationState, setImpersonationState] = useState({
|
||||
isImpersonating: false,
|
||||
impersonatedUser: null,
|
||||
originalUser: null,
|
||||
})
|
||||
|
||||
// Start impersonation
|
||||
const startImpersonation = useCallback((userToImpersonate, currentUser) => {
|
||||
console.log('Starting impersonation:', { userToImpersonate, currentUser })
|
||||
const newState = {
|
||||
isImpersonating: true,
|
||||
impersonatedUser: userToImpersonate,
|
||||
originalUser: currentUser,
|
||||
}
|
||||
|
||||
setImpersonationState(newState)
|
||||
|
||||
// Store in localStorage for persistence across page refreshes
|
||||
localStorage.setItem('impersonation', JSON.stringify(newState))
|
||||
localStorage.setItem('impersonatedUserId', userToImpersonate.userId)
|
||||
}, [])
|
||||
|
||||
// Stop impersonation
|
||||
const stopImpersonation = useCallback(() => {
|
||||
console.log('Stopping impersonation')
|
||||
setImpersonationState({
|
||||
isImpersonating: false,
|
||||
impersonatedUser: null,
|
||||
originalUser: null,
|
||||
})
|
||||
|
||||
// Remove from localStorage
|
||||
localStorage.removeItem('impersonation')
|
||||
localStorage.removeItem('impersonatedUserId')
|
||||
}, [])
|
||||
|
||||
// Get effective user (impersonated user if impersonating, otherwise current user)
|
||||
const getEffectiveUser = useCallback(
|
||||
currentUser => {
|
||||
if (
|
||||
impersonationState.isImpersonating &&
|
||||
impersonationState.impersonatedUser
|
||||
) {
|
||||
return impersonationState.impersonatedUser
|
||||
}
|
||||
return currentUser
|
||||
},
|
||||
[impersonationState],
|
||||
)
|
||||
|
||||
// Get impersonation headers for API calls
|
||||
const getImpersonationHeaders = useCallback(() => {
|
||||
if (
|
||||
impersonationState.isImpersonating &&
|
||||
impersonationState.impersonatedUser
|
||||
) {
|
||||
return {
|
||||
'X-Impersonate-User-ID':
|
||||
impersonationState.impersonatedUser.id.toString(),
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}, [impersonationState])
|
||||
|
||||
// Check if user can impersonate (admin or manager)
|
||||
// Note: This is a basic check. The component using this should also check circle membership
|
||||
const canImpersonate = useCallback((user, circleMembers = []) => {
|
||||
if (!user?.id) return false
|
||||
|
||||
// If circleMembers is provided, check role from there
|
||||
if (circleMembers.length > 0) {
|
||||
const member = circleMembers.find(m => m.userId === user.id)
|
||||
return member?.role === 'admin' || member?.role === 'manager'
|
||||
}
|
||||
|
||||
// Fallback to user.role property (if available)
|
||||
return user?.role === 'admin' || user?.role === 'manager'
|
||||
}, [])
|
||||
|
||||
// Restore impersonation state from localStorage on mount
|
||||
useEffect(() => {
|
||||
const storedImpersonation = localStorage.getItem('impersonation')
|
||||
if (storedImpersonation) {
|
||||
try {
|
||||
const parsed = JSON.parse(storedImpersonation)
|
||||
if (
|
||||
parsed.isImpersonating &&
|
||||
parsed.impersonatedUser &&
|
||||
parsed.originalUser
|
||||
) {
|
||||
setImpersonationState(parsed)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to restore impersonation state:', error)
|
||||
localStorage.removeItem('impersonation')
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const value = {
|
||||
// State
|
||||
...impersonationState,
|
||||
|
||||
// Actions
|
||||
startImpersonation,
|
||||
stopImpersonation,
|
||||
|
||||
getEffectiveUser,
|
||||
getImpersonationHeaders,
|
||||
canImpersonate,
|
||||
|
||||
// Computed properties
|
||||
isImpersonating: impersonationState.isImpersonating,
|
||||
impersonatedUser: impersonationState.impersonatedUser,
|
||||
originalUser: impersonationState.originalUser,
|
||||
|
||||
// Legacy support
|
||||
setImpersonatedUser: user => {
|
||||
if (user) {
|
||||
// If setting a user, assume we're starting impersonation
|
||||
// Note: This won't have originalUser, so it's for backward compatibility only
|
||||
setImpersonationState(prev => ({
|
||||
isImpersonating: true,
|
||||
impersonatedUser: user,
|
||||
originalUser: prev.originalUser,
|
||||
}))
|
||||
} else {
|
||||
stopImpersonation()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<ImpersonateUserContext.Provider
|
||||
value={{ impersonatedUser, setImpersonatedUser }}
|
||||
>
|
||||
<ImpersonateUserContext.Provider value={value}>
|
||||
{children}
|
||||
</ImpersonateUserContext.Provider>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
const QueryContext = ({ children }) => {
|
||||
const queryClient = new QueryClient()
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 300000, // 5 minutes
|
||||
gcTime: 600000, // 10 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import App from '@/App'
|
||||
import ChoreEdit from '@/views/ChoreEdit/ChoreEdit'
|
||||
import ChoresOverview from '@/views/ChoresOverview'
|
||||
import Error from '@/views/Error'
|
||||
import AccountSettings from '@/views/Settings/AccountSettings'
|
||||
import AdvancedSettings from '@/views/Settings/AdvancedSettings'
|
||||
import ChildUserSettings from '@/views/Settings/ChildUserSettings'
|
||||
import CircleSettings from '@/views/Settings/CircleSettings'
|
||||
import DeveloperSettings from '@/views/Settings/DeveloperSettings'
|
||||
import Settings from '@/views/Settings/Settings'
|
||||
import SettingsOverview from '@/views/Settings/SettingsOverview'
|
||||
import SettingsRoutes from '@/views/Settings/SettingsRoutes'
|
||||
import ThemeSettings from '@/views/Settings/ThemeSettings'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { RouterProvider, createBrowserRouter } from 'react-router-dom'
|
||||
import AuthenticationLoading from '../views/Authorization/Authenticating'
|
||||
@@ -12,21 +19,30 @@ import LoginView from '../views/Authorization/LoginView'
|
||||
import SignupView from '../views/Authorization/Signup'
|
||||
import UpdatePasswordView from '../views/Authorization/UpdatePasswordView'
|
||||
import ChoreView from '../views/ChoreEdit/ChoreView'
|
||||
import ArchivedTasks from '../views/Chores/ArchivedTasks'
|
||||
import MyChores from '../views/Chores/MyChores'
|
||||
import JoinCircleView from '../views/Circles/JoinCircle'
|
||||
import NotFound from '../views/components/NotFound'
|
||||
import ChoreHistory from '../views/History/ChoreHistory'
|
||||
import LabelView from '../views/Labels/LabelView'
|
||||
import Landing from '../views/Landing/Landing'
|
||||
import PaymentCancelledView from '../views/Payments/PaymentFailView'
|
||||
import PaymentSuccessView from '../views/Payments/PaymentSuccessView'
|
||||
import PrivacyPolicyView from '../views/PrivacyPolicy/PrivacyPolicyView'
|
||||
import ProjectView from '../views/Projects/ProjectView'
|
||||
import APITokenSettings from '../views/Settings/APITokenSettings'
|
||||
import MFASettings from '../views/Settings/MFASettings'
|
||||
import NotificationSetting from '../views/Settings/NotificationSetting'
|
||||
import ProfileSettings from '../views/Settings/ProfileSettings'
|
||||
import SidepanelSettings from '../views/Settings/SidepanelSettings'
|
||||
import StorageSettings from '../views/Settings/StorageSettings'
|
||||
import TermsView from '../views/Terms/TermsView'
|
||||
import TestView from '../views/TestView/Test'
|
||||
import ThingsHistory from '../views/Things/ThingsHistory'
|
||||
import ThingsView from '../views/Things/ThingsView'
|
||||
import TimerDetails from '../views/Timer/TimerDetails'
|
||||
import UserActivities from '../views/User/UserActivities'
|
||||
import UserPoints from '../views/User/UserPoints'
|
||||
import NotFound from '../views/components/NotFound'
|
||||
const getMainRoute = () => {
|
||||
if (
|
||||
import.meta.env.VITE_IS_LANDING_DEFAULT === 'true' &&
|
||||
@@ -48,11 +64,73 @@ const Router = createBrowserRouter([
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
element: <Settings />,
|
||||
element: <SettingsRoutes />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <SettingsOverview />,
|
||||
},
|
||||
{
|
||||
path: 'detailed',
|
||||
element: <Settings />,
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
element: <ProfileSettings />,
|
||||
},
|
||||
{
|
||||
path: 'circle',
|
||||
element: <CircleSettings />,
|
||||
},
|
||||
{
|
||||
path: 'account',
|
||||
element: <AccountSettings />,
|
||||
},
|
||||
{
|
||||
path: 'subaccounts',
|
||||
element: <ChildUserSettings />,
|
||||
},
|
||||
{
|
||||
path: 'notifications',
|
||||
element: <NotificationSetting />,
|
||||
},
|
||||
{
|
||||
path: 'mfa',
|
||||
element: <MFASettings />,
|
||||
},
|
||||
{
|
||||
path: 'apitokens',
|
||||
element: <APITokenSettings />,
|
||||
},
|
||||
{
|
||||
path: 'storage',
|
||||
element: <StorageSettings />,
|
||||
},
|
||||
{
|
||||
path: 'sidepanel',
|
||||
element: <SidepanelSettings />,
|
||||
},
|
||||
{
|
||||
path: 'theme',
|
||||
element: <ThemeSettings />,
|
||||
},
|
||||
{
|
||||
path: 'advanced',
|
||||
element: <AdvancedSettings />,
|
||||
},
|
||||
{
|
||||
path: 'developer',
|
||||
element: <DeveloperSettings />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/chores',
|
||||
element: <ChoresOverview />,
|
||||
element: <MyChores />,
|
||||
},
|
||||
{
|
||||
path: '/archived',
|
||||
element: <ArchivedTasks />,
|
||||
},
|
||||
{
|
||||
path: '/chores/:choreId/edit',
|
||||
@@ -70,6 +148,10 @@ const Router = createBrowserRouter([
|
||||
path: '/chores/:choreId/history',
|
||||
element: <ChoreHistory />,
|
||||
},
|
||||
{
|
||||
path: '/chores/:choreId/timer',
|
||||
element: <TimerDetails />,
|
||||
},
|
||||
{
|
||||
path: '/my/chores',
|
||||
element: <MyChores />,
|
||||
@@ -94,6 +176,7 @@ const Router = createBrowserRouter([
|
||||
path: '/signup',
|
||||
element: <SignupView />,
|
||||
},
|
||||
|
||||
{
|
||||
path: '/auth/:provider',
|
||||
element: <AuthenticationLoading />,
|
||||
@@ -146,6 +229,10 @@ const Router = createBrowserRouter([
|
||||
path: 'labels/',
|
||||
element: <LabelView />,
|
||||
},
|
||||
{
|
||||
path: 'projects/',
|
||||
element: <ProjectView />,
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
element: <NotFound />,
|
||||
|
||||
25
src/contexts/SSEContext.jsx
Normal file
25
src/contexts/SSEContext.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
import { useSSE } from '../hooks/useSSE'
|
||||
|
||||
export const SSEContext = createContext({
|
||||
connectionState: 2, // CLOSED
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
lastEvent: null,
|
||||
error: null,
|
||||
connect: () => {},
|
||||
disconnect: () => {},
|
||||
getConnectionStatus: () => 'disconnected',
|
||||
})
|
||||
|
||||
export const useSSEContext = () => {
|
||||
return useContext(SSEContext)
|
||||
}
|
||||
|
||||
export const SSEProvider = ({ children }) => {
|
||||
const sseState = useSSE()
|
||||
|
||||
return <SSEContext.Provider value={sseState}>{children}</SSEContext.Provider>
|
||||
}
|
||||
|
||||
export default SSEProvider
|
||||
@@ -1,8 +0,0 @@
|
||||
import { createContext } from 'react'
|
||||
|
||||
const UserContext = createContext({
|
||||
userProfile: null,
|
||||
setUserProfile: () => {},
|
||||
})
|
||||
|
||||
export { UserContext }
|
||||
39
src/hooks/useAcknowledgmentModal.js
Normal file
39
src/hooks/useAcknowledgmentModal.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
const useAcknowledgmentModal = () => {
|
||||
const [ackModalConfig, setAckModalConfig] = useState({})
|
||||
|
||||
const showAcknowledgment = (
|
||||
message,
|
||||
title,
|
||||
onAcknowledge,
|
||||
acknowledgeText = 'Got it',
|
||||
color = 'primary',
|
||||
) => {
|
||||
setAckModalConfig({
|
||||
isOpen: true,
|
||||
message,
|
||||
title,
|
||||
acknowledgeText,
|
||||
color,
|
||||
onClose: () => {
|
||||
if (onAcknowledge) {
|
||||
onAcknowledge()
|
||||
}
|
||||
setAckModalConfig({})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const hideAcknowledgment = () => {
|
||||
setAckModalConfig({})
|
||||
}
|
||||
|
||||
return {
|
||||
ackModalConfig,
|
||||
showAcknowledgment,
|
||||
hideAcknowledgment,
|
||||
}
|
||||
}
|
||||
|
||||
export default useAcknowledgmentModal
|
||||
90
src/hooks/useAnimations.js
Normal file
90
src/hooks/useAnimations.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
// Hook to detect user's motion preferences
|
||||
export const useReducedMotion = () => {
|
||||
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
setPrefersReducedMotion(mediaQuery.matches)
|
||||
|
||||
const handleChange = (event) => {
|
||||
setPrefersReducedMotion(event.matches)
|
||||
}
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange)
|
||||
return () => mediaQuery.removeEventListener('change', handleChange)
|
||||
}, [])
|
||||
|
||||
return prefersReducedMotion
|
||||
}
|
||||
|
||||
// Hook for staggered animations
|
||||
export const useStaggeredAnimation = (itemCount, delay = 50) => {
|
||||
const [visibleItems, setVisibleItems] = useState(new Set())
|
||||
const prefersReducedMotion = useReducedMotion()
|
||||
|
||||
useEffect(() => {
|
||||
if (prefersReducedMotion) {
|
||||
// Show all items immediately if reduced motion is preferred
|
||||
setVisibleItems(new Set(Array.from({ length: itemCount }, (_, i) => i)))
|
||||
return
|
||||
}
|
||||
|
||||
const timeouts = []
|
||||
|
||||
// Stagger the appearance of items
|
||||
for (let i = 0; i < itemCount; i++) {
|
||||
const timeout = setTimeout(() => {
|
||||
setVisibleItems(prev => new Set([...prev, i]))
|
||||
}, i * delay)
|
||||
|
||||
timeouts.push(timeout)
|
||||
}
|
||||
|
||||
return () => {
|
||||
timeouts.forEach(clearTimeout)
|
||||
}
|
||||
}, [itemCount, delay, prefersReducedMotion])
|
||||
|
||||
return visibleItems
|
||||
}
|
||||
|
||||
// Hook for intersection observer animations
|
||||
export const useInViewAnimation = (threshold = 0.1) => {
|
||||
const [isInView, setIsInView] = useState(false)
|
||||
const [element, setElement] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!element) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
setIsInView(entry.isIntersecting)
|
||||
},
|
||||
{ threshold }
|
||||
)
|
||||
|
||||
observer.observe(element)
|
||||
|
||||
return () => {
|
||||
observer.unobserve(element)
|
||||
}
|
||||
}, [element, threshold])
|
||||
|
||||
return [setElement, isInView]
|
||||
}
|
||||
|
||||
// Hook for page transition context
|
||||
export const usePageTransition = () => {
|
||||
const [isTransitioning, setIsTransitioning] = useState(false)
|
||||
|
||||
const startTransition = () => setIsTransitioning(true)
|
||||
const endTransition = () => setIsTransitioning(false)
|
||||
|
||||
return {
|
||||
isTransitioning,
|
||||
startTransition,
|
||||
endTransition,
|
||||
}
|
||||
}
|
||||
121
src/hooks/useAuth.jsx
Normal file
121
src/hooks/useAuth.jsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { clearAllTokens, saveTokens } from '../utils/TokenStorage'
|
||||
import { apiClient } from '../utils/ApiClient'
|
||||
|
||||
const AuthContext = createContext(null)
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext)
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [token, setToken] = useState(() => localStorage.getItem('token'))
|
||||
const [user, setUser] = useState(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const navigate = useNavigate()
|
||||
const baseURL = apiClient.getApiURL()
|
||||
const isAuthenticated = !!token
|
||||
|
||||
const isTokenExpired = () => {
|
||||
const expiry = localStorage.getItem('token_expiry')
|
||||
if (!expiry) return false
|
||||
return new Date() >= new Date(expiry)
|
||||
}
|
||||
|
||||
const clearAuth = async () => {
|
||||
setToken(null)
|
||||
setUser(null)
|
||||
await clearAllTokens()
|
||||
}
|
||||
|
||||
const login = async credentials => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await fetch(`${baseURL}/auth/login`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(credentials),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
return { success: false, error: error.message || 'Login failed' }
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const userToken = data.token || data.access_token
|
||||
|
||||
if (userToken) {
|
||||
setToken(userToken)
|
||||
|
||||
// Use centralized token storage
|
||||
await saveTokens({
|
||||
accessToken: userToken,
|
||||
accessTokenExpiry: data.expire || data.access_token_expiry,
|
||||
refreshToken: data.refresh_token,
|
||||
refreshTokenExpiry: data.refresh_token_expiry,
|
||||
})
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
return { success: true, data }
|
||||
} catch (error) {
|
||||
setIsLoading(false)
|
||||
return { success: false, error: 'Network error' }
|
||||
}
|
||||
}
|
||||
|
||||
const fetchUser = async () => {
|
||||
if (!token) return null
|
||||
|
||||
try {
|
||||
const response = await apiClient.get('/users/profile')
|
||||
|
||||
if (!response.ok) {
|
||||
return null
|
||||
}
|
||||
|
||||
const userData = await response.json()
|
||||
setUser(userData)
|
||||
return userData
|
||||
} catch (error) {
|
||||
console.error('Fetch user error:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// const initAuth = async () => {
|
||||
// if (token && !isTokenExpired()) {
|
||||
// await fetchUser()
|
||||
// } else if (token && isTokenExpired()) {
|
||||
// // Token is expired, but don't refresh here
|
||||
// // Let the first API call handle refresh via ApiClient
|
||||
// // Just try to fetch user - if it fails, ApiClient will handle refresh
|
||||
// await fetchUser()
|
||||
// } else {
|
||||
// clearAuth()
|
||||
// navigate('/login')
|
||||
// }
|
||||
// setIsLoading(false)
|
||||
// }
|
||||
// initAuth()
|
||||
}, [token, navigate])
|
||||
|
||||
const value = {
|
||||
token,
|
||||
user,
|
||||
isLoading,
|
||||
isAuthenticated,
|
||||
login,
|
||||
fetchUser,
|
||||
}
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
}
|
||||
41
src/hooks/useConfirmationModal.js
Normal file
41
src/hooks/useConfirmationModal.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
const useConfirmationModal = () => {
|
||||
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({})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const hideConfirmation = () => {
|
||||
setConfirmModalConfig({})
|
||||
}
|
||||
|
||||
return {
|
||||
confirmModalConfig,
|
||||
showConfirmation,
|
||||
hideConfirmation,
|
||||
}
|
||||
}
|
||||
|
||||
export default useConfirmationModal
|
||||
18
src/hooks/useResponsiveModal.js
Normal file
18
src/hooks/useResponsiveModal.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import BottomSheetModal from '../components/common/BottomSheetModal'
|
||||
import FadeModal from '../components/common/FadeModal'
|
||||
import useWindowWidth from './useWindowWidth'
|
||||
|
||||
/**
|
||||
* Hook that returns the appropriate modal component based on screen size
|
||||
* @param {number} breakpoint - Screen width breakpoint to switch between modals (default: 768px)
|
||||
* @returns {Object} - { Modal: Component, isMobile: boolean }
|
||||
*/
|
||||
export const useResponsiveModal = (breakpoint = 768) => {
|
||||
const windowWidth = useWindowWidth()
|
||||
const isMobile = windowWidth <= breakpoint
|
||||
|
||||
return {
|
||||
ResponsiveModal: isMobile ? BottomSheetModal : FadeModal,
|
||||
isMobile,
|
||||
}
|
||||
}
|
||||
750
src/hooks/useSSE.js
Normal file
750
src/hooks/useSSE.js
Normal file
@@ -0,0 +1,750 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { EventSourcePolyfill } from 'event-source-polyfill'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useUserProfile } from '../queries/UserQueries'
|
||||
import { useAlerts } from '../service/AlertsProvider'
|
||||
import { useNotification } from '../service/NotificationProvider'
|
||||
import { apiClient } from '../utils/ApiClient'
|
||||
import { useAuth } from './useAuth.jsx'
|
||||
|
||||
const SSE_STATES = {
|
||||
CONNECTING: 0,
|
||||
OPEN: 1,
|
||||
CLOSED: 2,
|
||||
}
|
||||
|
||||
const RECONNECT_INTERVALS = [10000, 30000, 360000, 600000, 900000, 6000000] // 10s, 30s, 6m, 10m, 15m , 1h
|
||||
const MAX_RECONNECT_ATTEMPTS = 10 // Circuit breaker limit
|
||||
const CIRCUIT_BREAKER_RESET_TIME = 600000 // 10 minutes
|
||||
|
||||
export const useSSE = () => {
|
||||
const { data: userProfile } = useUserProfile()
|
||||
|
||||
const { isAuthenticated, token } = useAuth()
|
||||
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 nextReconnectTimeRef = useRef(null)
|
||||
// Track if reconnect is already scheduled to prevent duplicates
|
||||
const isReconnectScheduledRef = useRef(false)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const { showError, showNotification } = useNotification()
|
||||
const { showAlert } = useAlerts()
|
||||
|
||||
const getSSEUrl = useCallback(() => {
|
||||
const authToken = token
|
||||
if (!authToken || !isAuthenticated) {
|
||||
console.log(
|
||||
'SSE: No valid authentication token',
|
||||
authToken,
|
||||
isAuthenticated,
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
// Get the API URL from apiManager
|
||||
const apiUrl = apiClient.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 }
|
||||
}, [token, isAuthenticated]) // Fixed: Added missing dependencies
|
||||
|
||||
const handleSSEMessage = useCallback(
|
||||
event => {
|
||||
try {
|
||||
const eventData = JSON.parse(event.data)
|
||||
setLastEvent(eventData)
|
||||
|
||||
// Update heartbeat timestamp
|
||||
if (eventData.type === 'heartbeat') {
|
||||
lastHeartbeatRef.current = Date.now()
|
||||
}
|
||||
|
||||
console.debug('SSE Message received:', eventData)
|
||||
|
||||
// Handle different event types and update React Query cache accordingly
|
||||
switch (eventData.type) {
|
||||
case 'chore.created':
|
||||
showNotification({
|
||||
type: 'info',
|
||||
title: 'New Task Created',
|
||||
message: `${eventData.data.user.displayName} created "${eventData.data.chore.name}"`,
|
||||
duration: 5000,
|
||||
})
|
||||
const newChore = eventData.data.chore
|
||||
|
||||
// Update individual chore cache
|
||||
queryClient.setQueryData(['chore', newChore.id], {
|
||||
res: newChore,
|
||||
})
|
||||
|
||||
// Update chores list cache
|
||||
queryClient.setQueryData(['chores', false], oldData => {
|
||||
if (!oldData || !oldData.res) {
|
||||
return { res: [newChore] }
|
||||
}
|
||||
return { res: [newChore, ...oldData.res] }
|
||||
})
|
||||
break
|
||||
case 'chore.updated':
|
||||
case 'chore.completed':
|
||||
case 'chore.status':
|
||||
case 'chore.skipped': {
|
||||
console.log('userProfile: ', userProfile, eventData.data.user)
|
||||
|
||||
if (eventData?.data?.user?.id !== userProfile?.id) {
|
||||
showNotification({
|
||||
type: 'info',
|
||||
title: `Task ${eventData.type.replace('chore.', '')}`,
|
||||
message: `${eventData.data.user.displayName} ${eventData.type.replace('chore.', '')} "${eventData.data.chore.name}"`,
|
||||
duration: 5000,
|
||||
})
|
||||
}
|
||||
const updatedChore = eventData.data.chore
|
||||
|
||||
// Update individual chore cache
|
||||
queryClient.setQueryData(['chore', updatedChore.id], oldData => {
|
||||
if (!oldData) return { res: updatedChore }
|
||||
return { res: { ...oldData.res, ...updatedChore } }
|
||||
})
|
||||
|
||||
// If chore update then also refetch chore details:
|
||||
if (
|
||||
eventData.type === 'chore.updated' ||
|
||||
eventData.type === 'chore.status'
|
||||
) {
|
||||
queryClient.invalidateQueries(['choreDetails', updatedChore.id])
|
||||
queryClient.refetchQueries({
|
||||
queryKey: ['choreDetails', updatedChore.id],
|
||||
})
|
||||
}
|
||||
|
||||
// Update chores list cache - add debugging
|
||||
queryClient.setQueryData(['chores', false], oldData => {
|
||||
if (!oldData) return { res: [updatedChore] }
|
||||
|
||||
if (!oldData.res || !Array.isArray(oldData.res)) {
|
||||
return { res: [updatedChore] }
|
||||
}
|
||||
|
||||
// If it's a one-time chore that's completed, we might need to remove it
|
||||
if (
|
||||
eventData.type === 'chore.completed' &&
|
||||
updatedChore.frequencyType === 'once'
|
||||
) {
|
||||
return {
|
||||
res: oldData.res.filter(
|
||||
chore => chore.id !== updatedChore.id,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise update the existing chore or add if it doesn't exist
|
||||
const newData = oldData.res.map(chore => {
|
||||
if (chore.id === updatedChore.id) {
|
||||
return { ...updatedChore }
|
||||
}
|
||||
return chore
|
||||
})
|
||||
|
||||
return { res: newData }
|
||||
})
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'chore.deleted':
|
||||
// update chores list cache
|
||||
queryClient.setQueryData(['chores', false], oldData => {
|
||||
if (!oldData || !oldData.res) return oldData
|
||||
return {
|
||||
res: oldData.res.filter(
|
||||
chore => chore.id !== eventData.data.choreId,
|
||||
),
|
||||
}
|
||||
})
|
||||
// same logic for archived chores view:
|
||||
queryClient.setQueryData(['chores', true], oldData => {
|
||||
if (!oldData || !oldData.res) return oldData
|
||||
return {
|
||||
res: oldData.res.filter(
|
||||
chore => chore.id !== eventData.data.choreId,
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
break
|
||||
|
||||
case 'subtask.updated':
|
||||
case 'subtask.completed':
|
||||
queryClient.setQueryData(
|
||||
['choreDetails', String(eventData.data.choreId)], // this should be string to match the query key type which is param in the url in choreView
|
||||
oldData => {
|
||||
if (!oldData) return oldData
|
||||
console.log('Old choreDetails data:', oldData)
|
||||
|
||||
// Update the specific subtask within the chore details
|
||||
const newChoreData = { ...oldData.res }
|
||||
newChoreData.subTasks = newChoreData.subTasks.map(subtask => {
|
||||
if (subtask.id === eventData.data.subtaskId) {
|
||||
return {
|
||||
...subtask,
|
||||
completedAt: eventData.data.completedAt,
|
||||
completedBy: eventData.data.user.id,
|
||||
}
|
||||
}
|
||||
return subtask
|
||||
})
|
||||
return { res: newChoreData }
|
||||
},
|
||||
)
|
||||
break
|
||||
|
||||
case 'heartbeat':
|
||||
// Heartbeat events don't need cache invalidation
|
||||
console.debug('SSE Heartbeat received at', new Date().toISOString())
|
||||
break
|
||||
|
||||
case 'connection.established':
|
||||
console.log('SSE connection established')
|
||||
setError(null)
|
||||
lastHeartbeatRef.current = Date.now()
|
||||
showAlert({
|
||||
type: 'success',
|
||||
color: 'success',
|
||||
message: 'You are now receiving real-time as they happen.',
|
||||
})
|
||||
break
|
||||
|
||||
case 'error':
|
||||
console.error('SSE error event:', eventData.data)
|
||||
showError({
|
||||
title: 'Real-time Error',
|
||||
message:
|
||||
eventData.data.message ||
|
||||
'An error occurred with real-time updates',
|
||||
})
|
||||
break
|
||||
|
||||
default:
|
||||
console.log('Unknown SSE event type:', eventData.type)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to parse SSE message:', err)
|
||||
showError({
|
||||
title: 'Message Error',
|
||||
message: 'Failed to parse server message',
|
||||
})
|
||||
return // Stop processing if JSON parsing fails
|
||||
}
|
||||
},
|
||||
[queryClient, showNotification, showError, userProfile, showAlert],
|
||||
)
|
||||
|
||||
const stopHeartbeatMonitor = useCallback(() => {
|
||||
if (heartbeatMonitorRef.current) {
|
||||
clearInterval(heartbeatMonitorRef.current)
|
||||
heartbeatMonitorRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Centralized reconnect scheduling function to prevent duplicate scheduling
|
||||
const scheduleReconnect = useCallback((delay, reason) => {
|
||||
// Prevent duplicate scheduling
|
||||
if (isReconnectScheduledRef.current) {
|
||||
console.log('SSE: Reconnect already scheduled, skipping duplicate')
|
||||
return
|
||||
}
|
||||
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`SSE: Scheduling reconnect in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1}, reason: ${reason})`,
|
||||
)
|
||||
|
||||
isReconnectScheduledRef.current = true
|
||||
nextReconnectTimeRef.current = Date.now() + delay
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
isReconnectScheduledRef.current = false
|
||||
reconnectAttemptsRef.current++
|
||||
nextReconnectTimeRef.current = null
|
||||
// Note: connect will be called by the caller after this returns
|
||||
// We need to trigger it here
|
||||
window.dispatchEvent(new CustomEvent('sse-reconnect'))
|
||||
}, delay)
|
||||
}, [])
|
||||
|
||||
// Create connect function that can be called from anywhere
|
||||
const connect = useCallback(() => {
|
||||
// Clear the scheduled flag when actually connecting
|
||||
isReconnectScheduledRef.current = false
|
||||
|
||||
if (isCircuitBreakerOpen) {
|
||||
console.log('SSE: Circuit breaker is open, preventing connection attempt')
|
||||
showError({
|
||||
title: 'Connection Temporarily Disabled',
|
||||
message:
|
||||
'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)
|
||||
showError({
|
||||
title: 'Connection Failed',
|
||||
message:
|
||||
'Maximum connection attempts reached. SSE disabled for 10 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
|
||||
|
||||
eventSourceRef.current = new EventSourcePolyfill(sseConfig.url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||
'Cache-Control': 'no-cache',
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
withCredentials: true,
|
||||
heartbeatTimeout: 120000,
|
||||
silentTimeoutRetry: true,
|
||||
})
|
||||
|
||||
eventSourceRef.current.onopen = () => {
|
||||
console.log('SSE connection opened')
|
||||
setConnectionState(SSE_STATES.OPEN)
|
||||
setError(null)
|
||||
reconnectAttemptsRef.current = 0
|
||||
nextReconnectTimeRef.current = null
|
||||
isReconnectScheduledRef.current = false
|
||||
lastHeartbeatRef.current = Date.now()
|
||||
|
||||
// Start heartbeat monitor
|
||||
stopHeartbeatMonitor()
|
||||
heartbeatMonitorRef.current = setInterval(() => {
|
||||
const timeSinceLastHeartbeat = Date.now() - lastHeartbeatRef.current
|
||||
const heartbeatTimeout = 150000 // 2.5 minutes
|
||||
|
||||
console.debug(
|
||||
`SSE: Heartbeat check - ${Math.round(timeSinceLastHeartbeat / 1000)}s since last heartbeat`,
|
||||
)
|
||||
|
||||
if (timeSinceLastHeartbeat > heartbeatTimeout) {
|
||||
console.warn(
|
||||
`SSE: No heartbeat received for ${Math.round(timeSinceLastHeartbeat / 1000)}s, connection may be stale. Reconnecting...`,
|
||||
)
|
||||
if (!isManuallyClosedRef.current) {
|
||||
// Clear current heartbeat monitor before reconnecting
|
||||
stopHeartbeatMonitor()
|
||||
|
||||
// Close current connection gracefully
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
eventSourceRef.current = null
|
||||
}
|
||||
setConnectionState(SSE_STATES.CLOSED)
|
||||
|
||||
// Calculate delay based on current attempt
|
||||
const attemptIndex = Math.min(
|
||||
reconnectAttemptsRef.current,
|
||||
RECONNECT_INTERVALS.length - 1,
|
||||
)
|
||||
const delay = RECONNECT_INTERVALS[attemptIndex]
|
||||
|
||||
// Schedule reconnect
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`SSE: Scheduling heartbeat-triggered reconnect in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`,
|
||||
)
|
||||
|
||||
isReconnectScheduledRef.current = true
|
||||
nextReconnectTimeRef.current = Date.now() + delay
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
isReconnectScheduledRef.current = false
|
||||
reconnectAttemptsRef.current++
|
||||
nextReconnectTimeRef.current = null
|
||||
connect()
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
}, 60000) // Check every minute
|
||||
}
|
||||
|
||||
eventSourceRef.current.onmessage = handleSSEMessage
|
||||
|
||||
eventSourceRef.current.onerror = async error => {
|
||||
console.error('SSE error:', error)
|
||||
setConnectionState(SSE_STATES.CLOSED)
|
||||
stopHeartbeatMonitor()
|
||||
|
||||
// Close the EventSource to prevent it from retrying on its own
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
eventSourceRef.current = null
|
||||
}
|
||||
|
||||
if (isManuallyClosedRef.current) {
|
||||
console.log('SSE: Manually closed, not reconnecting')
|
||||
return
|
||||
}
|
||||
|
||||
// Check if reconnect is already scheduled
|
||||
if (isReconnectScheduledRef.current) {
|
||||
console.log('SSE: Reconnect already scheduled, skipping')
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is a 401 unauthorized error
|
||||
const is401Error =
|
||||
error.status === 401 ||
|
||||
error.error?.message?.includes('401') ||
|
||||
error.error?.message?.includes('Unauthorized')
|
||||
|
||||
// Check if this is a timeout error specifically
|
||||
const isTimeoutError =
|
||||
error.error?.message?.includes('No activity within') ||
|
||||
error.error?.message?.includes('timeout')
|
||||
|
||||
if (is401Error) {
|
||||
console.log('SSE 401 error detected, attempting token refresh...')
|
||||
setError('Authentication expired - refreshing token...')
|
||||
|
||||
try {
|
||||
const refreshResult = await apiClient.refreshToken()
|
||||
|
||||
if (refreshResult.success) {
|
||||
console.log(
|
||||
'Token refreshed successfully, retrying SSE connection...',
|
||||
)
|
||||
setError('Token refreshed - reconnecting...')
|
||||
|
||||
if (apiClient.failedQueue && apiClient.failedQueue.length > 0) {
|
||||
console.log(
|
||||
`Processing ${apiClient.failedQueue.length} queued requests after SSE token refresh`,
|
||||
)
|
||||
apiClient.processQueue(null, refreshResult.token)
|
||||
}
|
||||
|
||||
// Reset reconnect attempts since we have a fresh token
|
||||
reconnectAttemptsRef.current = 0
|
||||
|
||||
// Schedule immediate reconnect with fresh token
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
}
|
||||
|
||||
isReconnectScheduledRef.current = true
|
||||
nextReconnectTimeRef.current = Date.now() + 1000
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
isReconnectScheduledRef.current = false
|
||||
nextReconnectTimeRef.current = null
|
||||
connect()
|
||||
}, 1000)
|
||||
|
||||
return
|
||||
} else if (
|
||||
refreshResult.error === 'Already refreshing' ||
|
||||
refreshResult.error === 'Refresh cooldown active'
|
||||
) {
|
||||
console.log(
|
||||
'SSE: Token refresh in progress by another request, waiting...',
|
||||
)
|
||||
setError('Token refresh in progress - reconnecting soon...')
|
||||
|
||||
reconnectAttemptsRef.current = 0
|
||||
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
}
|
||||
|
||||
isReconnectScheduledRef.current = true
|
||||
nextReconnectTimeRef.current = Date.now() + 1500
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
isReconnectScheduledRef.current = false
|
||||
nextReconnectTimeRef.current = null
|
||||
connect()
|
||||
}, 1500)
|
||||
|
||||
return
|
||||
} else if (refreshResult.error === 'Refresh token expired') {
|
||||
console.error('Refresh token expired, user must login again')
|
||||
setError('Session expired - please log in again')
|
||||
return
|
||||
} else {
|
||||
console.error('Token refresh failed:', refreshResult.error)
|
||||
setError('Authentication failed - please log in again')
|
||||
return
|
||||
}
|
||||
} catch (refreshError) {
|
||||
console.error('Token refresh error:', refreshError)
|
||||
setError('Authentication error - please log in again')
|
||||
return
|
||||
}
|
||||
} else if (isTimeoutError) {
|
||||
console.log('SSE timeout detected, attempting reconnection...')
|
||||
setError('Connection timeout - reconnecting...')
|
||||
} else {
|
||||
setError('Connection error occurred')
|
||||
}
|
||||
|
||||
// Schedule reconnect for non-401 errors
|
||||
const attemptIndex = Math.min(
|
||||
reconnectAttemptsRef.current,
|
||||
RECONNECT_INTERVALS.length - 1,
|
||||
)
|
||||
const delay = RECONNECT_INTERVALS[attemptIndex]
|
||||
|
||||
console.log(
|
||||
`SSE: Scheduling error-triggered reconnect in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`,
|
||||
)
|
||||
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
}
|
||||
|
||||
isReconnectScheduledRef.current = true
|
||||
nextReconnectTimeRef.current = Date.now() + delay
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
isReconnectScheduledRef.current = false
|
||||
reconnectAttemptsRef.current++
|
||||
nextReconnectTimeRef.current = null
|
||||
connect()
|
||||
}, delay)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create SSE connection:', err)
|
||||
showError({
|
||||
title: 'Connection Error',
|
||||
message: 'Failed to establish real-time connection. Please try again.',
|
||||
})
|
||||
setConnectionState(SSE_STATES.CLOSED)
|
||||
}
|
||||
}, [
|
||||
getSSEUrl,
|
||||
handleSSEMessage,
|
||||
stopHeartbeatMonitor,
|
||||
isCircuitBreakerOpen,
|
||||
showError,
|
||||
])
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
isManuallyClosedRef.current = true
|
||||
isReconnectScheduledRef.current = false
|
||||
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
reconnectTimeoutRef.current = null
|
||||
}
|
||||
|
||||
nextReconnectTimeRef.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: isAuthenticated,
|
||||
})
|
||||
localStorage.setItem('sse_enabled', enabled.toString())
|
||||
if (enabled && isAuthenticated) {
|
||||
console.log('SSE toggleSSEEnabled: Calling connect()')
|
||||
connect()
|
||||
} else {
|
||||
console.log('SSE toggleSSEEnabled: Calling disconnect()')
|
||||
disconnect()
|
||||
}
|
||||
},
|
||||
[connect, disconnect, isAuthenticated],
|
||||
)
|
||||
|
||||
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:', isAuthenticated)
|
||||
|
||||
const isSSEEnabledSetting = localStorage.getItem('sse_enabled') === 'true'
|
||||
console.log('SSE enabled in settings:', isSSEEnabledSetting)
|
||||
|
||||
if (isAuthenticated && isSSEEnabledSetting) {
|
||||
console.log('SSE: Conditions met, attempting to connect')
|
||||
connect()
|
||||
} else {
|
||||
console.log('SSE: Conditions not met, disconnecting')
|
||||
disconnect()
|
||||
}
|
||||
|
||||
return () => {
|
||||
disconnect()
|
||||
}
|
||||
}, [isAuthenticated]) // Fixed: Added isAuthenticated dependency
|
||||
|
||||
// Cleanup timeouts on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
}
|
||||
stopHeartbeatMonitor()
|
||||
}
|
||||
}, [stopHeartbeatMonitor])
|
||||
|
||||
// Update EventSource message handler when handleSSEMessage changes
|
||||
useEffect(() => {
|
||||
if (
|
||||
eventSourceRef.current &&
|
||||
eventSourceRef.current.readyState === SSE_STATES.OPEN
|
||||
) {
|
||||
console.log('SSE: Updating message handler with latest userProfile')
|
||||
eventSourceRef.current.onmessage = handleSSEMessage
|
||||
}
|
||||
}, [handleSSEMessage])
|
||||
|
||||
// Handle visibility changes for better performance
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
console.log(
|
||||
'SSE: App backgrounded, maintaining connection but reducing activity',
|
||||
)
|
||||
} else {
|
||||
console.log('SSE: App foregrounded, ensuring connection is active')
|
||||
|
||||
const isSSEEnabledSetting =
|
||||
localStorage.getItem('sse_enabled') === 'true'
|
||||
|
||||
// Check actual EventSource state, not React state
|
||||
const isCurrentlyConnected =
|
||||
eventSourceRef.current?.readyState === SSE_STATES.OPEN
|
||||
const isCurrentlyConnecting =
|
||||
eventSourceRef.current?.readyState === SSE_STATES.CONNECTING
|
||||
|
||||
if (
|
||||
isAuthenticated &&
|
||||
isSSEEnabledSetting &&
|
||||
!isCurrentlyConnected &&
|
||||
!isCurrentlyConnecting &&
|
||||
!isReconnectScheduledRef.current
|
||||
) {
|
||||
console.log('SSE: Reconnecting after visibility change')
|
||||
connect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
}
|
||||
}, [connect, isAuthenticated])
|
||||
|
||||
return {
|
||||
connectionState,
|
||||
isConnected: connectionState === SSE_STATES.OPEN,
|
||||
isConnecting: connectionState === SSE_STATES.CONNECTING,
|
||||
lastEvent,
|
||||
error,
|
||||
connect,
|
||||
disconnect,
|
||||
toggleSSEEnabled,
|
||||
isSSEEnabled,
|
||||
getConnectionStatus: () => {
|
||||
switch (connectionState) {
|
||||
case SSE_STATES.CONNECTING:
|
||||
return 'connecting'
|
||||
case SSE_STATES.OPEN:
|
||||
return 'connected'
|
||||
case SSE_STATES.CLOSED:
|
||||
default:
|
||||
return 'disconnected'
|
||||
}
|
||||
},
|
||||
getDebugInfo: () => ({
|
||||
connectionState,
|
||||
reconnectAttempts: reconnectAttemptsRef.current,
|
||||
isCircuitBreakerOpen,
|
||||
isReconnectScheduled: isReconnectScheduledRef.current,
|
||||
lastHeartbeat: lastHeartbeatRef.current,
|
||||
timeSinceLastHeartbeat: Date.now() - lastHeartbeatRef.current,
|
||||
isManuallyCloseRef: isManuallyClosedRef.current,
|
||||
nextReconnectTime: nextReconnectTimeRef.current,
|
||||
timeUntilReconnect: nextReconnectTimeRef.current
|
||||
? nextReconnectTimeRef.current - Date.now()
|
||||
: null,
|
||||
reconnectIntervals: RECONNECT_INTERVALS,
|
||||
currentReconnectDelay:
|
||||
reconnectAttemptsRef.current < RECONNECT_INTERVALS.length
|
||||
? RECONNECT_INTERVALS[reconnectAttemptsRef.current]
|
||||
: RECONNECT_INTERVALS[RECONNECT_INTERVALS.length - 1],
|
||||
maxReconnectAttempts: MAX_RECONNECT_ATTEMPTS,
|
||||
circuitBreakerResetTime: CIRCUIT_BREAKER_RESET_TIME,
|
||||
heartbeatTimeout: 120000,
|
||||
heartbeatMonitorInterval: 60000,
|
||||
heartbeatMonitorTimeout: 150000,
|
||||
}),
|
||||
}
|
||||
}
|
||||
6
src/hooks/useSSEContext.js
Normal file
6
src/hooks/useSSEContext.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { useContext } from 'react'
|
||||
import { SSEContext } from '../contexts/SSEContext'
|
||||
|
||||
export const useSSEContext = () => {
|
||||
return useContext(SSEContext)
|
||||
}
|
||||
115
src/hooks/useTimer.js
Normal file
115
src/hooks/useTimer.js
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Custom hook for timer functionality with high-resolution timing
|
||||
* Fixes timing drift issues by using timestamps instead of interval counting
|
||||
*/
|
||||
const useTimer = (onTimeUpdate = () => {}) => {
|
||||
const [timerState, setTimerState] = useState('stopped') // 'stopped' | 'running' | 'paused'
|
||||
const [time, setTime] = useState(0) // Current time in seconds
|
||||
|
||||
// Refs for timing calculations
|
||||
const startTimeRef = useRef(null)
|
||||
const pausedTimeRef = useRef(0)
|
||||
const intervalRef = useRef(null)
|
||||
const lastNotifiedTimeRef = useRef(0)
|
||||
|
||||
// Update display and notify parent
|
||||
const updateTime = useCallback(() => {
|
||||
if (timerState === 'running' && startTimeRef.current) {
|
||||
const elapsed = Math.floor((Date.now() - startTimeRef.current) / 1000)
|
||||
const newTime = pausedTimeRef.current + elapsed
|
||||
|
||||
setTime(newTime)
|
||||
|
||||
// Only call onTimeUpdate when the second changes to avoid excessive calls
|
||||
if (newTime !== lastNotifiedTimeRef.current) {
|
||||
lastNotifiedTimeRef.current = newTime
|
||||
onTimeUpdate(newTime)
|
||||
}
|
||||
}
|
||||
}, [timerState, onTimeUpdate])
|
||||
|
||||
// Timer effect with high-frequency updates for smooth display
|
||||
useEffect(() => {
|
||||
if (timerState === 'running') {
|
||||
intervalRef.current = setInterval(updateTime, 200)
|
||||
} else {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
}
|
||||
}, [timerState, updateTime])
|
||||
|
||||
// Timer control functions
|
||||
const startTimer = useCallback(() => {
|
||||
const now = Date.now()
|
||||
startTimeRef.current = now
|
||||
pausedTimeRef.current = 0
|
||||
lastNotifiedTimeRef.current = 0
|
||||
setTime(0)
|
||||
setTimerState('running')
|
||||
onTimeUpdate(0)
|
||||
}, [onTimeUpdate])
|
||||
|
||||
const pauseTimer = useCallback(() => {
|
||||
if (timerState === 'running' && startTimeRef.current) {
|
||||
// Calculate and store the elapsed time
|
||||
const elapsed = Math.floor((Date.now() - startTimeRef.current) / 1000)
|
||||
pausedTimeRef.current = pausedTimeRef.current + elapsed
|
||||
setTimerState('paused')
|
||||
}
|
||||
}, [timerState])
|
||||
|
||||
const resumeTimer = useCallback(() => {
|
||||
if (timerState === 'paused') {
|
||||
// Reset start time for resumed session
|
||||
startTimeRef.current = Date.now()
|
||||
setTimerState('running')
|
||||
}
|
||||
}, [timerState])
|
||||
|
||||
const stopTimer = useCallback(() => {
|
||||
setTimerState('stopped')
|
||||
setTime(0)
|
||||
pausedTimeRef.current = 0
|
||||
startTimeRef.current = null
|
||||
lastNotifiedTimeRef.current = 0
|
||||
onTimeUpdate(0)
|
||||
}, [onTimeUpdate])
|
||||
|
||||
const resetTimer = useCallback(() => {
|
||||
stopTimer()
|
||||
}, [stopTimer])
|
||||
|
||||
// Computed properties
|
||||
const isRunning = timerState === 'running'
|
||||
const isPaused = timerState === 'paused'
|
||||
const isStopped = timerState === 'stopped'
|
||||
|
||||
return {
|
||||
// State
|
||||
time,
|
||||
timerState,
|
||||
isRunning,
|
||||
isPaused,
|
||||
isStopped,
|
||||
|
||||
// Actions
|
||||
startTimer,
|
||||
pauseTimer,
|
||||
resumeTimer,
|
||||
stopTimer,
|
||||
resetTimer,
|
||||
}
|
||||
}
|
||||
|
||||
export default useTimer
|
||||
@@ -1,3 +1,51 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Global animations for smooth interactions */
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth scrolling for better UX */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Global focus styles for better accessibility */
|
||||
*:focus-visible {
|
||||
outline: 2px solid #0ea5e9;
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Improve button and interactive element performance */
|
||||
button, a, [role="button"] {
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
/* Ensure smooth transitions for dynamic content */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Optimize animations for performance */
|
||||
.animate-optimized {
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.animate-optimized.animation-complete {
|
||||
will-change: auto;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import Contexts from './contexts/Contexts.jsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<Contexts />
|
||||
<Contexts>
|
||||
<App />
|
||||
</Contexts>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
@@ -1,22 +1,38 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { networkManager } from '../hooks/NetworkManager'
|
||||
import { FEATURES, isFeatureEnabled } from '../utils/FeatureToggle'
|
||||
import {
|
||||
ApproveChore,
|
||||
ArchiveChore,
|
||||
CreateChore,
|
||||
DeleteChore,
|
||||
DeleteChoreHistory,
|
||||
GetChoreByID,
|
||||
GetChoreDetailById,
|
||||
GetChoreHistory,
|
||||
GetChoresHistory,
|
||||
GetChoresNew,
|
||||
MarkChoreComplete,
|
||||
RejectChore,
|
||||
SaveChore,
|
||||
SkipChore,
|
||||
UnArchiveChore,
|
||||
UpdateChoreHistory,
|
||||
} from '../utils/Fetcher'
|
||||
import { localStore } from '../utils/LocalStore'
|
||||
|
||||
export const useChores = includeArchive => {
|
||||
return useQuery({
|
||||
queryKey: ['chores'],
|
||||
queryKey: ['chores', includeArchive],
|
||||
queryFn: async () => {
|
||||
const onlineChores = await GetChoresNew(includeArchive)
|
||||
|
||||
// Only handle offline tasks if experimental offline mode is enabled
|
||||
if (!isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
|
||||
return onlineChores
|
||||
}
|
||||
|
||||
const offlineTasks = (await localStore.getFromCache('offlineTasks')) || []
|
||||
// go throught each and if there is two chores with same id in offline and online, prefer the offline one:
|
||||
var finalChores = []
|
||||
@@ -51,41 +67,106 @@ export const useChores = includeArchive => {
|
||||
},
|
||||
})
|
||||
}
|
||||
export const useDeleteChores = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async choreIds => {
|
||||
// If offline mode is enabled and we're offline, handle deletion locally
|
||||
if (!networkManager.isOnline && isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
|
||||
const offlineTasks =
|
||||
(await localStore.getFromCache('offlineTasks')) || []
|
||||
const updatedOfflineTasks = offlineTasks.filter(
|
||||
task =>
|
||||
!choreIds.includes(task.id) && !choreIds.includes(task.tempId),
|
||||
)
|
||||
await localStore.saveToCache('offlineTasks', updatedOfflineTasks)
|
||||
// Force the chores query to refetch
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
return
|
||||
}
|
||||
|
||||
// If online, proceed with server-side deletion
|
||||
await Promise.all(
|
||||
choreIds.map(async id => {
|
||||
const resp = await DeleteChore(id)
|
||||
if (!resp || !resp.ok) {
|
||||
throw new Error(`Failed to delete chore with ID: ${id}`)
|
||||
}
|
||||
}),
|
||||
)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
},
|
||||
})
|
||||
}
|
||||
export const useCreateChore = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: CreateChore,
|
||||
onMutate: async newTask => {
|
||||
if (!networkManager.isOnline) {
|
||||
const tempId = crypto.randomUUID() // Generate temp ID
|
||||
const offlineTasks =
|
||||
(await localStore.getFromCache('offlineTasks')) || []
|
||||
const updateOfflineTasks = [
|
||||
...offlineTasks,
|
||||
{ ...newTask, id: tempId, tempId }, // Use the tempId for offline tracking
|
||||
]
|
||||
await localStore.saveToCache('offlineTasks', updateOfflineTasks) // Save to local storage
|
||||
// force useChores to refetch:
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
// Force the chores query to refetch
|
||||
queryClient.refetchQueries(['chores'])
|
||||
// Update the chores query cache immediately
|
||||
// queryClient.setQueryData(['chores'], oldData => {
|
||||
// console.log('ATTEMPT TO SAVE OFFLINE TASKS:', updateOfflineTasks)
|
||||
|
||||
// if (!oldData)
|
||||
// return {
|
||||
// res: [{ ...newTask, id: tempId, tempId }],
|
||||
// } // If no data, return offline tasks
|
||||
// return {
|
||||
// res: [...oldData.res, { ...newTask, id: tempId, tempId }],
|
||||
// }
|
||||
// })
|
||||
return { tempId }
|
||||
mutationFn: async newTask => {
|
||||
const resp = await CreateChore(newTask)
|
||||
if (!resp || !resp.ok) {
|
||||
throw new Error('Failed to create chore')
|
||||
}
|
||||
return { tempId: null }
|
||||
const createdChore = await resp.json()
|
||||
if (!createdChore) {
|
||||
throw new Error('Failed to get created chore data')
|
||||
}
|
||||
// Successfully created the chore on the server, return the created chore
|
||||
// update the local chores cache with the new chore:
|
||||
queryClient.setQueryData(['chores'], oldData => {
|
||||
if (!oldData) return { res: [createdChore.res] }
|
||||
return { res: [...oldData.res, createdChore.res] }
|
||||
})
|
||||
return { res: createdChore }
|
||||
},
|
||||
|
||||
// onMutate: async newTask => {
|
||||
// if (!networkManager.isOnline && isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
|
||||
// const tempId = crypto.randomUUID() // Generate temp ID
|
||||
// const offlineTasks =
|
||||
// (await localStore.getFromCache('offlineTasks')) || []
|
||||
// const updateOfflineTasks = [
|
||||
// ...offlineTasks,
|
||||
// { ...newTask, id: tempId, tempId }, // Use the tempId for offline tracking
|
||||
// ]
|
||||
// await localStore.saveToCache('offlineTasks', updateOfflineTasks) // Save to local storage
|
||||
// // force useChores to refetch:
|
||||
// queryClient.invalidateQueries(['chores'])
|
||||
// // Force the chores query to refetch
|
||||
// queryClient.refetchQueries(['chores'])
|
||||
// // Update the chores query cache immediately
|
||||
// // queryClient.setQueryData(['chores'], oldData => {
|
||||
// // console.log('ATTEMPT TO SAVE OFFLINE TASKS:', updateOfflineTasks)
|
||||
|
||||
// // if (!oldData)
|
||||
// // return {
|
||||
// // res: [{ ...newTask, id: tempId, tempId }],
|
||||
// // } // If no data, return offline tasks
|
||||
// // return {
|
||||
// // res: [...oldData.res, { ...newTask, id: tempId, tempId }],
|
||||
// // }
|
||||
// // })
|
||||
// return { tempId }
|
||||
// }
|
||||
// const tempId = crypto.randomUUID() // Generate temp ID
|
||||
// // Update the chores query cache immediately
|
||||
// queryClient.setQueryData(['chores'], oldData => {
|
||||
// if (!oldData)
|
||||
// return {
|
||||
// res: [{ ...newTask, id: tempId, tempId }],
|
||||
// } // If no data, return offline tasks
|
||||
// return {
|
||||
// res: [...oldData.res, { ...newTask, id: tempId, tempId }],
|
||||
// }
|
||||
// })
|
||||
// return { tempId: null }
|
||||
// },
|
||||
onSuccess: () => {
|
||||
// Invalidate the chores query to refresh the data
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -95,7 +176,7 @@ export const useUpdateChore = () => {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async updatedChore => {
|
||||
if (!networkManager.isOnline) {
|
||||
if (!networkManager.isOnline && isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
|
||||
updatedChore['updatedAt'] = new Date().toISOString()
|
||||
if (!updatedChore['nextDueDate']) {
|
||||
updatedChore['nextDueDate'] = updatedChore['dueDate']
|
||||
@@ -142,15 +223,26 @@ export const useUpdateChore = () => {
|
||||
throw new Error('Failed to get updated chore data')
|
||||
}
|
||||
// Successfully updated the chore on the server, return the updated chore
|
||||
// update the local chores cache with the updated chore:
|
||||
queryClient.setQueryData(['chores'], oldData => {
|
||||
if (!oldData) return { res: [updatedChore] }
|
||||
return {
|
||||
res: oldData.res.map(chore =>
|
||||
chore.id === updatedChore.id ? updatedChore : chore,
|
||||
),
|
||||
}
|
||||
})
|
||||
return updatedChoreRes?.res || updatedChoreRes
|
||||
}
|
||||
},
|
||||
onSuccess: (data, variables) => {
|
||||
// Invalidate the chores query to refresh the data
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
// Invalidate history for the specific chore
|
||||
queryClient.invalidateQueries(['choreHistory', variables.id])
|
||||
},
|
||||
onMutate: async updatedChore => {
|
||||
if (!networkManager.isOnline) {
|
||||
if (!networkManager.isOnline && isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
|
||||
// Handle offline case here if needed
|
||||
return
|
||||
}
|
||||
@@ -167,6 +259,7 @@ export const useChoresHistory = (initialLimit, includeMembers) => {
|
||||
const resp = await GetChoresHistory(limit, includeMembers)
|
||||
return resp?.res || []
|
||||
},
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
const handleLimitChange = newLimit => {
|
||||
@@ -178,7 +271,7 @@ export const useChoresHistory = (initialLimit, includeMembers) => {
|
||||
|
||||
export const useChoreDetails = choreId => {
|
||||
return useQuery({
|
||||
queryKey: ['chore', choreId],
|
||||
queryKey: ['choreDetails', choreId],
|
||||
queryFn: async () => {
|
||||
var onlineChore = null
|
||||
|
||||
@@ -192,6 +285,11 @@ export const useChoreDetails = choreId => {
|
||||
console.error('Error fetching chore detail:', error)
|
||||
}
|
||||
|
||||
// Only check offline tasks if experimental offline mode is enabled
|
||||
if (!isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
|
||||
return onlineChore
|
||||
}
|
||||
|
||||
const offlineTasks = (await localStore.getFromCache('offlineTasks')) || []
|
||||
const offline = offlineTasks.find(task => {
|
||||
// Match by tempId or id if it was created offline
|
||||
@@ -224,6 +322,11 @@ export const useChore = choreId => {
|
||||
console.error('Error fetching chore detail:', error)
|
||||
}
|
||||
|
||||
// Only check offline tasks if experimental offline mode is enabled
|
||||
if (!isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
|
||||
return onlineChore
|
||||
}
|
||||
|
||||
const offlineTasks = (await localStore.getFromCache('offlineTasks')) || []
|
||||
const offline = offlineTasks.find(task => {
|
||||
return (
|
||||
@@ -239,3 +342,123 @@ export const useChore = choreId => {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useArchiveChore = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ArchiveChore,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useUnArchiveChore = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: UnArchiveChore,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useChoreHistory = choreId => {
|
||||
return useQuery({
|
||||
queryKey: ['choreHistory', choreId],
|
||||
queryFn: async () => {
|
||||
if (!choreId) {
|
||||
throw new Error('Chore ID is required to fetch history')
|
||||
}
|
||||
const response = await GetChoreHistory(choreId)
|
||||
if (response && response.ok) {
|
||||
return await response.json()
|
||||
}
|
||||
throw new Error('Failed to fetch chore history')
|
||||
},
|
||||
enabled: !!choreId,
|
||||
staleTime: 0, // Always consider data stale
|
||||
cacheTime: 0, // Don't cache the data
|
||||
refetchOnMount: true, // Always refetch when component mounts
|
||||
refetchOnWindowFocus: true, // Refetch when window gains focus
|
||||
})
|
||||
}
|
||||
|
||||
export const useUpdateChoreHistory = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ choreId, historyId, historyData }) =>
|
||||
UpdateChoreHistory(choreId, historyId, historyData),
|
||||
onSuccess: (data, { choreId }) => {
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useDeleteChoreHistory = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ choreId, historyId }) =>
|
||||
DeleteChoreHistory(choreId, historyId),
|
||||
onSuccess: (data, { choreId }) => {
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useMarkChoreComplete = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ choreId, body, completedDate, performer }) =>
|
||||
MarkChoreComplete(choreId, body, completedDate, performer),
|
||||
onSuccess: (data, { choreId }) => {
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
queryClient.invalidateQueries(['choreDetails', choreId])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useSkipChore = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: SkipChore,
|
||||
onSuccess: (data, choreId) => {
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
queryClient.invalidateQueries(['choreDetails', choreId])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useApproveChore = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ApproveChore,
|
||||
onSuccess: (data, choreId) => {
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
queryClient.invalidateQueries(['choreDetails', choreId])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useRejectChore = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: RejectChore,
|
||||
onSuccess: (data, choreId) => {
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
queryClient.invalidateQueries(['choreDetails', choreId])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { GetResource } from '../utils/Fetcher'
|
||||
|
||||
export const useResource = () => {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: [],
|
||||
queryKey: ['resource'],
|
||||
queryFn: async () => {
|
||||
const response = await GetResource()
|
||||
return response
|
||||
|
||||
29
src/queries/ThingQueries.jsx
Normal file
29
src/queries/ThingQueries.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { GetThingHistory } from '../utils/Fetcher'
|
||||
|
||||
export const useThingHistory = (thingId, limit = 10) => {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ['thingHistory', thingId],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
const response = await GetThingHistory(thingId, pageParam)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch thing history')
|
||||
}
|
||||
const data = await response.json()
|
||||
return data
|
||||
},
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
// If the last page has fewer items than the limit, there are no more pages
|
||||
if (lastPage.res.length < limit) {
|
||||
return undefined
|
||||
}
|
||||
// Calculate the offset for the next page
|
||||
const totalItems = allPages.reduce(
|
||||
(acc, page) => acc + page.res.length,
|
||||
0,
|
||||
)
|
||||
return totalItems
|
||||
},
|
||||
enabled: !!thingId, // Only run query if thingId exists
|
||||
})
|
||||
}
|
||||
107
src/queries/TimeQueries.jsx
Normal file
107
src/queries/TimeQueries.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
ClearChoreTimer,
|
||||
DeleteTimeSession,
|
||||
GetChoreTimer,
|
||||
PauseChore,
|
||||
ResetChoreTimer,
|
||||
StartChore,
|
||||
UpdateTimeSession,
|
||||
} from '../utils/Fetcher'
|
||||
|
||||
export const useChoreTimer = choreId => {
|
||||
return useQuery({
|
||||
queryKey: ['choreTimer', choreId],
|
||||
queryFn: async () => {
|
||||
if (!choreId) {
|
||||
throw new Error('Chore ID is required to fetch timer')
|
||||
}
|
||||
const response = await GetChoreTimer(choreId)
|
||||
if (response && response.ok) {
|
||||
return await response.json()
|
||||
}
|
||||
throw new Error('Failed to fetch chore timer')
|
||||
},
|
||||
enabled: !!choreId,
|
||||
})
|
||||
}
|
||||
|
||||
export const useStartChore = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: StartChore,
|
||||
onSuccess: (data, choreId) => {
|
||||
queryClient.invalidateQueries(['choreTimer', choreId])
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const usePauseChore = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: PauseChore,
|
||||
onSuccess: (data, choreId) => {
|
||||
queryClient.invalidateQueries(['choreTimer', choreId])
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useUpdateTimeSession = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ choreId, sessionId, sessionData }) =>
|
||||
UpdateTimeSession(choreId, sessionId, sessionData),
|
||||
onSuccess: (data, { choreId }) => {
|
||||
queryClient.invalidateQueries(['choreTimer', choreId])
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useDeleteTimeSession = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ choreId, sessionId }) =>
|
||||
DeleteTimeSession(choreId, sessionId),
|
||||
onSuccess: (data, { choreId }) => {
|
||||
queryClient.invalidateQueries(['choreTimer', choreId])
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useResetChoreTimer = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ResetChoreTimer,
|
||||
onSuccess: (data, choreId) => {
|
||||
queryClient.invalidateQueries(['choreTimer', choreId])
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useClearChoreTimer = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ClearChoreTimer,
|
||||
onSuccess: (data, choreId) => {
|
||||
queryClient.invalidateQueries(['choreTimer', choreId])
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,5 +1,22 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { GetAllCircleMembers, GetAllUsers } from '../utils/Fetcher'
|
||||
import {
|
||||
GetAllCircleMembers,
|
||||
GetAllUsers,
|
||||
GetChildUsers,
|
||||
GetDeviceTokens,
|
||||
GetUserProfile,
|
||||
} from '../utils/Fetcher'
|
||||
|
||||
// Helper to check if we have a valid token
|
||||
const isTokenValid = () => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token) return false
|
||||
|
||||
const expiry = localStorage.getItem('token_expiry')
|
||||
if (!expiry) return true // No expiry set, assume valid
|
||||
|
||||
return new Date() < new Date(expiry)
|
||||
}
|
||||
|
||||
export const useAllUsers = () => {
|
||||
return useQuery({
|
||||
@@ -22,3 +39,76 @@ export const useCircleMembers = () => {
|
||||
|
||||
return { data, error, isLoading, handleRefetch }
|
||||
}
|
||||
|
||||
export const useUserProfile = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data, error, isLoading } = useQuery({
|
||||
queryKey: ['userProfile'],
|
||||
queryFn: async () => {
|
||||
const resp = await GetUserProfile()
|
||||
const result = await resp.json()
|
||||
// if we got 403 then user probably deleted their account and token is still valid. navigate to login
|
||||
|
||||
return result.res || null
|
||||
},
|
||||
staleTime: 30 * 60 * 1000, // 30 minutes in milliseconds
|
||||
gcTime: 30 * 60 * 1000, // 30 minutes in milliseconds
|
||||
})
|
||||
return {
|
||||
data,
|
||||
error,
|
||||
isLoading,
|
||||
refetch: () => queryClient.invalidateQueries(['userProfile']),
|
||||
}
|
||||
}
|
||||
|
||||
export const useDeviceTokens = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data, error, isLoading } = useQuery({
|
||||
queryKey: ['deviceTokens'],
|
||||
queryFn: async () => {
|
||||
if (!isTokenValid()) {
|
||||
return null
|
||||
}
|
||||
const resp = await GetDeviceTokens(true) // Only get active devices
|
||||
const result = await resp.json()
|
||||
return result.res || []
|
||||
},
|
||||
staleTime: 0, // Always fetch fresh data
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||
})
|
||||
|
||||
return {
|
||||
data,
|
||||
error,
|
||||
isLoading,
|
||||
refetch: () => queryClient.invalidateQueries(['deviceTokens']),
|
||||
}
|
||||
}
|
||||
|
||||
export const useChildUsers = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data, error, isLoading } = useQuery({
|
||||
queryKey: ['childUsers'],
|
||||
queryFn: async () => {
|
||||
if (!isTokenValid()) {
|
||||
return null
|
||||
}
|
||||
const resp = await GetChildUsers()
|
||||
const result = await resp.json()
|
||||
return result.res || []
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||
})
|
||||
|
||||
return {
|
||||
data,
|
||||
error,
|
||||
isLoading,
|
||||
refetch: () => queryClient.invalidateQueries(['childUsers']),
|
||||
}
|
||||
}
|
||||
|
||||
87
src/service/AlertsProvider.jsx
Normal file
87
src/service/AlertsProvider.jsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Alert, Box } from '@mui/joy'
|
||||
import PropTypes from 'prop-types'
|
||||
import { createContext, useCallback, useContext, useState } from 'react'
|
||||
import Z_INDEX from '../constants/zIndex'
|
||||
|
||||
const FADE_DURATION = 400 // ms
|
||||
const ALERT_DURATION = 5000 // ms
|
||||
|
||||
const AlertsContext = createContext()
|
||||
|
||||
// Helper function to create a delay
|
||||
const delay = ms => new Promise(res => setTimeout(res, ms))
|
||||
|
||||
export const AlertsProvider = ({ children }) => {
|
||||
const [show, setShow] = useState(false)
|
||||
const [visibleAlert, setVisibleAlert] = useState(null)
|
||||
|
||||
const showAlert = useCallback(async alertObj => {
|
||||
setVisibleAlert(alertObj)
|
||||
setShow(false)
|
||||
|
||||
await delay(10)
|
||||
|
||||
setShow(true)
|
||||
await delay(ALERT_DURATION)
|
||||
|
||||
setShow(false)
|
||||
await delay(FADE_DURATION)
|
||||
|
||||
setVisibleAlert(null)
|
||||
}, [])
|
||||
|
||||
const hideAlert = useCallback(() => {
|
||||
setShow(false)
|
||||
// Wait for the fade out transition to complete before unmounting
|
||||
setTimeout(() => {
|
||||
setVisibleAlert(null)
|
||||
}, FADE_DURATION)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AlertsContext.Provider value={{ showAlert, hideAlert }}>
|
||||
{children}
|
||||
{visibleAlert && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
zIndex: Z_INDEX.ALERTS,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Alert
|
||||
variant='soft'
|
||||
color={visibleAlert.color || 'primary'}
|
||||
startDecorator={visibleAlert.icon}
|
||||
onClick={hideAlert}
|
||||
sx={{
|
||||
transition: `transform ${FADE_DURATION}ms ease-in-out, opacity ${FADE_DURATION}ms ease-in-out`,
|
||||
transform: show ? 'translateY(0)' : 'translateY(-100%)',
|
||||
opacity: show ? 1 : 0,
|
||||
pointerEvents: show ? 'auto' : 'none',
|
||||
width: '100%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: '4px',
|
||||
paddingTop: `calc(var(--safe-area-inset-top, 0px))`,
|
||||
|
||||
fontSize: '10px',
|
||||
fontWeight: 'md',
|
||||
}}
|
||||
>
|
||||
{visibleAlert.message}
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
</AlertsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
AlertsProvider.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
}
|
||||
|
||||
export const useAlerts = () => useContext(AlertsContext)
|
||||
@@ -1,18 +0,0 @@
|
||||
import React, { createContext, useState } from 'react'
|
||||
|
||||
const AuthenticationContext = createContext({})
|
||||
|
||||
const AuthenticationProvider = ({ children }) => {
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false)
|
||||
const [userProfile, setUserProfile] = useState({})
|
||||
return (
|
||||
<AuthenticationContext.Provider
|
||||
value={{ isLoggedIn, setIsLoggedIn, userProfile, setUserProfile }}
|
||||
>
|
||||
{children}
|
||||
</AuthenticationContext.Provider>
|
||||
)
|
||||
}
|
||||
export { AuthenticationContext, AuthenticationProvider }
|
||||
|
||||
// export default AuthenticationProvider;
|
||||
@@ -1,51 +0,0 @@
|
||||
import { Error } from '@mui/icons-material'
|
||||
import { Box, Button, Snackbar, Typography } from '@mui/joy'
|
||||
import React, { createContext, useContext, useState } from 'react'
|
||||
|
||||
const ErrorContext = createContext()
|
||||
|
||||
export const useError = () => useContext(ErrorContext)
|
||||
|
||||
export const ErrorProvider = ({ children }) => {
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const showError = error => {
|
||||
setError(error)
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorContext.Provider value={{ showError }}>
|
||||
{children}
|
||||
<Snackbar
|
||||
open={Boolean(error)}
|
||||
autoHideDuration={6000}
|
||||
onClose={() => setError(null)}
|
||||
startDecorator={<Error color='danger' />}
|
||||
endDecorator={
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='danger'
|
||||
onClick={() => setError(null)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{typeof error === 'string' ? (
|
||||
<Typography color='danger' level='body-md'>
|
||||
{error}
|
||||
</Typography>
|
||||
) : (
|
||||
<Box>
|
||||
<Typography color='danger' level='title-sm'>
|
||||
{error?.title}
|
||||
</Typography>
|
||||
<Typography color='danger' level='body-sm'>
|
||||
{error?.message}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Snackbar>
|
||||
</ErrorContext.Provider>
|
||||
)
|
||||
}
|
||||
269
src/service/NotificationProvider.jsx
Normal file
269
src/service/NotificationProvider.jsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import { CheckCircle, Error, Info, Undo, Warning } from '@mui/icons-material'
|
||||
import { Box, Button, Snackbar, Typography } from '@mui/joy'
|
||||
import React, { createContext, useContext, useState } from 'react'
|
||||
|
||||
const NotificationContext = createContext()
|
||||
|
||||
export const useNotification = () => useContext(NotificationContext)
|
||||
|
||||
// For backward compatibility
|
||||
export const useError = () => {
|
||||
const { showError } = useNotification()
|
||||
return { showError }
|
||||
}
|
||||
|
||||
// Notification types configuration with default titles
|
||||
const NOTIFICATION_TYPES = {
|
||||
error: {
|
||||
color: 'danger',
|
||||
icon: <Error color='danger' />,
|
||||
autoHideDuration: 6000,
|
||||
showDismissButton: true,
|
||||
defaultTitle: 'Error',
|
||||
},
|
||||
success: {
|
||||
color: 'success',
|
||||
icon: <CheckCircle color='success' />,
|
||||
autoHideDuration: 5000,
|
||||
showDismissButton: false,
|
||||
defaultTitle: 'Success',
|
||||
},
|
||||
undo: {
|
||||
color: 'success',
|
||||
icon: <Undo color='success' />,
|
||||
autoHideDuration: null,
|
||||
showDismissButton: false,
|
||||
defaultTitle: 'Undone Successfully',
|
||||
},
|
||||
warning: {
|
||||
color: 'warning',
|
||||
icon: <Warning color='warning' />,
|
||||
autoHideDuration: 4000,
|
||||
showDismissButton: false,
|
||||
defaultTitle: 'Warning',
|
||||
},
|
||||
info: {
|
||||
color: 'primary',
|
||||
icon: <Info color='primary' />,
|
||||
autoHideDuration: 4000,
|
||||
showDismissButton: false,
|
||||
defaultTitle: 'Information',
|
||||
},
|
||||
custom: {
|
||||
color: 'neutral',
|
||||
icon: null,
|
||||
autoHideDuration: null,
|
||||
showDismissButton: false,
|
||||
defaultTitle: 'Notification',
|
||||
},
|
||||
}
|
||||
|
||||
export const NotificationProvider = ({ children }) => {
|
||||
const [notifications, setNotifications] = useState([])
|
||||
|
||||
const addNotification = notification => {
|
||||
const id = Date.now() + Math.random()
|
||||
const newNotification = {
|
||||
id,
|
||||
...notification,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
setNotifications(prev => [...prev, newNotification])
|
||||
|
||||
// Auto-remove notification if it has a duration
|
||||
const config =
|
||||
NOTIFICATION_TYPES[notification.type] || NOTIFICATION_TYPES.info
|
||||
if (config.autoHideDuration) {
|
||||
setTimeout(() => {
|
||||
removeNotification(id)
|
||||
}, config.autoHideDuration)
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
const removeNotification = id => {
|
||||
setNotifications(prev => prev.filter(n => n.id !== id))
|
||||
}
|
||||
|
||||
const clearAllNotifications = () => {
|
||||
setNotifications([])
|
||||
}
|
||||
|
||||
// Helper function to normalize notification input
|
||||
const normalizeNotification = (input, type) => {
|
||||
if (typeof input === 'string') {
|
||||
return {
|
||||
type,
|
||||
message: input,
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof input === 'object' && input !== null) {
|
||||
// If it's already a properly structured notification
|
||||
if (input.title || input.message) {
|
||||
return {
|
||||
type,
|
||||
...input,
|
||||
}
|
||||
}
|
||||
|
||||
// If it's a simple object with just message content
|
||||
return {
|
||||
type,
|
||||
message: input.message || input.toString(),
|
||||
title: input.title,
|
||||
...input,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
message: input?.toString() || 'Unknown notification',
|
||||
}
|
||||
}
|
||||
|
||||
// Unified notification method
|
||||
const showNotification = notification => {
|
||||
// Handle different input formats
|
||||
if (typeof notification === 'string') {
|
||||
return addNotification(normalizeNotification(notification, 'info'))
|
||||
}
|
||||
|
||||
return addNotification(
|
||||
normalizeNotification(notification, notification.type || 'info'),
|
||||
)
|
||||
}
|
||||
|
||||
// Specific notification methods with enhanced language
|
||||
const showError = error => {
|
||||
return addNotification(normalizeNotification(error, 'error'))
|
||||
}
|
||||
|
||||
const showUndo = message => {
|
||||
return addNotification(normalizeNotification(message, 'undo'))
|
||||
}
|
||||
|
||||
const showSuccess = message => {
|
||||
return addNotification(normalizeNotification(message, 'success'))
|
||||
}
|
||||
|
||||
const showWarning = message => {
|
||||
return addNotification(normalizeNotification(message, 'warning'))
|
||||
}
|
||||
|
||||
const showInfo = message => {
|
||||
return addNotification(normalizeNotification(message, 'info'))
|
||||
}
|
||||
|
||||
const renderNotification = notification => {
|
||||
const config =
|
||||
NOTIFICATION_TYPES[notification.type] || NOTIFICATION_TYPES.info
|
||||
|
||||
// Handle custom notifications with components
|
||||
if (notification.type === 'custom' && notification.component) {
|
||||
return (
|
||||
<Snackbar
|
||||
key={notification.id}
|
||||
open={true}
|
||||
onClose={() => removeNotification(notification.id)}
|
||||
anchorOrigin={
|
||||
notification.anchorOrigin || {
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}
|
||||
}
|
||||
{...(notification.snackbarProps || {})}
|
||||
>
|
||||
{React.cloneElement(notification.component, {
|
||||
onClose: () => removeNotification(notification.id),
|
||||
...notification.componentProps,
|
||||
})}
|
||||
</Snackbar>
|
||||
)
|
||||
}
|
||||
|
||||
// Handle standard notifications
|
||||
// Determine the icon to use
|
||||
const notificationIcon = notification.icon || config.icon
|
||||
|
||||
// Determine title and message
|
||||
const title = notification.title || config.defaultTitle
|
||||
const message = notification.message
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
key={notification.id}
|
||||
open={true}
|
||||
autoHideDuration={config.autoHideDuration}
|
||||
onClose={() => removeNotification(notification.id)}
|
||||
startDecorator={notificationIcon}
|
||||
endDecorator={
|
||||
notification.undoAction ? (
|
||||
<Button
|
||||
variant='outlined'
|
||||
color={config.color}
|
||||
onClick={() => {
|
||||
notification.undoAction()
|
||||
removeNotification(notification.id)
|
||||
}}
|
||||
>
|
||||
Undo
|
||||
</Button>
|
||||
) : config.showDismissButton ? (
|
||||
<Button
|
||||
variant='outlined'
|
||||
color={config.color}
|
||||
onClick={() => removeNotification(notification.id)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
anchorOrigin={
|
||||
notification.anchorOrigin || {
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}
|
||||
}
|
||||
{...(notification.snackbarProps || {})}
|
||||
>
|
||||
{/* Enhanced structure like ErrorProvider - always show title and message for consistency */}
|
||||
{title && message ? (
|
||||
<Box>
|
||||
<Typography color={config.color} level='title-sm'>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography color={config.color} level='body-sm'>
|
||||
{message}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography color={config.color} level='body-md'>
|
||||
{message || title || 'Notification'}
|
||||
</Typography>
|
||||
)}
|
||||
</Snackbar>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider
|
||||
value={{
|
||||
showNotification,
|
||||
showError,
|
||||
showSuccess,
|
||||
showUndo,
|
||||
showWarning,
|
||||
showInfo,
|
||||
removeNotification,
|
||||
clearAllNotifications,
|
||||
notifications,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{notifications.map(renderNotification)}
|
||||
</NotificationContext.Provider>
|
||||
)
|
||||
}
|
||||
270
src/utils/ApiClient.js
Normal file
270
src/utils/ApiClient.js
Normal file
@@ -0,0 +1,270 @@
|
||||
import { Preferences } from '@capacitor/preferences'
|
||||
import { API_URL } from '../Config'
|
||||
import { logout, RefreshToken } from './Fetcher'
|
||||
import {
|
||||
clearAllTokens,
|
||||
isRefreshTokenExpired,
|
||||
saveTokens,
|
||||
} from './TokenStorage'
|
||||
|
||||
class ApiClient {
|
||||
constructor() {
|
||||
this.customServerURL = `${API_URL}/api/v1`
|
||||
this.isRefreshing = false
|
||||
this.failedQueue = []
|
||||
this.lastRefreshTime = 0
|
||||
this.refreshCooldown = 3 * 1000 // 3 seconds in milliseconds
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (this.initPromise) {
|
||||
return this.initPromise
|
||||
}
|
||||
|
||||
if (this.initialized) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
this.initPromise = this._doInit()
|
||||
return this.initPromise
|
||||
}
|
||||
|
||||
async _doInit() {
|
||||
const { value: serverURL } = await Preferences.get({
|
||||
key: 'customServerUrl',
|
||||
})
|
||||
|
||||
this.customServerURL = `${serverURL || API_URL}/api/v1`
|
||||
this.initialized = true
|
||||
}
|
||||
getApiURL() {
|
||||
return this.customServerURL
|
||||
}
|
||||
|
||||
async refreshToken() {
|
||||
// Check if refresh token is expired BEFORE attempting refresh
|
||||
const refreshExpired = await isRefreshTokenExpired()
|
||||
if (refreshExpired) {
|
||||
console.log('Refresh token expired, forcing logout')
|
||||
await clearAllTokens()
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return { success: false, error: 'Refresh token expired' }
|
||||
}
|
||||
|
||||
if (this.isRefreshing) {
|
||||
return { success: false, error: 'Already refreshing' }
|
||||
}
|
||||
|
||||
// Check cooldown
|
||||
const now = Date.now()
|
||||
if (now - this.lastRefreshTime < this.refreshCooldown) {
|
||||
return { success: false, error: 'Refresh cooldown active' }
|
||||
}
|
||||
|
||||
this.isRefreshing = true
|
||||
|
||||
try {
|
||||
const refreshReq = await RefreshToken()
|
||||
|
||||
if (refreshReq.ok) {
|
||||
const data = await refreshReq.json()
|
||||
const newToken = data.token || data.access_token
|
||||
|
||||
// Save all tokens including rotated refresh token
|
||||
await saveTokens({
|
||||
accessToken: newToken,
|
||||
accessTokenExpiry: data.expire || data.access_token_expiry,
|
||||
refreshToken: data.refresh_token,
|
||||
refreshTokenExpiry: data.refresh_token_expiry,
|
||||
})
|
||||
|
||||
// Update last refresh time
|
||||
this.lastRefreshTime = Date.now()
|
||||
|
||||
return { success: true, token: newToken }
|
||||
} else {
|
||||
return { success: false, error: 'Refresh failed' }
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message }
|
||||
} finally {
|
||||
this.isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
getToken() {
|
||||
return localStorage.getItem('token')
|
||||
}
|
||||
|
||||
getHeaders(customHeaders = {}) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...customHeaders,
|
||||
}
|
||||
|
||||
const token = this.getToken()
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const impersonateUserId = localStorage.getItem('impersonatedUserId')
|
||||
if (impersonateUserId) {
|
||||
headers['X-Impersonate-User-ID'] = impersonateUserId
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
// Process queued requests after refresh attempt
|
||||
processQueue(error, token = null) {
|
||||
this.failedQueue.forEach(({ resolve, reject }) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve(token)
|
||||
}
|
||||
})
|
||||
|
||||
this.failedQueue = []
|
||||
}
|
||||
|
||||
// Helper to avoid repeating cleanup code
|
||||
async handleLogout() {
|
||||
await clearAllTokens()
|
||||
try {
|
||||
await logout()
|
||||
} catch (e) {
|
||||
console.error('Error during logout', e)
|
||||
}
|
||||
|
||||
if (window.location.pathname !== '/login') window.location.href = '/login'
|
||||
// fire and forget
|
||||
}
|
||||
async request(endpoint, options = {}) {
|
||||
await this.init()
|
||||
const url = `${this.customServerURL}${endpoint}`
|
||||
const config = {
|
||||
// credentials: 'include',
|
||||
...options,
|
||||
headers: this.getHeaders(options.headers),
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Initial Request
|
||||
let response = await fetch(url, config)
|
||||
|
||||
// 2. Check for 401 (Unauthorized)
|
||||
if (response.status === 401) {
|
||||
// Always queue this request first
|
||||
const queuedPromise = new Promise((resolve, reject) => {
|
||||
this.failedQueue.push({
|
||||
resolve: async token => {
|
||||
if (!token) {
|
||||
reject(new Error('Token refresh failed'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const newHeaders = this.getHeaders(options?.headers)
|
||||
const retryConfig = {
|
||||
...config,
|
||||
headers: newHeaders,
|
||||
}
|
||||
const retryResponse = await fetch(url, retryConfig)
|
||||
resolve(retryResponse)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
},
|
||||
reject,
|
||||
})
|
||||
})
|
||||
|
||||
// If already refreshing, just return the queued promise
|
||||
if (this.isRefreshing) {
|
||||
console.log('Token refresh already in progress, queueing request')
|
||||
return queuedPromise
|
||||
}
|
||||
|
||||
// Attempt to refresh the token
|
||||
const refreshResult = await this.refreshToken()
|
||||
|
||||
if (refreshResult.success) {
|
||||
// Process queue with success - this will retry all queued requests
|
||||
this.processQueue(null, refreshResult.token)
|
||||
} else if (refreshResult.error === 'Refresh cooldown active') {
|
||||
// We're in cooldown - token was just refreshed, retry with current token
|
||||
console.log('Refresh cooldown - retrying with current token')
|
||||
const currentToken = this.getToken()
|
||||
if (currentToken) {
|
||||
this.processQueue(null, currentToken)
|
||||
} else {
|
||||
this.processQueue(new Error('No token available'), null)
|
||||
this.handleLogout()
|
||||
return null
|
||||
}
|
||||
} else if (refreshResult.error === 'Already refreshing') {
|
||||
// This shouldn't happen since we check isRefreshing above, but handle it anyway
|
||||
console.log('Already refreshing - waiting for refresh to complete')
|
||||
return queuedPromise
|
||||
} else {
|
||||
// Actual refresh failure - logout
|
||||
this.processQueue(new Error(refreshResult.error), null)
|
||||
this.handleLogout()
|
||||
return null
|
||||
}
|
||||
|
||||
// Return the queued promise for this request
|
||||
return queuedPromise
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Request failed', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async get(endpoint, options = {}) {
|
||||
return this.request(endpoint, { ...options, method: 'GET' })
|
||||
}
|
||||
|
||||
async post(endpoint, data, options = {}) {
|
||||
return this.request(endpoint, {
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
async put(endpoint, data, options = {}) {
|
||||
return this.request(endpoint, {
|
||||
...options,
|
||||
method: 'PUT',
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
async delete(endpoint, options = {}) {
|
||||
return this.request(endpoint, { ...options, method: 'DELETE' })
|
||||
}
|
||||
|
||||
async upload(endpoint, formData, options = {}) {
|
||||
const headers = options.headers || {}
|
||||
delete headers['Content-Type']
|
||||
|
||||
return this.request(endpoint, {
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
getAssetURL(path) {
|
||||
return `${this.customServerURL}/assets/${path}`
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient()
|
||||
@@ -2,7 +2,27 @@ import moment from 'moment'
|
||||
import { TASK_COLOR } from './Colors.jsx'
|
||||
|
||||
const priorityOrder = [1, 2, 3, 4, 0]
|
||||
// ChoreGrouperOptions enum:
|
||||
export const GROUPING_OPTIONS = Object.freeze({
|
||||
SMART: 'default',
|
||||
DUE_DATE: 'due_date',
|
||||
PRIORITY: 'priority',
|
||||
LABELS: 'labels',
|
||||
})
|
||||
|
||||
export const ChoreHistoryStatus = Object.freeze({
|
||||
STARTED: 0,
|
||||
COMPLETED: 1,
|
||||
SKIPPED: 2,
|
||||
PENDING_APPROVAL: 3,
|
||||
REJECTED: 4,
|
||||
})
|
||||
export const ChoreStatus = Object.freeze({
|
||||
INACTIVE: 0,
|
||||
ACTIVE: 1,
|
||||
PAUSED: 2,
|
||||
PENDING_APPROVAL: 3,
|
||||
})
|
||||
export const ChoresGrouper = (groupBy, chores, filter) => {
|
||||
if (filter) {
|
||||
chores = chores.filter(chore => filter(chore))
|
||||
@@ -12,12 +32,127 @@ export const ChoresGrouper = (groupBy, chores, filter) => {
|
||||
chores.sort(ChoreSorter)
|
||||
var groups = []
|
||||
switch (groupBy) {
|
||||
case 'default':
|
||||
// same as due_date but hide empty groups: and if status is 1 or 2 have seperated catigory as Started:
|
||||
var groupRaw = {
|
||||
PendingApproval: [],
|
||||
Started: [],
|
||||
Today: [],
|
||||
Tomorrow: [],
|
||||
'Next 7 Days': [],
|
||||
'Later This Month': [],
|
||||
Future: [],
|
||||
Overdue: [],
|
||||
Anytime: [],
|
||||
}
|
||||
chores.forEach(chore => {
|
||||
if (chore.status === 1 || chore.status === 2) {
|
||||
groupRaw['Started'].push(chore)
|
||||
} else if (chore.status === 3) {
|
||||
groupRaw['PendingApproval'].push(chore)
|
||||
} else if (chore.nextDueDate === null) {
|
||||
groupRaw['Anytime'].push(chore)
|
||||
} else if (new Date(chore.nextDueDate) < new Date()) {
|
||||
groupRaw['Overdue'].push(chore)
|
||||
} else if (
|
||||
new Date(chore.nextDueDate).toDateString() ===
|
||||
new Date().toDateString()
|
||||
) {
|
||||
groupRaw['Today'].push(chore)
|
||||
} else if (
|
||||
new Date(chore.nextDueDate).toDateString() ===
|
||||
new Date(Date.now() + 24 * 60 * 60 * 1000).toDateString()
|
||||
) {
|
||||
groupRaw['Tomorrow'].push(chore)
|
||||
} else if (
|
||||
new Date(chore.nextDueDate) <
|
||||
new Date(Date.now() + 8 * 24 * 60 * 60 * 1000) &&
|
||||
new Date(chore.nextDueDate) >
|
||||
new Date(Date.now() + 24 * 60 * 60 * 1000)
|
||||
) {
|
||||
groupRaw['Next 7 Days'].push(chore)
|
||||
} else if (
|
||||
new Date(chore.nextDueDate).getMonth() === new Date().getMonth() &&
|
||||
new Date(chore.nextDueDate).getFullYear() === new Date().getFullYear()
|
||||
) {
|
||||
groupRaw['Later This Month'].push(chore)
|
||||
} else {
|
||||
groupRaw['Future'].push(chore)
|
||||
}
|
||||
})
|
||||
groups = []
|
||||
if (groupRaw['Started'].length > 0) {
|
||||
groups.push({
|
||||
name: 'Started',
|
||||
content: groupRaw['Started'],
|
||||
color: TASK_COLOR.STARTED,
|
||||
})
|
||||
}
|
||||
if (groupRaw['PendingApproval'].length > 0) {
|
||||
groups.push({
|
||||
name: 'Pending Approval',
|
||||
content: groupRaw['PendingApproval'],
|
||||
color: TASK_COLOR.LATE,
|
||||
})
|
||||
}
|
||||
if (groupRaw['Overdue'].length > 0) {
|
||||
groups.push({
|
||||
name: 'Overdue',
|
||||
content: groupRaw['Overdue'],
|
||||
color: TASK_COLOR.OVERDUE,
|
||||
})
|
||||
}
|
||||
if (groupRaw['Today'].length > 0) {
|
||||
groups.push({
|
||||
name: 'Today',
|
||||
content: groupRaw['Today'],
|
||||
color: TASK_COLOR.TODAY,
|
||||
})
|
||||
}
|
||||
if (groupRaw['Tomorrow'].length > 0) {
|
||||
groups.push({
|
||||
name: 'Tomorrow',
|
||||
content: groupRaw['Tomorrow'],
|
||||
color: TASK_COLOR.TOMORROW,
|
||||
})
|
||||
}
|
||||
if (groupRaw['Next 7 Days'].length > 0) {
|
||||
groups.push({
|
||||
name: 'Next 7 Days',
|
||||
content: groupRaw['Next 7 Days'],
|
||||
color: TASK_COLOR.NEXT_7_DAYS,
|
||||
})
|
||||
}
|
||||
if (groupRaw['Later This Month'].length > 0) {
|
||||
groups.push({
|
||||
name: 'Later This Month',
|
||||
content: groupRaw['Later This Month'],
|
||||
color: TASK_COLOR.LATER_THIS_MONTH,
|
||||
})
|
||||
}
|
||||
if (groupRaw['Future'].length > 0) {
|
||||
groups.push({
|
||||
name: 'Future',
|
||||
content: groupRaw['Future'],
|
||||
color: TASK_COLOR.FUTURE,
|
||||
})
|
||||
}
|
||||
if (groupRaw['Anytime'].length > 0) {
|
||||
groups.push({
|
||||
name: 'Anytime',
|
||||
content: groupRaw['Anytime'],
|
||||
color: TASK_COLOR.ANYTIME,
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
case 'due_date':
|
||||
var groupRaw = {
|
||||
Today: [],
|
||||
'In a week': [],
|
||||
'This month': [],
|
||||
Later: [],
|
||||
Tomorrow: [],
|
||||
'Next 7 Days': [],
|
||||
'Later This Month': [],
|
||||
Future: [],
|
||||
Overdue: [],
|
||||
Anytime: [],
|
||||
}
|
||||
@@ -32,17 +167,24 @@ export const ChoresGrouper = (groupBy, chores, filter) => {
|
||||
) {
|
||||
groupRaw['Today'].push(chore)
|
||||
} else if (
|
||||
new Date(chore.nextDueDate) <
|
||||
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) &&
|
||||
new Date(chore.nextDueDate) > new Date()
|
||||
new Date(chore.nextDueDate).toDateString() ===
|
||||
new Date(Date.now() + 24 * 60 * 60 * 1000).toDateString()
|
||||
) {
|
||||
groupRaw['In a week'].push(chore)
|
||||
groupRaw['Tomorrow'].push(chore)
|
||||
} else if (
|
||||
new Date(chore.nextDueDate).getMonth() === new Date().getMonth()
|
||||
new Date(chore.nextDueDate) <
|
||||
new Date(Date.now() + 8 * 24 * 60 * 60 * 1000) &&
|
||||
new Date(chore.nextDueDate) >
|
||||
new Date(Date.now() + 24 * 60 * 60 * 1000)
|
||||
) {
|
||||
groupRaw['This month'].push(chore)
|
||||
groupRaw['Next 7 Days'].push(chore)
|
||||
} else if (
|
||||
new Date(chore.nextDueDate).getMonth() === new Date().getMonth() &&
|
||||
new Date(chore.nextDueDate).getFullYear() === new Date().getFullYear()
|
||||
) {
|
||||
groupRaw['Later This Month'].push(chore)
|
||||
} else {
|
||||
groupRaw['Later'].push(chore)
|
||||
groupRaw['Future'].push(chore)
|
||||
}
|
||||
})
|
||||
groups = [
|
||||
@@ -53,16 +195,25 @@ export const ChoresGrouper = (groupBy, chores, filter) => {
|
||||
},
|
||||
{ name: 'Today', content: groupRaw['Today'], color: TASK_COLOR.TODAY },
|
||||
{
|
||||
name: 'In a week',
|
||||
content: groupRaw['In a week'],
|
||||
color: TASK_COLOR.IN_A_WEEK,
|
||||
name: 'Tomorrow',
|
||||
content: groupRaw['Tomorrow'],
|
||||
color: TASK_COLOR.TOMORROW,
|
||||
},
|
||||
{
|
||||
name: 'This month',
|
||||
content: groupRaw['This month'],
|
||||
color: TASK_COLOR.THIS_MONTH,
|
||||
name: 'Next 7 Days',
|
||||
content: groupRaw['Next 7 Days'],
|
||||
color: TASK_COLOR.NEXT_7_DAYS,
|
||||
},
|
||||
{
|
||||
name: 'Later This Month',
|
||||
content: groupRaw['Later This Month'],
|
||||
color: TASK_COLOR.LATER_THIS_MONTH,
|
||||
},
|
||||
{
|
||||
name: 'Future',
|
||||
content: groupRaw['Future'],
|
||||
color: TASK_COLOR.FUTURE,
|
||||
},
|
||||
{ name: 'Later', content: groupRaw['Later'], color: TASK_COLOR.LATER },
|
||||
{
|
||||
name: 'Anytime',
|
||||
content: groupRaw['Anytime'],
|
||||
@@ -176,12 +327,43 @@ export const notInCompletionWindow = chore => {
|
||||
moment().add(chore.completionWindow, 'hours') < moment(chore.nextDueDate)
|
||||
)
|
||||
}
|
||||
export const ChoreFilters = userProfile => ({
|
||||
export const ChoreFilters = userId => ({
|
||||
anyone: () => true,
|
||||
assigned_to_me: chore => {
|
||||
return chore.assignedTo && chore.assignedTo === userProfile?.id
|
||||
return chore.assignedTo && chore.assignedTo === userId
|
||||
},
|
||||
assigned_to_others: chore => {
|
||||
return chore.assignedTo && chore.assignedTo !== userProfile?.id
|
||||
return chore.assignedTo && chore.assignedTo !== userId
|
||||
},
|
||||
assigned_to_me_tasks: chore => {
|
||||
return (
|
||||
chore.assignees &&
|
||||
chore.assignees.some(assignee => assignee.userId === userId)
|
||||
)
|
||||
},
|
||||
created_by_me: chore => {
|
||||
return chore.createdBy && chore.createdBy === userId
|
||||
},
|
||||
})
|
||||
|
||||
// Project filter function - separate from ChoreFilters since it's independent
|
||||
export const filterByProject = (chores, selectedProject) => {
|
||||
if (
|
||||
!selectedProject ||
|
||||
selectedProject === 'Default Project' ||
|
||||
selectedProject === 'default'
|
||||
) {
|
||||
// Default project should show tasks without a project (projectId is null/undefined/empty)
|
||||
// Based on ChoreEdit.jsx, default projects save projectId as null
|
||||
return chores.filter(
|
||||
chore =>
|
||||
!chore.projectId || chore.projectId === null || chore.projectId === '',
|
||||
)
|
||||
}
|
||||
|
||||
// For custom projects, match by project ID
|
||||
return chores.filter(chore => {
|
||||
// Match by project ID (this should be the primary way chores are linked to projects)
|
||||
return chore.projectId === selectedProject
|
||||
})
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ const LABEL_COLORS = [
|
||||
]
|
||||
|
||||
export const COLORS = {
|
||||
white: '#FFFFFF',
|
||||
salmon: '#ff7961',
|
||||
teal: '#26a69a',
|
||||
skyBlue: '#80d8ff',
|
||||
@@ -52,6 +51,12 @@ export const COLORS = {
|
||||
blush: '#f8bbd0',
|
||||
ash: '#90a4ae',
|
||||
sand: '#d7ccc8',
|
||||
white: '#FFFFFF',
|
||||
}
|
||||
export const NOTIFICATION_TYPE = {
|
||||
PREDUE: '#4ec1a2',
|
||||
DUE_DATE: '#f6ad55',
|
||||
POSTDUE: '#F03A47',
|
||||
}
|
||||
|
||||
export const TASK_COLOR = {
|
||||
@@ -60,25 +65,47 @@ export const TASK_COLOR = {
|
||||
MISSED: '#F03A47',
|
||||
UPCOMING: '#AF5B5B',
|
||||
SKIPPED: '#E2C2FF',
|
||||
IN_PROGRESS: '#00bcd4',
|
||||
// PENDING_REVIEW: '#b39ddb',
|
||||
OVERDUE: '#F03A47',
|
||||
SCHEDULED: '#10B982',
|
||||
PENDING_REVIEW: '#8B6CE1',
|
||||
|
||||
// For the calendar
|
||||
OVERDUE: '#F03A47',
|
||||
TODAY: '#ffc107',
|
||||
TOMORROW: '#4ec1a2',
|
||||
NEXT_7_DAYS: '#00bcd4',
|
||||
LATER_THIS_MONTH: '#b39ddb',
|
||||
FUTURE: '#d7ccc8',
|
||||
ANYTIME: '#90a4ae',
|
||||
|
||||
// Legacy colors for backward compatibility
|
||||
IN_A_WEEK: '#4ec1a2',
|
||||
THIS_MONTH: '#00bcd4',
|
||||
LATER: '#d7ccc8',
|
||||
ANYTIME: '#90a4ae',
|
||||
|
||||
// FOR ASSIGNEE:
|
||||
ASSIGNED_TO_ME: '#4ec1a2',
|
||||
ASSIGNED_TO_OTHER: '#b39ddb',
|
||||
UNASSIGNED: '#ffc107',
|
||||
|
||||
// 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
|
||||
|
||||
@@ -90,3 +117,18 @@ export const getTextColorFromBackgroundColor = bgColor => {
|
||||
const b = parseInt(hex.substring(4, 6), 16)
|
||||
return r * 0.299 + g * 0.587 + b * 0.114 > 186 ? '#000000' : '#ffffff'
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
}
|
||||
|
||||
134
src/utils/FeatureToggle.js
Normal file
134
src/utils/FeatureToggle.js
Normal file
@@ -0,0 +1,134 @@
|
||||
export const FEATURES = {
|
||||
OFFLINE_MODE: 'experimental_feature_offline_mode',
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current state of a feature flag from localStorage
|
||||
* @param {string} featureKey - The feature key from FEATURES constant
|
||||
* @param {boolean} defaultValue - Default value if feature is not set (default: false)
|
||||
* @returns {boolean} - Whether the feature is enabled
|
||||
*/
|
||||
export const isFeatureEnabled = (featureKey, defaultValue = false) => {
|
||||
try {
|
||||
const value = localStorage.getItem(featureKey)
|
||||
|
||||
if (value === 'true') return true
|
||||
if (value === 'false') return false
|
||||
|
||||
if (value === null || value === undefined) return defaultValue
|
||||
|
||||
return Boolean(value)
|
||||
} catch (error) {
|
||||
console.warn(`FeatureToggle: Error reading feature "${featureKey}":`, error)
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the state of a feature flag in localStorage
|
||||
* @param {string} featureKey - The feature key from FEATURES constant
|
||||
* @param {boolean} enabled - Whether to enable the feature
|
||||
*/
|
||||
export const setFeatureEnabled = (featureKey, enabled) => {
|
||||
try {
|
||||
localStorage.setItem(featureKey, enabled.toString())
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`FeatureToggle: Error setting feature "${featureKey}":`,
|
||||
error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const toggleFeature = featureKey => {
|
||||
const currentState = isFeatureEnabled(featureKey)
|
||||
const newState = !currentState
|
||||
setFeatureEnabled(featureKey, newState)
|
||||
return newState
|
||||
}
|
||||
|
||||
export const getAllFeatureStates = () => {
|
||||
const states = {}
|
||||
Object.entries(FEATURES).forEach(([name, key]) => {
|
||||
states[name] = isFeatureEnabled(key)
|
||||
})
|
||||
return states
|
||||
}
|
||||
|
||||
export const clearAllFeatures = () => {
|
||||
try {
|
||||
Object.values(FEATURES).forEach(featureKey => {
|
||||
localStorage.removeItem(featureKey)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('FeatureToggle: Error clearing features:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current instance is the official donetick.com service
|
||||
* @returns {Promise<boolean>} - Whether this is the official donetick.com instance
|
||||
*/
|
||||
export const isOfficialDonetickInstance = async () => {
|
||||
try {
|
||||
// Import here to avoid circular dependencies
|
||||
const { Preferences } = await import('@capacitor/preferences')
|
||||
const { API_URL } = await import('../Config')
|
||||
|
||||
// Get custom server URL from preferences
|
||||
const { value: customServerUrl } = await Preferences.get({
|
||||
key: 'customServerUrl',
|
||||
})
|
||||
|
||||
// Use custom URL if set, otherwise fall back to API_URL
|
||||
const serverUrl = customServerUrl || API_URL
|
||||
|
||||
// Check if the server URL contains donetick.com
|
||||
return serverUrl.toLowerCase().includes('donetick.com')
|
||||
} catch (error) {
|
||||
console.warn('FeatureToggle: Error checking server instance:', error)
|
||||
// Default to false for safety (self-hosted assumption)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous version that checks based on current API manager state
|
||||
* Note: This requires apiManager to be initialized first
|
||||
* @returns {boolean} - Whether this is the official donetick.com instance
|
||||
*/
|
||||
export const isOfficialDonetickInstanceSync = () => {
|
||||
try {
|
||||
// Dynamic import to avoid circular dependencies
|
||||
return import('./ApiClient')
|
||||
.then(({ apiClient }) => {
|
||||
const currentApiUrl = apiClient.baseURL
|
||||
// Check if the API URL contains donetick.com
|
||||
return currentApiUrl.toLowerCase().includes('donetick.com')
|
||||
})
|
||||
.catch(error => {
|
||||
console.warn(
|
||||
'FeatureToggle: Error checking server instance (sync):',
|
||||
error,
|
||||
)
|
||||
// Default to false for safety (self-hosted assumption)
|
||||
return false
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('FeatureToggle: Error checking server instance (sync):', error)
|
||||
// Default to false for safety (self-hosted assumption)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Export default object for easier imports
|
||||
export default {
|
||||
FEATURES,
|
||||
isFeatureEnabled,
|
||||
setFeatureEnabled,
|
||||
toggleFeature,
|
||||
getAllFeatureStates,
|
||||
clearAllFeatures,
|
||||
isOfficialDonetickInstance,
|
||||
isOfficialDonetickInstanceSync,
|
||||
}
|
||||
@@ -1,4 +1,18 @@
|
||||
import { Fetch, HEADERS, apiManager } from './TokenManager'
|
||||
import { apiClient } from './ApiClient'
|
||||
|
||||
// Migration helpers to maintain compatibility with existing code
|
||||
const Fetch = async (endpoint, options = {}) => {
|
||||
const response = await apiClient.request(endpoint, options)
|
||||
return response
|
||||
}
|
||||
|
||||
const HEADERS = () => {
|
||||
return apiClient.getHeaders()
|
||||
}
|
||||
|
||||
const apiManager = {
|
||||
getApiURL: () => apiClient.getApiURL(),
|
||||
}
|
||||
|
||||
const createChore = userID => {
|
||||
return Fetch(`/chores/`, {
|
||||
@@ -41,6 +55,14 @@ const login = (username, password) => {
|
||||
})
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
const baseURL = apiManager.getApiURL()
|
||||
return fetch(`${baseURL}/auth/logout`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
}
|
||||
|
||||
const GetAllUsers = () => {
|
||||
return Fetch(`/users/`, {
|
||||
method: 'GET',
|
||||
@@ -123,12 +145,26 @@ const MarkChoreComplete = (id, body, completedDate, performer) => {
|
||||
})
|
||||
}
|
||||
|
||||
const CompleteSubTask = (id, choreId, performedAt) => {
|
||||
const StartChore = id => {
|
||||
return Fetch(`/chores/${id}/start`, {
|
||||
method: 'PUT',
|
||||
headers: HEADERS(),
|
||||
})
|
||||
}
|
||||
|
||||
const PauseChore = id => {
|
||||
return Fetch(`/chores/${id}/pause`, {
|
||||
method: 'PUT',
|
||||
headers: HEADERS(),
|
||||
})
|
||||
}
|
||||
|
||||
const CompleteSubTask = (id, choreId, completedAt) => {
|
||||
var markChoreURL = `/chores/${choreId}/subtask`
|
||||
return Fetch(markChoreURL, {
|
||||
method: 'PUT',
|
||||
headers: HEADERS(),
|
||||
body: JSON.stringify({ performedAt, id, choreId }),
|
||||
body: JSON.stringify({ completedAt, id, choreId }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -142,11 +178,49 @@ const SkipChore = id => {
|
||||
})
|
||||
}
|
||||
|
||||
const ApproveChore = id => {
|
||||
return Fetch(`/chores/${id}/approve`, {
|
||||
method: 'POST',
|
||||
headers: HEADERS(),
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
}
|
||||
|
||||
const RejectChore = id => {
|
||||
return Fetch(`/chores/${id}/reject`, {
|
||||
method: 'POST',
|
||||
headers: HEADERS(),
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
}
|
||||
|
||||
const UndoChoreAction = id => {
|
||||
return Fetch(`/chores/${id}/undo`, {
|
||||
method: 'POST',
|
||||
headers: HEADERS(),
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
}
|
||||
|
||||
const NudgeChore = (id, { message, notifyAllAssignees }) => {
|
||||
return Fetch(`/chores/${id}/nudge`, {
|
||||
method: 'POST',
|
||||
headers: HEADERS(),
|
||||
body: JSON.stringify({
|
||||
all_assignees: notifyAllAssignees,
|
||||
message: message || '',
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
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(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -201,14 +275,6 @@ const UpdateChoreHistory = (choreId, id, choreHistory) => {
|
||||
})
|
||||
}
|
||||
|
||||
const UpdateChoreStatus = (choreId, status) => {
|
||||
return Fetch(`/chores/${choreId}/status`, {
|
||||
method: 'PUT',
|
||||
headers: HEADERS(),
|
||||
body: JSON.stringify({ status }),
|
||||
})
|
||||
}
|
||||
|
||||
const GetAllCircleMembers = async () => {
|
||||
const resp = await Fetch(`/circles/members`, {
|
||||
method: 'GET',
|
||||
@@ -499,6 +565,7 @@ const UpdateDueDate = (id, dueDate) => {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
dueDate: dueDate ? new Date(dueDate).toISOString() : null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -510,12 +577,39 @@ const RedeemPoints = (userId, points, circleID) => {
|
||||
body: JSON.stringify({ points, userId }),
|
||||
})
|
||||
}
|
||||
const RefreshToken = () => {
|
||||
const RefreshToken = async () => {
|
||||
const basedURL = apiManager.getApiURL()
|
||||
return fetch(`${basedURL}/auth/refresh`, {
|
||||
method: 'GET',
|
||||
headers: HEADERS(),
|
||||
})
|
||||
|
||||
// Check if running on native platform
|
||||
const isNative =
|
||||
typeof window !== 'undefined' && window.Capacitor?.isNativePlatform?.()
|
||||
|
||||
if (isNative) {
|
||||
// For native platforms, send refresh token in request body
|
||||
const { Preferences } = await import('@capacitor/preferences')
|
||||
const { value: refreshToken } = await Preferences.get({
|
||||
key: 'refresh_token',
|
||||
})
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available')
|
||||
}
|
||||
|
||||
return fetch(`${basedURL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
})
|
||||
} else {
|
||||
// For web, continue using cookies
|
||||
return fetch(`${basedURL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: HEADERS(),
|
||||
})
|
||||
}
|
||||
}
|
||||
const GetChoresHistory = async (limit, includeMembers) => {
|
||||
var url = `/chores/history`
|
||||
@@ -549,37 +643,249 @@ const GetStorageUsage = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// Timer/TimeSession API functions
|
||||
const GetChoreTimer = choreId => {
|
||||
return Fetch(`/chores/${choreId}/timer`, {
|
||||
method: 'GET',
|
||||
headers: HEADERS(),
|
||||
})
|
||||
}
|
||||
|
||||
const UpdateTimeSession = (choreId, sessionId, sessionData) => {
|
||||
return Fetch(`/chores/${choreId}/timer/${sessionId}`, {
|
||||
method: 'PUT',
|
||||
headers: HEADERS(),
|
||||
body: JSON.stringify(sessionData),
|
||||
})
|
||||
}
|
||||
|
||||
const DeleteTimeSession = (choreId, sessionId) => {
|
||||
return Fetch(`/chores/${choreId}/timer/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
headers: HEADERS(),
|
||||
})
|
||||
}
|
||||
|
||||
const ResetChoreTimer = choreId => {
|
||||
return Fetch(`/chores/${choreId}/timer/reset`, {
|
||||
method: 'PUT',
|
||||
headers: HEADERS(),
|
||||
})
|
||||
}
|
||||
|
||||
const ClearChoreTimer = choreId => {
|
||||
return Fetch(`/chores/${choreId}/timer`, {
|
||||
method: 'DELETE',
|
||||
headers: HEADERS(),
|
||||
})
|
||||
}
|
||||
|
||||
const CheckUserDeletion = password => {
|
||||
return Fetch(`/users/delete/check`, {
|
||||
method: 'POST',
|
||||
headers: HEADERS(),
|
||||
body: JSON.stringify({
|
||||
password,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const DeleteUser = (password, confirmation, transferOptions = []) => {
|
||||
return Fetch(`/users/delete`, {
|
||||
method: 'DELETE',
|
||||
headers: HEADERS(),
|
||||
body: JSON.stringify({
|
||||
password,
|
||||
confirmation,
|
||||
transferOptions,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const CreateBackup = (encryptionKey, includeAssets = true, backupName = '') => {
|
||||
return Fetch(`/backup/create`, {
|
||||
method: 'POST',
|
||||
headers: HEADERS(),
|
||||
body: JSON.stringify({
|
||||
encryption_key: encryptionKey,
|
||||
include_assets: includeAssets,
|
||||
backup_name: backupName,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const RestoreBackup = (encryptionKey, backupData) => {
|
||||
return Fetch(`/backup/restore`, {
|
||||
method: 'POST',
|
||||
headers: HEADERS(),
|
||||
body: JSON.stringify({
|
||||
encryption_key: encryptionKey,
|
||||
backup_data: backupData,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const RegisterDeviceToken = (
|
||||
token,
|
||||
deviceId,
|
||||
platform,
|
||||
appVersion,
|
||||
deviceModel,
|
||||
) => {
|
||||
return Fetch(`/devices/tokens`, {
|
||||
method: 'POST',
|
||||
headers: HEADERS(),
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
deviceId,
|
||||
platform,
|
||||
appVersion,
|
||||
deviceModel,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const UnregisterDeviceToken = (deviceId, token) => {
|
||||
return Fetch(`/devices/tokens`, {
|
||||
method: 'DELETE',
|
||||
headers: HEADERS(),
|
||||
body: JSON.stringify({
|
||||
deviceId,
|
||||
token,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const GetDeviceTokens = (active = true) => {
|
||||
return Fetch(`/devices/tokens?active=${active}`, {
|
||||
method: 'GET',
|
||||
headers: HEADERS(),
|
||||
})
|
||||
}
|
||||
|
||||
// Child User Management Functions
|
||||
const CreateChildUser = (childName, displayName, password) => {
|
||||
return Fetch(`/users/subaccounts`, {
|
||||
method: 'POST',
|
||||
headers: HEADERS(),
|
||||
body: JSON.stringify({
|
||||
childName,
|
||||
displayName,
|
||||
password,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const GetChildUsers = () => {
|
||||
return Fetch(`/users/subaccounts`, {
|
||||
method: 'GET',
|
||||
headers: HEADERS(),
|
||||
})
|
||||
}
|
||||
|
||||
const UpdateChildPassword = (childUserId, password) => {
|
||||
return Fetch(`/users/subaccounts/password`, {
|
||||
method: 'PUT',
|
||||
headers: HEADERS(),
|
||||
body: JSON.stringify({
|
||||
childUserId,
|
||||
password,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const DeleteChildUser = childUserId => {
|
||||
return Fetch(`/users/subaccounts/${childUserId}`, {
|
||||
method: 'DELETE',
|
||||
headers: HEADERS(),
|
||||
})
|
||||
}
|
||||
|
||||
// Project-related API functions
|
||||
const GetProjects = () => {
|
||||
return Fetch(`/projects`, {
|
||||
method: 'GET',
|
||||
headers: HEADERS(),
|
||||
})
|
||||
}
|
||||
|
||||
const GetProjectById = id => {
|
||||
return Fetch(`/projects/${id}`, {
|
||||
method: 'GET',
|
||||
headers: HEADERS(),
|
||||
})
|
||||
}
|
||||
|
||||
const CreateProject = project => {
|
||||
return Fetch(`/projects`, {
|
||||
method: 'POST',
|
||||
headers: HEADERS(),
|
||||
body: JSON.stringify(project),
|
||||
})
|
||||
}
|
||||
|
||||
const UpdateProject = (id, project) => {
|
||||
return Fetch(`/projects/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: HEADERS(),
|
||||
body: JSON.stringify(project),
|
||||
})
|
||||
}
|
||||
|
||||
const DeleteProject = id => {
|
||||
return Fetch(`/projects/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: HEADERS(),
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
AcceptCircleMemberRequest,
|
||||
ApproveChore,
|
||||
ArchiveChore,
|
||||
CancelSubscription,
|
||||
ChangePassword,
|
||||
CheckUserDeletion,
|
||||
ClearChoreTimer,
|
||||
CompleteSubTask,
|
||||
ConfirmMFA,
|
||||
CreateBackup,
|
||||
CreateChildUser,
|
||||
CreateChore,
|
||||
createChore,
|
||||
CreateLabel,
|
||||
CreateLongLiveToken,
|
||||
CreateProject,
|
||||
CreateThing,
|
||||
DeleteChildUser,
|
||||
DeleteChore,
|
||||
DeleteChoreHistory,
|
||||
DeleteCircleMember,
|
||||
DeleteLabel,
|
||||
DeleteLongLiveToken,
|
||||
DeleteProject,
|
||||
DeleteThing,
|
||||
DeleteTimeSession,
|
||||
DeleteUser,
|
||||
DisableMFA,
|
||||
GetAllCircleMembers,
|
||||
GetAllUsers,
|
||||
GetArchivedChores,
|
||||
GetChildUsers,
|
||||
GetChoreByID,
|
||||
GetChoreDetailById,
|
||||
GetChoreHistory,
|
||||
GetChores,
|
||||
GetChoresHistory,
|
||||
GetChoresNew,
|
||||
GetChoreTimer,
|
||||
GetCircleMemberRequests,
|
||||
GetDeviceTokens,
|
||||
GetLabels,
|
||||
GetLongLiveTokens,
|
||||
GetMFAStatus,
|
||||
GetProjectById,
|
||||
GetProjects,
|
||||
GetResource,
|
||||
GetStorageUsage,
|
||||
GetSubscriptionSession,
|
||||
@@ -589,31 +895,42 @@ export {
|
||||
GetUserProfile,
|
||||
JoinCircle,
|
||||
LeaveCircle,
|
||||
login,
|
||||
logout,
|
||||
MarkChoreComplete,
|
||||
NudgeChore,
|
||||
PauseChore,
|
||||
PutNotificationTarget,
|
||||
PutWebhookURL,
|
||||
RedeemPoints,
|
||||
RefreshToken,
|
||||
RegenerateBackupCodes,
|
||||
RegisterDeviceToken,
|
||||
RejectChore,
|
||||
ResetChoreTimer,
|
||||
ResetPassword,
|
||||
RestoreBackup,
|
||||
SaveChore,
|
||||
SaveThing,
|
||||
SetupMFA,
|
||||
signUp,
|
||||
SkipChore,
|
||||
StartChore,
|
||||
UnArchiveChore,
|
||||
UndoChoreAction,
|
||||
UnregisterDeviceToken,
|
||||
UpdateChildPassword,
|
||||
UpdateChoreAssignee,
|
||||
UpdateChoreHistory,
|
||||
UpdateChorePriority,
|
||||
UpdateChoreStatus,
|
||||
UpdateDueDate,
|
||||
UpdateLabel,
|
||||
UpdateMemberRole,
|
||||
UpdateNotificationTarget,
|
||||
UpdatePassword,
|
||||
UpdateProject,
|
||||
UpdateThingState,
|
||||
UpdateTimeSession,
|
||||
UpdateUserDetails,
|
||||
VerifyMFA,
|
||||
createChore,
|
||||
login,
|
||||
signUp,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import moment from 'moment'
|
||||
import { getAssetURL } from './TokenManager'
|
||||
import { apiClient } from './ApiClient'
|
||||
|
||||
const isPlusAccount = userProfile => {
|
||||
return userProfile?.expiration && moment(userProfile?.expiration).isAfter()
|
||||
@@ -11,7 +11,7 @@ const resolvePhotoURL = url => {
|
||||
return url
|
||||
}
|
||||
if (url.startsWith('assets')) {
|
||||
return getAssetURL(url)
|
||||
return apiClient.getAssetURL(url)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
63
src/utils/PlatformUtils.js
Normal file
63
src/utils/PlatformUtils.js
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Utility functions for platform detection
|
||||
*/
|
||||
|
||||
/**
|
||||
* Detects if the current platform is macOS using modern APIs with fallback
|
||||
* @returns {boolean} True if running on macOS, false otherwise
|
||||
*/
|
||||
export const isMacOS = () => {
|
||||
// Modern approach using User-Agent Client Hints API
|
||||
if (navigator.userAgentData) {
|
||||
return navigator.userAgentData.platform === 'macOS'
|
||||
}
|
||||
|
||||
// Fallback for older browsers
|
||||
return /Mac|iPhone|iPad|iPod/.test(navigator.userAgent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the appropriate keyboard shortcut text for the current platform
|
||||
* @param {string} key - The key combination (e.g., 'F', 'K', 'S')
|
||||
* @param {boolean} withCtrl - Whether to include Ctrl/Cmd modifier
|
||||
* @param {boolean} withShift - Whether to include Shift modifier
|
||||
* @returns {string} Platform-appropriate keyboard shortcut text
|
||||
*/
|
||||
export const getKeyboardShortcut = (
|
||||
key,
|
||||
withCtrl = true,
|
||||
withShift = false,
|
||||
) => {
|
||||
let shortcut = ''
|
||||
|
||||
if (withCtrl) {
|
||||
const modifier = isMacOS() ? '⌘' : 'Ctrl+'
|
||||
shortcut += modifier
|
||||
}
|
||||
|
||||
if (withShift) {
|
||||
if (isMacOS()) {
|
||||
shortcut += '⇧'
|
||||
} else {
|
||||
shortcut += 'Shift+'
|
||||
}
|
||||
}
|
||||
|
||||
shortcut += key
|
||||
return shortcut
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets common keyboard shortcuts for the current platform
|
||||
*/
|
||||
export const getCommonShortcuts = () => ({
|
||||
search: getKeyboardShortcut('F'),
|
||||
newTask: getKeyboardShortcut('K'),
|
||||
selectAll: getKeyboardShortcut('A'),
|
||||
multiSelect: getKeyboardShortcut('S'),
|
||||
save: getKeyboardShortcut('S'),
|
||||
copy: getKeyboardShortcut('C'),
|
||||
paste: getKeyboardShortcut('V'),
|
||||
undo: getKeyboardShortcut('Z'),
|
||||
redo: getKeyboardShortcut('Z', true, true), // Ctrl/Cmd + Shift + Z
|
||||
})
|
||||
60
src/utils/ProjectIcons.jsx
Normal file
60
src/utils/ProjectIcons.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
AccountBalance,
|
||||
Book,
|
||||
Build,
|
||||
BusinessCenter,
|
||||
Code,
|
||||
Computer,
|
||||
DirectionsCar,
|
||||
FitnessCenter,
|
||||
Flight,
|
||||
FolderOpen,
|
||||
Games,
|
||||
Home,
|
||||
LocalHospital,
|
||||
MusicNote,
|
||||
Palette,
|
||||
Pets,
|
||||
PhotoCamera,
|
||||
Restaurant,
|
||||
School,
|
||||
Science,
|
||||
ShoppingCart,
|
||||
SportsSoccer,
|
||||
Work,
|
||||
Yard,
|
||||
} from '@mui/icons-material'
|
||||
|
||||
const PROJECT_ICONS = [
|
||||
{ name: 'Folder', icon: FolderOpen, value: 'FolderOpen' },
|
||||
{ name: 'Work', icon: Work, value: 'Work' },
|
||||
{ name: 'Home', icon: Home, value: 'Home' },
|
||||
{ name: 'School', icon: School, value: 'School' },
|
||||
{ name: 'Business', icon: BusinessCenter, value: 'BusinessCenter' },
|
||||
{ name: 'Code', icon: Code, value: 'Code' },
|
||||
{ name: 'Build', icon: Build, value: 'Build' },
|
||||
{ name: 'Design', icon: Palette, value: 'Palette' },
|
||||
{ name: 'Sports', icon: SportsSoccer, value: 'SportsSoccer' },
|
||||
{ name: 'Fitness', icon: FitnessCenter, value: 'FitnessCenter' },
|
||||
{ name: 'Shopping', icon: ShoppingCart, value: 'ShoppingCart' },
|
||||
{ name: 'Food', icon: Restaurant, value: 'Restaurant' },
|
||||
{ name: 'Travel', icon: Flight, value: 'Flight' },
|
||||
{ name: 'Study', icon: Book, value: 'Book' },
|
||||
{ name: 'Music', icon: MusicNote, value: 'MusicNote' },
|
||||
{ name: 'Photo', icon: PhotoCamera, value: 'PhotoCamera' },
|
||||
{ name: 'Games', icon: Games, value: 'Games' },
|
||||
{ name: 'Science', icon: Science, value: 'Science' },
|
||||
{ name: 'Finance', icon: AccountBalance, value: 'AccountBalance' },
|
||||
{ name: 'Health', icon: LocalHospital, value: 'LocalHospital' },
|
||||
{ name: 'Auto', icon: DirectionsCar, value: 'DirectionsCar' },
|
||||
{ name: 'Pets', icon: Pets, value: 'Pets' },
|
||||
{ name: 'Garden', icon: Yard, value: 'Garden' },
|
||||
{ name: 'Tech', icon: Computer, value: 'Computer' },
|
||||
]
|
||||
|
||||
export default PROJECT_ICONS
|
||||
|
||||
export const getIconComponent = iconValue => {
|
||||
const iconData = PROJECT_ICONS.find(icon => icon.value === iconValue)
|
||||
return iconData ? iconData.icon : FolderOpen
|
||||
}
|
||||
89
src/utils/SafeAreaUtils.js
Normal file
89
src/utils/SafeAreaUtils.js
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
|
||||
/**
|
||||
* Utility functions for handling safe area insets consistently across the app
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get the appropriate bottom value that accounts for safe area insets
|
||||
* @param {number|string} baseBottom - The base bottom value (default: 0)
|
||||
* @param {number|string} extraPadding - Additional padding to add (default: 0)
|
||||
* @returns {string} - CSS calc() expression for bottom positioning
|
||||
*/
|
||||
export const getSafeBottom = (baseBottom = 0, extraPadding = 0) => {
|
||||
const base = typeof baseBottom === 'number' ? `${baseBottom}px` : baseBottom
|
||||
const extra =
|
||||
typeof extraPadding === 'number' ? `${extraPadding}px` : extraPadding
|
||||
|
||||
if (Capacitor.getPlatform() === 'android') {
|
||||
if (extraPadding) {
|
||||
return `calc(var(--safe-area-inset-bottom, 0px) + ${base} + ${extra})`
|
||||
}
|
||||
return `calc(var(--safe-area-inset-bottom, 0px) + ${base})`
|
||||
}
|
||||
|
||||
// For iOS and web, safe area is already handled by the system
|
||||
if (extraPadding) {
|
||||
return `calc(${base} + ${extra})`
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
/**
|
||||
* Get safe area padding for bottom elements
|
||||
* @param {number|string} extraPadding - Additional padding to add
|
||||
* @returns {string} - CSS calc() expression for padding
|
||||
*/
|
||||
export const getSafeBottomPadding = (extraPadding = 0) => {
|
||||
const extra =
|
||||
typeof extraPadding === 'number' ? `${extraPadding * 8}px` : extraPadding
|
||||
|
||||
if (Capacitor.getPlatform() === 'android') {
|
||||
if (extraPadding) {
|
||||
return `calc(var(--safe-area-inset-bottom, 0px) + ${extra})`
|
||||
}
|
||||
return `var(--safe-area-inset-bottom, 0px)`
|
||||
}
|
||||
|
||||
return extra || '0px'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get safe area styles object for common bottom-positioned elements
|
||||
* @param {object} options - Configuration options
|
||||
* @param {number|string} options.bottom - Bottom position value
|
||||
* @param {number|string} options.padding - Additional padding
|
||||
* @param {'fixed'|'absolute'|'sticky'} options.position - Position type
|
||||
* @returns {object} - Style object
|
||||
*/
|
||||
export const getSafeBottomStyles = ({
|
||||
bottom = 0,
|
||||
padding = 0,
|
||||
position = 'fixed',
|
||||
} = {}) => {
|
||||
return {
|
||||
position,
|
||||
bottom: getSafeBottom(bottom, padding),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook-like function to get safe area values for use in components
|
||||
* @returns {object} - Object with safe area utility functions
|
||||
*/
|
||||
export const useSafeArea = () => {
|
||||
return {
|
||||
getSafeBottom,
|
||||
getSafeBottomPadding,
|
||||
getSafeBottomStyles,
|
||||
isAndroid: Capacitor.getPlatform() === 'android',
|
||||
isNative: Capacitor.isNativePlatform(),
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
getSafeBottom,
|
||||
getSafeBottomPadding,
|
||||
getSafeBottomStyles,
|
||||
useSafeArea,
|
||||
}
|
||||
65
src/utils/SidepanelConfig.js
Normal file
65
src/utils/SidepanelConfig.js
Normal file
@@ -0,0 +1,65 @@
|
||||
export const DEFAULT_SIDEPANEL_CONFIG = [
|
||||
{
|
||||
id: 'welcome', // legacy name, now represents User Switcher
|
||||
name: 'User Switcher',
|
||||
description: 'Allows admins/managers to view tasks as different users',
|
||||
iconName: 'SupervisorAccount',
|
||||
enabled: true,
|
||||
order: 0,
|
||||
},
|
||||
{
|
||||
id: 'assignees',
|
||||
name: 'Tasks by Assignee',
|
||||
description: 'Groups tasks by who they are assigned to',
|
||||
iconName: 'Person',
|
||||
enabled: true,
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
id: 'calendar',
|
||||
name: 'Calendar View',
|
||||
description: 'Shows tasks in a calendar format',
|
||||
iconName: 'CalendarMonth',
|
||||
enabled: true,
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
id: 'activities',
|
||||
name: 'Recent Activities',
|
||||
description: 'Shows recent task completions and activities',
|
||||
iconName: 'History',
|
||||
enabled: true,
|
||||
order: 3,
|
||||
},
|
||||
{
|
||||
id: 'weeklyGoals',
|
||||
name: 'Weekly Goals',
|
||||
description: 'Shows weekly progress and family completion stats',
|
||||
iconName: 'EmojiEvents',
|
||||
enabled: true,
|
||||
order: 4,
|
||||
},
|
||||
]
|
||||
|
||||
export const getSidepanelConfig = () => {
|
||||
const saved = localStorage.getItem('sidepanelConfig')
|
||||
if (saved) {
|
||||
try {
|
||||
return JSON.parse(saved)
|
||||
} catch (error) {
|
||||
console.error('Error parsing sidepanel config:', error)
|
||||
return DEFAULT_SIDEPANEL_CONFIG
|
||||
}
|
||||
}
|
||||
return DEFAULT_SIDEPANEL_CONFIG
|
||||
}
|
||||
|
||||
export const saveSidepanelConfig = config => {
|
||||
localStorage.setItem('sidepanelConfig', JSON.stringify(config))
|
||||
window.dispatchEvent(new Event('sidepanelConfigChanged'))
|
||||
}
|
||||
|
||||
export const resetSidepanelConfig = () => {
|
||||
saveSidepanelConfig(DEFAULT_SIDEPANEL_CONFIG)
|
||||
return DEFAULT_SIDEPANEL_CONFIG
|
||||
}
|
||||
228
src/utils/StatusBarManager.js
Normal file
228
src/utils/StatusBarManager.js
Normal file
@@ -0,0 +1,228 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { StatusBar, Style } from '@capacitor/status-bar'
|
||||
import { SafeArea } from 'capacitor-plugin-safe-area'
|
||||
|
||||
/**
|
||||
* StatusBarManager - A utility class to handle status bar configuration
|
||||
* following Capacitor best practices and theme-aware styling
|
||||
*/
|
||||
class StatusBarManager {
|
||||
constructor() {
|
||||
this.isNativePlatform = Capacitor.isNativePlatform()
|
||||
this.platform = Capacitor.getPlatform()
|
||||
this.listeners = []
|
||||
this.currentTheme = 'light'
|
||||
this.safeAreaApplied = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the status bar with proper configuration
|
||||
* @param {string} initialTheme - The initial theme ('light' | 'dark' | 'system')
|
||||
*/
|
||||
async initialize(initialTheme = 'light') {
|
||||
if (!this.isNativePlatform) {
|
||||
console.log('StatusBarManager: Not running on native platform')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Configure basic status bar settings - use overlay: true for precise control
|
||||
await StatusBar.setOverlaysWebView({ overlay: false })
|
||||
await StatusBar.show()
|
||||
|
||||
// Set initial theme
|
||||
await this.setTheme(initialTheme)
|
||||
|
||||
// Apply safe area insets
|
||||
await this.applySafeAreaInsets()
|
||||
|
||||
console.log('StatusBarManager: Initialized successfully')
|
||||
} catch (error) {
|
||||
console.error('StatusBarManager: Failed to initialize:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set status bar style based on theme
|
||||
* @param {string} theme - The theme ('light' | 'dark' | 'system')
|
||||
*/
|
||||
async setTheme(theme) {
|
||||
if (!this.isNativePlatform) return
|
||||
|
||||
this.currentTheme = theme
|
||||
|
||||
try {
|
||||
let style = Style.Light // Default to light content (dark status bar)
|
||||
|
||||
if (theme === 'dark') {
|
||||
style = Style.Dark // Dark content (light status bar)
|
||||
} else if (theme === 'system') {
|
||||
// For system theme, we need to detect the actual system preference
|
||||
// Joy UI's useColorScheme will handle this, but we default to light
|
||||
style = Style.Light
|
||||
}
|
||||
|
||||
await StatusBar.setStyle({ style })
|
||||
console.log(`StatusBarManager: Theme set to ${theme}, style: ${style}`)
|
||||
} catch (error) {
|
||||
console.error('StatusBarManager: Failed to set theme:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply safe area insets using CSS custom properties
|
||||
* Components should use env() variables or CSS custom properties for proper safe area handling
|
||||
*/
|
||||
async applySafeAreaInsets() {
|
||||
if (!this.isNativePlatform || this.safeAreaApplied) return
|
||||
|
||||
try {
|
||||
// Get safe area insets
|
||||
const { insets } = await SafeArea.getSafeAreaInsets()
|
||||
|
||||
// Apply CSS custom properties for safe area
|
||||
this.applySafeAreaCSS(insets)
|
||||
|
||||
this.safeAreaApplied = true
|
||||
console.log('StatusBarManager: Safe area insets applied:', insets)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'StatusBarManager: Failed to apply safe area insets:',
|
||||
error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply safe area insets using CSS custom properties
|
||||
* @param {Object} insets - The safe area insets
|
||||
*/
|
||||
applySafeAreaCSS(insets) {
|
||||
const root = document.documentElement
|
||||
|
||||
// Set CSS custom properties that can be used throughout the app
|
||||
root.style.setProperty('--safe-area-inset-top', `${insets.top}px`)
|
||||
root.style.setProperty('--safe-area-inset-right', `${insets.right}px`)
|
||||
root.style.setProperty('--safe-area-inset-bottom', `${insets.bottom}px`)
|
||||
root.style.setProperty('--safe-area-inset-left', `${insets.left}px`)
|
||||
|
||||
// Note: We no longer apply padding directly to the body to avoid double
|
||||
// application with component-level safe area handling. Components should
|
||||
// use the CSS custom properties or the utility classes from safe-area.css
|
||||
|
||||
// Let's apply it directly to body for now:
|
||||
if (Capacitor.getPlatform() === 'android') {
|
||||
// removing the top padding on android as it is handled by the navbar
|
||||
// and adding it causes double padding
|
||||
// document.body.style.paddingTop = `${insets.top}px`
|
||||
document.body.style.paddingRight = `${insets.right}px`
|
||||
document.body.style.paddingBottom = `${insets.bottom}px`
|
||||
document.body.style.paddingLeft = `${insets.left}px`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a listener for theme changes
|
||||
* @param {Function} callback - Function to call when theme changes
|
||||
* @returns {Function} - Cleanup function to remove the listener
|
||||
*/
|
||||
addThemeChangeListener(callback) {
|
||||
this.listeners.push(callback)
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
const index = this.listeners.indexOf(callback)
|
||||
if (index > -1) {
|
||||
this.listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all listeners of theme change
|
||||
* @param {string} newTheme - The new theme
|
||||
*/
|
||||
notifyThemeChange(newTheme) {
|
||||
this.listeners.forEach(callback => {
|
||||
try {
|
||||
callback(newTheme)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'StatusBarManager: Error in theme change listener:',
|
||||
error,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update status bar based on resolved theme (after system detection)
|
||||
* @param {string} resolvedTheme - The actual theme being used ('light' | 'dark')
|
||||
*/
|
||||
async updateResolvedTheme(resolvedTheme) {
|
||||
if (!this.isNativePlatform) return
|
||||
|
||||
try {
|
||||
const style = resolvedTheme === 'dark' ? Style.Dark : Style.Light
|
||||
await StatusBar.setStyle({ style })
|
||||
console.log(
|
||||
`StatusBarManager: Resolved theme updated to ${resolvedTheme}`,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('StatusBarManager: Failed to update resolved theme:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the status bar
|
||||
*/
|
||||
async hide() {
|
||||
if (!this.isNativePlatform) return
|
||||
|
||||
try {
|
||||
await StatusBar.hide()
|
||||
} catch (error) {
|
||||
console.error('StatusBarManager: Failed to hide status bar:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the status bar
|
||||
*/
|
||||
async show() {
|
||||
if (!this.isNativePlatform) return
|
||||
|
||||
try {
|
||||
await StatusBar.show()
|
||||
} catch (error) {
|
||||
console.error('StatusBarManager: Failed to show status bar:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current status bar info
|
||||
*/
|
||||
async getInfo() {
|
||||
if (!this.isNativePlatform) return null
|
||||
|
||||
try {
|
||||
return await StatusBar.getInfo()
|
||||
} catch (error) {
|
||||
console.error('StatusBarManager: Failed to get status bar info:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all listeners and reset state
|
||||
*/
|
||||
cleanup() {
|
||||
this.listeners = []
|
||||
this.safeAreaApplied = false
|
||||
console.log('StatusBarManager: Cleaned up')
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export a singleton instance
|
||||
const statusBarManager = new StatusBarManager()
|
||||
export default statusBarManager
|
||||
@@ -1,202 +0,0 @@
|
||||
import { Network } from '@capacitor/network'
|
||||
import { Preferences } from '@capacitor/preferences'
|
||||
import Cookies from 'js-cookie'
|
||||
import murmurhash from 'murmurhash'
|
||||
import { API_URL } from '../Config'
|
||||
import { networkManager } from '../hooks/NetworkManager'
|
||||
import { RefreshToken } from './Fetcher'
|
||||
import { localStore } from './LocalStore'
|
||||
|
||||
class ApiManager {
|
||||
constructor() {
|
||||
this.customServerURL = `${API_URL}/api/v1`
|
||||
this.initialized = false
|
||||
this.navigateToLogin = () => {}
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (this.initialized) {
|
||||
return
|
||||
}
|
||||
|
||||
const { value: serverURL } = await Preferences.get({
|
||||
key: 'customServerUrl',
|
||||
})
|
||||
|
||||
this.customServerURL = `${serverURL || API_URL}/api/v1`
|
||||
await localStore.initDatabase()
|
||||
this.initialized = true
|
||||
}
|
||||
|
||||
getApiURL() {
|
||||
return this.customServerURL
|
||||
}
|
||||
|
||||
updateApiURL(url) {
|
||||
this.customServerURL = url
|
||||
this.init()
|
||||
}
|
||||
setNavigateToLogin(callback) {
|
||||
this.navigateToLogin = callback
|
||||
}
|
||||
}
|
||||
|
||||
export const apiManager = new ApiManager()
|
||||
|
||||
export const getAssetURL = path => {
|
||||
const baseURL = apiManager.getApiURL()
|
||||
return `${baseURL}/assets/${path}`
|
||||
}
|
||||
export async function UploadFile(url, options) {
|
||||
if (!isTokenValid()) {
|
||||
Cookies.set('ca_redirect', window.location.pathname)
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
if (!options) {
|
||||
options = {}
|
||||
}
|
||||
const headers = HEADERS()
|
||||
options.headers = { Authorization: headers['Authorization'] }
|
||||
|
||||
const baseURL = apiManager.getApiURL()
|
||||
const fullURL = `${baseURL}${url}`
|
||||
|
||||
return fetch(fullURL, options)
|
||||
}
|
||||
|
||||
export async function Fetch(url, options) {
|
||||
if (!isTokenValid()) {
|
||||
Cookies.set('ca_redirect', window.location.pathname)
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
if (!options) {
|
||||
options = {}
|
||||
}
|
||||
options.headers = { ...options.headers, ...HEADERS() }
|
||||
|
||||
const baseURL = apiManager.getApiURL()
|
||||
const fullURL = `${baseURL}${url}`
|
||||
|
||||
const networkStatus = await Network.getStatus()
|
||||
|
||||
if (!networkStatus.connected) {
|
||||
return handleOfflineRequest(fullURL, options)
|
||||
}
|
||||
|
||||
// Online: Perform the fetch
|
||||
try {
|
||||
const response = await fetch(fullURL, options)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.clone().json()
|
||||
const optionsHash = murmurhash.v3(JSON.stringify(options))
|
||||
await localStore.saveToCache(fullURL + optionsHash, data)
|
||||
networkManager.setOnline()
|
||||
} else if (response.status === 401) {
|
||||
// Handle 401 Unauthorized
|
||||
const errorData = await response.json()
|
||||
console.error('Unauthorized:', errorData)
|
||||
localStorage.removeItem('ca_token')
|
||||
localStorage.removeItem('ca_expiration')
|
||||
apiManager.navigateToLogin()
|
||||
} else if (
|
||||
response.status === 503 ||
|
||||
response.type === 'opaque' ||
|
||||
response.status === 0
|
||||
) {
|
||||
networkManager.setOffline()
|
||||
return handleOfflineRequest(fullURL, options)
|
||||
}
|
||||
// return promise that resolves to response object:
|
||||
return Promise.resolve(response)
|
||||
} catch (error) {
|
||||
networkManager.setOffline()
|
||||
console.error('Fetch error:', error)
|
||||
// throw error
|
||||
return handleOfflineRequest(fullURL, options)
|
||||
}
|
||||
}
|
||||
|
||||
export const HEADERS = () => {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer ' + localStorage.getItem('ca_token'),
|
||||
}
|
||||
}
|
||||
|
||||
export const isTokenValid = () => {
|
||||
const expiration = localStorage.getItem('ca_expiration')
|
||||
const token = localStorage.getItem('ca_token')
|
||||
|
||||
if (token) {
|
||||
const now = new Date()
|
||||
const expire = new Date(expiration)
|
||||
if (now < expire) {
|
||||
if (now.getTime() + 24 * 60 * 60 * 1000 > expire.getTime()) {
|
||||
refreshAccessToken()
|
||||
}
|
||||
return true
|
||||
} else {
|
||||
localStorage.removeItem('ca_token')
|
||||
localStorage.removeItem('ca_expiration')
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const refreshAccessToken = () => {
|
||||
RefreshToken().then(res => {
|
||||
if (res.status === 200) {
|
||||
res.json().then(data => {
|
||||
localStorage.setItem('ca_token', data.token)
|
||||
localStorage.setItem('ca_expiration', data.expire)
|
||||
})
|
||||
} else {
|
||||
return res.json().then(error => {
|
||||
console.log(error)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleOfflineRequest(url, options) {
|
||||
// if get request then attempt to fetch from cache otherewise queue it :
|
||||
if (options.method === 'GET') {
|
||||
return attemptFetchFromCache(url, options)
|
||||
} else {
|
||||
// Queue the request for later processing
|
||||
const requestId = murmurhash.v3(JSON.stringify({ url, options }))
|
||||
await localStore.queueRequest(requestId, { url, options })
|
||||
console.log('Request queued for later processing:', requestId)
|
||||
return Promise.reject({
|
||||
error: 'Offline and request queued',
|
||||
requestId,
|
||||
queued: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
async function attemptFetchFromCache(url, options) {
|
||||
const optionsHash = murmurhash.v3(JSON.stringify(options))
|
||||
const cachedData = await localStore.getFromCache(url + optionsHash)
|
||||
networkManager.setOffline()
|
||||
|
||||
if (cachedData) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => cachedData,
|
||||
})
|
||||
} else {
|
||||
// TODO: change this to throw error instead of returning promise
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
'No cached data found for URL: ' +
|
||||
url +
|
||||
' with options hash: ' +
|
||||
optionsHash,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
190
src/utils/TokenStorage.js
Normal file
190
src/utils/TokenStorage.js
Normal file
@@ -0,0 +1,190 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { Preferences } from '@capacitor/preferences'
|
||||
|
||||
// Token storage keys
|
||||
const TOKEN_KEYS = {
|
||||
ACCESS_TOKEN: 'token',
|
||||
ACCESS_TOKEN_EXPIRY: 'token_expiry',
|
||||
REFRESH_TOKEN: 'refresh_token',
|
||||
REFRESH_TOKEN_EXPIRY: 'refresh_token_expiry',
|
||||
}
|
||||
|
||||
// Cache platform detection to avoid repeated checks
|
||||
let _isNativePlatform = null
|
||||
|
||||
// Platform detection
|
||||
const isNativePlatform = () => {
|
||||
if (_isNativePlatform === null) {
|
||||
try {
|
||||
_isNativePlatform =
|
||||
typeof window !== 'undefined' && window.Capacitor?.isNativePlatform?.()
|
||||
} catch (error) {
|
||||
console.warn('Platform detection failed, defaulting to web:', error)
|
||||
_isNativePlatform = false
|
||||
}
|
||||
}
|
||||
return _isNativePlatform
|
||||
}
|
||||
|
||||
// Track if we're currently clearing tokens to prevent race conditions
|
||||
let clearingTokens = false
|
||||
|
||||
/**
|
||||
* Save tokens based on platform
|
||||
* Web: Only access tokens to localStorage
|
||||
* Native: Access tokens to localStorage + refresh tokens to Capacitor Preferences
|
||||
*/
|
||||
export const saveTokens = async ({
|
||||
accessToken,
|
||||
accessTokenExpiry,
|
||||
refreshToken,
|
||||
refreshTokenExpiry,
|
||||
}) => {
|
||||
try {
|
||||
// Always save access tokens to localStorage
|
||||
if (accessToken) {
|
||||
localStorage.setItem(TOKEN_KEYS.ACCESS_TOKEN, accessToken)
|
||||
}
|
||||
if (accessTokenExpiry) {
|
||||
localStorage.setItem(TOKEN_KEYS.ACCESS_TOKEN_EXPIRY, accessTokenExpiry)
|
||||
}
|
||||
if (refreshTokenExpiry) {
|
||||
localStorage.setItem(TOKEN_KEYS.REFRESH_TOKEN_EXPIRY, refreshTokenExpiry)
|
||||
}
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
// On native platforms, also save refresh tokens to Capacitor Preferences
|
||||
try {
|
||||
if (refreshToken) {
|
||||
await Preferences.set({
|
||||
key: TOKEN_KEYS.REFRESH_TOKEN,
|
||||
value: refreshToken,
|
||||
})
|
||||
}
|
||||
if (refreshTokenExpiry) {
|
||||
await Preferences.set({
|
||||
key: TOKEN_KEYS.REFRESH_TOKEN_EXPIRY,
|
||||
value: refreshTokenExpiry,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to save refresh tokens to Capacitor Preferences:',
|
||||
error,
|
||||
)
|
||||
// Don't throw - access token is still saved to localStorage
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving tokens:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get refresh token from storage
|
||||
* Web: Returns null (uses HTTP-only cookies)
|
||||
* Native: Returns from Capacitor Preferences
|
||||
*/
|
||||
export const getRefreshToken = async () => {
|
||||
if (!isNativePlatform()) {
|
||||
return null // Web uses HTTP-only cookies
|
||||
}
|
||||
|
||||
try {
|
||||
const { value } = await Preferences.get({ key: TOKEN_KEYS.REFRESH_TOKEN })
|
||||
return value
|
||||
} catch (error) {
|
||||
console.error('Error reading refresh token from Preferences:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get refresh token expiry from storage
|
||||
* Web: Returns null (uses HTTP-only cookies)
|
||||
* Native: Returns from Capacitor Preferences
|
||||
*/
|
||||
export const getRefreshTokenExpiry = async () => {
|
||||
if (!isNativePlatform()) {
|
||||
return null // Web uses HTTP-only cookies
|
||||
}
|
||||
|
||||
try {
|
||||
const { value } = await Preferences.get({
|
||||
key: TOKEN_KEYS.REFRESH_TOKEN_EXPIRY,
|
||||
})
|
||||
return value
|
||||
} catch (error) {
|
||||
console.error('Error reading refresh token expiry from Preferences:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if refresh token is expired
|
||||
* Web: Returns false (backend handles cookie expiration)
|
||||
* Native: Checks expiry from Capacitor Preferences
|
||||
*/
|
||||
export const isRefreshTokenExpired = async () => {
|
||||
if (!isNativePlatform()) {
|
||||
return false // Web uses cookies, backend handles expiration
|
||||
}
|
||||
|
||||
const expiry = await getRefreshTokenExpiry()
|
||||
if (!expiry) {
|
||||
return true // No expiry means no token
|
||||
}
|
||||
|
||||
try {
|
||||
const expiryDate = new Date(expiry)
|
||||
if (isNaN(expiryDate.getTime())) {
|
||||
console.error('Invalid refresh token expiry date:', expiry)
|
||||
return true // Treat invalid date as expired
|
||||
}
|
||||
return new Date() >= expiryDate
|
||||
} catch (error) {
|
||||
console.error('Error parsing refresh token expiry:', error)
|
||||
return true // Treat parse error as expired
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all tokens from all storage locations
|
||||
* Idempotent - safe to call multiple times
|
||||
*/
|
||||
export const clearAllTokens = async () => {
|
||||
if (clearingTokens) {
|
||||
return // Already clearing, don't run concurrently
|
||||
}
|
||||
|
||||
clearingTokens = true
|
||||
|
||||
try {
|
||||
// Clear localStorage
|
||||
localStorage.removeItem(TOKEN_KEYS.ACCESS_TOKEN)
|
||||
localStorage.removeItem(TOKEN_KEYS.ACCESS_TOKEN_EXPIRY)
|
||||
// Clean up legacy keys
|
||||
localStorage.removeItem('ca_token')
|
||||
localStorage.removeItem('ca_expiration')
|
||||
localStorage.removeItem('access_token')
|
||||
|
||||
// Clear Capacitor Preferences on native
|
||||
if (isNativePlatform()) {
|
||||
try {
|
||||
await Preferences.remove({ key: TOKEN_KEYS.REFRESH_TOKEN })
|
||||
await Preferences.remove({ key: TOKEN_KEYS.REFRESH_TOKEN_EXPIRY })
|
||||
} catch (error) {
|
||||
console.error('Error clearing tokens from Preferences:', error)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error clearing tokens:', error)
|
||||
} finally {
|
||||
clearingTokens = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export platform detection helper
|
||||
*/
|
||||
export const isNative = isNativePlatform
|
||||
66
src/utils/UserHelpers.js
Normal file
66
src/utils/UserHelpers.js
Normal file
@@ -0,0 +1,66 @@
|
||||
// User type detection and permission utilities
|
||||
|
||||
export const USER_TYPES = {
|
||||
PARENT: 0,
|
||||
CHILD: 1,
|
||||
}
|
||||
|
||||
export const isParentUser = (user) => {
|
||||
if (!user) return false
|
||||
return user.userType === USER_TYPES.PARENT && !user.parentUserId
|
||||
}
|
||||
|
||||
export const isChildUser = (user) => {
|
||||
if (!user) return false
|
||||
return user.userType === USER_TYPES.CHILD && user.parentUserId !== null
|
||||
}
|
||||
|
||||
export const canManageChildUsers = (user) => {
|
||||
return isParentUser(user)
|
||||
}
|
||||
|
||||
export const canCreateChores = (user) => {
|
||||
// Both parent and child users can create chores
|
||||
return user && (isParentUser(user) || isChildUser(user))
|
||||
}
|
||||
|
||||
export const canManageCircle = (user) => {
|
||||
// Only parent users can manage circle settings
|
||||
return isParentUser(user)
|
||||
}
|
||||
|
||||
export const canAccessAdminSettings = (user) => {
|
||||
// Only parent users can access admin settings like API tokens, MFA, etc.
|
||||
return isParentUser(user)
|
||||
}
|
||||
|
||||
export const getUserDisplayInfo = (user) => {
|
||||
if (!user) return { displayName: '', username: '', userType: 'unknown' }
|
||||
|
||||
return {
|
||||
displayName: user.displayName || user.username,
|
||||
username: user.username,
|
||||
userType: isParentUser(user) ? 'parent' : isChildUser(user) ? 'child' : 'unknown',
|
||||
parentUserId: user.parentUserId,
|
||||
circleID: user.circleID,
|
||||
}
|
||||
}
|
||||
|
||||
export const getChildUsernameFromCombined = (combinedUsername) => {
|
||||
// Extract child name from format: parent_child
|
||||
const parts = combinedUsername.split('_')
|
||||
if (parts.length >= 2) {
|
||||
return parts.slice(1).join('_') // In case child name contains underscores
|
||||
}
|
||||
return combinedUsername
|
||||
}
|
||||
|
||||
export const getParentUsernameFromCombined = (combinedUsername) => {
|
||||
// Extract parent name from format: parent_child
|
||||
const parts = combinedUsername.split('_')
|
||||
return parts[0] || combinedUsername
|
||||
}
|
||||
|
||||
export const buildChildUsername = (parentUsername, childName) => {
|
||||
return `${parentUsername}_${childName}`
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
import { Box, Button, CircularProgress, Container, Typography } from '@mui/joy'
|
||||
import { useContext, useEffect, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import Logo from '../../Logo'
|
||||
import { apiManager } from '../../utils/TokenManager'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
|
||||
import Cookies from 'js-cookie'
|
||||
import { useRef } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { UserContext } from '../../contexts/UserContext'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { GetUserProfile } from '../../utils/Fetcher'
|
||||
|
||||
const AuthenticationLoading = () => {
|
||||
const { userProfile, setUserProfile } = useContext(UserContext)
|
||||
const { data: userProfile, refetch: refetchUserProfile } = useUserProfile()
|
||||
const Navigate = useNavigate()
|
||||
const hasCalledHandleOAuth2 = useRef(false)
|
||||
const [message, setMessage] = useState('Authenticating')
|
||||
@@ -29,15 +29,16 @@ const AuthenticationLoading = () => {
|
||||
const getUserProfileAndNavigateToHome = () => {
|
||||
GetUserProfile().then(data => {
|
||||
data.json().then(data => {
|
||||
setUserProfile(data.res)
|
||||
// check if redirect url is set in cookie:
|
||||
const redirectUrl = Cookies.get('ca_redirect')
|
||||
if (redirectUrl) {
|
||||
Cookies.remove('ca_redirect')
|
||||
Navigate(redirectUrl)
|
||||
} else {
|
||||
Navigate('/my/chores')
|
||||
}
|
||||
refetchUserProfile().then(() => {
|
||||
// check if redirect url is set in cookie:
|
||||
const redirectUrl = Cookies.get('ca_redirect')
|
||||
if (redirectUrl) {
|
||||
Cookies.remove('ca_redirect')
|
||||
Navigate(redirectUrl)
|
||||
} else {
|
||||
Navigate('/chores')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -57,7 +58,7 @@ const AuthenticationLoading = () => {
|
||||
}
|
||||
|
||||
if (code) {
|
||||
const baseURL = apiManager.getApiURL()
|
||||
const baseURL = apiClient.baseURL
|
||||
fetch(`${baseURL}/auth/${provider}/callback`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -70,8 +71,8 @@ const AuthenticationLoading = () => {
|
||||
}).then(response => {
|
||||
if (response.status === 200) {
|
||||
return response.json().then(data => {
|
||||
localStorage.setItem('ca_token', data.token)
|
||||
localStorage.setItem('ca_expiration', data.expire)
|
||||
localStorage.setItem('token', data.token)
|
||||
localStorage.setItem('token_expiry', data.expire)
|
||||
|
||||
const redirectUrl = Cookies.get('ca_redirect')
|
||||
if (redirectUrl) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// create boilerplate for ResetPasswordView:
|
||||
import LogoSVG from '@/assets/logo.svg'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -8,21 +7,20 @@ 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 Logo from '../../Logo'
|
||||
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',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,19 +77,12 @@ const ForgotPasswordView = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Container
|
||||
component='main'
|
||||
maxWidth='xs'
|
||||
|
||||
// make content center in the middle of the page:
|
||||
>
|
||||
<Container component='main' maxWidth='xs'>
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
@@ -94,120 +97,111 @@ const ForgotPasswordView = () => {
|
||||
padding: 2,
|
||||
borderRadius: '8px',
|
||||
boxShadow: 'md',
|
||||
minHeight: '70vh',
|
||||
justifyContent: 'space-between',
|
||||
justifyItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<img src={LogoSVG} alt='logo' width='128px' height='128px' />
|
||||
{/* <Logo /> */}
|
||||
<Typography level='h2'>
|
||||
Done
|
||||
<span
|
||||
style={{
|
||||
color: '#06b6d4',
|
||||
}}
|
||||
>
|
||||
tick
|
||||
</span>
|
||||
</Typography>
|
||||
</Box>
|
||||
{/* HERE */}
|
||||
<Box sx={{ textAlign: 'center' }}></Box>
|
||||
<Logo />
|
||||
|
||||
<Typography level='h2'>
|
||||
Done
|
||||
<span style={{ color: '#06b6d4' }}>tick</span>
|
||||
</Typography>
|
||||
{resetStatusOk === null && (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className='grid gap-6'>
|
||||
<Typography level='body2' gutterBottom>
|
||||
Enter your email, and we'll send you a link to get into your
|
||||
account.
|
||||
</Typography>
|
||||
<FormControl error={emailError !== null}>
|
||||
<Input
|
||||
placeholder='Email'
|
||||
type='email'
|
||||
variant='soft'
|
||||
fullWidth
|
||||
size='lg'
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
error={emailError !== null}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<FormHelperText>{emailError}</FormHelperText>
|
||||
</FormControl>
|
||||
<Box>
|
||||
<Button
|
||||
variant='solid'
|
||||
size='lg'
|
||||
fullWidth
|
||||
sx={{
|
||||
mb: 1,
|
||||
}}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Reset Password
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='soft'
|
||||
sx={{
|
||||
width: '100%',
|
||||
border: 'moccasin',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
onClick={() => {
|
||||
navigate('/login')
|
||||
}}
|
||||
color='neutral'
|
||||
>
|
||||
Back to Login
|
||||
</Button>
|
||||
</Box>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{resetStatusOk != null && (
|
||||
<>
|
||||
<Box mt={-30}>
|
||||
<Typography level='body-md'>
|
||||
if there is an account associated with the email you entered,
|
||||
you will receive an email with instructions on how to reset
|
||||
your
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography level='body2' sx={{ mb: 3 }}>
|
||||
Enter your email, and we'll send you a link to get into your
|
||||
account.
|
||||
</Typography>
|
||||
|
||||
<Typography level='body2' alignSelf={'start'} mb={1}>
|
||||
Email Address
|
||||
</Typography>
|
||||
<FormControl
|
||||
error={emailError !== null}
|
||||
sx={{ width: '100%', mb: 2 }}
|
||||
>
|
||||
<Input
|
||||
margin='normal'
|
||||
required
|
||||
fullWidth
|
||||
id='email'
|
||||
placeholder='Enter your email address'
|
||||
type='email'
|
||||
name='email'
|
||||
autoComplete='email'
|
||||
autoFocus
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
error={emailError !== null}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<FormHelperText>{emailError}</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
variant='soft'
|
||||
type='submit'
|
||||
fullWidth
|
||||
size='lg'
|
||||
sx={{ position: 'relative', bottom: '0' }}
|
||||
variant='solid'
|
||||
sx={{
|
||||
width: '100%',
|
||||
mt: 3,
|
||||
mb: 2,
|
||||
border: 'moccasin',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Reset Password
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='plain'
|
||||
sx={{
|
||||
width: '100%',
|
||||
mb: 2,
|
||||
border: 'moccasin',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
onClick={() => {
|
||||
navigate('/login')
|
||||
}}
|
||||
color='neutral'
|
||||
>
|
||||
Back to Login
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{resetStatusOk != null && (
|
||||
<>
|
||||
<Typography
|
||||
level='body-md'
|
||||
sx={{ textAlign: 'center', mt: 2, mb: 3 }}
|
||||
>
|
||||
If there is an account associated with the email you entered,
|
||||
you will receive an email with instructions on how to reset your
|
||||
password.
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant='solid'
|
||||
size='lg'
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
navigate('/login')
|
||||
}}
|
||||
>
|
||||
Go to Login
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Snackbar
|
||||
open={resetStatusOk ? resetStatusOk : resetStatusOk === false}
|
||||
autoHideDuration={5000}
|
||||
onClose={() => {
|
||||
if (resetStatusOk) {
|
||||
navigate('/login')
|
||||
}
|
||||
}}
|
||||
>
|
||||
{resetStatusOk
|
||||
? 'Reset email sent, check your email'
|
||||
: 'Reset email failed, try again later'}
|
||||
</Snackbar>
|
||||
</Sheet>
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
import { Preferences } from '@capacitor/preferences'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Input,
|
||||
Sheet,
|
||||
Snackbar,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { Box, Button, Container, Input, Sheet, Typography } from '@mui/joy'
|
||||
import React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { API_URL } from '../../Config'
|
||||
import Logo from '../../Logo'
|
||||
import { apiManager } from '../../utils/TokenManager'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
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,14 +104,18 @@ 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({
|
||||
key: 'customServerUrl',
|
||||
value: serverURL,
|
||||
}).then(() => {
|
||||
apiManager.updateApiURL(serverURL + '/api/v1')
|
||||
apiClient.customServerURL = serverURL + '/api/v1'
|
||||
Navigate('/login')
|
||||
})
|
||||
}}
|
||||
@@ -150,14 +146,6 @@ const LoginSettings = () => {
|
||||
</Button>
|
||||
</Sheet>
|
||||
</Box>
|
||||
<Snackbar
|
||||
open={error !== null}
|
||||
onClose={() => setError(null)}
|
||||
autoHideDuration={3000}
|
||||
message={error}
|
||||
>
|
||||
{error}
|
||||
</Snackbar>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Browser } from '@capacitor/browser'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { Device } from '@capacitor/device'
|
||||
// import { GoogleAuth } from '@codetrix-studio/capacitor-google-auth'
|
||||
import { SocialLogin } from '@capgo/capacitor-social-login'
|
||||
import { Settings } from '@mui/icons-material'
|
||||
import AppleIcon from '@mui/icons-material/Apple'
|
||||
import GoogleIcon from '@mui/icons-material/Google'
|
||||
import {
|
||||
Avatar,
|
||||
@@ -12,29 +15,55 @@ import {
|
||||
IconButton,
|
||||
Input,
|
||||
Sheet,
|
||||
Snackbar,
|
||||
Tab,
|
||||
TabList,
|
||||
TabPanel,
|
||||
Tabs,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import Cookies from 'js-cookie'
|
||||
import React, { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { LoginSocialGoogle } from 'reactjs-social-login'
|
||||
import { GOOGLE_CLIENT_ID, REDIRECT_URL } from '../../Config'
|
||||
import { UserContext } from '../../contexts/UserContext'
|
||||
import { useAuth } from '../../hooks/useAuth.jsx'
|
||||
import Logo from '../../Logo'
|
||||
import { useResource } from '../../queries/ResourceQueries'
|
||||
import { GetUserProfile, login } from '../../utils/Fetcher'
|
||||
import { apiManager } from '../../utils/TokenManager'
|
||||
import { useUserProfile } from '../../queries/UserQueries.jsx'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
import { saveTokens } from '../../utils/TokenStorage'
|
||||
import { buildChildUsername, getUserDisplayInfo } from '../../utils/UserHelpers'
|
||||
import MFAVerificationModal from './MFAVerificationModal'
|
||||
|
||||
const LoginView = () => {
|
||||
const { userProfile, setUserProfile } = React.useContext(UserContext)
|
||||
const [username, setUsername] = React.useState('')
|
||||
const [password, setPassword] = React.useState('')
|
||||
const [error, setError] = React.useState(null)
|
||||
const [mfaModalOpen, setMfaModalOpen] = React.useState(false)
|
||||
const [mfaSessionToken, setMfaSessionToken] = React.useState('')
|
||||
// Use React Query client directly to invalidate the user profile query
|
||||
const queryClient = useQueryClient()
|
||||
// const [userProfile, setUserProfile] = useState(null)
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [mfaModalOpen, setMfaModalOpen] = useState(false)
|
||||
const [mfaSessionToken, setMfaSessionToken] = useState('')
|
||||
const [isAppleSignInSupported, setIsAppleSignInSupported] = useState(false)
|
||||
|
||||
// Child login state
|
||||
const [loginType, setLoginType] = useState('primary')
|
||||
const [parentUsername, setParentUsername] = useState('')
|
||||
const [childName, setChildName] = useState('')
|
||||
|
||||
// Clear fields when switching login modes
|
||||
const handleLoginModeChange = (event, newValue) => {
|
||||
setLoginType(newValue)
|
||||
setUsername('')
|
||||
setParentUsername('')
|
||||
setChildName('')
|
||||
setPassword('')
|
||||
}
|
||||
const { data: resource } = useResource()
|
||||
const { showError } = useNotification()
|
||||
const { isAuthenticated, login: authLogin, user } = useAuth()
|
||||
const Navigate = useNavigate()
|
||||
useEffect(() => {
|
||||
const initializeSocialLogin = async () => {
|
||||
@@ -45,81 +74,145 @@ const LoginView = () => {
|
||||
mode: 'online', // replaces grantOfflineAccess
|
||||
},
|
||||
})
|
||||
|
||||
// Check if Apple Sign In is supported (iOS 13+)
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
try {
|
||||
const deviceInfo = await Device.getInfo()
|
||||
if (deviceInfo.platform === 'ios') {
|
||||
const majorVersion = parseInt(deviceInfo.osVersion.split('.')[0])
|
||||
setIsAppleSignInSupported(majorVersion >= 13)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(
|
||||
'Could not determine device info for Apple Sign In support',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
initializeSocialLogin()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && user) {
|
||||
setUserProfile(user)
|
||||
Navigate('/chores')
|
||||
}
|
||||
}, [isAuthenticated, user, Navigate])
|
||||
const handleSubmit = async e => {
|
||||
e.preventDefault()
|
||||
login(username, password)
|
||||
.then(response => {
|
||||
if (response.status === 200) {
|
||||
return response.json().then(data => {
|
||||
// Check if MFA is required
|
||||
if (data.mfaRequired) {
|
||||
setMfaSessionToken(data.sessionToken)
|
||||
setMfaModalOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Normal login without MFA
|
||||
localStorage.setItem('ca_token', data.token)
|
||||
localStorage.setItem('ca_expiration', data.expire)
|
||||
const redirectUrl = Cookies.get('ca_redirect')
|
||||
if (redirectUrl) {
|
||||
Cookies.remove('ca_redirect')
|
||||
Navigate(redirectUrl)
|
||||
} else {
|
||||
Navigate('/my/chores')
|
||||
}
|
||||
})
|
||||
} else if (response.status === 401) {
|
||||
setError('Wrong username or password')
|
||||
} else {
|
||||
setError('An error occurred, please try again')
|
||||
console.log('Login failed')
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
setError('Unable to communicate with server, please try again')
|
||||
console.log('Login failed', err)
|
||||
})
|
||||
}
|
||||
|
||||
const loggedWithProvider = function (provider, data) {
|
||||
const baseURL = apiManager.getApiURL()
|
||||
|
||||
const getAccessToken = data => {
|
||||
if (data['access_token']) {
|
||||
// data["access_token"] is for Google
|
||||
return data['access_token']
|
||||
} else if (data['accessToken']) {
|
||||
// data["accessToken"] is for Google Capacitor
|
||||
return data['accessToken']['token']
|
||||
// Validation for child login
|
||||
if (loginType === 'sub') {
|
||||
if (!parentUsername.trim()) {
|
||||
showError({
|
||||
title: 'Validation Error',
|
||||
message: 'Primary username is required for sub account login',
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!childName.trim()) {
|
||||
showError({
|
||||
title: 'Validation Error',
|
||||
message: 'Sub account name is required for sub account login',
|
||||
})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (!username.trim()) {
|
||||
showError({
|
||||
title: 'Validation Error',
|
||||
message: 'Username is required',
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(`${baseURL}/auth/${provider}/callback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
if (!password) {
|
||||
showError({
|
||||
title: 'Validation Error',
|
||||
message: 'Password is required',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Determine the actual username to send
|
||||
const actualUsername =
|
||||
loginType === 'sub'
|
||||
? buildChildUsername(parentUsername, childName)
|
||||
: username
|
||||
|
||||
const result = await authLogin({ username: actualUsername, password })
|
||||
|
||||
if (result.success) {
|
||||
if (result.data?.mfaRequired) {
|
||||
setMfaSessionToken(result.data.sessionToken)
|
||||
setMfaModalOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Refetch user profile after successful login
|
||||
queryClient.refetchQueries(['userProfile'])
|
||||
|
||||
const redirectUrl = Cookies.get('ca_redirect')
|
||||
if (redirectUrl && redirectUrl !== '/') {
|
||||
Cookies.remove('ca_redirect')
|
||||
Navigate(redirectUrl)
|
||||
} else {
|
||||
Navigate('/chores')
|
||||
}
|
||||
} else {
|
||||
showError({
|
||||
title: 'Login Failed',
|
||||
message: result.error || 'An error occurred, please try again',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const loggedWithProvider = async function (provider, data) {
|
||||
const getAccessToken = data => {
|
||||
if (data['access_token']) {
|
||||
return data['access_token']
|
||||
} else if (data['accessToken']) {
|
||||
return data['accessToken']['token']
|
||||
} else if (data['response'] && data['response']['id_token']) {
|
||||
return data['response']['id_token']
|
||||
} else if (data['id_token']) {
|
||||
return data['id_token']
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.post(`/auth/${provider}/callback`, {
|
||||
provider: provider,
|
||||
token: getAccessToken(data),
|
||||
data: data,
|
||||
}),
|
||||
}).then(response => {
|
||||
if (response.status === 200) {
|
||||
return response.json().then(data => {
|
||||
// Check if MFA is required for OAuth login
|
||||
if (data.mfaRequired) {
|
||||
setMfaSessionToken(data.sessionToken)
|
||||
setMfaModalOpen(true)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
// Normal OAuth login without MFA
|
||||
localStorage.setItem('ca_token', data.token)
|
||||
localStorage.setItem('ca_expiration', data.expire)
|
||||
if (response.ok) {
|
||||
const responseData = await response.json()
|
||||
|
||||
// Check if MFA is required for OAuth login
|
||||
if (responseData.mfaRequired) {
|
||||
setMfaSessionToken(responseData.sessionToken)
|
||||
setMfaModalOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Use new auth system to handle token storage
|
||||
if (responseData.token || responseData.access_token) {
|
||||
const token = responseData.token || responseData.access_token
|
||||
const expiry = responseData.expire || responseData.access_token_expiry
|
||||
|
||||
// Save all tokens including refresh tokens
|
||||
await saveTokens({
|
||||
accessToken: token,
|
||||
accessTokenExpiry: expiry,
|
||||
refreshToken: responseData.refresh_token,
|
||||
refreshTokenExpiry: responseData.refresh_token_expiry,
|
||||
})
|
||||
|
||||
// Refetch user profile after successful OAuth login
|
||||
queryClient.invalidateQueries(['userProfile'])
|
||||
|
||||
const redirectUrl = Cookies.get('ca_redirect')
|
||||
if (redirectUrl) {
|
||||
@@ -128,46 +221,65 @@ const LoginView = () => {
|
||||
} else {
|
||||
getUserProfileAndNavigateToHome()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const providerName = provider === 'apple' ? 'Apple' : 'Google'
|
||||
showError({
|
||||
title: `${providerName} Login Failed`,
|
||||
message: `Couldn't log in with ${providerName}, please try again`,
|
||||
})
|
||||
}
|
||||
return response.json().then(() => {
|
||||
setError("Couldn't log in with Google, please try again")
|
||||
} catch (error) {
|
||||
const providerName = provider === 'apple' ? 'Apple' : 'Google'
|
||||
showError({
|
||||
title: `${providerName} Login Error`,
|
||||
message: 'Network error occurred, please try again',
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
const getUserProfileAndNavigateToHome = () => {
|
||||
GetUserProfile().then(data => {
|
||||
data.json().then(data => {
|
||||
setUserProfile(data.res)
|
||||
// check if redirect url is set in cookie:
|
||||
const redirectUrl = Cookies.get('ca_redirect')
|
||||
if (redirectUrl) {
|
||||
Cookies.remove('ca_redirect')
|
||||
Navigate(redirectUrl)
|
||||
} else {
|
||||
Navigate('/my/chores')
|
||||
}
|
||||
})
|
||||
// Refetch user profile after login using React Query
|
||||
queryClient.invalidateQueries(['userProfile']).then(() => {
|
||||
// check if redirect url is set in cookie:
|
||||
const redirectUrl = Cookies.get('ca_redirect')
|
||||
if (redirectUrl) {
|
||||
Cookies.remove('ca_redirect')
|
||||
Navigate(redirectUrl)
|
||||
} else {
|
||||
Navigate('/chores')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleMFASuccess = data => {
|
||||
localStorage.setItem('ca_token', data.token)
|
||||
localStorage.setItem('ca_expiration', data.expire)
|
||||
const handleMFASuccess = async data => {
|
||||
// Save all tokens including refresh tokens
|
||||
await saveTokens({
|
||||
accessToken: data.token,
|
||||
accessTokenExpiry: data.expire,
|
||||
refreshToken: data.refresh_token,
|
||||
refreshTokenExpiry: data.refresh_token_expiry,
|
||||
})
|
||||
|
||||
setMfaModalOpen(false)
|
||||
setMfaSessionToken('')
|
||||
|
||||
// Refetch user profile after MFA success
|
||||
queryClient.invalidateQueries(['userProfile'])
|
||||
|
||||
const redirectUrl = Cookies.get('ca_redirect')
|
||||
if (redirectUrl) {
|
||||
Cookies.remove('ca_redirect')
|
||||
Navigate(redirectUrl)
|
||||
} else {
|
||||
Navigate('/my/chores')
|
||||
Navigate('/chores')
|
||||
}
|
||||
}
|
||||
|
||||
const handleMFAError = errorMessage => {
|
||||
setError(errorMessage)
|
||||
showError({
|
||||
title: 'Two-Factor Authentication Failed',
|
||||
message: errorMessage,
|
||||
})
|
||||
}
|
||||
|
||||
const handleMFAClose = () => {
|
||||
@@ -185,19 +297,52 @@ const LoginView = () => {
|
||||
return randomState
|
||||
}
|
||||
|
||||
const handleAuthentikLogin = () => {
|
||||
const handleAuthentikLogin = async () => {
|
||||
const authentikAuthorizeUrl = resource?.identity_provider?.auth_url
|
||||
const state = generateRandomState()
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: resource?.identity_provider?.client_id,
|
||||
redirect_uri: `${window.location.origin}/auth/oauth2`,
|
||||
scope: 'openid profile email', // Your scopes
|
||||
state: generateRandomState(),
|
||||
})
|
||||
console.log('redirect', `${authentikAuthorizeUrl}?${params.toString()}`)
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
// For mobile devices, use a custom URL scheme for the redirect
|
||||
const redirectUri = 'donetick://auth/oauth2'
|
||||
|
||||
window.location.href = `${authentikAuthorizeUrl}?${params.toString()}`
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: resource?.identity_provider?.client_id,
|
||||
redirect_uri: redirectUri,
|
||||
scope: 'openid profile email',
|
||||
state: state,
|
||||
})
|
||||
|
||||
const authUrl = `${authentikAuthorizeUrl}?${params.toString()}`
|
||||
console.log('Opening OAuth in browser:', authUrl)
|
||||
|
||||
try {
|
||||
// Open OAuth flow in system browser
|
||||
await Browser.open({ url: authUrl })
|
||||
|
||||
// Note: The OAuth callback will be handled by deep link handling
|
||||
// You'll need to implement deep link handling to catch the redirect
|
||||
// and extract the authorization code
|
||||
} catch (error) {
|
||||
console.error('Failed to open OAuth browser:', error)
|
||||
showError({
|
||||
title: 'OAuth Error',
|
||||
message: 'Failed to open authentication browser',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// For web platforms, use the current approach
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: resource?.identity_provider?.client_id,
|
||||
redirect_uri: `${window.location.origin}/auth/oauth2`,
|
||||
scope: 'openid profile email',
|
||||
state: state,
|
||||
})
|
||||
|
||||
console.log('redirect', `${authentikAuthorizeUrl}?${params.toString()}`)
|
||||
window.location.href = `${authentikAuthorizeUrl}?${params.toString()}`
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -258,6 +403,16 @@ const LoginView = () => {
|
||||
<Typography level='body-md' alignSelf={'center'}>
|
||||
Welcome back,{' '}
|
||||
{userProfile?.displayName || userProfile?.username}
|
||||
{getUserDisplayInfo(userProfile).userType === 'child' && (
|
||||
<Typography
|
||||
component='span'
|
||||
level='body-xs'
|
||||
color='neutral'
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
(Sub Account)
|
||||
</Typography>
|
||||
)}
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
@@ -274,7 +429,6 @@ const LoginView = () => {
|
||||
type='submit'
|
||||
fullWidth
|
||||
size='lg'
|
||||
q
|
||||
variant='plain'
|
||||
sx={{
|
||||
width: '100%',
|
||||
@@ -283,11 +437,7 @@ const LoginView = () => {
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
onClick={() => {
|
||||
setUserProfile(null)
|
||||
localStorage.removeItem('ca_token')
|
||||
localStorage.removeItem('ca_expiration')
|
||||
// go to login page:
|
||||
window.location.href = '/login'
|
||||
apiClient.handleLogout()
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
@@ -296,27 +446,109 @@ const LoginView = () => {
|
||||
)}
|
||||
{!userProfile && (
|
||||
<>
|
||||
<Typography level='body2'>
|
||||
<Typography level='body2' sx={{ mb: 3 }}>
|
||||
Sign in to your account to continue
|
||||
</Typography>
|
||||
<Typography level='body2' alignSelf={'start'} mt={4}>
|
||||
Username
|
||||
</Typography>
|
||||
<Input
|
||||
margin='normal'
|
||||
required
|
||||
fullWidth
|
||||
id='email'
|
||||
label='Email Address'
|
||||
name='email'
|
||||
autoComplete='email'
|
||||
autoFocus
|
||||
value={username}
|
||||
onChange={e => {
|
||||
setUsername(e.target.value)
|
||||
}}
|
||||
/>
|
||||
<Typography level='body2' alignSelf={'start'}>
|
||||
|
||||
{/* Login Type Tabs */}
|
||||
<Tabs
|
||||
value={loginType}
|
||||
onChange={handleLoginModeChange}
|
||||
sx={{ width: '100%', mb: 3 }}
|
||||
>
|
||||
<TabList
|
||||
sx={{
|
||||
width: '100%',
|
||||
p: 0.5,
|
||||
borderBottom: 'none',
|
||||
boxShadow: 'none',
|
||||
'&::after': {
|
||||
display: 'none',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tab
|
||||
value='primary'
|
||||
variant='plain'
|
||||
sx={{
|
||||
flex: 1,
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Primary Account
|
||||
</Tab>
|
||||
<Tab
|
||||
value='sub'
|
||||
variant='plain'
|
||||
sx={{
|
||||
flex: 1,
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Sub Account
|
||||
</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanel value='primary' sx={{ p: 0, mt: 2 }}>
|
||||
<Typography level='body2' alignSelf={'start'} mb={1}>
|
||||
Username
|
||||
</Typography>
|
||||
<Input
|
||||
margin='normal'
|
||||
required
|
||||
fullWidth
|
||||
id='email'
|
||||
label='Email Address'
|
||||
name='email'
|
||||
autoComplete='email'
|
||||
autoFocus
|
||||
value={username}
|
||||
onChange={e => {
|
||||
setUsername(e.target.value)
|
||||
}}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value='sub' sx={{ p: 0, mt: 2 }}>
|
||||
<Typography level='body2' alignSelf={'start'} mb={1}>
|
||||
Primary Account Username
|
||||
</Typography>
|
||||
<Input
|
||||
margin='normal'
|
||||
required
|
||||
fullWidth
|
||||
id='parentUsername'
|
||||
name='parentUsername'
|
||||
placeholder='Enter primary account username'
|
||||
autoFocus
|
||||
value={parentUsername}
|
||||
onChange={e => {
|
||||
setParentUsername(e.target.value)
|
||||
}}
|
||||
/>
|
||||
<Typography level='body2' alignSelf={'start'} mt={1} mb={1}>
|
||||
Sub Account Username
|
||||
</Typography>
|
||||
<Input
|
||||
margin='normal'
|
||||
required
|
||||
fullWidth
|
||||
id='childName'
|
||||
name='childName'
|
||||
placeholder='Enter sub account name'
|
||||
value={childName}
|
||||
onChange={e => {
|
||||
setChildName(e.target.value)
|
||||
}}
|
||||
/>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
<Typography level='body2' alignSelf={'start'} mb={1}>
|
||||
Password:
|
||||
</Typography>
|
||||
<Input
|
||||
@@ -327,6 +559,7 @@ const LoginView = () => {
|
||||
label='Password'
|
||||
type='password'
|
||||
id='password'
|
||||
autoComplete='password'
|
||||
value={password}
|
||||
onChange={e => {
|
||||
setPassword(e.target.value)
|
||||
@@ -347,13 +580,12 @@ const LoginView = () => {
|
||||
}}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Sign In
|
||||
{loginType === 'sub' ? 'Sign In as Sub Account' : 'Sign In'}
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
fullWidth
|
||||
size='lg'
|
||||
q
|
||||
variant='plain'
|
||||
sx={{
|
||||
width: '100%',
|
||||
@@ -382,8 +614,12 @@ const LoginView = () => {
|
||||
onResolve={({ provider, data }) => {
|
||||
loggedWithProvider(provider, data)
|
||||
}}
|
||||
onReject={err => {
|
||||
setError("Couldn't log in with Google, please try again")
|
||||
onReject={() => {
|
||||
showError({
|
||||
title: 'Google Login Failed',
|
||||
message:
|
||||
"Couldn't log in with Google, please try again",
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
@@ -405,8 +641,50 @@ const LoginView = () => {
|
||||
</div>
|
||||
</Button>
|
||||
</LoginSocialGoogle>
|
||||
|
||||
{/* <Button
|
||||
fullWidth
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='lg'
|
||||
sx={{
|
||||
mt: 1,
|
||||
mb: 1,
|
||||
backgroundColor: 'black',
|
||||
color: 'white',
|
||||
'&:hover': {
|
||||
backgroundColor: '#333',
|
||||
},
|
||||
}}
|
||||
onClick={() => {
|
||||
SocialLogin.login({
|
||||
provider: 'apple',
|
||||
options: {
|
||||
scopes: ['email', 'name'],
|
||||
},
|
||||
})
|
||||
.then(user => {
|
||||
console.log('Apple user', user)
|
||||
loggedWithProvider('apple', user)
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Apple login error:', error)
|
||||
showError({
|
||||
title: 'Apple Login Failed',
|
||||
message:
|
||||
"Couldn't log in with Apple, please try again",
|
||||
})
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className='flex gap-2'>
|
||||
<AppleIcon />
|
||||
Continue with Apple
|
||||
</div>
|
||||
</Button> */}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{Capacitor.isNativePlatform() && (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Button
|
||||
@@ -415,16 +693,6 @@ const LoginView = () => {
|
||||
size='lg'
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
onClick={() => {
|
||||
// GoogleAuth.initialize({
|
||||
// clientId: import.meta.env.VITE_APP_GOOGLE_CLIENT_ID,
|
||||
// scopes: ['profile', 'email', 'openid'],
|
||||
// grantOfflineAccess: true,
|
||||
// })
|
||||
// GoogleAuth.signIn().then(user => {
|
||||
// console.log('Google user', user)
|
||||
// loggedWithProvider('google', user.authentication)
|
||||
// })
|
||||
|
||||
SocialLogin.login({
|
||||
provider: 'google',
|
||||
options: { scopes: ['profile', 'email', 'openid'] },
|
||||
@@ -439,6 +707,45 @@ const LoginView = () => {
|
||||
Continue with Google
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* Apple Sign In Button for Native Platforms */}
|
||||
{isAppleSignInSupported && (
|
||||
<Button
|
||||
fullWidth
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='lg'
|
||||
sx={{
|
||||
mb: 1,
|
||||
}}
|
||||
onClick={() => {
|
||||
SocialLogin.login({
|
||||
provider: 'apple',
|
||||
options: {
|
||||
scopes: ['email', 'name'],
|
||||
state: 'random_string',
|
||||
},
|
||||
})
|
||||
.then(user => {
|
||||
console.log('Apple user', user)
|
||||
loggedWithProvider('apple', user)
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Apple login error:', error)
|
||||
showError({
|
||||
title: 'Apple Login Failed',
|
||||
message:
|
||||
"Couldn't log in with Apple, please try again",
|
||||
})
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className='flex gap-2'>
|
||||
<AppleIcon />
|
||||
Continue with Apple
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
@@ -467,16 +774,31 @@ const LoginView = () => {
|
||||
>
|
||||
Create new account
|
||||
</Button>
|
||||
|
||||
<Box
|
||||
sx={{ display: 'flex', justifyContent: 'center', gap: 2, mt: 2 }}
|
||||
>
|
||||
<Button
|
||||
variant='plain'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
window.open('https://donetick.com/privacy', '_blank')
|
||||
}}
|
||||
>
|
||||
Privacy Policy
|
||||
</Button>
|
||||
<Button
|
||||
variant='plain'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
window.open('https://donetick.com/terms', '_blank')
|
||||
}}
|
||||
>
|
||||
Terms of Use
|
||||
</Button>
|
||||
</Box>
|
||||
</Sheet>
|
||||
</Box>
|
||||
<Snackbar
|
||||
open={error !== null}
|
||||
onClose={() => setError(null)}
|
||||
autoHideDuration={3000}
|
||||
message={error}
|
||||
>
|
||||
{error}
|
||||
</Snackbar>
|
||||
|
||||
<MFAVerificationModal
|
||||
open={mfaModalOpen}
|
||||
|
||||
@@ -5,13 +5,13 @@ import {
|
||||
Button,
|
||||
Input,
|
||||
Link,
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalDialog,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
import { VerifyMFA } from '../../utils/Fetcher'
|
||||
|
||||
const MFAVerificationModal = ({
|
||||
@@ -25,7 +25,7 @@ const MFAVerificationModal = ({
|
||||
const [isBackupCode, setIsBackupCode] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const handleVerify = async () => {
|
||||
if (!verificationCode.trim()) {
|
||||
setError('Please enter a verification code')
|
||||
@@ -70,90 +70,88 @@ const MFAVerificationModal = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={handleClose}>
|
||||
<ModalDialog size='sm' sx={{ maxWidth: 400 }}>
|
||||
<ModalClose />
|
||||
<ResponsiveModal open={open} onClose={handleClose} size='sm'>
|
||||
<ModalClose />
|
||||
|
||||
<Box className='mb-4 text-center'>
|
||||
<Security sx={{ fontSize: 48, color: 'primary.main', mb: 2 }} />
|
||||
<Typography level='h4' sx={{ mb: 1 }}>
|
||||
Two-Factor Authentication
|
||||
</Typography>
|
||||
<Typography level='body-md' sx={{ color: 'text.secondary' }}>
|
||||
Enter the verification code from your authenticator app
|
||||
<Box className='mb-4 text-center'>
|
||||
<Security sx={{ fontSize: 48, color: 'primary.main', mb: 2 }} />
|
||||
<Typography level='h4' sx={{ mb: 1 }}>
|
||||
Two-Factor Authentication
|
||||
</Typography>
|
||||
<Typography level='body-md' sx={{ color: 'text.secondary' }}>
|
||||
Enter the verification code from your authenticator app
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Stack spacing={3}>
|
||||
<Box>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
{isBackupCode ? 'Backup Code' : 'Verification Code'}
|
||||
</Typography>
|
||||
<Input
|
||||
placeholder={
|
||||
isBackupCode ? 'Enter backup code' : 'Enter 6-digit code'
|
||||
}
|
||||
value={verificationCode}
|
||||
onChange={e => setVerificationCode(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
fontSize: '1.1em',
|
||||
letterSpacing: isBackupCode ? 'normal' : '0.1em',
|
||||
}}
|
||||
slotProps={{
|
||||
input: {
|
||||
maxLength: isBackupCode ? 50 : 6,
|
||||
pattern: isBackupCode ? undefined : '[0-9]*',
|
||||
},
|
||||
}}
|
||||
startDecorator={<Smartphone />}
|
||||
autoFocus
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Stack spacing={3}>
|
||||
<Box>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
{isBackupCode ? 'Backup Code' : 'Verification Code'}
|
||||
</Typography>
|
||||
<Input
|
||||
placeholder={
|
||||
isBackupCode ? 'Enter backup code' : 'Enter 6-digit code'
|
||||
}
|
||||
value={verificationCode}
|
||||
onChange={e => setVerificationCode(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
fontSize: '1.1em',
|
||||
letterSpacing: isBackupCode ? 'normal' : '0.1em',
|
||||
}}
|
||||
slotProps={{
|
||||
input: {
|
||||
maxLength: isBackupCode ? 50 : 6,
|
||||
pattern: isBackupCode ? undefined : '[0-9]*',
|
||||
},
|
||||
}}
|
||||
startDecorator={<Smartphone />}
|
||||
autoFocus
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert color='danger' size='sm'>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
color='primary'
|
||||
loading={loading}
|
||||
onClick={handleVerify}
|
||||
disabled={!verificationCode.trim()}
|
||||
size='lg'
|
||||
>
|
||||
Verify & Sign In
|
||||
</Button>
|
||||
|
||||
<Box className='text-center'>
|
||||
<Link
|
||||
component='button'
|
||||
type='button'
|
||||
onClick={() => {
|
||||
setIsBackupCode(!isBackupCode)
|
||||
setVerificationCode('')
|
||||
setError('')
|
||||
}}
|
||||
sx={{ fontSize: 'sm' }}
|
||||
>
|
||||
{isBackupCode
|
||||
? 'Use authenticator app instead'
|
||||
: "Can't access your authenticator? Use a backup code"}
|
||||
</Link>
|
||||
</Box>
|
||||
|
||||
<Alert color='neutral' size='sm'>
|
||||
<Typography level='body-xs'>
|
||||
Having trouble? Make sure your authenticator app is synced and try
|
||||
again. Each backup code can only be used once.
|
||||
</Typography>
|
||||
{error && (
|
||||
<Alert color='danger' size='sm'>
|
||||
{error}
|
||||
</Alert>
|
||||
</Stack>
|
||||
</ModalDialog>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
<Button
|
||||
color='primary'
|
||||
loading={loading}
|
||||
onClick={handleVerify}
|
||||
disabled={!verificationCode.trim()}
|
||||
size='lg'
|
||||
>
|
||||
Verify & Sign In
|
||||
</Button>
|
||||
|
||||
<Box className='text-center'>
|
||||
<Link
|
||||
component='button'
|
||||
type='button'
|
||||
onClick={() => {
|
||||
setIsBackupCode(!isBackupCode)
|
||||
setVerificationCode('')
|
||||
setError('')
|
||||
}}
|
||||
sx={{ fontSize: 'sm' }}
|
||||
>
|
||||
{isBackupCode
|
||||
? 'Use authenticator app instead'
|
||||
: "Can't access your authenticator? Use a backup code"}
|
||||
</Link>
|
||||
</Box>
|
||||
|
||||
<Alert color='neutral' size='sm'>
|
||||
<Typography level='body-xs'>
|
||||
Having trouble? Make sure your authenticator app is synced and try
|
||||
again. Each backup code can only be used once.
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Stack>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,38 +7,38 @@ import {
|
||||
FormHelperText,
|
||||
Input,
|
||||
Sheet,
|
||||
Snackbar,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import Logo from '../../Logo'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { login, signUp } from '../../utils/Fetcher'
|
||||
|
||||
const SignupView = () => {
|
||||
const [username, setUsername] = React.useState('')
|
||||
const [password, setPassword] = React.useState('')
|
||||
const Navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const [displayName, setDisplayName] = React.useState('')
|
||||
const [email, setEmail] = React.useState('')
|
||||
const [usernameError, setUsernameError] = React.useState('')
|
||||
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) {
|
||||
response.json().then(res => {
|
||||
localStorage.setItem('ca_token', res.token)
|
||||
localStorage.setItem('ca_expiration', res.expire)
|
||||
setTimeout(() => {
|
||||
// TODO: not sure if there is a race condition here
|
||||
// but on first sign up it renavigates to login.
|
||||
Navigate('/my/chores')
|
||||
}, 500)
|
||||
localStorage.setItem('token', res.token)
|
||||
localStorage.setItem('token_expiry', res.expire)
|
||||
|
||||
// Invalidate user profile queries to ensure fresh data
|
||||
queryClient.invalidateQueries(['userProfile'])
|
||||
|
||||
Navigate('/chores')
|
||||
})
|
||||
} else {
|
||||
console.log('Login failed', response)
|
||||
@@ -85,10 +85,10 @@ const SignupView = () => {
|
||||
isValid = false
|
||||
}
|
||||
|
||||
// username should only contain letters , numbers , dot and dash:
|
||||
if (!/^[a-zA-Z0-9.-]+$/.test(username)) {
|
||||
// username should only contain lowercase letters, dot and dash:
|
||||
if (!/^[a-z.-]+$/.test(username)) {
|
||||
setUsernameError(
|
||||
'Username can only contain letters, numbers, dot and dash',
|
||||
'Username can only contain lowercase letters, dot and dash',
|
||||
)
|
||||
isValid = false
|
||||
}
|
||||
@@ -104,11 +104,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',
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -240,12 +246,18 @@ const SignupView = () => {
|
||||
<FormControl error={displayNameError}>
|
||||
<FormHelperText>{displayNameError}</FormHelperText>
|
||||
</FormControl>
|
||||
<Typography
|
||||
level='body2'
|
||||
sx={{ mt: 2, mb: 1, textAlign: 'center', color: 'text.secondary' }}
|
||||
>
|
||||
By signing up, you agree to our Terms of Service and Privacy Policy
|
||||
</Typography>
|
||||
<Button
|
||||
// type='submit'
|
||||
size='lg'
|
||||
fullWidth
|
||||
variant='solid'
|
||||
sx={{ mt: 3, mb: 1 }}
|
||||
sx={{ mt: 1, mb: 1 }}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Sign Up
|
||||
@@ -262,16 +274,31 @@ const SignupView = () => {
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
|
||||
<Box
|
||||
sx={{ display: 'flex', justifyContent: 'center', gap: 2, mt: 2 }}
|
||||
>
|
||||
<Button
|
||||
variant='plain'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
window.open('https://donetick.com/privacy-policy', '_blank')
|
||||
}}
|
||||
>
|
||||
Privacy Policy
|
||||
</Button>
|
||||
<Button
|
||||
variant='plain'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
window.open('https://donetick.com/terms', '_blank')
|
||||
}}
|
||||
>
|
||||
Terms of Use
|
||||
</Button>
|
||||
</Box>
|
||||
</Sheet>
|
||||
</Box>
|
||||
<Snackbar
|
||||
open={error !== null}
|
||||
onClose={() => setError(null)}
|
||||
autoHideDuration={5000}
|
||||
message={error}
|
||||
>
|
||||
{error}
|
||||
</Snackbar>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,13 +7,13 @@ import {
|
||||
FormHelperText,
|
||||
Input,
|
||||
Sheet,
|
||||
Snackbar,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
|
||||
import Logo from '../../Logo'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { ChangePassword } from '../../utils/Fetcher'
|
||||
|
||||
const UpdatePasswordView = () => {
|
||||
@@ -24,8 +24,7 @@ const UpdatePasswordView = () => {
|
||||
const [passworConfirmationError, setPasswordConfirmationError] =
|
||||
useState(null)
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
const [updateStatusOk, setUpdateStatusOk] = useState(null)
|
||||
const { showError, showNotification } = useNotification()
|
||||
|
||||
const verifiticationCode = searchParams.get('c')
|
||||
|
||||
@@ -55,16 +54,27 @@ const UpdatePasswordView = () => {
|
||||
const response = await ChangePassword(verifiticationCode, password)
|
||||
|
||||
if (response.ok) {
|
||||
setUpdateStatusOk(true)
|
||||
showNotification({
|
||||
type: 'success',
|
||||
title: 'Password Updated',
|
||||
message:
|
||||
'Your password has been updated successfully. Redirecting to login...',
|
||||
})
|
||||
// wait 3 seconds and then redirect to login:
|
||||
setTimeout(() => {
|
||||
navigate('/login')
|
||||
}, 3000)
|
||||
} else {
|
||||
setUpdateStatusOk(false)
|
||||
showError({
|
||||
title: 'Password Update Failed',
|
||||
message: 'Failed to update password, please try again later',
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
setUpdateStatusOk(false)
|
||||
showError({
|
||||
title: 'Password Update Failed',
|
||||
message: 'Failed to update password, please try again later',
|
||||
})
|
||||
}
|
||||
}
|
||||
return (
|
||||
@@ -169,15 +179,6 @@ const UpdatePasswordView = () => {
|
||||
</Button>
|
||||
</Sheet>
|
||||
</Box>
|
||||
<Snackbar
|
||||
open={updateStatusOk === false}
|
||||
autoHideDuration={6000}
|
||||
onClose={() => {
|
||||
setUpdateStatusOk(null)
|
||||
}}
|
||||
>
|
||||
Password update failed, try again later
|
||||
</Snackbar>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
Archive,
|
||||
CalendarMonth,
|
||||
CancelScheduleSend,
|
||||
Check,
|
||||
@@ -6,11 +7,16 @@ import {
|
||||
CloseFullscreen,
|
||||
Edit,
|
||||
History,
|
||||
HourglassEmpty,
|
||||
LowPriority,
|
||||
OpenInFull,
|
||||
PeopleAlt,
|
||||
Person,
|
||||
PlayArrow,
|
||||
SwitchAccessShortcut,
|
||||
ThumbDown,
|
||||
ThumbUp,
|
||||
Unarchive,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
@@ -33,26 +39,40 @@ 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'
|
||||
|
||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||
import { useChoreDetails } from '../../queries/ChoreQueries.jsx'
|
||||
import { useCircleMembers } from '../../queries/UserQueries.jsx'
|
||||
import { notInCompletionWindow } from '../../utils/Chores.jsx'
|
||||
import {
|
||||
useChoreTimer,
|
||||
useDeleteTimeSession,
|
||||
usePauseChore,
|
||||
useResetChoreTimer,
|
||||
useStartChore,
|
||||
} from '../../queries/TimeQueries'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
|
||||
import { ChoreStatus, notInCompletionWindow } from '../../utils/Chores.jsx'
|
||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||
import {
|
||||
ApproveChore,
|
||||
GetChoreDetailById,
|
||||
MarkChoreComplete,
|
||||
RejectChore,
|
||||
SkipChore,
|
||||
UnArchiveChore,
|
||||
UpdateChorePriority,
|
||||
} from '../../utils/Fetcher'
|
||||
import Priorities from '../../utils/Priorities'
|
||||
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import LoadingComponent from '../components/Loading.jsx'
|
||||
import RichTextEditor from '../components/RichTextEditor.jsx'
|
||||
import SubTasks from '../components/SubTask.jsx'
|
||||
import TimePassedCard from './TimePassedCard.jsx'
|
||||
import TimerSplitButton from './TimerSplitButton.jsx'
|
||||
|
||||
const ChoreView = () => {
|
||||
const [chore, setChore] = useState({})
|
||||
@@ -61,6 +81,7 @@ const ChoreView = () => {
|
||||
const [infoCards, setInfoCards] = useState([])
|
||||
const { choreId } = useParams()
|
||||
const [note, setNote] = useState(null)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
@@ -68,21 +89,25 @@ const ChoreView = () => {
|
||||
const [timeoutId, setTimeoutId] = useState(null)
|
||||
const [secondsLeftToCancel, setSecondsLeftToCancel] = useState(null)
|
||||
const [completedDate, setCompletedDate] = useState(null)
|
||||
const [confirmModelConfig, setConfirmModelConfig] = useState({})
|
||||
const [confirmModelConfig, setConfirmModelConfig] = useState({
|
||||
isOpen: false,
|
||||
})
|
||||
const [chorePriority, setChorePriority] = useState(null)
|
||||
const [isDescriptionOpen, setIsDescriptionOpen] = useState(false)
|
||||
const {
|
||||
data: circleMembersData,
|
||||
isLoading: isCircleMembersLoading,
|
||||
handleRefetch: handleCircleMembersRefetch,
|
||||
} = useCircleMembers()
|
||||
const [timerActionConfig, setTimerActionConfig] = useState({ isOpen: false })
|
||||
const { data: circleMembersData, isLoading: isCircleMembersLoading } =
|
||||
useCircleMembers()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const { impersonatedUser } = useImpersonateUser()
|
||||
|
||||
const {
|
||||
data: choreData,
|
||||
isLoading: isChoreLoading,
|
||||
refetch: refetchChore,
|
||||
} = useChoreDetails(choreId)
|
||||
const { data: choreData, isLoading: isChoreLoading } =
|
||||
useChoreDetails(choreId)
|
||||
|
||||
const startChore = useStartChore()
|
||||
const pauseChore = usePauseChore()
|
||||
const deleteTimeSession = useDeleteTimeSession()
|
||||
const resetChoreTimer = useResetChoreTimer()
|
||||
const { data: choreTimer } = useChoreTimer(choreId)
|
||||
|
||||
useEffect(() => {
|
||||
if (!choreData || !choreData.res || !circleMembersData) {
|
||||
@@ -107,8 +132,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'])
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -120,11 +147,13 @@ const ChoreView = () => {
|
||||
icon: <PeopleAlt />,
|
||||
title: 'Assignment',
|
||||
text: `Assigned: ${
|
||||
performers.find(p => p.id === chore.assignedTo)?.displayName || 'N/A'
|
||||
performers.find(p => p.userId === chore.assignedTo)?.displayName ||
|
||||
'N/A'
|
||||
}`,
|
||||
subtext: ` Last: ${
|
||||
chore.lastCompletedDate
|
||||
? performers.find(p => p.id === chore.lastCompletedBy)?.displayName
|
||||
? performers.find(p => p.userId === chore.lastCompletedBy)
|
||||
?.displayName
|
||||
: '--'
|
||||
}`,
|
||||
},
|
||||
@@ -152,7 +181,8 @@ const ChoreView = () => {
|
||||
icon: <Person />,
|
||||
title: 'Details',
|
||||
subtext: `Created By: ${
|
||||
performers.find(p => p.id === chore.createdBy)?.displayName || 'N/A'
|
||||
performers.find(p => p.userId === chore.createdBy)?.displayName ||
|
||||
'N/A'
|
||||
}`,
|
||||
},
|
||||
]
|
||||
@@ -195,6 +225,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,10 +248,145 @@ const ChoreView = () => {
|
||||
response.json().then(data => {
|
||||
const newChore = data.res
|
||||
setChore(newChore)
|
||||
// Invalidate chores cache to refetch data
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
const handleChoreStart = () => {
|
||||
startChore.mutate(choreId, {
|
||||
onSuccess: data => {
|
||||
const newChore = {
|
||||
...chore,
|
||||
...data.res,
|
||||
}
|
||||
setChore(newChore)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleChorePause = () => {
|
||||
pauseChore.mutate(choreId, {
|
||||
onSuccess: data => {
|
||||
const newChore = {
|
||||
...chore,
|
||||
...data.res,
|
||||
}
|
||||
setChore(newChore)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleResetTimer = () => {
|
||||
setTimerActionConfig({
|
||||
isOpen: true,
|
||||
title: 'Reset Timer',
|
||||
message:
|
||||
'Are you sure you want to reset the timer? This will clear all time records since you started the task.',
|
||||
confirmText: 'Reset Timer',
|
||||
cancelText: 'Cancel',
|
||||
onClose: confirmed => {
|
||||
if (confirmed) {
|
||||
resetChoreTimer.mutate(choreId, {
|
||||
onSuccess: data => {
|
||||
const newChore = {
|
||||
...chore,
|
||||
...data.res,
|
||||
}
|
||||
setChore(newChore)
|
||||
},
|
||||
})
|
||||
}
|
||||
setTimerActionConfig({})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleClearAllTime = () => {
|
||||
setTimerActionConfig({
|
||||
isOpen: true,
|
||||
title: 'Clear All Time Records',
|
||||
message:
|
||||
'This will permanently delete all timers for this task and set it back to "not started".',
|
||||
confirmText: 'Clear All Time',
|
||||
cancelText: 'Cancel',
|
||||
onClose: async confirmed => {
|
||||
if (confirmed) {
|
||||
if (choreTimer?.res?.id) {
|
||||
deleteTimeSession.mutate(
|
||||
{ choreId, sessionId: choreTimer.res.id },
|
||||
{
|
||||
onSuccess: data => {
|
||||
const newChore = {
|
||||
...chore,
|
||||
...data.res,
|
||||
}
|
||||
setChore(newChore)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
setTimerActionConfig({})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleApproveChore = () => {
|
||||
ApproveChore(choreId).then(response => {
|
||||
if (response.ok) {
|
||||
response.json().then(data => {
|
||||
setChore(data.res)
|
||||
// Invalidate chores cache to refetch data
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleRejectChore = () => {
|
||||
RejectChore(choreId).then(response => {
|
||||
if (response.ok) {
|
||||
response.json().then(data => {
|
||||
setChore(data.res)
|
||||
// Invalidate chores cache to refetch data
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleUnarchiveChore = () => {
|
||||
UnArchiveChore(choreId).then(response => {
|
||||
if (response.ok) {
|
||||
response.json().then(data => {
|
||||
setChore({ ...chore, isActive: true })
|
||||
// Invalidate chores cache to refetch data
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Check if the current user can approve/reject (admin, manager, or task owner)
|
||||
const canApproveReject = () => {
|
||||
if (!circleMembersData?.res || !chore) return false
|
||||
|
||||
const currentUser = circleMembersData.res.find(
|
||||
member => member.userId === (impersonatedUser?.userId || userProfile?.id),
|
||||
)
|
||||
|
||||
// User can approve/reject if they are:
|
||||
// 1. Admin or manager of the circle
|
||||
// 2. Owner/creator of the task
|
||||
return (
|
||||
currentUser?.role === 'admin' ||
|
||||
currentUser?.role === 'manager' ||
|
||||
chore.createdBy === (impersonatedUser?.userId || userProfile?.id)
|
||||
)
|
||||
}
|
||||
|
||||
if (isChoreLoading || isCircleMembersLoading) {
|
||||
// while loading the chore or circle members, return a loading state
|
||||
return <LoadingComponent />
|
||||
@@ -256,6 +423,16 @@ const ChoreView = () => {
|
||||
>
|
||||
{chore.name}
|
||||
</Typography>
|
||||
{chore.isActive === false && (
|
||||
<Chip
|
||||
startDecorator={<Archive />}
|
||||
size='md'
|
||||
color='warning'
|
||||
sx={{ mb: 1 }}
|
||||
>
|
||||
Archived
|
||||
</Chip>
|
||||
)}
|
||||
<Chip startDecorator={<CalendarMonth />} size='md' sx={{ mb: 1 }}>
|
||||
{chore.nextDueDate
|
||||
? `Due at ${moment(chore.nextDueDate).format('MM/DD/YYYY hh:mm A')}`
|
||||
@@ -296,8 +473,23 @@ const ChoreView = () => {
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
{[ChoreStatus.ACTIVE, ChoreStatus.PAUSED].includes(chore.status) && (
|
||||
<Grid xs={12}>
|
||||
<TimePassedCard
|
||||
chore={chore}
|
||||
handleAction={action => {
|
||||
if (action === 'pause') {
|
||||
handleChorePause()
|
||||
} else if (action === 'resume') {
|
||||
handleChoreStart()
|
||||
}
|
||||
}}
|
||||
onShowDetails={() => navigate(`/chores/${choreId}/timer`)}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
{infoCards.map((card, index) => (
|
||||
<Grid item xs={6} sm={6} key={index}>
|
||||
<Grid xs={6} sm={6} key={index}>
|
||||
<Card
|
||||
variant='soft'
|
||||
sx={{
|
||||
@@ -306,6 +498,7 @@ const ChoreView = () => {
|
||||
px: 2,
|
||||
py: 1,
|
||||
minHeight: 90,
|
||||
height: '100%',
|
||||
// change from space-between to start:
|
||||
justifyContent: 'start',
|
||||
}}
|
||||
@@ -363,6 +556,7 @@ const ChoreView = () => {
|
||||
>
|
||||
<Dropdown>
|
||||
<MenuButton
|
||||
disabled={chore.isActive === false}
|
||||
color={
|
||||
chorePriority?.name === 'P1'
|
||||
? 'danger'
|
||||
@@ -375,8 +569,8 @@ const ChoreView = () => {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 1,
|
||||
width: '100%',
|
||||
}}
|
||||
fullWidth
|
||||
variant='plain'
|
||||
>
|
||||
{chorePriority ? chorePriority.icon : <LowPriority />}
|
||||
@@ -423,6 +617,7 @@ const ChoreView = () => {
|
||||
color='neutral'
|
||||
variant='plain'
|
||||
fullWidth
|
||||
disabled={chore.isActive === false}
|
||||
onClick={() => {
|
||||
navigate(`/chores/${choreId}/history`)
|
||||
}}
|
||||
@@ -441,6 +636,7 @@ const ChoreView = () => {
|
||||
color='neutral'
|
||||
variant='plain'
|
||||
fullWidth
|
||||
disabled={chore.isActive === false}
|
||||
sx={{
|
||||
// top right of the card:
|
||||
flexDirection: 'column',
|
||||
@@ -525,6 +721,7 @@ const ChoreView = () => {
|
||||
>
|
||||
<SubTasks
|
||||
editMode={false}
|
||||
performers={performers}
|
||||
tasks={chore.subTasks}
|
||||
setTasks={tasks => {
|
||||
setChore({
|
||||
@@ -544,17 +741,19 @@ const ChoreView = () => {
|
||||
p: 2,
|
||||
borderRadius: 'md',
|
||||
boxShadow: 'sm',
|
||||
paddingBottom: getSafeBottomPadding(2, '8px'),
|
||||
}}
|
||||
variant='soft'
|
||||
>
|
||||
<Typography level='body-md' sx={{ mb: 1 }}>
|
||||
Complete the task
|
||||
Task Actions
|
||||
</Typography>
|
||||
|
||||
<FormControl size='sm'>
|
||||
<Checkbox
|
||||
checked={note !== null}
|
||||
size='lg'
|
||||
disabled={chore.isActive === false}
|
||||
onChange={e => {
|
||||
if (e.target.checked) {
|
||||
setNote('')
|
||||
@@ -571,35 +770,30 @@ const ChoreView = () => {
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
Add Additional Notes
|
||||
Add a note
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
{note !== null && (
|
||||
<Input
|
||||
fullWidth
|
||||
multiline
|
||||
label='Additional Notes'
|
||||
placeholder='note or information about the task'
|
||||
value={note || ''}
|
||||
onChange={e => {
|
||||
if (e.target.value.trim() === '') {
|
||||
setNote(null)
|
||||
return
|
||||
}
|
||||
setNote(e.target.value)
|
||||
}}
|
||||
sx={{
|
||||
mb: 1,
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
Additional Notes:
|
||||
</Typography>
|
||||
<RichTextEditor
|
||||
value={note || ''}
|
||||
onChange={setNote}
|
||||
entityType={'chore_completion_note'}
|
||||
placeholder='Add a note about the completion...'
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<FormControl size='sm'>
|
||||
<Checkbox
|
||||
checked={completedDate !== null}
|
||||
size='lg'
|
||||
disabled={chore.isActive === false}
|
||||
onChange={e => {
|
||||
if (e.target.checked) {
|
||||
setCompletedDate(
|
||||
@@ -624,7 +818,7 @@ const ChoreView = () => {
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
Specify completion date
|
||||
Set custom completion time
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
@@ -640,65 +834,188 @@ const ChoreView = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: 1,
|
||||
alignContent: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
onClick={handleTaskCompletion}
|
||||
disabled={
|
||||
isPendingCompletion ||
|
||||
notInCompletionWindow(chore) ||
|
||||
(chore.lastCompletedDate !== null &&
|
||||
chore.frequencyType === 'once')
|
||||
}
|
||||
color={isPendingCompletion ? 'danger' : 'success'}
|
||||
startDecorator={<Check />}
|
||||
{chore.isActive === false ? (
|
||||
// Archived chore - only show unarchive button
|
||||
<Box
|
||||
sx={{
|
||||
flex: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
alignContent: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Box>Mark as done</Box>
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
onClick={handleUnarchiveChore}
|
||||
color='primary'
|
||||
startDecorator={<Unarchive />}
|
||||
>
|
||||
Unarchive
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
// Active chore - show all normal actions
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
alignContent: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: 1,
|
||||
alignContent: 'center',
|
||||
justifyContent: 'center',
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
{chore.status === 3 ? (
|
||||
// Pending approval: Show approve/reject for admins/managers/owners, grayed out button for others
|
||||
canApproveReject() ? (
|
||||
<>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
onClick={handleApproveChore}
|
||||
color='success'
|
||||
startDecorator={<ThumbUp />}
|
||||
sx={{
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
onClick={handleRejectChore}
|
||||
color='danger'
|
||||
startDecorator={<ThumbDown />}
|
||||
sx={{
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<Box>Reject</Box>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
disabled={true}
|
||||
color='neutral'
|
||||
startDecorator={<HourglassEmpty />}
|
||||
>
|
||||
<Box>Pending Approval</Box>
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
// Normal completion flow
|
||||
<>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
onClick={handleTaskCompletion}
|
||||
disabled={
|
||||
isPendingCompletion ||
|
||||
notInCompletionWindow(chore) ||
|
||||
(chore.lastCompletedDate !== null &&
|
||||
chore.frequencyType === 'once')
|
||||
}
|
||||
color={isPendingCompletion ? 'danger' : 'success'}
|
||||
startDecorator={<Check />}
|
||||
sx={{
|
||||
flex: 4,
|
||||
}}
|
||||
>
|
||||
<Box>Mark as done</Box>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
onClick={() => {
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
title: 'Skip Task',
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
onClick={() => {
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
title: 'Skip Task',
|
||||
|
||||
message: 'Are you sure you want to skip this task?',
|
||||
message: 'Are you sure you want to skip this task?',
|
||||
|
||||
confirmText: 'Skip',
|
||||
cancelText: 'Cancel',
|
||||
onClose: confirmed => {
|
||||
if (confirmed) {
|
||||
handleSkippingTask()
|
||||
confirmText: 'Skip',
|
||||
cancelText: 'Cancel',
|
||||
onClose: confirmed => {
|
||||
if (confirmed) {
|
||||
handleSkippingTask()
|
||||
}
|
||||
setConfirmModelConfig({})
|
||||
},
|
||||
})
|
||||
}}
|
||||
disabled={
|
||||
chore.lastCompletedDate !== null &&
|
||||
chore.frequencyType === 'once'
|
||||
}
|
||||
startDecorator={<SwitchAccessShortcut />}
|
||||
sx={{
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<Box>Skip</Box>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
{/* Timer Button - Show split button when timer is active, regular button otherwise */}
|
||||
{[ChoreStatus.ACTIVE, ChoreStatus.PAUSED].includes(chore.status) ? (
|
||||
<TimerSplitButton
|
||||
disabled={
|
||||
chore.lastCompletedDate !== null &&
|
||||
chore.frequencyType === 'once'
|
||||
}
|
||||
chore={chore}
|
||||
onAction={action => {
|
||||
if (action === 'pause') {
|
||||
handleChorePause()
|
||||
} else if (action === 'resume') {
|
||||
handleChoreStart()
|
||||
}
|
||||
setConfirmModelConfig({})
|
||||
},
|
||||
})
|
||||
}}
|
||||
disabled={
|
||||
chore.lastCompletedDate !== null && chore.frequencyType === 'once'
|
||||
}
|
||||
startDecorator={<SwitchAccessShortcut />}
|
||||
sx={{
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<Box>Skip</Box>
|
||||
</Button>
|
||||
</Box>
|
||||
}}
|
||||
onShowDetails={() => navigate(`/chores/${choreId}/timer`)}
|
||||
onResetTimer={handleResetTimer}
|
||||
onClearAllTime={handleClearAllTime}
|
||||
fullWidth
|
||||
/>
|
||||
) : chore.status === ChoreStatus.PENDING_APPROVAL ? (
|
||||
<></>
|
||||
) : (
|
||||
<Button
|
||||
size='lg'
|
||||
onClick={() => {
|
||||
handleChoreStart()
|
||||
}}
|
||||
variant='soft'
|
||||
color='success'
|
||||
disabled={
|
||||
chore.lastCompletedDate !== null &&
|
||||
chore.frequencyType === 'once'
|
||||
}
|
||||
startDecorator={<PlayArrow />}
|
||||
sx={{
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Snackbar
|
||||
open={isPendingCompletion}
|
||||
@@ -726,6 +1043,7 @@ const ChoreView = () => {
|
||||
</Typography>
|
||||
</Snackbar>
|
||||
<ConfirmationModal config={confirmModelConfig} />
|
||||
<ConfirmationModal config={timerActionConfig} />
|
||||
</Card>
|
||||
</Container>
|
||||
)
|
||||
|
||||
@@ -17,8 +17,9 @@ import {
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useContext, useEffect, useState } from 'react'
|
||||
import { UserContext } from '../../contexts/UserContext'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { isPlusAccount } from '../../utils/Helpers'
|
||||
import ThingTriggerSection from './ThingTriggerSection'
|
||||
|
||||
@@ -61,6 +62,51 @@ const DAYS = [
|
||||
'saturday',
|
||||
'sunday',
|
||||
]
|
||||
|
||||
const WEEK_PATTERNS = {
|
||||
every_week: 'Every week',
|
||||
week_of_month: 'Specific occurrences in the month',
|
||||
}
|
||||
|
||||
const DAY_OCCURRENCE_OPTIONS = [
|
||||
{ value: 1, label: '1st occurrence' },
|
||||
{ value: 2, label: '2nd occurrence' },
|
||||
{ value: 3, label: '3rd occurrence' },
|
||||
{ value: 4, label: '4th occurrence' },
|
||||
{ value: -1, label: 'Last occurrence' },
|
||||
]
|
||||
// Helper function to generate schedule preview text
|
||||
const generateSchedulePreview = metadata => {
|
||||
if (!metadata?.days?.length) return ''
|
||||
|
||||
const dayNames = metadata.days
|
||||
.map(day => day.charAt(0).toUpperCase() + day.slice(1, 3))
|
||||
.join(', ')
|
||||
|
||||
const timeStr = metadata.time
|
||||
? moment(metadata.time).format('h:mm A')
|
||||
: '6:00 PM'
|
||||
|
||||
if (metadata.weekPattern === 'every_week' || !metadata.weekPattern) {
|
||||
return `Every ${dayNames} at ${timeStr}`
|
||||
}
|
||||
|
||||
if (
|
||||
metadata.weekPattern === 'week_of_month' &&
|
||||
metadata.occurrences?.length
|
||||
) {
|
||||
const occurrenceStr = metadata.occurrences
|
||||
.map(w => {
|
||||
if (w === -1) return 'last'
|
||||
return `${w}${w === 1 ? 'st' : w === 2 ? 'nd' : w === 3 ? 'rd' : 'th'}`
|
||||
})
|
||||
.join(', ')
|
||||
return `Every ${occurrenceStr} ${dayNames} of the month at ${timeStr}`
|
||||
}
|
||||
|
||||
return `Every ${dayNames} at ${timeStr}`
|
||||
}
|
||||
|
||||
const RepeatOnSections = ({
|
||||
frequencyType,
|
||||
frequency,
|
||||
@@ -68,7 +114,6 @@ const RepeatOnSections = ({
|
||||
frequencyMetadata,
|
||||
onFrequencyMetadataUpdate,
|
||||
}) => {
|
||||
const [intervalUnit, setIntervalUnit] = useState('days')
|
||||
// if time on frequencyMetadata is not set, try to set it to the nextDueDate if available,
|
||||
// otherwise set it to 18:00 of the current day
|
||||
useEffect(() => {
|
||||
@@ -77,13 +122,30 @@ const RepeatOnSections = ({
|
||||
moment(new Date()).format('YYYY-MM-DD') + 'T' + '18:00',
|
||||
).format()
|
||||
}
|
||||
}, [frequencyMetadata])
|
||||
// Initialize weekPattern if not set
|
||||
if (!frequencyMetadata?.weekPattern) {
|
||||
onFrequencyMetadataUpdate({
|
||||
...frequencyMetadata,
|
||||
weekPattern: 'every_week',
|
||||
occurrences: [],
|
||||
})
|
||||
}
|
||||
}, [frequencyMetadata, onFrequencyMetadataUpdate])
|
||||
|
||||
const timePickerComponent = (
|
||||
<Grid item sm={12} sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Typography level='h5'>At: </Typography>
|
||||
<Grid
|
||||
item
|
||||
sm={12}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
direction: 'column',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Typography level='h5'>Time of day: </Typography>
|
||||
<Input
|
||||
type='time'
|
||||
sx={{ width: '150px' }}
|
||||
defaultValue={moment(frequencyMetadata?.time).format('HH:mm')}
|
||||
onChange={e => {
|
||||
onFrequencyMetadataUpdate({
|
||||
@@ -117,13 +179,16 @@ const RepeatOnSections = ({
|
||||
onFrequencyUpdate(e.target.value)
|
||||
}}
|
||||
/>
|
||||
<Select placeholder='Unit' value={intervalUnit}>
|
||||
<Select
|
||||
placeholder='Unit'
|
||||
value={frequencyMetadata?.unit || 'days'}
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
{['hours', 'days', 'weeks', 'months', 'years'].map(item => (
|
||||
<Option
|
||||
key={item}
|
||||
value={item}
|
||||
onClick={() => {
|
||||
setIntervalUnit(item)
|
||||
onFrequencyMetadataUpdate({
|
||||
...frequencyMetadata,
|
||||
unit: item,
|
||||
@@ -189,6 +254,8 @@ const RepeatOnSections = ({
|
||||
onFrequencyMetadataUpdate({
|
||||
...frequencyMetadata,
|
||||
days: [],
|
||||
weekPattern: 'every_week',
|
||||
occurrences: [],
|
||||
})
|
||||
} else {
|
||||
onFrequencyMetadataUpdate({
|
||||
@@ -206,6 +273,140 @@ const RepeatOnSections = ({
|
||||
</Button>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item sm={12} sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Box>
|
||||
<RadioGroup
|
||||
value={frequencyMetadata?.weekPattern || 'every_week'}
|
||||
onChange={event => {
|
||||
const newPattern = event.target.value
|
||||
onFrequencyMetadataUpdate({
|
||||
...frequencyMetadata,
|
||||
weekPattern: newPattern,
|
||||
occurrences:
|
||||
newPattern === 'every_week'
|
||||
? []
|
||||
: frequencyMetadata?.occurrences || [],
|
||||
})
|
||||
}}
|
||||
sx={{ gap: 1, '& > div': { p: 1 } }}
|
||||
>
|
||||
{Object.entries(WEEK_PATTERNS).map(([value, label]) => (
|
||||
<FormControl key={value}>
|
||||
<Radio value={value} label={label} variant='soft' />
|
||||
{value === 'every_week' && (
|
||||
<FormHelperText>
|
||||
Task repeats every week on selected days
|
||||
</FormHelperText>
|
||||
)}
|
||||
{value === 'week_of_month' && (
|
||||
<FormHelperText>
|
||||
Task repeats on specific day occurrences each month
|
||||
(e.g., 1st Monday, 3rd Friday)
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
))}
|
||||
</RadioGroup>
|
||||
|
||||
{frequencyMetadata?.weekPattern === 'week_of_month' && (
|
||||
<Box mt={2}>
|
||||
<Typography level='body-sm' mb={1}>
|
||||
Select which occurrences of the selected days:
|
||||
</Typography>
|
||||
<Typography level='body-xs' color='neutral' mb={2}>
|
||||
Example: "1st Monday" means the first Monday of each month
|
||||
</Typography>
|
||||
<Card>
|
||||
<List
|
||||
orientation='horizontal'
|
||||
wrap
|
||||
sx={{
|
||||
'--List-gap': '8px',
|
||||
'--ListItem-radius': '20px',
|
||||
}}
|
||||
>
|
||||
{DAY_OCCURRENCE_OPTIONS.map(option => (
|
||||
<ListItem key={option.value}>
|
||||
<Checkbox
|
||||
checked={
|
||||
frequencyMetadata?.occurrences?.includes(
|
||||
option.value,
|
||||
) || false
|
||||
}
|
||||
onChange={() => {
|
||||
const currentOccurrences =
|
||||
frequencyMetadata?.occurrences || []
|
||||
const newOccurrences =
|
||||
currentOccurrences.includes(option.value)
|
||||
? currentOccurrences.filter(
|
||||
w => w !== option.value,
|
||||
)
|
||||
: [...currentOccurrences, option.value]
|
||||
onFrequencyMetadataUpdate({
|
||||
...frequencyMetadata,
|
||||
occurrences: newOccurrences.sort((a, b) => {
|
||||
if (a === -1) return 1 // Last occurrence goes to end
|
||||
if (b === -1) return -1
|
||||
return a - b
|
||||
}),
|
||||
})
|
||||
}}
|
||||
overlay
|
||||
disableIcon
|
||||
variant='soft'
|
||||
label={option.label}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
onClick={() => {
|
||||
if (
|
||||
frequencyMetadata?.occurrences?.length ===
|
||||
DAY_OCCURRENCE_OPTIONS.length
|
||||
) {
|
||||
onFrequencyMetadataUpdate({
|
||||
...frequencyMetadata,
|
||||
occurrences: [],
|
||||
})
|
||||
} else {
|
||||
onFrequencyMetadataUpdate({
|
||||
...frequencyMetadata,
|
||||
occurrences: DAY_OCCURRENCE_OPTIONS.map(
|
||||
option => option.value,
|
||||
),
|
||||
})
|
||||
}
|
||||
}}
|
||||
overlay
|
||||
disableIcon
|
||||
>
|
||||
{frequencyMetadata?.occurrences?.length ===
|
||||
DAY_OCCURRENCE_OPTIONS.length
|
||||
? 'Unselect All'
|
||||
: 'Select All'}
|
||||
</Button>
|
||||
</Card>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Quarter week pattern removed - doesn't make sense with Nth day approach */}
|
||||
|
||||
{/* Live Preview */}
|
||||
{frequencyMetadata?.days?.length > 0 && (
|
||||
<Card mt={2} p={2}>
|
||||
<Typography level='body-sm' color='primary'>
|
||||
{generateSchedulePreview(frequencyMetadata)}
|
||||
</Typography>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{timePickerComponent}
|
||||
</>
|
||||
)
|
||||
@@ -337,10 +538,11 @@ const RepeatSection = ({
|
||||
isAttemptToSave,
|
||||
selectedThing,
|
||||
}) => {
|
||||
const { userProfile } = useContext(UserContext)
|
||||
const { data: userProfile } = useUserProfile()
|
||||
|
||||
return (
|
||||
<Box mt={2}>
|
||||
<Typography level='h4'>Repeat :</Typography>
|
||||
<Typography level='h4'>Repeat:</Typography>
|
||||
<FormControl sx={{ mt: 1 }}>
|
||||
<Checkbox
|
||||
onChange={e => {
|
||||
@@ -457,6 +659,8 @@ const RepeatSection = ({
|
||||
onFrequencyMetadataUpdate({
|
||||
...frequencyMetadata,
|
||||
days: [],
|
||||
weekPattern: 'every_week',
|
||||
weekNumbers: [],
|
||||
})
|
||||
} else if (item === 'day_of_the_month') {
|
||||
onFrequencyMetadataUpdate({
|
||||
|
||||
@@ -7,9 +7,6 @@ import {
|
||||
Chip,
|
||||
FormControl,
|
||||
Input,
|
||||
ListItem,
|
||||
ListItemContent,
|
||||
ListItemDecorator,
|
||||
Option,
|
||||
Select,
|
||||
TextField,
|
||||
@@ -113,7 +110,7 @@ const ThingTriggerSection = ({
|
||||
onChange={(e, newValue) => setSelectedThing(newValue)}
|
||||
getOptionLabel={option => option.name}
|
||||
renderOption={(props, option) => (
|
||||
<ListItem {...props}>
|
||||
<Box {...props}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
@@ -123,19 +120,19 @@ const ThingTriggerSection = ({
|
||||
p: 1,
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator sx={{ alignSelf: 'flex-start' }}>
|
||||
<Box sx={{ alignSelf: 'flex-start' }}>
|
||||
<Typography level='body-lg' textColor='primary'>
|
||||
{option.name}
|
||||
</Typography>
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography level='body2' textColor='text.secondary'>
|
||||
<Chip>type: {option.type}</Chip>{' '}
|
||||
<Chip>state: {option.state}</Chip>
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</Box>
|
||||
</Box>
|
||||
</ListItem>
|
||||
</Box>
|
||||
)}
|
||||
renderInput={params => (
|
||||
<TextField {...params} label='Select a thing' />
|
||||
|
||||
233
src/views/ChoreEdit/TimePassedCard.jsx
Normal file
233
src/views/ChoreEdit/TimePassedCard.jsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import {
|
||||
Flag,
|
||||
OpenInFull,
|
||||
Pause,
|
||||
PlayArrow,
|
||||
Schedule,
|
||||
} from '@mui/icons-material'
|
||||
import { Box, Card, Chip, Typography } from '@mui/joy'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
const TimePassedCard = ({ chore, handleAction, onShowDetails }) => {
|
||||
const navigate = useNavigate()
|
||||
const [time, setTime] = useState(0)
|
||||
const [shouldAnimate, setShouldAnimate] = useState(false)
|
||||
const [prevStatus, setPrevStatus] = useState(null) // Initialize as null
|
||||
const intervalRef = useRef(null)
|
||||
|
||||
// Track status changes to trigger animation
|
||||
useEffect(() => {
|
||||
// Only trigger animation if we have a previous status and it changed from 0 to 1
|
||||
if (prevStatus !== null && prevStatus === 0 && chore.status === 1) {
|
||||
setShouldAnimate(true)
|
||||
// Reset animation after it completes
|
||||
const timer = setTimeout(() => setShouldAnimate(false), 300)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
setPrevStatus(chore.status)
|
||||
}, [chore.status, prevStatus])
|
||||
|
||||
// Single effect to handle both time calculation and timer
|
||||
useEffect(() => {
|
||||
// Calculate current time based on chore data
|
||||
const calculateCurrentTime = () => {
|
||||
if (chore.timerUpdatedAt && chore.status === 1) {
|
||||
// Active session: base duration + time since start
|
||||
const timeSinceStart = Math.floor(
|
||||
(Date.now() - new Date(chore.timerUpdatedAt).getTime()) / 1000,
|
||||
)
|
||||
|
||||
return timeSinceStart + (chore.duration || 0)
|
||||
}
|
||||
// Not active: just return accumulated duration
|
||||
return chore.duration || 0
|
||||
}
|
||||
|
||||
// Clear any existing timer first
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
|
||||
// Set initial time
|
||||
const currentTime = calculateCurrentTime()
|
||||
setTime(currentTime)
|
||||
|
||||
// Handle timer based on status
|
||||
if (chore.status === 1) {
|
||||
// Active: start interval timer
|
||||
intervalRef.current = setInterval(() => {
|
||||
const newTime = calculateCurrentTime()
|
||||
setTime(newTime)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
}
|
||||
}, [chore.status, chore.duration, chore.timerUpdatedAt])
|
||||
|
||||
const formatTime = seconds => {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = seconds % 60
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
variant='soft'
|
||||
sx={{
|
||||
borderRadius: 'md',
|
||||
boxShadow: 1,
|
||||
gap: 0,
|
||||
px: 2,
|
||||
py: 1,
|
||||
height: '75px',
|
||||
alignItems: 'center',
|
||||
...(shouldAnimate && {
|
||||
animation: 'slideInUp 0.3s ease-out',
|
||||
}),
|
||||
'@keyframes slideInUp': {
|
||||
'0%': {
|
||||
opacity: 0,
|
||||
transform: 'translateY(20px) scale(0.95)',
|
||||
},
|
||||
'100%': {
|
||||
opacity: 1,
|
||||
transform: 'translateY(0) scale(1)',
|
||||
},
|
||||
},
|
||||
transition: 'all 0.3s ease',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={e => {
|
||||
// if this click on this element itself and not its children:
|
||||
if (e.target !== e.currentTarget) return
|
||||
navigate('./timer')
|
||||
}}
|
||||
>
|
||||
<OpenInFull
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
zIndex: 2,
|
||||
cursor: 'pointer',
|
||||
color: 'text.secondary',
|
||||
fontSize: '15px',
|
||||
'&:hover': { color: 'primary.main' },
|
||||
}}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
navigate('./timer')
|
||||
}}
|
||||
></OpenInFull>
|
||||
<Typography
|
||||
level='h4'
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: chore.status === 1 ? 'success.main' : 'text.primary',
|
||||
// mb: 0.5,
|
||||
mb: 0.5,
|
||||
transition: 'all 0.3s ease',
|
||||
transform: chore.status === 1 ? 'scale(1.40)' : 'scale(1)',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
}}
|
||||
onClick={() => onShowDetails?.()}
|
||||
>
|
||||
{formatTime(time)}
|
||||
</Typography>
|
||||
|
||||
{/* Status and info section */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0 }}>
|
||||
{/* Show start time and user if active */}
|
||||
{chore.status === 1 ? (
|
||||
<Chip
|
||||
variant='soft'
|
||||
color='warning'
|
||||
size='md'
|
||||
startDecorator={<Pause sx={{ fontSize: 14 }} />}
|
||||
onClick={() => {
|
||||
handleAction('pause')
|
||||
}}
|
||||
>
|
||||
Pause
|
||||
</Chip>
|
||||
) : (
|
||||
<Chip
|
||||
variant='solid'
|
||||
color='success'
|
||||
size='md'
|
||||
startDecorator={<PlayArrow sx={{ fontSize: 14 }} />}
|
||||
onClick={() => {
|
||||
handleAction('resume')
|
||||
}}
|
||||
>
|
||||
Resume
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
{/* Chips for start time and current session */}
|
||||
{chore.status === 1 && chore.timerUpdatedAt && (
|
||||
<>
|
||||
{/* Original start time */}
|
||||
{chore.startTime && (
|
||||
<Chip
|
||||
variant='plain'
|
||||
color='primary'
|
||||
size='md'
|
||||
startDecorator={<Flag sx={{ fontSize: 14 }} />}
|
||||
>
|
||||
{new Date(chore.startTime).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
{/* Current session start time */}
|
||||
{chore.timerUpdatedAt !== chore.startTime && (
|
||||
<Chip
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
size='md'
|
||||
startDecorator={<Schedule sx={{ fontSize: 14 }} />}
|
||||
>
|
||||
{new Date(chore.timerUpdatedAt).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</Chip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chips for paused state */}
|
||||
{chore.status === 2 && (
|
||||
<Chip
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
size='md'
|
||||
startDecorator={<Schedule sx={{ fontSize: 14 }} />}
|
||||
>
|
||||
{new Date(chore.timerUpdatedAt).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default TimePassedCard
|
||||
161
src/views/ChoreEdit/TimerSplitButton.jsx
Normal file
161
src/views/ChoreEdit/TimerSplitButton.jsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
ArrowDropDown,
|
||||
DeleteSweep,
|
||||
Info,
|
||||
Pause,
|
||||
PlayArrow,
|
||||
} from '@mui/icons-material'
|
||||
import { Box, ButtonGroup, IconButton, Menu, MenuItem } from '@mui/joy'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
const TimerSplitButton = ({
|
||||
chore,
|
||||
onAction,
|
||||
onShowDetails,
|
||||
onResetTimer,
|
||||
onClearAllTime,
|
||||
disabled = false,
|
||||
fullWidth = false,
|
||||
}) => {
|
||||
const [anchorEl, setAnchorEl] = useState(null)
|
||||
const isMenuOpen = Boolean(anchorEl)
|
||||
const menuRef = useRef(null)
|
||||
|
||||
const handleMainAction = () => {
|
||||
if (chore.status === 1) {
|
||||
onAction('pause')
|
||||
} else if (chore.status === 2) {
|
||||
onAction('resume')
|
||||
}
|
||||
}
|
||||
|
||||
const handleMenuOpen = event => {
|
||||
setAnchorEl(event.currentTarget)
|
||||
}
|
||||
|
||||
const handleMenuClose = () => {
|
||||
setAnchorEl(null)
|
||||
}
|
||||
|
||||
const handleShowDetails = () => {
|
||||
onShowDetails()
|
||||
handleMenuClose()
|
||||
}
|
||||
|
||||
const handleResetTimer = () => {
|
||||
onResetTimer()
|
||||
handleMenuClose()
|
||||
}
|
||||
|
||||
const handleClearAllTime = () => {
|
||||
onClearAllTime()
|
||||
handleMenuClose()
|
||||
}
|
||||
|
||||
// Handle outside clicks to close menu
|
||||
useEffect(() => {
|
||||
const handleMenuOutsideClick = event => {
|
||||
if (
|
||||
anchorEl &&
|
||||
!anchorEl.contains(event.target) &&
|
||||
menuRef.current &&
|
||||
!menuRef.current.contains(event.target)
|
||||
) {
|
||||
handleMenuClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleMenuOutsideClick)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleMenuOutsideClick)
|
||||
}
|
||||
}, [anchorEl])
|
||||
|
||||
// Only show the split button when there's an active timer (status 1 or 2)
|
||||
if (chore.status === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: fullWidth ? '100%' : 'auto',
|
||||
}}
|
||||
>
|
||||
<ButtonGroup
|
||||
variant='soft'
|
||||
color={chore.status === 1 ? 'warning' : 'success'}
|
||||
sx={{
|
||||
'--ButtonGroup-separatorSize': '1px',
|
||||
'--ButtonGroup-connected': '1',
|
||||
width: fullWidth ? '100%' : 'auto',
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
{/* Main action button */}
|
||||
<IconButton
|
||||
onClick={handleMainAction}
|
||||
disabled={disabled}
|
||||
size='md'
|
||||
sx={{
|
||||
px: 3,
|
||||
py: 1,
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
minWidth: fullWidth ? 'auto' : 120,
|
||||
flex: fullWidth ? 1 : 'none',
|
||||
}}
|
||||
>
|
||||
{chore.status === 1 ? <Pause /> : <PlayArrow />}
|
||||
{chore.status === 1 ? 'Pause' : 'Resume'}
|
||||
</IconButton>
|
||||
|
||||
{/* Dropdown arrow button */}
|
||||
<IconButton
|
||||
onClick={handleMenuOpen}
|
||||
disabled={disabled}
|
||||
size='lg'
|
||||
sx={{
|
||||
px: 1,
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
borderLeft: '1px solid',
|
||||
borderLeftColor: 'divider',
|
||||
minWidth: 'auto',
|
||||
}}
|
||||
>
|
||||
<ArrowDropDown />
|
||||
</IconButton>
|
||||
</ButtonGroup>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
<Menu
|
||||
ref={menuRef}
|
||||
anchorEl={anchorEl}
|
||||
open={isMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
placement='bottom-end'
|
||||
sx={{
|
||||
mt: 1,
|
||||
}}
|
||||
>
|
||||
<MenuItem onClick={handleShowDetails}>
|
||||
<Info sx={{ mr: 1 }} />
|
||||
Timer Details
|
||||
</MenuItem>
|
||||
{/* <MenuItem onClick={handleResetTimer}>
|
||||
<RestartAlt sx={{ mr: 1 }} />
|
||||
Restart timer
|
||||
</MenuItem> */}
|
||||
<MenuItem onClick={handleClearAllTime} color='danger'>
|
||||
<DeleteSweep sx={{ mr: 1 }} />
|
||||
Clear & Reset
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default TimerSplitButton
|
||||
@@ -1,10 +1,13 @@
|
||||
import {
|
||||
CheckCircle,
|
||||
EventNote,
|
||||
HourglassEmpty,
|
||||
Notes,
|
||||
Person,
|
||||
Redo,
|
||||
Refresh,
|
||||
ThumbDown,
|
||||
Timelapse,
|
||||
Toll,
|
||||
WatchLater,
|
||||
} from '@mui/icons-material'
|
||||
@@ -32,9 +35,9 @@ const ActivityItem = ({ activity, members }) => {
|
||||
member => member.userId === activity.completedBy,
|
||||
)
|
||||
|
||||
const getTimeDisplay = performedAt => {
|
||||
const getTimeDisplay = dateToDisplay => {
|
||||
const now = moment()
|
||||
const completed = moment(performedAt)
|
||||
const completed = moment(dateToDisplay)
|
||||
const diffInHours = now.diff(completed, 'hours')
|
||||
const diffInDays = now.diff(completed, 'days')
|
||||
|
||||
@@ -50,37 +53,55 @@ const ActivityItem = ({ activity, members }) => {
|
||||
}
|
||||
|
||||
const getStatusInfo = activity => {
|
||||
if (!activity.status === 1) {
|
||||
if (activity.status === 0) {
|
||||
return {
|
||||
color: 'neutral',
|
||||
text: 'Completed',
|
||||
icon: <CheckCircle />,
|
||||
color: 'primary',
|
||||
text: 'Started',
|
||||
icon: <Timelapse />,
|
||||
}
|
||||
} else if (activity.status === 1) {
|
||||
const wasOnTime = moment(activity.performedAt).isSameOrBefore(
|
||||
moment(activity.dueDate),
|
||||
)
|
||||
|
||||
if (wasOnTime) {
|
||||
return {
|
||||
color: 'success',
|
||||
text: 'Done',
|
||||
icon: <CheckCircle />,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
color: 'primary',
|
||||
text: 'Late',
|
||||
icon: <WatchLater />,
|
||||
}
|
||||
}
|
||||
} else if (activity.status === 2) {
|
||||
// skipped
|
||||
return {
|
||||
color: 'warning',
|
||||
text: 'Skipped',
|
||||
icon: <Redo />,
|
||||
}
|
||||
} else if (activity.status === 3) {
|
||||
return {
|
||||
color: 'neutral',
|
||||
text: 'Pending Approval',
|
||||
icon: <HourglassEmpty />,
|
||||
}
|
||||
} else if (activity.status === 4) {
|
||||
return {
|
||||
color: 'danger',
|
||||
text: 'Rejected',
|
||||
icon: <ThumbDown />,
|
||||
}
|
||||
}
|
||||
|
||||
const wasOnTime = moment(activity.performedAt).isSameOrBefore(
|
||||
moment(activity.dueDate),
|
||||
)
|
||||
|
||||
if (wasOnTime) {
|
||||
return {
|
||||
color: 'success',
|
||||
text: 'Done',
|
||||
icon: <CheckCircle />,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
color: 'primary',
|
||||
text: 'Late',
|
||||
icon: <WatchLater />,
|
||||
}
|
||||
// Fallback for completed status
|
||||
return {
|
||||
color: 'success',
|
||||
text: 'Completed',
|
||||
icon: <CheckCircle />,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +126,11 @@ const ActivityItem = ({ activity, members }) => {
|
||||
{activity.choreName}
|
||||
</Typography>
|
||||
<Typography level='body-xs' color='text.secondary'>
|
||||
{getTimeDisplay(activity.performedAt)}
|
||||
{getTimeDisplay(
|
||||
activity.performedAt ||
|
||||
activity.updatedAt ||
|
||||
activity.createdAt,
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -127,18 +152,6 @@ const ActivityItem = ({ activity, members }) => {
|
||||
completedByMember?.name ||
|
||||
'Unknown'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Status, Points, and Notes */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 0.5,
|
||||
mt: 0.5,
|
||||
ml: 2.5,
|
||||
}}
|
||||
>
|
||||
{/* Points chip */}
|
||||
{activity.points && activity.points > 0 && (
|
||||
<Chip
|
||||
@@ -152,6 +165,17 @@ const ActivityItem = ({ activity, members }) => {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Status, Points, and Notes */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 0.5,
|
||||
mt: 0.5,
|
||||
ml: 2.5,
|
||||
}}
|
||||
></Box>
|
||||
|
||||
{/* Notes */}
|
||||
{activity.notes && (
|
||||
<Box sx={{ mt: 0.5, ml: 2.5 }}>
|
||||
@@ -180,7 +204,9 @@ const groupActivitiesByDate = activities => {
|
||||
const groups = {}
|
||||
|
||||
activities.forEach(activity => {
|
||||
const date = moment(activity.performedAt).format('YYYY-MM-DD')
|
||||
const date = moment(
|
||||
activity.performedAt || activity.updatedAt || activity.createdAt,
|
||||
).format('YYYY-MM-DD')
|
||||
if (!groups[date]) {
|
||||
groups[date] = []
|
||||
}
|
||||
@@ -216,7 +242,7 @@ const ActivitiesCard = ({ title = 'Recent Activities' }) => {
|
||||
|
||||
// Refresh function to refetch all data
|
||||
const handleRefresh = async () => {
|
||||
await Promise.all([refetchChores(), refetchHistory(), refetchMembers()])
|
||||
await Promise.all([refetchChores(), refetchHistory, refetchMembers])
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
@@ -228,12 +254,11 @@ const ActivitiesCard = ({ title = 'Recent Activities' }) => {
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
boxShadow: 'sm',
|
||||
borderRadius: 20,
|
||||
width: '310px',
|
||||
minHeight: 300,
|
||||
width: '315px',
|
||||
maxHeight: 400,
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
@@ -270,7 +295,8 @@ const ActivitiesCard = ({ title = 'Recent Activities' }) => {
|
||||
const sortedHistory = enrichedHistory
|
||||
.sort(
|
||||
(a, b) =>
|
||||
moment(b.performedAt).valueOf() - moment(a.performedAt).valueOf(),
|
||||
moment(b.performedAt || b.updatedAt).valueOf() -
|
||||
moment(a.performedAt || a.updatedAt).valueOf(),
|
||||
)
|
||||
.slice(0, 10) // Show only latest 10 activities
|
||||
|
||||
@@ -284,12 +310,11 @@ const ActivitiesCard = ({ title = 'Recent Activities' }) => {
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
boxShadow: 'sm',
|
||||
borderRadius: 20,
|
||||
// width: '290px',
|
||||
width: '310px',
|
||||
minHeight: 300,
|
||||
maxHeight: 400,
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
|
||||
868
src/views/Chores/ArchivedTasks.jsx
Normal file
868
src/views/Chores/ArchivedTasks.jsx
Normal file
@@ -0,0 +1,868 @@
|
||||
import {
|
||||
Archive,
|
||||
CheckBox,
|
||||
CheckBoxOutlineBlank,
|
||||
Close,
|
||||
Delete,
|
||||
SelectAll,
|
||||
Unarchive,
|
||||
ViewAgenda,
|
||||
ViewModule,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Divider,
|
||||
IconButton,
|
||||
Input,
|
||||
List,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import Fuse from 'fuse.js'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||
import { useUnArchiveChore } from '../../queries/ChoreQueries'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { ChoreSorter } from '../../utils/Chores'
|
||||
import { DeleteChore, GetArchivedChores } from '../../utils/Fetcher'
|
||||
import LoadingComponent from '../components/Loading'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import ChoreCard from './ChoreCard'
|
||||
import CompactChoreCard from './CompactChoreCard'
|
||||
import MultiSelectHelp from './MultiSelectHelp'
|
||||
|
||||
const ArchivedTasks = () => {
|
||||
const { data: userProfile, isLoading: isUserProfileLoading } =
|
||||
useUserProfile()
|
||||
const { showSuccess, showError } = useNotification()
|
||||
const { impersonatedUser } = useImpersonateUser()
|
||||
const unArchiveChore = useUnArchiveChore()
|
||||
const [archivedChores, setArchivedChores] = useState([])
|
||||
const [filteredChores, setFilteredChores] = useState([])
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [performers, setPerformers] = useState([])
|
||||
const navigate = useNavigate()
|
||||
const [viewMode, setViewMode] = useState(
|
||||
localStorage.getItem('archivedChoreCardViewMode') || 'default',
|
||||
)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
|
||||
const searchInputRef = useRef(null)
|
||||
|
||||
// Multi-select state
|
||||
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false)
|
||||
const [selectedChores, setSelectedChores] = useState(new Set())
|
||||
const [confirmModelConfig, setConfirmModelConfig] = useState({})
|
||||
|
||||
const { data: membersData, isLoading: membersLoading } = useCircleMembers()
|
||||
|
||||
useEffect(() => {
|
||||
const loadArchivedChores = async () => {
|
||||
if (!membersLoading && userProfile) {
|
||||
setPerformers(membersData.res)
|
||||
try {
|
||||
const response = await GetArchivedChores()
|
||||
const data = await response.json()
|
||||
const sortedChores = data.res.sort(ChoreSorter)
|
||||
setArchivedChores(sortedChores)
|
||||
setFilteredChores(sortedChores)
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Failed to load archived tasks',
|
||||
message: 'Please try again later.',
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
loadArchivedChores()
|
||||
}, [membersLoading, userProfile, membersData])
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = event => {
|
||||
const isHoldingCmdOrCtrl = event.ctrlKey || event.metaKey
|
||||
|
||||
if (isHoldingCmdOrCtrl) {
|
||||
setShowKeyboardShortcuts(true)
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + F to focus search input
|
||||
if (isHoldingCmdOrCtrl && event.key === 'f') {
|
||||
event.preventDefault()
|
||||
searchInputRef.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + S Toggle Multi-select mode
|
||||
if (isHoldingCmdOrCtrl && event.key === 's') {
|
||||
event.preventDefault()
|
||||
toggleMultiSelectMode()
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + A to select all
|
||||
if (
|
||||
isHoldingCmdOrCtrl &&
|
||||
!event.shiftKey &&
|
||||
event.key === 'a' &&
|
||||
!['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)
|
||||
) {
|
||||
event.preventDefault()
|
||||
if (!isMultiSelectMode) {
|
||||
setIsMultiSelectMode(true)
|
||||
setTimeout(() => {
|
||||
selectAllVisibleChores()
|
||||
}, 0)
|
||||
} else {
|
||||
selectAllVisibleChores()
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-select keyboard shortcuts
|
||||
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
|
||||
}
|
||||
|
||||
// "r" key for bulk restore (unarchive)
|
||||
if (
|
||||
isHoldingCmdOrCtrl &&
|
||||
event.key === 'r' &&
|
||||
selectedChores.size > 0
|
||||
) {
|
||||
event.preventDefault()
|
||||
handleBulkRestore()
|
||||
return
|
||||
}
|
||||
|
||||
// "e" key for bulk delete
|
||||
if (
|
||||
isHoldingCmdOrCtrl &&
|
||||
event.key === 'e' &&
|
||||
selectedChores.size > 0
|
||||
) {
|
||||
event.preventDefault()
|
||||
handleBulkDelete()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyUp = event => {
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
setShowKeyboardShortcuts(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
document.addEventListener('keyup', handleKeyUp)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
document.removeEventListener('keyup', handleKeyUp)
|
||||
}
|
||||
}, [isMultiSelectMode, selectedChores.size])
|
||||
|
||||
const toggleViewMode = () => {
|
||||
const modes = ['default', 'compact']
|
||||
const currentIndex = modes.indexOf(viewMode)
|
||||
const nextIndex = (currentIndex + 1) % modes.length
|
||||
const newMode = modes[nextIndex]
|
||||
setViewMode(newMode)
|
||||
localStorage.setItem('archivedChoreCardViewMode', newMode)
|
||||
}
|
||||
|
||||
const searchOptions = {
|
||||
keys: ['name', 'raw_label'],
|
||||
includeScore: true,
|
||||
isCaseSensitive: false,
|
||||
findAllMatches: true,
|
||||
}
|
||||
|
||||
const fuse = new Fuse(
|
||||
archivedChores.map(c => ({
|
||||
...c,
|
||||
raw_label: c.labelsV2?.map(c => c.name).join(' '),
|
||||
})),
|
||||
searchOptions,
|
||||
)
|
||||
|
||||
const handleSearchChange = e => {
|
||||
const search = e.target.value
|
||||
if (search === '') {
|
||||
setFilteredChores(archivedChores)
|
||||
setSearchTerm('')
|
||||
return
|
||||
}
|
||||
|
||||
const term = search.toLowerCase()
|
||||
setSearchTerm(term)
|
||||
setFilteredChores(fuse.search(term).map(result => result.item))
|
||||
}
|
||||
|
||||
const handleSearchClose = () => {
|
||||
setSearchTerm('')
|
||||
setFilteredChores(archivedChores)
|
||||
searchInputRef.current?.blur()
|
||||
}
|
||||
|
||||
const handleChoreUpdated = (updatedChore, event) => {
|
||||
if (event === 'unarchive') {
|
||||
// Remove from archived list when unarchived
|
||||
const newArchivedChores = archivedChores.filter(
|
||||
chore => chore.id !== updatedChore.id,
|
||||
)
|
||||
const newFilteredChores = filteredChores.filter(
|
||||
chore => chore.id !== updatedChore.id,
|
||||
)
|
||||
setArchivedChores(newArchivedChores)
|
||||
setFilteredChores(newFilteredChores)
|
||||
|
||||
showSuccess({
|
||||
title: 'Task Restored',
|
||||
message: 'The task has been restored and is now active.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleChoreDeleted = deletedChore => {
|
||||
const newArchivedChores = archivedChores.filter(
|
||||
chore => chore.id !== deletedChore.id,
|
||||
)
|
||||
const newFilteredChores = filteredChores.filter(
|
||||
chore => chore.id !== deletedChore.id,
|
||||
)
|
||||
setArchivedChores(newArchivedChores)
|
||||
setFilteredChores(newFilteredChores)
|
||||
|
||||
showSuccess({
|
||||
title: 'Task Deleted',
|
||||
message: 'The archived task has been permanently deleted.',
|
||||
})
|
||||
}
|
||||
|
||||
// Multi-select helper functions
|
||||
const toggleMultiSelectMode = () => {
|
||||
const newMode = !isMultiSelectMode
|
||||
setIsMultiSelectMode(newMode)
|
||||
|
||||
if (!newMode) {
|
||||
setSelectedChores(new Set())
|
||||
}
|
||||
}
|
||||
|
||||
const toggleChoreSelection = choreId => {
|
||||
const newSelection = new Set(selectedChores)
|
||||
if (newSelection.has(choreId)) {
|
||||
newSelection.delete(choreId)
|
||||
} else {
|
||||
newSelection.add(choreId)
|
||||
}
|
||||
setSelectedChores(newSelection)
|
||||
}
|
||||
|
||||
const selectAllVisibleChores = () => {
|
||||
const visibleChores =
|
||||
searchTerm?.length > 0 ? filteredChores : archivedChores
|
||||
if (visibleChores.length > 0) {
|
||||
const allIds = new Set(visibleChores.map(chore => chore.id))
|
||||
setSelectedChores(allIds)
|
||||
}
|
||||
}
|
||||
|
||||
const clearSelection = () => {
|
||||
if (selectedChores.size === 0) {
|
||||
setIsMultiSelectMode(false)
|
||||
return
|
||||
}
|
||||
setSelectedChores(new Set())
|
||||
}
|
||||
|
||||
const getSelectedChoresData = () => {
|
||||
return Array.from(selectedChores)
|
||||
.map(id => archivedChores.find(chore => chore.id === id))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
// Bulk operations
|
||||
const handleBulkRestore = async () => {
|
||||
const selectedData = getSelectedChoresData()
|
||||
if (selectedData.length === 0) return
|
||||
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
title: 'Restore Tasks',
|
||||
confirmText: 'Restore',
|
||||
cancelText: 'Cancel',
|
||||
message: `Restore ${selectedData.length} task${selectedData.length > 1 ? 's' : ''} to active list?`,
|
||||
onClose: async isConfirmed => {
|
||||
if (isConfirmed === true) {
|
||||
try {
|
||||
const restoredTasks = []
|
||||
const failedTasks = []
|
||||
|
||||
for (const chore of selectedData) {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
unArchiveChore.mutate(chore.id, {
|
||||
onSuccess: data => {
|
||||
restoredTasks.push(chore)
|
||||
resolve(data)
|
||||
},
|
||||
onError: error => {
|
||||
failedTasks.push(chore)
|
||||
reject(error)
|
||||
},
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
// Error already handled in onError callback
|
||||
}
|
||||
}
|
||||
|
||||
if (restoredTasks.length > 0) {
|
||||
showSuccess({
|
||||
title: '📤 Tasks Restored',
|
||||
message: `Successfully restored ${restoredTasks.length} task${restoredTasks.length > 1 ? 's' : ''}.`,
|
||||
})
|
||||
|
||||
// Remove restored tasks from archived list
|
||||
const restoredIds = new Set(restoredTasks.map(c => c.id))
|
||||
const newArchivedChores = archivedChores.filter(
|
||||
c => !restoredIds.has(c.id),
|
||||
)
|
||||
const newFilteredChores = filteredChores.filter(
|
||||
c => !restoredIds.has(c.id),
|
||||
)
|
||||
setArchivedChores(newArchivedChores)
|
||||
setFilteredChores(newFilteredChores)
|
||||
}
|
||||
|
||||
if (failedTasks.length > 0) {
|
||||
showError({
|
||||
title: 'Some Tasks Failed',
|
||||
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be restored.`,
|
||||
})
|
||||
}
|
||||
|
||||
clearSelection()
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Bulk Restore 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 Archived Tasks',
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
message: `Permanently delete ${selectedData.length} archived 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))
|
||||
const newArchivedChores = archivedChores.filter(
|
||||
c => !deletedIds.has(c.id),
|
||||
)
|
||||
const newFilteredChores = filteredChores.filter(
|
||||
c => !deletedIds.has(c.id),
|
||||
)
|
||||
setArchivedChores(newArchivedChores)
|
||||
setFilteredChores(newFilteredChores)
|
||||
}
|
||||
|
||||
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({})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to render the appropriate card component
|
||||
const renderChoreCard = (chore, key) => {
|
||||
const CardComponent = viewMode === 'compact' ? CompactChoreCard : ChoreCard
|
||||
return (
|
||||
<CardComponent
|
||||
key={key || chore.id}
|
||||
chore={chore}
|
||||
onChoreUpdate={handleChoreUpdated}
|
||||
onChoreRemove={handleChoreDeleted}
|
||||
performers={performers}
|
||||
viewOnly={false}
|
||||
showActions={false}
|
||||
// Multi-select props
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
isSelected={selectedChores.has(chore.id)}
|
||||
onSelectionToggle={() => toggleChoreSelection(chore.id)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isUserProfileLoading || performers.length === 0 || isLoading) {
|
||||
return <LoadingComponent />
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth='md'>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
{/* <EmojiEvents sx={{ fontSize: '2rem', color: '#FFD700' }} /> */}
|
||||
<Stack sx={{ flex: 1 }}>
|
||||
<Typography
|
||||
level='h3'
|
||||
sx={{ fontWeight: 'lg', color: 'text.primary' }}
|
||||
>
|
||||
Archived Tasks
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
View and manage tasks that have been archived or completed.
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
{/* Header */}
|
||||
{/* <Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 2,
|
||||
pt: 2,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
level='h3'
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
|
||||
>
|
||||
<Archive />
|
||||
Archived Tasks
|
||||
</Typography>
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<Close />}
|
||||
onClick={() => navigate('/chores')}
|
||||
sx={{ ml: 'auto' }}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</Box> */}
|
||||
|
||||
{/* Search and Controls */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
slotProps={{ input: { ref: searchInputRef } }}
|
||||
placeholder='Search archived tasks'
|
||||
value={searchTerm}
|
||||
fullWidth
|
||||
sx={{
|
||||
borderRadius: 24,
|
||||
height: 24,
|
||||
borderColor: 'text.disabled',
|
||||
padding: 1,
|
||||
}}
|
||||
onChange={handleSearchChange}
|
||||
startDecorator={
|
||||
<KeyboardShortcutHint shortcut='F' show={showKeyboardShortcuts} />
|
||||
}
|
||||
endDecorator={
|
||||
searchTerm && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<KeyboardShortcutHint
|
||||
shortcut='X'
|
||||
show={showKeyboardShortcuts}
|
||||
/>
|
||||
<IconButton
|
||||
variant='plain'
|
||||
size='sm'
|
||||
onClick={handleSearchClose}
|
||||
sx={{ borderRadius: '50%' }}
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* View Mode Toggle Button */}
|
||||
<IconButton
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
sx={{
|
||||
height: 32,
|
||||
width: 32,
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
onClick={toggleViewMode}
|
||||
title={
|
||||
viewMode === 'default'
|
||||
? 'Switch to Compact View'
|
||||
: 'Switch to Card View'
|
||||
}
|
||||
>
|
||||
{viewMode === 'default' ? <ViewAgenda /> : <ViewModule />}
|
||||
</IconButton>
|
||||
|
||||
{/* Multi-select Toggle Button */}
|
||||
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
|
||||
<IconButton
|
||||
variant={isMultiSelectMode ? 'solid' : 'outlined'}
|
||||
color={isMultiSelectMode ? 'primary' : 'neutral'}
|
||||
size='sm'
|
||||
sx={{
|
||||
height: 32,
|
||||
width: 32,
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
onClick={toggleMultiSelectMode}
|
||||
title={
|
||||
isMultiSelectMode
|
||||
? 'Exit Multi-select Mode (Ctrl+S)'
|
||||
: 'Enable Multi-select Mode (Ctrl+S)'
|
||||
}
|
||||
>
|
||||
{isMultiSelectMode ? <CheckBox /> : <CheckBoxOutlineBlank />}
|
||||
</IconButton>
|
||||
<KeyboardShortcutHint
|
||||
shortcut='S'
|
||||
show={showKeyboardShortcuts}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Multi-select Toolbar */}
|
||||
{isMultiSelectMode && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 1000,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: 'background.surface',
|
||||
backdropFilter: 'blur(8px)',
|
||||
borderRadius: 'lg',
|
||||
p: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
boxShadow: 'm',
|
||||
gap: 2,
|
||||
display: 'flex',
|
||||
flexDirection: {
|
||||
sm: 'column',
|
||||
md: 'row',
|
||||
},
|
||||
alignItems: {
|
||||
xs: 'stretch',
|
||||
sm: 'center',
|
||||
},
|
||||
justifyContent: {
|
||||
xs: 'center',
|
||||
sm: 'space-between',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Selection Info and Controls */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
flexWrap: {
|
||||
xs: 'wrap',
|
||||
sm: 'nowrap',
|
||||
},
|
||||
justifyContent: {
|
||||
xs: 'center',
|
||||
sm: 'flex-start',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CheckBox sx={{ color: 'primary.500' }} />
|
||||
<Typography level='body-sm' fontWeight='md'>
|
||||
{selectedChores.size} task
|
||||
{selectedChores.size !== 1 ? 's' : ''} selected
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider
|
||||
orientation='vertical'
|
||||
sx={{
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
onClick={selectAllVisibleChores}
|
||||
startDecorator={<SelectAll />}
|
||||
disabled={selectedChores.size === filteredChores.length}
|
||||
sx={{
|
||||
minWidth: 'auto',
|
||||
'--Button-paddingInline': '0.75rem',
|
||||
position: 'relative',
|
||||
}}
|
||||
title='Select all visible tasks (Ctrl+A)'
|
||||
>
|
||||
All
|
||||
{showKeyboardShortcuts && (
|
||||
<KeyboardShortcutHint
|
||||
shortcut='A'
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
onClick={clearSelection}
|
||||
startDecorator={
|
||||
selectedChores.size === 0 ? (
|
||||
<Close />
|
||||
) : (
|
||||
<CheckBoxOutlineBlank />
|
||||
)
|
||||
}
|
||||
sx={{
|
||||
minWidth: 'auto',
|
||||
'--Button-paddingInline': '0.75rem',
|
||||
position: 'relative',
|
||||
}}
|
||||
title={`${selectedChores.size === 0 ? 'Close' : 'Clear'} multi-select (Esc)`}
|
||||
>
|
||||
{selectedChores.size === 0 ? 'Close' : 'Clear'}
|
||||
{showKeyboardShortcuts && (
|
||||
<KeyboardShortcutHint
|
||||
withCtrl={false}
|
||||
shortcut='Esc'
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flexWrap: {
|
||||
xs: 'wrap',
|
||||
sm: 'nowrap',
|
||||
},
|
||||
justifyContent: {
|
||||
xs: 'center',
|
||||
sm: 'flex-end',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='success'
|
||||
onClick={handleBulkRestore}
|
||||
startDecorator={<Unarchive />}
|
||||
disabled={selectedChores.size === 0}
|
||||
sx={{
|
||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||
position: 'relative',
|
||||
}}
|
||||
title='Restore selected tasks (R)'
|
||||
>
|
||||
Restore
|
||||
{showKeyboardShortcuts && selectedChores.size > 0 && (
|
||||
<KeyboardShortcutHint
|
||||
shortcut='R'
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={handleBulkDelete}
|
||||
startDecorator={<Delete />}
|
||||
disabled={selectedChores.size === 0}
|
||||
sx={{
|
||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||
position: 'relative',
|
||||
}}
|
||||
title='Delete selected tasks (E)'
|
||||
>
|
||||
Delete
|
||||
{showKeyboardShortcuts && selectedChores.size > 0 && (
|
||||
<KeyboardShortcutHint
|
||||
shortcut='E'
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{filteredChores.length === 0 ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
height: '50vh',
|
||||
}}
|
||||
>
|
||||
<Archive
|
||||
sx={{
|
||||
fontSize: '4rem',
|
||||
mb: 1,
|
||||
color: 'text.tertiary',
|
||||
}}
|
||||
/>
|
||||
<Typography level='title-md' gutterBottom>
|
||||
{searchTerm ? 'No archived tasks found' : 'No archived tasks'}
|
||||
</Typography>
|
||||
<Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}>
|
||||
{searchTerm
|
||||
? 'Try adjusting your search terms'
|
||||
: 'Archived tasks will appear here when you archive them from the main task list'}
|
||||
</Typography>
|
||||
{searchTerm && (
|
||||
<Button
|
||||
onClick={handleSearchClose}
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
>
|
||||
Clear search
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
<Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}>
|
||||
{filteredChores.length} archived task
|
||||
{filteredChores.length !== 1 ? 's' : ''}
|
||||
{searchTerm && ` matching "${searchTerm}"`}
|
||||
</Typography>
|
||||
|
||||
<List sx={{ gap: viewMode === 'compact' ? 0 : 1 }}>
|
||||
{filteredChores.map(chore =>
|
||||
renderChoreCard(chore, `archived-${chore.id}`),
|
||||
)}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Multi-select Help */}
|
||||
<MultiSelectHelp isVisible={isMultiSelectMode} />
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
{confirmModelConfig?.isOpen && (
|
||||
<ConfirmationModal config={confirmModelConfig} />
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default ArchivedTasks
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,128 +1,216 @@
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { LocalNotifications } from '@capacitor/local-notifications';
|
||||
import { Preferences } from '@capacitor/preferences';
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { LocalNotifications } from '@capacitor/local-notifications'
|
||||
import { Preferences } from '@capacitor/preferences'
|
||||
import murmurhash from 'murmurhash'
|
||||
|
||||
const getNotificationPreferences = async () => {
|
||||
const ret = await Preferences.get({ key: 'notificationPreferences' });
|
||||
return JSON.parse(ret.value);
|
||||
};
|
||||
|
||||
const canScheduleNotification = () => {
|
||||
if (Capacitor.isNativePlatform() === false) {
|
||||
return false;
|
||||
}
|
||||
const notificationPreferences = getNotificationPreferences();
|
||||
if (notificationPreferences["granted"] === false) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
const ret = await Preferences.get({ key: 'notificationPreferences' })
|
||||
return JSON.parse(ret.value)
|
||||
}
|
||||
|
||||
const canScheduleNotification = async () => {
|
||||
if (Capacitor.isNativePlatform() === false) {
|
||||
return false
|
||||
}
|
||||
const notificationPreferences = await getNotificationPreferences()
|
||||
console.log('Notification preferences:', notificationPreferences)
|
||||
|
||||
const scheduleChoreNotification = async (chores, userProfile,allPerformers) => {
|
||||
// for each chore will create local notification:
|
||||
const notifications = [];
|
||||
if (notificationPreferences['granted'] === false) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const getIdFromTemplate = (choreId, template) => {
|
||||
const hash = murmurhash.v3(`${choreId}-${template.value}-${template.unit}`)
|
||||
// Use Math.abs() with modulo to ensure positive ID within Java int range
|
||||
// This guarantees the ID is always positive and within 1 to 2^31-1
|
||||
return Math.abs(hash) % 2147483647
|
||||
}
|
||||
|
||||
const getTimeFromTemplate = (template, relativeTime) => {
|
||||
let time = relativeTime
|
||||
switch (template.unit) {
|
||||
case 'm':
|
||||
time = new Date(relativeTime.getTime() + template.value * 60 * 1000)
|
||||
break
|
||||
case 'h':
|
||||
time = new Date(relativeTime.getTime() + template.value * 60 * 60 * 1000)
|
||||
break
|
||||
case 'd':
|
||||
time = new Date(
|
||||
relativeTime.getTime() + template.value * 24 * 60 * 60 * 1000,
|
||||
)
|
||||
break
|
||||
default:
|
||||
time = relativeTime
|
||||
}
|
||||
return time
|
||||
}
|
||||
const scheduleNotificationFromTemplate = (
|
||||
chore,
|
||||
userProfile,
|
||||
allPerformers,
|
||||
notifications,
|
||||
) => {
|
||||
for (const template of chore.notificationMetadata?.templates || []) {
|
||||
// convert the template to time:
|
||||
const dueDate = new Date(chore.nextDueDate)
|
||||
const now = new Date()
|
||||
|
||||
const devicePreferences = await getNotificationPreferences();
|
||||
|
||||
for (let i = 0; i < chores.length; i++) {
|
||||
const time = getTimeFromTemplate(template, dueDate)
|
||||
const notificationId = getIdFromTemplate(chore.id, template)
|
||||
const { title, body } = getNotificationText(chore.name, template)
|
||||
if (time > now) {
|
||||
notifications.push({
|
||||
title,
|
||||
body: `${body} at ${time.toLocaleTimeString()}`,
|
||||
id: notificationId,
|
||||
allowWhileIdle: true,
|
||||
schedule: {
|
||||
at: time,
|
||||
},
|
||||
extra: {
|
||||
choreId: chore.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const chore = chores[i];
|
||||
const chorePreferences = JSON.parse(chore.notificationMetadata)
|
||||
if ( chore.notification ===false || chore.nextDueDate === null) {
|
||||
continue;
|
||||
const getNotificationText = (choreName, template = {}) => {
|
||||
// Determine notification type based on template value
|
||||
const getNotificationType = () => {
|
||||
if (!template || template.value === undefined) {
|
||||
return 'due'
|
||||
}
|
||||
|
||||
if (template.value < 0) {
|
||||
return 'reminder'
|
||||
} else if (template.value === 0) {
|
||||
return 'due'
|
||||
} else {
|
||||
return 'overdue'
|
||||
}
|
||||
}
|
||||
|
||||
const notificationType = getNotificationType()
|
||||
|
||||
// Truncate chore name if too long for better readability
|
||||
const maxChoreNameLength = 25
|
||||
const truncatedName =
|
||||
choreName.length > maxChoreNameLength
|
||||
? `${choreName.substring(0, maxChoreNameLength)}...`
|
||||
: choreName
|
||||
|
||||
// Generate time-based descriptive text
|
||||
const getTimeDescription = () => {
|
||||
if (!template || !template.value || !template.unit) {
|
||||
return 'soon'
|
||||
}
|
||||
|
||||
const { value, unit } = template
|
||||
const absValue = Math.abs(value)
|
||||
|
||||
switch (unit) {
|
||||
case 'm':
|
||||
if (absValue === 1) return value < 0 ? 'in 1 minute' : '1 minute ago'
|
||||
if (absValue < 60)
|
||||
return value < 0
|
||||
? `in ${absValue} minutes`
|
||||
: `${absValue} minutes ago`
|
||||
break
|
||||
case 'h':
|
||||
if (absValue === 1) return value < 0 ? 'in 1 hour' : '1 hour ago'
|
||||
if (absValue < 24)
|
||||
return value < 0 ? `in ${absValue} hours` : `${absValue} hours ago`
|
||||
break
|
||||
case 'd':
|
||||
if (absValue === 1) return value < 0 ? 'tomorrow' : 'yesterday'
|
||||
if (absValue === 7) return value < 0 ? 'next week' : 'last week'
|
||||
if (absValue < 7)
|
||||
return value < 0 ? `in ${absValue} days` : `${absValue} days ago`
|
||||
if (absValue < 30) {
|
||||
const weeks = Math.round(absValue / 7)
|
||||
return value < 0 ? `in ${weeks} weeks` : `${weeks} weeks ago`
|
||||
}
|
||||
scheduleDueNotification(chore, userProfile, allPerformers,chorePreferences,devicePreferences, notifications)
|
||||
schedulePreDueNotification(chore, userProfile, allPerformers,chorePreferences, devicePreferences,notifications)
|
||||
scheduleNaggingNotification(chore, userProfile, allPerformers,chorePreferences,devicePreferences, notifications)
|
||||
|
||||
|
||||
break
|
||||
default:
|
||||
return value < 0 ? `in ${absValue} ${unit}` : `${absValue} ${unit} ago`
|
||||
}
|
||||
LocalNotifications.schedule({
|
||||
|
||||
return value < 0 ? `in ${absValue} ${unit}` : `${absValue} ${unit} ago`
|
||||
}
|
||||
|
||||
const messages = {
|
||||
reminder: {
|
||||
title: `📋 ${truncatedName}`,
|
||||
body: `Reminder: Due ${getTimeDescription()}`,
|
||||
},
|
||||
due: {
|
||||
title: `🔔 ${truncatedName}`,
|
||||
body: 'Due now - Time to get started!',
|
||||
},
|
||||
overdue: {
|
||||
title: `❗ ${truncatedName}`,
|
||||
body: `Overdue ${getTimeDescription()} - Complete when you can`,
|
||||
},
|
||||
}
|
||||
|
||||
// Fallback to due if type not found
|
||||
const messageTemplate = messages[notificationType] || messages.due
|
||||
|
||||
return {
|
||||
title: messageTemplate.title,
|
||||
body: messageTemplate.body,
|
||||
}
|
||||
}
|
||||
const cancelPendingNotifications = async () => {
|
||||
try {
|
||||
const pending = await LocalNotifications.getPending()
|
||||
if (pending.notifications.length > 0) {
|
||||
await LocalNotifications.cancel({ notifications: pending.notifications })
|
||||
console.log('Cancelled pending notifications:', pending.notifications)
|
||||
} else {
|
||||
console.log('No pending notifications to cancel.')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cancelling pending notifications:', error)
|
||||
}
|
||||
}
|
||||
const scheduleChoreNotification = async (
|
||||
chores,
|
||||
userProfile,
|
||||
allPerformers,
|
||||
) => {
|
||||
await cancelPendingNotifications()
|
||||
const notifications = []
|
||||
|
||||
for (let i = 0; i < chores.length; i++) {
|
||||
const chore = chores[i]
|
||||
try {
|
||||
if (chore.notification === false || chore.nextDueDate === null) {
|
||||
continue
|
||||
}
|
||||
scheduleNotificationFromTemplate(
|
||||
chore,
|
||||
userProfile,
|
||||
allPerformers,
|
||||
notifications,
|
||||
});
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Error parsing notification metadata for chore:',
|
||||
chore.id,
|
||||
error,
|
||||
)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
LocalNotifications.schedule({
|
||||
notifications,
|
||||
})
|
||||
return notifications
|
||||
}
|
||||
|
||||
const scheduleDueNotification = (chore, userProfile, allPerformers,chorePreferences,devicePreferences, notifications) => {
|
||||
|
||||
if (devicePreferences['dueNotification'] !== true || chorePreferences['dueDate'] !== true){
|
||||
return
|
||||
}
|
||||
|
||||
const nextDueDate = new Date(chore.nextDueDate)
|
||||
const diff = nextDueDate - now
|
||||
|
||||
if (diff < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const notification = {
|
||||
title: `${chore.name} is due! 🕒`,
|
||||
body: userProfile.id === chore.assignedTo ? `It's assigned to you!` : `It is ${allPerformers[chore.assignedTo].name}'s turn`,
|
||||
id: chore.id,
|
||||
allowWhileIdle: true,
|
||||
schedule: {
|
||||
at: new Date(chore.nextDueDate),
|
||||
},
|
||||
extra: {
|
||||
choreId: chore.id,
|
||||
},
|
||||
};
|
||||
notifications.push(notification);
|
||||
}
|
||||
|
||||
const schedulePreDueNotification = (chore, userProfile, allPerformers,chorePreferences,devicePreferences, notifications) => {
|
||||
if (devicePreferences['preDueNotification'] !== true || chorePreferences['preDue'] !== true){
|
||||
return
|
||||
}
|
||||
|
||||
const nextDueDate = new Date(chore.nextDueDate)
|
||||
const diff = nextDueDate - now
|
||||
|
||||
if (diff < 0 || userProfile.id !== chore.assignedTo) {
|
||||
return
|
||||
}
|
||||
|
||||
const notification = {
|
||||
title: `${chore.name} is due soon! 🕒`,
|
||||
body: `is due at ${nextDueDate.toLocaleTimeString()}`,
|
||||
id: chore.id,
|
||||
allowWhileIdle: true,
|
||||
schedule: {
|
||||
// 1 hour before
|
||||
at: new Date(nextDueDate - 60 * 60 * 1000),
|
||||
},
|
||||
extra: {
|
||||
choreId: chore.id,
|
||||
},
|
||||
};
|
||||
notifications.push(notification);
|
||||
}
|
||||
const scheduleNaggingNotification = (chore, userProfile, allPerformers,chorePreferences,devicePreferences, notifications) => {
|
||||
if (devicePreferences['naggingNotification'] === false || chorePreferences.nagging !== true){
|
||||
return
|
||||
}
|
||||
const nextDueDate = new Date(chore.nextDueDate)
|
||||
const diff = nextDueDate - now
|
||||
|
||||
if (diff > 0 || userProfile.id !== chore.assignedTo) {
|
||||
return
|
||||
}
|
||||
|
||||
const notification = {
|
||||
title: `${chore.name} is overdue! 🕒`,
|
||||
body: `❗ It was due at ${nextDueDate.toLocaleTimeString()}`,
|
||||
id: chore.id,
|
||||
allowWhileIdle: true,
|
||||
schedule: {
|
||||
at: new Date(chore.nextDueDate),
|
||||
},
|
||||
extra: {
|
||||
choreId: chore.id,
|
||||
},
|
||||
};
|
||||
notifications.push(notification);
|
||||
}
|
||||
|
||||
export{ scheduleChoreNotification, canScheduleNotification }
|
||||
export { canScheduleNotification, scheduleChoreNotification }
|
||||
|
||||
170
src/views/Chores/MultiSelectHelp.jsx
Normal file
170
src/views/Chores/MultiSelectHelp.jsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { Close, HelpOutline, Keyboard } from '@mui/icons-material'
|
||||
import { Box, Button, Card, Divider, IconButton, Typography } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
|
||||
const MultiSelectHelp = ({ isVisible = true }) => {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
|
||||
const [isHelpOpen, setIsHelpOpen] = useState(false)
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Help Button */}
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
onClick={() => setIsHelpOpen(true)}
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 24,
|
||||
right: 24,
|
||||
zIndex: 1000,
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: '50%',
|
||||
boxShadow: 'lg',
|
||||
}}
|
||||
title='Show keyboard shortcuts'
|
||||
>
|
||||
<HelpOutline />
|
||||
</IconButton>
|
||||
|
||||
{/* Help Modal */}
|
||||
<ResponsiveModal open={isHelpOpen} onClose={() => setIsHelpOpen(false)}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Keyboard color='primary' />
|
||||
<Typography level='title-lg'>Multi-select Mode</Typography>
|
||||
</Box>
|
||||
<IconButton
|
||||
variant='plain'
|
||||
size='sm'
|
||||
onClick={() => setIsHelpOpen(false)}
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Typography level='body-md' sx={{ mb: 3, color: 'text.secondary' }}>
|
||||
Use these keyboard shortcuts to work more efficiently with multiple
|
||||
tasks:
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{/* Selection shortcuts */}
|
||||
<Card variant='soft' sx={{ p: 2 }}>
|
||||
<Typography level='title-sm' sx={{ mb: 1.5, color: 'primary.600' }}>
|
||||
Selection
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<ShortcutItem
|
||||
keys={['Ctrl', 'A']}
|
||||
description='Select all visible tasks'
|
||||
/>
|
||||
<ShortcutItem
|
||||
keys={['Esc']}
|
||||
description='Clear selection or exit multi-select mode'
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* Action shortcuts */}
|
||||
<Card variant='soft' sx={{ p: 2 }}>
|
||||
<Typography level='title-sm' sx={{ mb: 1.5, color: 'success.600' }}>
|
||||
Actions
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<ShortcutItem
|
||||
keys={['Enter']}
|
||||
description='Mark selected tasks as completed'
|
||||
/>
|
||||
<ShortcutItem
|
||||
keys={['Del', '⌫']}
|
||||
description='Delete selected tasks'
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* Interface shortcuts */}
|
||||
<Card variant='soft' sx={{ p: 2 }}>
|
||||
<Typography level='title-sm' sx={{ mb: 1.5, color: 'warning.600' }}>
|
||||
Interface
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<ShortcutItem
|
||||
keys={['Ctrl', 'K']}
|
||||
description='Quick add new task'
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
<Divider sx={{ my: 3 }} />
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Button
|
||||
variant='soft'
|
||||
onClick={() => setIsHelpOpen(false)}
|
||||
sx={{ minWidth: 120 }}
|
||||
>
|
||||
Got it!
|
||||
</Button>
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ShortcutItem = ({ keys, description }) => (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center' }}>
|
||||
<Typography level='body-sm'>{description}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
{keys.map((key, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}
|
||||
>
|
||||
{index > 0 && (
|
||||
<Typography level='body-xs' color='text.secondary'>
|
||||
+
|
||||
</Typography>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
bgcolor: 'background.level2',
|
||||
borderRadius: 'sm',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
minWidth: 32,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-xs' fontWeight='bold'>
|
||||
{key}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
export default MultiSelectHelp
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,78 +1,94 @@
|
||||
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, Snackbar, Stack, Typography } from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { registerPushNotifications } from '../../CapacitorListener'
|
||||
|
||||
const NotificationAccessSnackbar = () => {
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
// Define the function outside of useEffect
|
||||
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;
|
||||
useEffect(() => {
|
||||
// Only run the effect on native platforms
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
getNotificationPreferences().then(data => {
|
||||
// if optOut is true then don't show the snackbar
|
||||
if (data?.optOut === true || data?.granted === true) {
|
||||
return
|
||||
}
|
||||
setOpen(true)
|
||||
})
|
||||
}
|
||||
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 early if not on a native platform
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
// autoHideDuration={5000}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="lg"
|
||||
variant='solid'
|
||||
color='primary'
|
||||
size='lg'
|
||||
invertedColors
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
sx={(theme) => ({
|
||||
sx={theme => ({
|
||||
background: `linear-gradient(45deg, ${theme.palette.primary[600]} 30%, ${theme.palette.primary[500]} 90%})`,
|
||||
maxWidth: 360,
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
<Typography level="title-lg">Need Notification?</Typography>
|
||||
<Typography level='title-lg'>Need Notification?</Typography>
|
||||
<Typography sx={{ mt: 1, mb: 2 }}>
|
||||
You need to enable permission to receive notifications, do you want to enable it?
|
||||
You need to enable permission to receive notifications, do you want to
|
||||
enable it?
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button variant="solid" color="primary" onClick={() => {
|
||||
const notificationPreferences = { optOut: false };
|
||||
LocalNotifications.requestPermissions().then((resp) => {
|
||||
<Stack direction='row' spacing={1}>
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
onClick={async () => {
|
||||
const notificationPreferences = { optOut: false }
|
||||
try {
|
||||
const resp = await LocalNotifications.requestPermissions()
|
||||
if (resp.display === 'granted') {
|
||||
notificationPreferences['granted'] = true;
|
||||
notificationPreferences['granted'] = true
|
||||
// Register for push notifications after local permission is granted
|
||||
await registerPushNotifications()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error setting up notifications:', error)
|
||||
}
|
||||
|
||||
await Preferences.set({
|
||||
key: 'notificationPreferences',
|
||||
value: JSON.stringify(notificationPreferences),
|
||||
})
|
||||
Preferences.set({ key: 'notificationPreferences', value: JSON.stringify(notificationPreferences) });
|
||||
setOpen(false);
|
||||
}}>
|
||||
Yes
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
Yes
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
variant='outlined'
|
||||
color='primary'
|
||||
onClick={() => {
|
||||
const notificationPreferences = { optOut: true };
|
||||
Preferences.set({ key: 'notificationPreferences', value: JSON.stringify(notificationPreferences) });
|
||||
setOpen(false);
|
||||
const notificationPreferences = { optOut: true }
|
||||
Preferences.set({
|
||||
key: 'notificationPreferences',
|
||||
value: JSON.stringify(notificationPreferences),
|
||||
})
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
No, Keep it Disabled
|
||||
@@ -80,8 +96,7 @@ return (
|
||||
</Stack>
|
||||
</div>
|
||||
</Snackbar>
|
||||
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export default NotificationAccessSnackbar;
|
||||
export default NotificationAccessSnackbar
|
||||
|
||||
@@ -3,13 +3,16 @@ import { useMediaQuery } from '@mui/material'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useChoresHistory } from '../../queries/ChoreQueries'
|
||||
import { ChoresGrouper } from '../../utils/Chores'
|
||||
import CalendarView from '../components/CalendarView'
|
||||
import { getSidepanelConfig } from '../../utils/SidepanelConfig'
|
||||
import CalendarCard from '../components/CalendarCard'
|
||||
import ActivitiesCard from './ActivitesCard'
|
||||
import WelcomeCard from './WelcomeCard'
|
||||
import TasksByAssigneeCard from './TasksByAssigneeCard'
|
||||
import UserSwitcher from './UserSwitcher'
|
||||
|
||||
const Sidepanel = ({ chores }) => {
|
||||
const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('md'))
|
||||
const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('lg'))
|
||||
const [dueDatePieChartData, setDueDatePieChartData] = useState([])
|
||||
const [sidepanelConfig, setSidepanelConfig] = useState([])
|
||||
const {
|
||||
data: choresHistory,
|
||||
isChoresHistoryLoading,
|
||||
@@ -18,6 +21,18 @@ const Sidepanel = ({ chores }) => {
|
||||
|
||||
useEffect(() => {
|
||||
setDueDatePieChartData(generateChoreDuePieChartData(chores))
|
||||
setSidepanelConfig(getSidepanelConfig())
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleConfigChange = () => {
|
||||
setSidepanelConfig(getSidepanelConfig())
|
||||
}
|
||||
|
||||
window.addEventListener('sidepanelConfigChanged', handleConfigChange)
|
||||
return () => {
|
||||
window.removeEventListener('sidepanelConfigChanged', handleConfigChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const generateChoreDuePieChartData = chores => {
|
||||
@@ -34,34 +49,60 @@ const Sidepanel = ({ chores }) => {
|
||||
.filter(item => item.value > 0)
|
||||
}
|
||||
|
||||
const renderCard = cardConfig => {
|
||||
if (!cardConfig.enabled) return null
|
||||
|
||||
switch (cardConfig.id) {
|
||||
case 'welcome':
|
||||
return <UserSwitcher key='welcome' chores={chores} />
|
||||
case 'assignees':
|
||||
return <TasksByAssigneeCard key='assignees' chores={chores} />
|
||||
case 'calendar':
|
||||
return (
|
||||
<Sheet
|
||||
key='calendar'
|
||||
variant='plain'
|
||||
sx={{
|
||||
my: 1,
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
mr: 10,
|
||||
justifyContent: 'space-between',
|
||||
boxShadow: 'sm',
|
||||
borderRadius: 20,
|
||||
width: '315px',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{ width: '100%', overflowY: 'hidden', overflowX: 'hidden' }}
|
||||
>
|
||||
<CalendarCard chores={chores} />
|
||||
</Box>
|
||||
</Sheet>
|
||||
)
|
||||
case 'activities':
|
||||
return (
|
||||
<ActivitiesCard
|
||||
key='activities'
|
||||
chores={chores}
|
||||
choreHistory={choresHistory}
|
||||
/>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (!isLargeScreen) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<Box>
|
||||
<WelcomeCard chores={chores} />
|
||||
<Sheet
|
||||
variant='plain'
|
||||
sx={{
|
||||
my: 1,
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
mr: 10,
|
||||
justifyContent: 'space-between',
|
||||
boxShadow: 'sm',
|
||||
borderRadius: 20,
|
||||
width: '315px',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: '100%', overflowY: 'hidden' }}>
|
||||
<CalendarView chores={chores} />
|
||||
</Box>
|
||||
</Sheet>
|
||||
<ActivitiesCard chores={chores} choreHistory={choresHistory} />
|
||||
</Box>
|
||||
)
|
||||
|
||||
const sortedCards = [...sidepanelConfig].sort((a, b) => a.order - b.order)
|
||||
|
||||
return <Box>{sortedCards.map(cardConfig => renderCard(cardConfig))}</Box>
|
||||
}
|
||||
|
||||
export default Sidepanel
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import IconButton from '@mui/joy/IconButton'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||
|
||||
const SortAndGrouping = ({
|
||||
@@ -100,6 +100,7 @@ const SortAndGrouping = ({
|
||||
</MenuItem>
|
||||
|
||||
{[
|
||||
{ name: 'Smart', value: 'default' },
|
||||
{ name: 'Due Date', value: 'due_date' },
|
||||
{ name: 'Priority', value: 'priority' },
|
||||
{ name: 'Labels', value: 'labels' },
|
||||
@@ -144,7 +145,7 @@ const SortAndGrouping = ({
|
||||
|
||||
<MenuItem key={`${k}-assignee-title`} disabled>
|
||||
<Typography level='body-xs' fontWeight='md'>
|
||||
Assigned to:
|
||||
Assigned to :
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
|
||||
@@ -173,6 +174,20 @@ const SortAndGrouping = ({
|
||||
<Typography level='body-sm'>Assigned to me</Typography>
|
||||
</MenuItem>
|
||||
|
||||
{/* <MenuItem
|
||||
key={`${k}-assignee-assignable-to-me`}
|
||||
onClick={() => {
|
||||
setFilter('assignable_to_me')
|
||||
handleMenuClose()
|
||||
}}
|
||||
>
|
||||
<Radio
|
||||
checked={selectedFilter === 'assignable_to_me'}
|
||||
variant='outlined'
|
||||
/>
|
||||
<Typography level='body-sm'>Available for me</Typography>
|
||||
</MenuItem> */}
|
||||
|
||||
<MenuItem
|
||||
key={`${k}-assignee-assigned-to-others`}
|
||||
onClick={() => {
|
||||
@@ -186,6 +201,21 @@ const SortAndGrouping = ({
|
||||
/>
|
||||
<Typography level='body-sm'>Assigned to others</Typography>
|
||||
</MenuItem>
|
||||
{/*
|
||||
// i need this but i think it have a bad UX and confusing so commenting it for now
|
||||
<MenuItem
|
||||
key={`${k}-assignee-created-by-me`}
|
||||
onClick={() => {
|
||||
setFilter('created_by_me')
|
||||
handleMenuClose()
|
||||
}}
|
||||
>
|
||||
<Radio
|
||||
checked={selectedFilter === 'created_by_me'}
|
||||
variant='outlined'
|
||||
/>
|
||||
<Typography level='body-sm'>Created by me</Typography>
|
||||
</MenuItem> */}
|
||||
</Menu>
|
||||
</>
|
||||
)
|
||||
|
||||
398
src/views/Chores/TasksByAssigneeCard.jsx
Normal file
398
src/views/Chores/TasksByAssigneeCard.jsx
Normal file
@@ -0,0 +1,398 @@
|
||||
import { BarChart, Person } from '@mui/icons-material'
|
||||
import { Avatar, Box, Sheet, Typography } from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCircleMembers } from '../../queries/UserQueries'
|
||||
import { TASK_COLOR } from '../../utils/Colors'
|
||||
import { resolvePhotoURL } from '../../utils/Helpers'
|
||||
|
||||
const TasksByAssigneeCard = ({ chores = [] }) => {
|
||||
const [assigneeData, setAssigneeData] = useState([])
|
||||
const { data: circleMembersData, isLoading: isCircleMembersLoading } =
|
||||
useCircleMembers()
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isCircleMembersLoading &&
|
||||
circleMembersData?.res &&
|
||||
chores.length > 0
|
||||
) {
|
||||
const members = circleMembersData.res
|
||||
const data = processTasksByAssignee(chores, members)
|
||||
setAssigneeData(data)
|
||||
}
|
||||
}, [chores, circleMembersData, isCircleMembersLoading])
|
||||
|
||||
const processTasksByAssignee = (chores, members) => {
|
||||
const assigneeStats = {}
|
||||
|
||||
// Initialize stats for all members
|
||||
members.forEach(member => {
|
||||
assigneeStats[member.userId] = {
|
||||
id: member.userId,
|
||||
name: member.displayName || member.name,
|
||||
image: member.image,
|
||||
inProgress: 0,
|
||||
overdue: 0,
|
||||
scheduled: 0,
|
||||
pendingReview: 0,
|
||||
total: 0,
|
||||
}
|
||||
})
|
||||
|
||||
// Count tasks by status for each assignee
|
||||
chores.forEach(chore => {
|
||||
if (chore.assignedTo && assigneeStats[chore.assignedTo]) {
|
||||
const assignee = assigneeStats[chore.assignedTo]
|
||||
assignee.total++
|
||||
|
||||
// Map chore status to our categories based on your system
|
||||
if (chore.status === 3) {
|
||||
// Pending approval/review
|
||||
assignee.pendingReview++
|
||||
} else if (chore.status === 1 || chore.status === 2) {
|
||||
// In progress (started or paused)
|
||||
assignee.inProgress++
|
||||
} else if (
|
||||
chore.nextDueDate &&
|
||||
new Date(chore.nextDueDate) < new Date()
|
||||
) {
|
||||
// Overdue - past due date
|
||||
assignee.overdue++
|
||||
} else {
|
||||
// Scheduled/planned - future due date or no due date
|
||||
assignee.scheduled++
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Filter out members with no tasks and sort by total tasks
|
||||
return Object.values(assigneeStats)
|
||||
.filter(assignee => assignee.total > 0)
|
||||
.sort((a, b) => b.total - a.total)
|
||||
}
|
||||
|
||||
const getStatusColor = status => {
|
||||
switch (status) {
|
||||
case 'inProgress':
|
||||
return TASK_COLOR.IN_PROGRESS
|
||||
case 'overdue':
|
||||
return TASK_COLOR.OVERDUE
|
||||
case 'scheduled':
|
||||
return TASK_COLOR.COMPLETED
|
||||
case 'pendingReview':
|
||||
return TASK_COLOR.PENDING_REVIEW
|
||||
default:
|
||||
return TASK_COLOR.DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
const maxTasks = Math.max(...assigneeData.map(a => a.total), 1)
|
||||
|
||||
if (isCircleMembersLoading) {
|
||||
return (
|
||||
<Sheet
|
||||
variant='plain'
|
||||
sx={{
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxShadow: 'sm',
|
||||
borderRadius: 20,
|
||||
width: '315px',
|
||||
minHeight: 300,
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm' color='neutral'>
|
||||
Loading tasks by assignee...
|
||||
</Typography>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
if (assigneeData.length === 0) {
|
||||
return (
|
||||
<Sheet
|
||||
variant='plain'
|
||||
sx={{
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: 'sm',
|
||||
borderRadius: 20,
|
||||
width: '315px',
|
||||
minHeight: 300,
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Person sx={{ fontSize: 48, opacity: 0.3, mb: 1 }} />
|
||||
<Typography level='body-sm' color='neutral'>
|
||||
No assigned tasks found
|
||||
</Typography>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
variant='plain'
|
||||
sx={{
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxShadow: 'sm',
|
||||
borderRadius: 20,
|
||||
width: '315px',
|
||||
minHeight: 300,
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<BarChart color='' />
|
||||
<Typography level='title-md'>Tasks by Assignee</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Legend */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: 1,
|
||||
mb: 3,
|
||||
px: 1,
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{
|
||||
key: 'inProgress',
|
||||
label: 'In Progress',
|
||||
color: getStatusColor('inProgress'),
|
||||
},
|
||||
{
|
||||
key: 'overdue',
|
||||
label: 'Overdue',
|
||||
color: getStatusColor('overdue'),
|
||||
},
|
||||
{
|
||||
key: 'scheduled',
|
||||
label: 'Scheduled',
|
||||
color: getStatusColor('scheduled'),
|
||||
},
|
||||
{
|
||||
key: 'pendingReview',
|
||||
label: 'Pending Review',
|
||||
color: getStatusColor('pendingReview'),
|
||||
},
|
||||
].map(status => (
|
||||
<Box
|
||||
key={status.key}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: status.color,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
fontSize: '10px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{status.label}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Chart Container */}
|
||||
<Box sx={{ position: 'relative', height: 200 }}>
|
||||
{/* Chart */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'end',
|
||||
gap: 1,
|
||||
height: '100%',
|
||||
pl: 4,
|
||||
pr: 2,
|
||||
pt: 2,
|
||||
}}
|
||||
>
|
||||
{assigneeData.slice(0, 6).map((assignee, index) => {
|
||||
const barHeight = Math.max((assignee.total / maxTasks) * 140, 8)
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={assignee.id}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
maxWidth: 45,
|
||||
}}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<Avatar
|
||||
size='sm'
|
||||
src={resolvePhotoURL(assignee.image)}
|
||||
sx={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
mb: 1,
|
||||
border: '2px solid white',
|
||||
boxShadow: 'sm',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
{assignee.name?.charAt(0) || <Person />}
|
||||
</Avatar>
|
||||
|
||||
{/* Stacked bars */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
height: barHeight,
|
||||
width: '100%',
|
||||
maxWidth: 28,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid #E5E7EB',
|
||||
backgroundColor: '#F9FAFB',
|
||||
}}
|
||||
>
|
||||
{/* Pending Review - bottom */}
|
||||
{assignee.pendingReview > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: `${(assignee.pendingReview / assignee.total) * 100}%`,
|
||||
backgroundColor: getStatusColor('pendingReview'),
|
||||
order: 4,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Scheduled */}
|
||||
{assignee.scheduled > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: `${(assignee.scheduled / assignee.total) * 100}%`,
|
||||
backgroundColor: getStatusColor('scheduled'),
|
||||
order: 3,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* In Progress */}
|
||||
{assignee.inProgress > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: `${(assignee.inProgress / assignee.total) * 100}%`,
|
||||
backgroundColor: getStatusColor('inProgress'),
|
||||
order: 2,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Overdue - top */}
|
||||
{assignee.overdue > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: `${(assignee.overdue / assignee.total) * 100}%`,
|
||||
backgroundColor: getStatusColor('overdue'),
|
||||
order: 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Name */}
|
||||
<Box sx={{ mt: 1, textAlign: 'center', width: '100%' }}>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
fontSize: '10px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{assignee.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{/* Y-axis labels */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
bottom: 20,
|
||||
height: 140,
|
||||
width: 32,
|
||||
pr: 0.5,
|
||||
}}
|
||||
>
|
||||
{[0, 20, 40, 60, 80, 100].map((value, index) => {
|
||||
const yPosition = (value / 100) * 140
|
||||
return (
|
||||
<Typography
|
||||
key={value}
|
||||
level='body-xs'
|
||||
sx={{
|
||||
fontSize: '9px',
|
||||
color: 'text.secondary',
|
||||
lineHeight: 1,
|
||||
position: 'absolute',
|
||||
bottom: `${yPosition}px`,
|
||||
right: 4,
|
||||
transform: 'translateY(50%)',
|
||||
}}
|
||||
>
|
||||
{Math.round((value / 100) * maxTasks)}
|
||||
</Typography>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
export default TasksByAssigneeCard
|
||||
@@ -1,35 +1,29 @@
|
||||
import { Person } from '@mui/icons-material'
|
||||
import { SupervisorAccount } from '@mui/icons-material'
|
||||
import { Avatar, Box, Button, Sheet, Typography } from '@mui/joy'
|
||||
|
||||
import { useContext, useEffect, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext'
|
||||
import { UserContext } from '../../contexts/UserContext'
|
||||
import { useCircleMembers } from '../../queries/UserQueries'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||
import UserModal from '../Modals/Inputs/UserModal'
|
||||
const WelcomeCard = () => {
|
||||
const { impersonatedUser, setImpersonatedUser } = useImpersonateUser()
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
const { userProfile } = useContext(UserContext)
|
||||
const UserSwitcher = () => {
|
||||
const {
|
||||
impersonatedUser,
|
||||
isImpersonating,
|
||||
startImpersonation,
|
||||
stopImpersonation,
|
||||
canImpersonate
|
||||
} = useImpersonateUser()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
|
||||
const { data: circleMembersData, isLoading: isCircleMembersLoading } =
|
||||
useCircleMembers()
|
||||
|
||||
useEffect(() => {
|
||||
if (userProfile && userProfile?.id) {
|
||||
const members = circleMembersData?.res || []
|
||||
const isUserAdmin = members.some(
|
||||
member =>
|
||||
member.userId === userProfile?.id &&
|
||||
(member.role === 'admin' || member.role === 'manager'),
|
||||
)
|
||||
|
||||
setIsAdmin(isUserAdmin)
|
||||
}
|
||||
}, [userProfile, circleMembersData])
|
||||
// Check if current user can impersonate
|
||||
const isAdmin = canImpersonate(userProfile, circleMembersData?.res)
|
||||
if (!isAdmin) {
|
||||
return null
|
||||
} else if (isCircleMembersLoading || impersonatedUser === null) {
|
||||
} else if (isCircleMembersLoading || !isImpersonating) {
|
||||
return (
|
||||
<Sheet
|
||||
variant='plain'
|
||||
@@ -57,13 +51,16 @@ const WelcomeCard = () => {
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Person color='' />
|
||||
<Typography level='title-md'>Current User</Typography>
|
||||
<SupervisorAccount color='' />
|
||||
<Typography level='title-md'>View tasks as</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography level='title-md' sx={{ mb: 0.5 }}>
|
||||
Who's checking in?
|
||||
Switch to user view
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ mb: 1, color: 'text.secondary' }}>
|
||||
Tasks will be filtered to show only assignments for selected user
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
@@ -72,13 +69,13 @@ const WelcomeCard = () => {
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
size='sm'
|
||||
>
|
||||
Select User
|
||||
Choose User
|
||||
</Button>
|
||||
<UserModal
|
||||
isOpen={isModalOpen}
|
||||
performers={circleMembersData?.res}
|
||||
onSelect={user => {
|
||||
setImpersonatedUser(user)
|
||||
startImpersonation(user, userProfile)
|
||||
setIsModalOpen(false)
|
||||
}}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
@@ -122,8 +119,8 @@ const WelcomeCard = () => {
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Person color='' />
|
||||
<Typography level='title-md'>Current User</Typography>
|
||||
<SupervisorAccount color='' />
|
||||
<Typography level='title-md'>View tasks as</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -164,7 +161,7 @@ const WelcomeCard = () => {
|
||||
size='sm'
|
||||
sx={{ ml: 0.5 }}
|
||||
onClick={() => {
|
||||
setImpersonatedUser(null)
|
||||
stopImpersonation()
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
@@ -177,7 +174,7 @@ const WelcomeCard = () => {
|
||||
isOpen={isModalOpen}
|
||||
performers={circleMembersData?.res}
|
||||
onSelect={user => {
|
||||
setImpersonatedUser(user)
|
||||
startImpersonation(user, userProfile)
|
||||
setIsModalOpen(false)
|
||||
}}
|
||||
onClose={() => {
|
||||
@@ -187,4 +184,4 @@ const WelcomeCard = () => {
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
export default WelcomeCard
|
||||
export default UserSwitcher
|
||||
@@ -2,12 +2,20 @@ import { Box, Container, Input, Sheet, Typography } from '@mui/joy'
|
||||
import Logo from '../../Logo'
|
||||
|
||||
import { Button } from '@mui/joy'
|
||||
import { useContext } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { UserContext } from '../../contexts/UserContext'
|
||||
import useAcknowledgmentModal from '../../hooks/useAcknowledgmentModal'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { JoinCircle } from '../../utils/Fetcher'
|
||||
import AcknowledgmentModal from '../Modals/Inputs/AcknowledgmentModal'
|
||||
|
||||
const JoinCircleView = () => {
|
||||
const { userProfile, setUserProfile } = useContext(UserContext)
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const { showError } = useNotification()
|
||||
const { ackModalConfig, showAcknowledgment } = useAcknowledgmentModal()
|
||||
const [isJoining, setIsJoining] = useState(false)
|
||||
|
||||
let [searchParams, setSearchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const code = searchParams.get('code')
|
||||
@@ -79,25 +87,31 @@ const JoinCircleView = () => {
|
||||
fullWidth
|
||||
size='lg'
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
disabled={isJoining}
|
||||
onClick={() => {
|
||||
setIsJoining(true)
|
||||
JoinCircle(code).then(resp => {
|
||||
if (resp.ok) {
|
||||
alert(
|
||||
'Joined circle successfully, wait for the circle owner to accept your request.',
|
||||
showAcknowledgment(
|
||||
'Your join request has been sent successfully! The circle admin will need to approve your request before you can access the circle and its chores. You will receive a notification once your request is approved.',
|
||||
'Join Request Sent!',
|
||||
() => navigate('/'),
|
||||
'Got it',
|
||||
'success',
|
||||
)
|
||||
navigate('/my/chores')
|
||||
} else {
|
||||
setIsJoining(false)
|
||||
if (resp.status === 409) {
|
||||
alert('You are already a member of this circle')
|
||||
showError('You are already a member of this circle')
|
||||
} else {
|
||||
alert('Failed to join circle')
|
||||
showError('Failed to join circle')
|
||||
}
|
||||
navigate('/my/chores')
|
||||
navigate('/')
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
Join Circle
|
||||
{isJoining ? 'Joining...' : 'Join Circle'}
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
@@ -111,7 +125,7 @@ const JoinCircleView = () => {
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
onClick={() => {
|
||||
navigate('/my/chores')
|
||||
navigate('/chores')
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
@@ -141,6 +155,7 @@ const JoinCircleView = () => {
|
||||
))}
|
||||
</Sheet>
|
||||
</Box>
|
||||
<AcknowledgmentModal config={ackModalConfig} />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { HomeRounded, Login } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Container,
|
||||
Textarea,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { Box, Button, CircularProgress, Container, Typography } from '@mui/joy'
|
||||
import { Link } from 'react-router-dom'
|
||||
import Logo from '../Logo' // Adjust the import path as necessary
|
||||
|
||||
@@ -50,7 +43,7 @@ const Error = () => {
|
||||
|
||||
<Button
|
||||
component={Link}
|
||||
to='/my/chores'
|
||||
to='/chores'
|
||||
variant='outlined'
|
||||
color='primary'
|
||||
sx={{ mt: 4 }}
|
||||
|
||||
@@ -1,68 +1,88 @@
|
||||
import { Checklist, EventBusy, Group, Timelapse } from '@mui/icons-material'
|
||||
import {
|
||||
Avatar,
|
||||
Analytics,
|
||||
Checklist,
|
||||
EventBusy,
|
||||
Group,
|
||||
History,
|
||||
Star,
|
||||
Timelapse,
|
||||
TrendingUp,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Card,
|
||||
Container,
|
||||
Grid,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemContent,
|
||||
Sheet,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import useConfirmationModal from '../../hooks/useConfirmationModal'
|
||||
import { ChoreHistoryStatus } from '../../utils/Chores'
|
||||
import {
|
||||
DeleteChoreHistory,
|
||||
GetAllCircleMembers,
|
||||
GetChoreHistory,
|
||||
UpdateChoreHistory,
|
||||
} from '../../utils/Fetcher'
|
||||
useChoreHistory,
|
||||
useDeleteChoreHistory,
|
||||
useUpdateChoreHistory,
|
||||
} from '../../queries/ChoreQueries'
|
||||
import { useCircleMembers } from '../../queries/UserQueries'
|
||||
import LoadingComponent from '../components/Loading'
|
||||
import EditHistoryModal from '../Modals/EditHistoryModal'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import HistoryCard from './HistoryCard'
|
||||
|
||||
const ChoreHistory = () => {
|
||||
const [choreHistory, setChoresHistory] = useState([])
|
||||
const [userHistory, setUserHistory] = useState([])
|
||||
const [performers, setPerformers] = useState([])
|
||||
const [historyInfo, setHistoryInfo] = useState([])
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true) // Add loading state
|
||||
const { choreId } = useParams()
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
|
||||
const [editHistory, setEditHistory] = useState({})
|
||||
const { confirmModalConfig, showConfirmation } = useConfirmationModal()
|
||||
|
||||
// React Query hooks
|
||||
const { data: choreHistoryData, isLoading } = useChoreHistory(choreId)
|
||||
const { data: circleMembersData } = useCircleMembers()
|
||||
const updateChoreHistory = useUpdateChoreHistory()
|
||||
const deleteChoreHistory = useDeleteChoreHistory()
|
||||
|
||||
const choreHistory = choreHistoryData?.res || []
|
||||
const performers = circleMembersData?.res || []
|
||||
|
||||
const handleDelete = historyEntry => {
|
||||
showConfirmation(
|
||||
`Are you sure you want to delete this history record?`,
|
||||
'Delete History Record',
|
||||
() => {
|
||||
deleteChoreHistory.mutate({
|
||||
choreId,
|
||||
historyId: historyEntry.id,
|
||||
})
|
||||
},
|
||||
'Delete',
|
||||
'Cancel',
|
||||
'danger',
|
||||
)
|
||||
}
|
||||
|
||||
const handleEdit = historyEntry => {
|
||||
setIsEditModalOpen(true)
|
||||
setEditHistory(historyEntry)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true) // Start loading
|
||||
|
||||
Promise.all([
|
||||
GetChoreHistory(choreId).then(res => res.json()),
|
||||
GetAllCircleMembers(),
|
||||
])
|
||||
.then(([historyData, usersData]) => {
|
||||
setChoresHistory(historyData.res)
|
||||
|
||||
const newUserChoreHistory = {}
|
||||
historyData.res.forEach(choreHistory => {
|
||||
const userId = choreHistory.completedBy
|
||||
newUserChoreHistory[userId] = (newUserChoreHistory[userId] || 0) + 1
|
||||
})
|
||||
setUserHistory(newUserChoreHistory)
|
||||
|
||||
setPerformers(usersData.res)
|
||||
updateHistoryInfo(historyData.res, newUserChoreHistory, usersData.res)
|
||||
if (choreHistory.length > 0 && performers.length > 0) {
|
||||
const newUserChoreHistory = {}
|
||||
choreHistory.forEach(historyEntry => {
|
||||
const userId = historyEntry.completedBy
|
||||
newUserChoreHistory[userId] = (newUserChoreHistory[userId] || 0) + 1
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching data:', error)
|
||||
// Handle errors, e.g., show an error message to the user
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false) // Finish loading
|
||||
})
|
||||
}, [choreId])
|
||||
setUserHistory(newUserChoreHistory)
|
||||
updateHistoryInfo(choreHistory, newUserChoreHistory, performers)
|
||||
}
|
||||
}, [choreHistory, performers])
|
||||
|
||||
const updateHistoryInfo = (histories, userHistories, performers) => {
|
||||
// average delay for task completaion from due date:
|
||||
@@ -91,46 +111,46 @@ const ChoreHistory = () => {
|
||||
const userCompletedByMost = Object.keys(userHistories).reduce((a, b) =>
|
||||
userHistories[a] > userHistories[b] ? a : b,
|
||||
)
|
||||
const userCompletedByLeast = Object.keys(userHistories).reduce((a, b) =>
|
||||
userHistories[a] < userHistories[b] ? a : b,
|
||||
)
|
||||
|
||||
const historyInfo = [
|
||||
{
|
||||
icon: <Checklist />,
|
||||
text: 'Total Completed',
|
||||
subtext: `${histories.length} times`,
|
||||
text: 'All Completed',
|
||||
subtext: `${histories.filter(h => h.status === ChoreHistoryStatus.COMPLETED).length} times`,
|
||||
},
|
||||
{
|
||||
icon: <TrendingUp />,
|
||||
text: 'Average Timing',
|
||||
subtext: moment.duration(averageDelayMoment).isValid()
|
||||
? moment.duration(averageDelayMoment).humanize()
|
||||
: 'On time',
|
||||
},
|
||||
{
|
||||
icon: <Timelapse />,
|
||||
text: 'Usually Within',
|
||||
subtext: moment.duration(averageDelayMoment).humanize(),
|
||||
text: 'Longest Delay',
|
||||
subtext: moment.duration(maxDelayMoment).isValid()
|
||||
? moment.duration(maxDelayMoment).humanize()
|
||||
: 'Never late',
|
||||
},
|
||||
{
|
||||
icon: <Timelapse />,
|
||||
text: 'Maximum Delay',
|
||||
subtext: moment.duration(maxDelayMoment).humanize(),
|
||||
},
|
||||
{
|
||||
icon: <Avatar />,
|
||||
text: ' Completed Most',
|
||||
icon: <Star />,
|
||||
text: 'Completed Most',
|
||||
subtext: `${
|
||||
performers.find(p => p.userId === Number(userCompletedByMost))
|
||||
?.displayName
|
||||
} `,
|
||||
?.displayName || 'Unknown'
|
||||
}`,
|
||||
},
|
||||
// contributes:
|
||||
{
|
||||
icon: <Group />,
|
||||
text: 'Total Performers',
|
||||
subtext: `${Object.keys(userHistories).length} users`,
|
||||
text: 'Members Involved',
|
||||
subtext: `${Object.keys(userHistories).length} members`,
|
||||
},
|
||||
{
|
||||
icon: <Avatar />,
|
||||
icon: <Analytics />,
|
||||
text: 'Last Completed',
|
||||
subtext: `${
|
||||
performers.find(p => p.userId === Number(histories[0].completedBy))
|
||||
?.displayName
|
||||
?.displayName || 'Unknown'
|
||||
}`,
|
||||
},
|
||||
]
|
||||
@@ -171,7 +191,7 @@ const ChoreHistory = () => {
|
||||
they'll show up here.
|
||||
</Typography>
|
||||
<Button variant='soft' sx={{ mt: 2 }}>
|
||||
<Link to='/my/chores'>Go back to chores</Link>
|
||||
<Link to='/chores'>Go back to chores</Link>
|
||||
</Button>
|
||||
</Container>
|
||||
)
|
||||
@@ -179,52 +199,103 @@ const ChoreHistory = () => {
|
||||
|
||||
return (
|
||||
<Container maxWidth='md'>
|
||||
<Typography level='title-md' mb={1.5}>
|
||||
Summary:
|
||||
</Typography>
|
||||
<Sheet
|
||||
// sx={{
|
||||
// mb: 1,
|
||||
// borderRadius: 'lg',
|
||||
// p: 2,
|
||||
// }}
|
||||
sx={{ borderRadius: 'sm', p: 2 }}
|
||||
variant='outlined'
|
||||
>
|
||||
<Grid container spacing={1}>
|
||||
{/* Enhanced Header Section */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
{/* Statistics Cards Grid - Compact Design */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<History sx={{ fontSize: '1.5rem' }} />
|
||||
<Typography
|
||||
level='title-md'
|
||||
sx={{ fontWeight: 'lg', color: 'text.primary' }}
|
||||
>
|
||||
Task Summary
|
||||
</Typography>
|
||||
</Box>
|
||||
<Grid container spacing={0.5} sx={{ mb: 2 }}>
|
||||
{historyInfo.map((info, index) => (
|
||||
<Grid item xs={4} key={index}>
|
||||
{/* divider between the list items: */}
|
||||
|
||||
<ListItem key={index}>
|
||||
<ListItemContent>
|
||||
<Typography level='body-xs' sx={{ fontWeight: 'md' }}>
|
||||
<Grid item xs={4} sm={2} key={index}>
|
||||
<Card
|
||||
variant='soft'
|
||||
sx={{
|
||||
borderRadius: 'sm',
|
||||
p: 1,
|
||||
height: 85,
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ opacity: 0.8, flexShrink: 0 }}>{info.icon}</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 0.25,
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
fontWeight: '600',
|
||||
color: 'text.primary',
|
||||
textAlign: 'center',
|
||||
lineHeight: 1.1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
width: '100%',
|
||||
fontSize: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{info.text}
|
||||
</Typography>
|
||||
<Chip color='primary' size='md' startDecorator={info.icon}>
|
||||
{info.subtext ? info.subtext : '--'}
|
||||
</Chip>
|
||||
</ListItemContent>
|
||||
</ListItem>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
textAlign: 'center',
|
||||
lineHeight: 1.1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
width: '100%',
|
||||
fontSize: '0.7rem',
|
||||
}}
|
||||
>
|
||||
{info.subtext || '--'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Sheet>
|
||||
</Box>
|
||||
|
||||
{/* User History Cards */}
|
||||
<Typography level='title-md' my={1.5}>
|
||||
History:
|
||||
</Typography>
|
||||
<Sheet sx={{ borderRadius: 'sm', p: 2, boxShadow: 'md' }}>
|
||||
{/* History Section Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<Analytics sx={{ fontSize: '1.5rem' }} />
|
||||
<Typography
|
||||
level='title-md'
|
||||
sx={{ fontWeight: 'lg', color: 'text.primary' }}
|
||||
>
|
||||
Task Activity
|
||||
</Typography>
|
||||
</Box>
|
||||
<Sheet variant='plain' sx={{ borderRadius: 'sm', boxShadow: 'md' }}>
|
||||
{/* Chore History List (Updated Style) */}
|
||||
|
||||
<List sx={{ p: 0 }}>
|
||||
{choreHistory.map((historyEntry, index) => (
|
||||
<HistoryCard
|
||||
onClick={() => {
|
||||
setIsEditModalOpen(true)
|
||||
setEditHistory(historyEntry)
|
||||
}}
|
||||
onClick={() => handleEdit(historyEntry)}
|
||||
onEditClick={handleEdit}
|
||||
onDeleteClick={handleDelete}
|
||||
historyEntry={historyEntry}
|
||||
performers={performers}
|
||||
allHistory={choreHistory}
|
||||
@@ -241,39 +312,44 @@ const ChoreHistory = () => {
|
||||
setIsEditModalOpen(false)
|
||||
},
|
||||
onSave: updated => {
|
||||
UpdateChoreHistory(choreId, editHistory.id, {
|
||||
performedAt: updated.performedAt,
|
||||
dueDate: updated.dueDate,
|
||||
notes: updated.notes,
|
||||
}).then(res => {
|
||||
if (!res.ok) {
|
||||
console.error('Failed to update chore history:', res)
|
||||
return
|
||||
}
|
||||
|
||||
const newRecord = res.json().then(data => {
|
||||
const newRecord = data.res
|
||||
const newHistory = choreHistory.map(record =>
|
||||
record.id === newRecord.id ? newRecord : record,
|
||||
)
|
||||
setChoresHistory(newHistory)
|
||||
setEditHistory(newRecord)
|
||||
setIsEditModalOpen(false)
|
||||
})
|
||||
})
|
||||
updateChoreHistory.mutate(
|
||||
{
|
||||
choreId,
|
||||
historyId: editHistory.id,
|
||||
historyData: {
|
||||
performedAt: updated.performedAt,
|
||||
dueDate: updated.dueDate,
|
||||
notes: updated.notes,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: data => {
|
||||
setEditHistory(data.res)
|
||||
setIsEditModalOpen(false)
|
||||
},
|
||||
onError: error => {
|
||||
console.error('Failed to update chore history:', error)
|
||||
},
|
||||
},
|
||||
)
|
||||
},
|
||||
onDelete: () => {
|
||||
DeleteChoreHistory(choreId, editHistory.id).then(() => {
|
||||
const newHistory = choreHistory.filter(
|
||||
record => record.id !== editHistory.id,
|
||||
)
|
||||
setChoresHistory(newHistory)
|
||||
setIsEditModalOpen(false)
|
||||
})
|
||||
deleteChoreHistory.mutate(
|
||||
{
|
||||
choreId,
|
||||
historyId: editHistory.id,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsEditModalOpen(false)
|
||||
},
|
||||
},
|
||||
)
|
||||
},
|
||||
}}
|
||||
historyRecord={editHistory}
|
||||
/>
|
||||
<ConfirmationModal config={confirmModalConfig} />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,67 +1,126 @@
|
||||
import { CalendarViewDay, Check, Timelapse } from '@mui/icons-material'
|
||||
import {
|
||||
AccessTime,
|
||||
CalendarMonth,
|
||||
Check,
|
||||
CheckCircle,
|
||||
Delete,
|
||||
Edit,
|
||||
EventNote,
|
||||
HourglassEmpty,
|
||||
Person,
|
||||
Redo,
|
||||
ThumbDown,
|
||||
Timelapse,
|
||||
Toll,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Chip,
|
||||
Grid,
|
||||
IconButton,
|
||||
ListDivider,
|
||||
ListItem,
|
||||
ListItemContent,
|
||||
ListItemDecorator,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { TASK_COLOR } from '../../utils/Colors.jsx'
|
||||
|
||||
export const getCompletedChip = historyEntry => {
|
||||
var text = 'No Due Date'
|
||||
var color = 'info'
|
||||
var icon = <CalendarViewDay />
|
||||
// if completed few hours +-6 hours
|
||||
if (
|
||||
historyEntry.dueDate &&
|
||||
historyEntry.performedAt > historyEntry.dueDate - 1000 * 60 * 60 * 6 &&
|
||||
historyEntry.performedAt < historyEntry.dueDate + 1000 * 60 * 60 * 6
|
||||
) {
|
||||
text = 'On Time'
|
||||
color = 'success'
|
||||
icon = <Check />
|
||||
} else if (
|
||||
historyEntry.dueDate &&
|
||||
historyEntry.performedAt < historyEntry.dueDate
|
||||
) {
|
||||
text = 'On Time'
|
||||
color = 'success'
|
||||
icon = <Check />
|
||||
const getCompletedChip = historyEntry => {
|
||||
if (historyEntry.status === 0) {
|
||||
return null
|
||||
}
|
||||
if (!historyEntry.dueDate) {
|
||||
return null
|
||||
// <Chip
|
||||
// size='sm'
|
||||
// variant='soft'
|
||||
// color='neutral'
|
||||
// startDecorator={<CalendarViewDay />}
|
||||
// >
|
||||
// No Due Date
|
||||
// </Chip>
|
||||
}
|
||||
|
||||
// if completed after due date then it's late
|
||||
else if (
|
||||
historyEntry.dueDate &&
|
||||
historyEntry.performedAt > historyEntry.dueDate
|
||||
) {
|
||||
text = 'Late'
|
||||
color = 'warning'
|
||||
icon = <Timelapse />
|
||||
const performedAt = moment(historyEntry.performedAt)
|
||||
const dueDate = moment(historyEntry.dueDate)
|
||||
// TODO: make this a config at some point
|
||||
const gracePeriod = 6 * 60 * 60 * 1000 // 6 hours in milliseconds
|
||||
|
||||
if (Math.abs(performedAt - dueDate) <= gracePeriod) {
|
||||
return (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
sx={{ backgroundColor: TASK_COLOR.COMPLETED, color: 'white' }}
|
||||
startDecorator={<Check />}
|
||||
>
|
||||
On Time
|
||||
</Chip>
|
||||
)
|
||||
} else if (performedAt.isBefore(dueDate)) {
|
||||
return (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
sx={{ backgroundColor: TASK_COLOR.SCHEDULED, color: 'white' }}
|
||||
startDecorator={<Check />}
|
||||
>
|
||||
Early
|
||||
</Chip>
|
||||
)
|
||||
} else {
|
||||
text = 'No Due Date'
|
||||
color = 'neutral'
|
||||
icon = <CalendarViewDay />
|
||||
return (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
sx={{ backgroundColor: TASK_COLOR.LATE, color: 'white' }}
|
||||
startDecorator={<Timelapse />}
|
||||
>
|
||||
Late
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Chip startDecorator={icon} color={color}>
|
||||
{text}
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
|
||||
const formatTime = seconds => {
|
||||
if (typeof seconds !== 'number' || isNaN(seconds) || seconds < 0) {
|
||||
return null
|
||||
}
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = seconds % 60
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact HistoryCard component with improved UX and 2-row height design
|
||||
*/
|
||||
const HistoryCard = ({
|
||||
allHistory,
|
||||
performers,
|
||||
historyEntry,
|
||||
index,
|
||||
onClick,
|
||||
onEditClick,
|
||||
onDeleteClick,
|
||||
}) => {
|
||||
function formatTimeDifference(startDate, endDate) {
|
||||
const performer = performers.find(p => p.userId === historyEntry.completedBy)
|
||||
const assignedTo = performers.find(p => p.userId === historyEntry.assignedTo)
|
||||
|
||||
// Swipe functionality state
|
||||
const [swipeTranslateX, setSwipeTranslateX] = useState(0)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [isSwipeRevealed, setIsSwipeRevealed] = useState(false)
|
||||
const [hoverTimer, setHoverTimer] = useState(null)
|
||||
const swipeThreshold = 80
|
||||
const maxSwipeDistance = 200
|
||||
const dragStartX = useRef(0)
|
||||
const cardRef = useRef(null)
|
||||
|
||||
const formatTimeDifference = (startDate, endDate) => {
|
||||
const diffInMinutes = moment(startDate).diff(endDate, 'minutes')
|
||||
let timeValue = diffInMinutes
|
||||
let unit = 'minute'
|
||||
@@ -81,87 +140,502 @@ const HistoryCard = ({
|
||||
return `${timeValue} ${unit}${timeValue !== 1 ? 's' : ''}`
|
||||
}
|
||||
|
||||
const getStatusAvatar = () => {
|
||||
const statusMap = {
|
||||
0: { icon: <AccessTime />, color: 'primary' }, // Started
|
||||
1: { icon: <Check />, color: 'success' }, // Completed
|
||||
2: { icon: <Redo />, color: 'warning' }, // Skipped
|
||||
3: { icon: <HourglassEmpty />, color: 'neutral' }, // Pending Approval
|
||||
4: { icon: <ThumbDown />, color: 'danger' }, // Rejected
|
||||
}
|
||||
|
||||
const config = statusMap[historyEntry.status] || statusMap[1]
|
||||
return (
|
||||
<Avatar
|
||||
size='sm'
|
||||
color={config.color}
|
||||
variant='soft'
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
'& svg': { fontSize: '14px' },
|
||||
}}
|
||||
>
|
||||
{config.icon}
|
||||
</Avatar>
|
||||
)
|
||||
}
|
||||
|
||||
// Swipe gesture handlers
|
||||
const handleTouchStart = e => {
|
||||
dragStartX.current = e.touches[0].clientX
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
const handleTouchMove = e => {
|
||||
if (!isDragging) return
|
||||
|
||||
const currentX = e.touches[0].clientX
|
||||
const deltaX = currentX - dragStartX.current
|
||||
|
||||
if (isSwipeRevealed) {
|
||||
if (deltaX > 0) {
|
||||
const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0)
|
||||
setSwipeTranslateX(clampedDelta)
|
||||
}
|
||||
} else {
|
||||
if (deltaX < 0) {
|
||||
const clampedDelta = Math.max(deltaX, -maxSwipeDistance)
|
||||
setSwipeTranslateX(clampedDelta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (!isDragging) return
|
||||
setIsDragging(false)
|
||||
|
||||
if (isSwipeRevealed) {
|
||||
if (swipeTranslateX > -swipeThreshold) {
|
||||
setSwipeTranslateX(0)
|
||||
setIsSwipeRevealed(false)
|
||||
} else {
|
||||
setSwipeTranslateX(-maxSwipeDistance)
|
||||
}
|
||||
} else {
|
||||
if (Math.abs(swipeTranslateX) > swipeThreshold) {
|
||||
setSwipeTranslateX(-maxSwipeDistance)
|
||||
setIsSwipeRevealed(true)
|
||||
} else {
|
||||
setSwipeTranslateX(0)
|
||||
setIsSwipeRevealed(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseDown = e => {
|
||||
dragStartX.current = e.clientX
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
const handleMouseMove = e => {
|
||||
if (!isDragging) return
|
||||
|
||||
const currentX = e.clientX
|
||||
const deltaX = currentX - dragStartX.current
|
||||
|
||||
if (isSwipeRevealed) {
|
||||
if (deltaX > 0) {
|
||||
const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0)
|
||||
setSwipeTranslateX(clampedDelta)
|
||||
}
|
||||
} else {
|
||||
if (deltaX < 0) {
|
||||
const clampedDelta = Math.max(deltaX, -maxSwipeDistance)
|
||||
setSwipeTranslateX(clampedDelta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (!isDragging) return
|
||||
setIsDragging(false)
|
||||
|
||||
if (isSwipeRevealed) {
|
||||
if (swipeTranslateX > -swipeThreshold) {
|
||||
setSwipeTranslateX(0)
|
||||
setIsSwipeRevealed(false)
|
||||
} else {
|
||||
setSwipeTranslateX(-maxSwipeDistance)
|
||||
}
|
||||
} else {
|
||||
if (Math.abs(swipeTranslateX) > swipeThreshold) {
|
||||
setSwipeTranslateX(-maxSwipeDistance)
|
||||
setIsSwipeRevealed(true)
|
||||
} else {
|
||||
setSwipeTranslateX(0)
|
||||
setIsSwipeRevealed(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resetSwipe = () => {
|
||||
setSwipeTranslateX(0)
|
||||
setIsSwipeRevealed(false)
|
||||
}
|
||||
|
||||
// Hover functionality for desktop - only trigger from drag area
|
||||
const handleMouseEnter = () => {
|
||||
if (isSwipeRevealed) return
|
||||
const timer = setTimeout(() => {
|
||||
setSwipeTranslateX(-maxSwipeDistance)
|
||||
setIsSwipeRevealed(true)
|
||||
setHoverTimer(null)
|
||||
}, 800) // Shorter delay for drag area
|
||||
setHoverTimer(timer)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (hoverTimer) {
|
||||
clearTimeout(hoverTimer)
|
||||
setHoverTimer(null)
|
||||
}
|
||||
// Only add hide timer if we're leaving the drag area and actions are NOT revealed
|
||||
// If actions are revealed, let the action area handle the hiding
|
||||
if (!isSwipeRevealed) {
|
||||
// Actions are not revealed, so we can safely hide after delay
|
||||
const hideTimer = setTimeout(() => {
|
||||
resetSwipe()
|
||||
}, 300)
|
||||
setHoverTimer(hideTimer)
|
||||
}
|
||||
}
|
||||
|
||||
const handleActionAreaMouseEnter = () => {
|
||||
// Clear any pending timer when entering action area
|
||||
if (hoverTimer) {
|
||||
clearTimeout(hoverTimer)
|
||||
setHoverTimer(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleActionAreaMouseLeave = () => {
|
||||
// Hide immediately when leaving action area
|
||||
if (isSwipeRevealed) {
|
||||
resetSwipe()
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hoverTimer) {
|
||||
clearTimeout(hoverTimer)
|
||||
}
|
||||
}
|
||||
}, [hoverTimer])
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItem sx={{ gap: 1.5, alignItems: 'flex-start' }} onClick={onClick}>
|
||||
{' '}
|
||||
{/* Adjusted spacing and alignment */}
|
||||
<ListItemDecorator>
|
||||
<Avatar sx={{ mr: 1 }}>
|
||||
{performers
|
||||
.find(p => p.userId === historyEntry.completedBy)
|
||||
?.displayName?.charAt(0) || '?'}
|
||||
</Avatar>
|
||||
</ListItemDecorator>
|
||||
<ListItemContent sx={{ my: 0 }}>
|
||||
{' '}
|
||||
{/* Removed vertical margin */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
// Only clear timers, don't auto-hide
|
||||
if (hoverTimer) {
|
||||
clearTimeout(hoverTimer)
|
||||
setHoverTimer(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Action buttons underneath (revealed on swipe) */}
|
||||
{(onEditClick || onDeleteClick) && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: maxSwipeDistance,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
boxShadow: 'inset 2px 0 4px rgba(0,0,0,0.06)',
|
||||
zIndex: 0,
|
||||
}}
|
||||
onMouseEnter={handleActionAreaMouseEnter}
|
||||
onMouseLeave={handleActionAreaMouseLeave}
|
||||
>
|
||||
{onEditClick && (
|
||||
<IconButton
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
resetSwipe()
|
||||
onEditClick(historyEntry)
|
||||
}}
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
mx: 1,
|
||||
}}
|
||||
>
|
||||
<Edit sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{onDeleteClick && (
|
||||
<IconButton
|
||||
variant='soft'
|
||||
color='danger'
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
resetSwipe()
|
||||
onDeleteClick(historyEntry)
|
||||
}}
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
mx: 1,
|
||||
}}
|
||||
>
|
||||
<Delete sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Main card content */}
|
||||
<ListItem
|
||||
ref={cardRef}
|
||||
onClick={() => {
|
||||
if (isSwipeRevealed) {
|
||||
resetSwipe()
|
||||
return
|
||||
}
|
||||
if (onClick) onClick()
|
||||
}}
|
||||
sx={{
|
||||
cursor: onClick ? 'pointer' : 'default',
|
||||
py: 1.5,
|
||||
px: 2,
|
||||
position: 'relative',
|
||||
bgcolor: 'background.surface',
|
||||
transform: `translateX(${swipeTranslateX}px)`,
|
||||
transition: isDragging ? 'none' : 'transform 0.3s ease-out',
|
||||
zIndex: 1,
|
||||
width: '100%',
|
||||
'&:hover': onClick
|
||||
? {
|
||||
bgcolor: isSwipeRevealed
|
||||
? 'background.surface'
|
||||
: 'background.level1',
|
||||
}
|
||||
: {},
|
||||
borderRadius: 'sm',
|
||||
}}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
>
|
||||
<ListItemContent>
|
||||
<Grid container spacing={1} alignItems='center'>
|
||||
{/* First Row/Column: Status and Time Info */}
|
||||
<Grid xs={12} sm={8}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
{getStatusAvatar()}
|
||||
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
fontWeight: 'md',
|
||||
}}
|
||||
>
|
||||
{historyEntry.status === 0
|
||||
? 'In Progress'
|
||||
: historyEntry.status === 1
|
||||
? 'Completed'
|
||||
: historyEntry.status === 2
|
||||
? 'Skipped'
|
||||
: historyEntry.status === 3
|
||||
? 'Pending Approval'
|
||||
: historyEntry.status === 4
|
||||
? 'Rejected'
|
||||
: 'Completed'}
|
||||
</Typography>
|
||||
|
||||
<Chip size='sm' startDecorator={<EventNote />}>
|
||||
{moment(
|
||||
historyEntry.performedAt || historyEntry.updatedAt,
|
||||
).format('MMM DD, h:mm A')}
|
||||
</Chip>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
{getCompletedChip(historyEntry)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Second Row/Column: Completion Status (right side on desktop) */}
|
||||
<Grid xs={12} sm={4}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: { xs: 'flex-start', sm: 'flex-end' },
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{historyEntry.dueDate && (
|
||||
<Chip size='sm' startDecorator={<CalendarMonth />}>
|
||||
{moment(historyEntry.dueDate).format('MMM DD h:mm A')}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Third Row: Performer and Assignment Info */}
|
||||
<Grid xs={12}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flexWrap: 'wrap',
|
||||
mt: 0.5,
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='success'
|
||||
startDecorator={<CheckCircle />}
|
||||
>
|
||||
Done by {performer?.displayName || 'Unknown'}
|
||||
</Chip>
|
||||
|
||||
{historyEntry.completedBy !== historyEntry.assignedTo &&
|
||||
assignedTo && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<Person />}
|
||||
>
|
||||
Assigned to {assignedTo.displayName}
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
{historyEntry.notes && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
startDecorator={<EventNote />}
|
||||
sx={{ maxWidth: '120px', overflow: 'hidden' }}
|
||||
>
|
||||
Note
|
||||
</Chip>
|
||||
)}
|
||||
{/* add a duration chip if we have duration */}
|
||||
{historyEntry?.duration > 0 && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='primary'
|
||||
startDecorator={<AccessTime />}
|
||||
>
|
||||
{formatTime(historyEntry.duration)}
|
||||
</Chip>
|
||||
)}
|
||||
{historyEntry?.points > 0 && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='success'
|
||||
startDecorator={<Toll />}
|
||||
>
|
||||
{historyEntry.points} pt
|
||||
{historyEntry.points > 1 ? 's' : ''}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</ListItemContent>
|
||||
|
||||
{/* Right drag area - only triggers reveal on hover */}
|
||||
{(onEditClick || onDeleteClick) && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '20px',
|
||||
cursor: 'grab',
|
||||
zIndex: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
opacity: isSwipeRevealed ? 0 : 0.3, // Hide when action area is revealed
|
||||
transition: 'opacity 0.2s ease',
|
||||
pointerEvents: isSwipeRevealed ? 'none' : 'auto', // Disable pointer events when revealed
|
||||
'&:hover': {
|
||||
opacity: isSwipeRevealed ? 0 : 0.7,
|
||||
},
|
||||
'&:active': {
|
||||
cursor: 'grabbing',
|
||||
},
|
||||
}}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{/* Drag indicator dots */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.25,
|
||||
}}
|
||||
>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
width: 3,
|
||||
height: 3,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'text.tertiary',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</ListItem>
|
||||
|
||||
{/* Compact Divider with Time Difference */}
|
||||
{index < allHistory.length - 1 && allHistory[index + 1].performedAt && (
|
||||
<ListDivider
|
||||
component='li'
|
||||
sx={{
|
||||
my: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography level='body1' sx={{ fontWeight: 'md' }}>
|
||||
{historyEntry.performedAt
|
||||
? moment(historyEntry.performedAt).format(
|
||||
'ddd MM/DD/yyyy HH:mm',
|
||||
)
|
||||
: 'Skipped'}
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
color: 'text.tertiary',
|
||||
backgroundColor: 'background.surface',
|
||||
px: 1,
|
||||
fontSize: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{formatTimeDifference(
|
||||
historyEntry.performedAt || historyEntry.updatedAt,
|
||||
allHistory[index + 1].performedAt,
|
||||
)}{' '}
|
||||
before
|
||||
</Typography>
|
||||
{getCompletedChip(historyEntry)}
|
||||
</Box>
|
||||
<Typography level='body2' color='text.tertiary'>
|
||||
<Chip>
|
||||
{
|
||||
performers.find(p => p.userId === historyEntry.completedBy)
|
||||
?.displayName
|
||||
}
|
||||
</Chip>{' '}
|
||||
completed
|
||||
{historyEntry.completedBy !== historyEntry.assignedTo && (
|
||||
<>
|
||||
{', '}
|
||||
assigned to{' '}
|
||||
<Chip>
|
||||
{
|
||||
performers.find(p => p.userId === historyEntry.assignedTo)
|
||||
?.displayName
|
||||
}
|
||||
</Chip>
|
||||
</>
|
||||
)}
|
||||
</Typography>
|
||||
{historyEntry.dueDate && (
|
||||
<Typography level='body2' color='text.tertiary'>
|
||||
Due: {moment(historyEntry.dueDate).format('ddd MM/DD/yyyy')}
|
||||
</Typography>
|
||||
)}
|
||||
{historyEntry.notes && (
|
||||
<Typography level='body2' color='text.tertiary'>
|
||||
Note: {historyEntry.notes}
|
||||
</Typography>
|
||||
)}
|
||||
</ListItemContent>
|
||||
</ListItem>
|
||||
{index < allHistory.length - 1 && (
|
||||
<>
|
||||
<ListDivider component='li'>
|
||||
{/* time between two completion: */}
|
||||
{index < allHistory.length - 1 &&
|
||||
allHistory[index + 1].performedAt && (
|
||||
<Typography level='body3' color='text.tertiary'>
|
||||
{formatTimeDifference(
|
||||
historyEntry.performedAt,
|
||||
allHistory[index + 1].performedAt,
|
||||
)}{' '}
|
||||
before
|
||||
</Typography>
|
||||
)}
|
||||
</ListDivider>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ const Home = () => {
|
||||
<Button
|
||||
sx={{ mt: 1 }}
|
||||
onClick={() => {
|
||||
Navigate('/my/chores')
|
||||
Navigate('/chores')
|
||||
}}
|
||||
>
|
||||
Get Started!
|
||||
|
||||
@@ -1,27 +1,464 @@
|
||||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import EditIcon from '@mui/icons-material/Edit'
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Container,
|
||||
IconButton,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import LabelModal from '../Modals/Inputs/LabelModal'
|
||||
|
||||
// import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Add } from '@mui/icons-material'
|
||||
import { Add, ColorLens } from '@mui/icons-material'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import LABEL_COLORS, {
|
||||
getTextColorFromBackgroundColor,
|
||||
} from '../../utils/Colors'
|
||||
import { DeleteLabel } from '../../utils/Fetcher'
|
||||
import { getSafeBottomStyles } from '../../utils/SafeAreaUtils'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import { useLabels } from './LabelQueries'
|
||||
|
||||
const LabelCard = ({ label, onEditClick, onDeleteClick, currentUserId }) => {
|
||||
// Helper function to get color name from hex value
|
||||
const getColorName = hexValue => {
|
||||
const colorObj = LABEL_COLORS.find(
|
||||
color => color.value.toLowerCase() === hexValue.toLowerCase(),
|
||||
)
|
||||
return colorObj ? colorObj.name : hexValue
|
||||
}
|
||||
|
||||
// Check if current user owns this label
|
||||
const isOwnedByCurrentUser = label.created_by === currentUserId
|
||||
|
||||
// Swipe functionality state
|
||||
const [swipeTranslateX, setSwipeTranslateX] = useState(0)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [isSwipeRevealed, setIsSwipeRevealed] = useState(false)
|
||||
const [hoverTimer, setHoverTimer] = useState(null)
|
||||
const swipeThreshold = 80
|
||||
const maxSwipeDistance = 160
|
||||
const dragStartX = useRef(0)
|
||||
const cardRef = useRef(null)
|
||||
|
||||
// Swipe gesture handlers
|
||||
const handleTouchStart = e => {
|
||||
dragStartX.current = e.touches[0].clientX
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
const handleTouchMove = e => {
|
||||
if (!isDragging) return
|
||||
|
||||
const currentX = e.touches[0].clientX
|
||||
const deltaX = currentX - dragStartX.current
|
||||
|
||||
if (isSwipeRevealed) {
|
||||
if (deltaX > 0) {
|
||||
const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0)
|
||||
setSwipeTranslateX(clampedDelta)
|
||||
}
|
||||
} else {
|
||||
if (deltaX < 0) {
|
||||
const clampedDelta = Math.max(deltaX, -maxSwipeDistance)
|
||||
setSwipeTranslateX(clampedDelta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (!isDragging) return
|
||||
setIsDragging(false)
|
||||
|
||||
if (isSwipeRevealed) {
|
||||
if (swipeTranslateX > -swipeThreshold) {
|
||||
setSwipeTranslateX(0)
|
||||
setIsSwipeRevealed(false)
|
||||
} else {
|
||||
setSwipeTranslateX(-maxSwipeDistance)
|
||||
}
|
||||
} else {
|
||||
if (Math.abs(swipeTranslateX) > swipeThreshold) {
|
||||
setSwipeTranslateX(-maxSwipeDistance)
|
||||
setIsSwipeRevealed(true)
|
||||
} else {
|
||||
setSwipeTranslateX(0)
|
||||
setIsSwipeRevealed(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseDown = e => {
|
||||
dragStartX.current = e.clientX
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
const handleMouseMove = e => {
|
||||
if (!isDragging) return
|
||||
|
||||
const currentX = e.clientX
|
||||
const deltaX = currentX - dragStartX.current
|
||||
|
||||
if (isSwipeRevealed) {
|
||||
if (deltaX > 0) {
|
||||
const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0)
|
||||
setSwipeTranslateX(clampedDelta)
|
||||
}
|
||||
} else {
|
||||
if (deltaX < 0) {
|
||||
const clampedDelta = Math.max(deltaX, -maxSwipeDistance)
|
||||
setSwipeTranslateX(clampedDelta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (!isDragging) return
|
||||
setIsDragging(false)
|
||||
|
||||
if (isSwipeRevealed) {
|
||||
if (swipeTranslateX > -swipeThreshold) {
|
||||
setSwipeTranslateX(0)
|
||||
setIsSwipeRevealed(false)
|
||||
} else {
|
||||
setSwipeTranslateX(-maxSwipeDistance)
|
||||
}
|
||||
} else {
|
||||
if (Math.abs(swipeTranslateX) > swipeThreshold) {
|
||||
setSwipeTranslateX(-maxSwipeDistance)
|
||||
setIsSwipeRevealed(true)
|
||||
} else {
|
||||
setSwipeTranslateX(0)
|
||||
setIsSwipeRevealed(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resetSwipe = () => {
|
||||
setSwipeTranslateX(0)
|
||||
setIsSwipeRevealed(false)
|
||||
}
|
||||
|
||||
// Hover functionality for desktop - only trigger from drag area
|
||||
const handleMouseEnter = () => {
|
||||
if (isSwipeRevealed) return
|
||||
const timer = setTimeout(() => {
|
||||
setSwipeTranslateX(-maxSwipeDistance)
|
||||
setIsSwipeRevealed(true)
|
||||
setHoverTimer(null)
|
||||
}, 800) // Shorter delay for drag area
|
||||
setHoverTimer(timer)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (hoverTimer) {
|
||||
clearTimeout(hoverTimer)
|
||||
setHoverTimer(null)
|
||||
}
|
||||
// Only add hide timer if we're leaving the drag area and actions are NOT revealed
|
||||
// If actions are revealed, let the action area handle the hiding
|
||||
if (!isSwipeRevealed) {
|
||||
// Actions are not revealed, so we can safely hide after delay
|
||||
const hideTimer = setTimeout(() => {
|
||||
resetSwipe()
|
||||
}, 300)
|
||||
setHoverTimer(hideTimer)
|
||||
}
|
||||
}
|
||||
|
||||
const handleActionAreaMouseEnter = () => {
|
||||
// Clear any pending timer when entering action area
|
||||
if (hoverTimer) {
|
||||
clearTimeout(hoverTimer)
|
||||
setHoverTimer(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleActionAreaMouseLeave = () => {
|
||||
// Hide immediately when leaving action area
|
||||
if (isSwipeRevealed) {
|
||||
resetSwipe()
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hoverTimer) {
|
||||
clearTimeout(hoverTimer)
|
||||
}
|
||||
}
|
||||
}, [hoverTimer])
|
||||
|
||||
return (
|
||||
<Box key={label.id + '-compact-box'}>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
'&:last-child': {
|
||||
borderBottom: 'none',
|
||||
},
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
// Only clear timers, don't auto-hide
|
||||
if (hoverTimer) {
|
||||
clearTimeout(hoverTimer)
|
||||
setHoverTimer(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Action buttons underneath (revealed on swipe) */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: maxSwipeDistance,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
boxShadow: 'inset 2px 0 4px rgba(0,0,0,0.06)',
|
||||
zIndex: 0,
|
||||
}}
|
||||
onMouseEnter={handleActionAreaMouseEnter}
|
||||
onMouseLeave={handleActionAreaMouseLeave}
|
||||
>
|
||||
<IconButton
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
resetSwipe()
|
||||
onEditClick(label)
|
||||
}}
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
mx: 1,
|
||||
}}
|
||||
>
|
||||
<EditIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
variant='soft'
|
||||
color='danger'
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
resetSwipe()
|
||||
onDeleteClick(label.id)
|
||||
}}
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
mx: 1,
|
||||
}}
|
||||
>
|
||||
<DeleteIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Main card content */}
|
||||
<Box
|
||||
ref={cardRef}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minHeight: 64,
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
bgcolor: 'background.body',
|
||||
transform: `translateX(${swipeTranslateX}px)`,
|
||||
transition: isDragging ? 'none' : 'transform 0.3s ease-out',
|
||||
zIndex: 1,
|
||||
'&:hover': {
|
||||
bgcolor: isSwipeRevealed
|
||||
? 'background.surface'
|
||||
: 'background.level1',
|
||||
boxShadow: isSwipeRevealed ? 'none' : 'sm',
|
||||
},
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isSwipeRevealed) {
|
||||
resetSwipe()
|
||||
return
|
||||
}
|
||||
// Optional: Navigate to label details or edit directly
|
||||
onEditClick(label)
|
||||
}}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
>
|
||||
{/* Right drag area - only triggers reveal on hover */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '20px',
|
||||
cursor: 'grab',
|
||||
zIndex: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
opacity: isSwipeRevealed ? 0 : 0.3, // Hide when action area is revealed
|
||||
transition: 'opacity 0.2s ease',
|
||||
pointerEvents: isSwipeRevealed ? 'none' : 'auto', // Disable pointer events when revealed
|
||||
'&:hover': {
|
||||
opacity: isSwipeRevealed ? 0 : 0.7,
|
||||
},
|
||||
'&:active': {
|
||||
cursor: 'grabbing',
|
||||
},
|
||||
}}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{/* Drag indicator dots */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.25,
|
||||
}}
|
||||
>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
width: 3,
|
||||
height: 3,
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'text.tertiary',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
{/* Color Avatar */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mr: 2,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
size='sm'
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
bgcolor: label.color,
|
||||
border: '2px solid',
|
||||
borderColor: isOwnedByCurrentUser
|
||||
? 'background.surface'
|
||||
: 'warning.300',
|
||||
boxShadow: isOwnedByCurrentUser
|
||||
? 'sm'
|
||||
: '0 0 0 1px var(--joy-palette-warning-300)',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
color: getTextColorFromBackgroundColor(label.color),
|
||||
fontWeight: 'bold',
|
||||
fontSize: 10,
|
||||
}}
|
||||
>
|
||||
{label.name.charAt(0).toUpperCase()}
|
||||
</Typography>
|
||||
</Avatar>
|
||||
</Box>
|
||||
|
||||
{/* Content - Center */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Label Name */}
|
||||
<Typography
|
||||
level='title-sm'
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
mb: 0.25,
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</Typography>
|
||||
|
||||
{/* Color Info */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{label.color && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
startDecorator={<ColorLens />}
|
||||
sx={{
|
||||
fontSize: 10,
|
||||
height: 18,
|
||||
px: 0.75,
|
||||
bgcolor: `${label.color}20`,
|
||||
color: label.color,
|
||||
border: `1px solid ${label.color}30`,
|
||||
}}
|
||||
>
|
||||
{getColorName(label.color)}
|
||||
</Chip>
|
||||
)}
|
||||
{!isOwnedByCurrentUser && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='warning'
|
||||
sx={{
|
||||
fontSize: 9,
|
||||
height: 16,
|
||||
px: 0.5,
|
||||
fontWeight: 'md',
|
||||
}}
|
||||
>
|
||||
Shared
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const LabelView = () => {
|
||||
const { data: labels, isLabelsLoading, isError } = useLabels()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
|
||||
const [userLabels, setUserLabels] = useState([])
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
@@ -61,7 +498,7 @@ const LabelView = () => {
|
||||
}
|
||||
|
||||
const handleDeleteLabel = id => {
|
||||
DeleteLabel(id).then(res => {
|
||||
DeleteLabel(id).then(() => {
|
||||
const updatedLabels = userLabels.filter(label => label.id !== id)
|
||||
setUserLabels(updatedLabels)
|
||||
|
||||
@@ -106,54 +543,57 @@ const LabelView = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth='md'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
{userLabels.map(label => (
|
||||
<div
|
||||
key={label}
|
||||
className='grid w-full grid-cols-[1fr,auto,auto] rounded-lg border border-zinc-200/80 p-4 shadow-sm dark:bg-zinc-900'
|
||||
<Container maxWidth='md' sx={{ px: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2, p: 2 }}>
|
||||
{/* <EmojiEvents sx={{ fontSize: '2rem', color: '#FFD700' }} /> */}
|
||||
<Stack sx={{ flex: 1 }}>
|
||||
<Typography
|
||||
level='h3'
|
||||
sx={{ fontWeight: 'lg', color: 'text.primary' }}
|
||||
>
|
||||
<Chip
|
||||
variant='outlined'
|
||||
color='primary'
|
||||
size='lg'
|
||||
sx={{
|
||||
background: label.color,
|
||||
borderColor: label.color,
|
||||
color: getTextColorFromBackgroundColor(label.color),
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</Chip>
|
||||
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
onClick={() => handleEditLabel(label)}
|
||||
startDecorator={<EditIcon />}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='soft'
|
||||
onClick={() => handleDeleteClicked(label.id)}
|
||||
color='danger'
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
Labels
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
Manage your labels and organize your tasks effectively. Labels will
|
||||
be automatically shared with your circle if they are used on a
|
||||
shared task.
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
// bgcolor: 'background.body',
|
||||
// border: '1px solid',
|
||||
// borderColor: 'divider',
|
||||
// borderRadius: 'md',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{userLabels.length === 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
height: '50vh',
|
||||
}}
|
||||
>
|
||||
<Typography level='title-md' gutterBottom>
|
||||
No labels available. Add a new label to get started.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{userLabels.map(label => (
|
||||
<LabelCard
|
||||
key={label.id}
|
||||
label={label}
|
||||
onEditClick={handleEditLabel}
|
||||
onDeleteClick={handleDeleteClicked}
|
||||
currentUserId={userProfile?.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{userLabels.length === 0 && (
|
||||
<Typography textAlign='center' mt={2}>
|
||||
No labels available. Add a new label to get started.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{modalOpen && (
|
||||
<LabelModal
|
||||
@@ -166,10 +606,8 @@ const LabelView = () => {
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
...getSafeBottomStyles({ bottom: 0, padding: 16 }),
|
||||
left: 10,
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 2,
|
||||
|
||||
@@ -52,7 +52,7 @@ const DemoAssignee = () => {
|
||||
data-aos-anchor='[data-aos-create-chore-assignee]'
|
||||
data-aos='fade-right'
|
||||
>
|
||||
<Typography level='h4'>Assignees :</Typography>
|
||||
<Typography level='h4'>Assignees:</Typography>
|
||||
<Typography level='h5'>Who can do this chore?</Typography>
|
||||
<Card>
|
||||
<List
|
||||
@@ -93,7 +93,7 @@ const DemoAssignee = () => {
|
||||
data-aos-anchor='[data-aos-create-chore-assignee]'
|
||||
data-aos='fade-right'
|
||||
>
|
||||
<Typography level='h4'>Assigned :</Typography>
|
||||
<Typography level='h4'>Assigned:</Typography>
|
||||
<Typography level='h5'>
|
||||
Who is assigned the next due chore?
|
||||
</Typography>
|
||||
@@ -128,7 +128,7 @@ const DemoAssignee = () => {
|
||||
data-aos-anchor='[data-aos-create-chore-assignee]'
|
||||
data-aos='fade-right'
|
||||
>
|
||||
<Typography level='h4'>Picking Mode :</Typography>
|
||||
<Typography level='h4'>Picking Mode:</Typography>
|
||||
<Typography level='h5'>
|
||||
How to pick the next assignee for the following chore?
|
||||
</Typography>
|
||||
|
||||
207
src/views/Landing/DemoCalendar.jsx
Normal file
207
src/views/Landing/DemoCalendar.jsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { Card, Grid, Typography } from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import CalendarMonthly from '../components/CalendarMonthly'
|
||||
|
||||
const DemoCalendar = () => {
|
||||
// Generate sample chore data across different dates
|
||||
const generateSampleChores = () => {
|
||||
const today = moment()
|
||||
const chores = []
|
||||
|
||||
// High priority tasks
|
||||
chores.push({
|
||||
id: 1,
|
||||
name: '🧹 Deep Clean Living Room',
|
||||
priority: 1,
|
||||
nextDueDate: today
|
||||
.clone()
|
||||
.add(2, 'days')
|
||||
.hour(10)
|
||||
.minute(0)
|
||||
.toISOString(),
|
||||
assignedTo: 1,
|
||||
})
|
||||
|
||||
chores.push({
|
||||
id: 2,
|
||||
name: '🚗 Car Maintenance Check',
|
||||
priority: 1,
|
||||
nextDueDate: today
|
||||
.clone()
|
||||
.add(5, 'days')
|
||||
.hour(14)
|
||||
.minute(30)
|
||||
.toISOString(),
|
||||
assignedTo: 1,
|
||||
})
|
||||
|
||||
// Medium priority tasks
|
||||
chores.push({
|
||||
id: 3,
|
||||
name: '🌱 Water Indoor Plants',
|
||||
priority: 2,
|
||||
nextDueDate: today.clone().add(1, 'days').hour(8).minute(0).toISOString(),
|
||||
assignedTo: 1,
|
||||
})
|
||||
|
||||
chores.push({
|
||||
id: 4,
|
||||
name: '🛒 Weekly Grocery Shopping',
|
||||
priority: 2,
|
||||
nextDueDate: today
|
||||
.clone()
|
||||
.add(3, 'days')
|
||||
.hour(16)
|
||||
.minute(0)
|
||||
.toISOString(),
|
||||
assignedTo: 1,
|
||||
})
|
||||
|
||||
chores.push({
|
||||
id: 5,
|
||||
name: '📧 Organize Email Inbox',
|
||||
priority: 2,
|
||||
nextDueDate: today
|
||||
.clone()
|
||||
.add(7, 'days')
|
||||
.hour(11)
|
||||
.minute(0)
|
||||
.toISOString(),
|
||||
assignedTo: 1,
|
||||
})
|
||||
|
||||
// Low priority tasks
|
||||
chores.push({
|
||||
id: 6,
|
||||
name: '📚 Organize Bookshelf',
|
||||
priority: 3,
|
||||
nextDueDate: today
|
||||
.clone()
|
||||
.add(4, 'days')
|
||||
.hour(15)
|
||||
.minute(0)
|
||||
.toISOString(),
|
||||
assignedTo: 1,
|
||||
})
|
||||
|
||||
chores.push({
|
||||
id: 7,
|
||||
name: '🎨 Paint Bedroom Wall',
|
||||
priority: 3,
|
||||
nextDueDate: today
|
||||
.clone()
|
||||
.add(10, 'days')
|
||||
.hour(9)
|
||||
.minute(0)
|
||||
.toISOString(),
|
||||
assignedTo: 1,
|
||||
})
|
||||
|
||||
// Tasks for today
|
||||
chores.push({
|
||||
id: 8,
|
||||
name: '🍽️ Do Dishes',
|
||||
priority: 2,
|
||||
nextDueDate: today.clone().hour(19).minute(0).toISOString(),
|
||||
assignedTo: 1,
|
||||
})
|
||||
|
||||
chores.push({
|
||||
id: 9,
|
||||
name: '🗑️ Take Out Trash',
|
||||
priority: 1,
|
||||
nextDueDate: today.clone().hour(7).minute(30).toISOString(),
|
||||
assignedTo: 1,
|
||||
})
|
||||
|
||||
// Tasks with no priority
|
||||
chores.push({
|
||||
id: 10,
|
||||
name: '🎵 Practice Guitar',
|
||||
priority: null,
|
||||
nextDueDate: today
|
||||
.clone()
|
||||
.add(6, 'days')
|
||||
.hour(18)
|
||||
.minute(0)
|
||||
.toISOString(),
|
||||
assignedTo: 1,
|
||||
})
|
||||
|
||||
// Multiple tasks on same day
|
||||
chores.push({
|
||||
id: 11,
|
||||
name: '🧺 Do Laundry',
|
||||
priority: 2,
|
||||
nextDueDate: today
|
||||
.clone()
|
||||
.add(2, 'days')
|
||||
.hour(12)
|
||||
.minute(0)
|
||||
.toISOString(),
|
||||
assignedTo: 1,
|
||||
})
|
||||
|
||||
chores.push({
|
||||
id: 12,
|
||||
name: '🏃 Morning Jog',
|
||||
priority: 3,
|
||||
nextDueDate: today
|
||||
.clone()
|
||||
.add(2, 'days')
|
||||
.hour(6)
|
||||
.minute(30)
|
||||
.toISOString(),
|
||||
assignedTo: 1,
|
||||
})
|
||||
|
||||
return chores
|
||||
}
|
||||
|
||||
const sampleChores = generateSampleChores()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid item xs={12} sm={7} data-aos-calendar-demo-section>
|
||||
<Card
|
||||
sx={{
|
||||
p: 5,
|
||||
height: 'fit-content',
|
||||
overflow: 'hidden',
|
||||
boxShadow: 'lg',
|
||||
}}
|
||||
data-aos-delay={100}
|
||||
data-aos-anchor='[data-aos-calendar-demo-section]'
|
||||
data-aos='fade-up'
|
||||
>
|
||||
<CalendarMonthly chores={sampleChores} />
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={5} data-aos-calendar-description>
|
||||
<Card
|
||||
sx={{
|
||||
p: 4,
|
||||
py: 6,
|
||||
height: 'fit-content',
|
||||
}}
|
||||
data-aos-delay={200}
|
||||
data-aos-anchor='[data-aos-calendar-description]'
|
||||
data-aos='fade-left'
|
||||
>
|
||||
<Typography level='h3' textAlign='center' sx={{ mt: 2, mb: 4 }}>
|
||||
Visual Task Calendar
|
||||
</Typography>
|
||||
<Typography level='body-lg' textAlign='center' sx={{ mb: 4 }}>
|
||||
Get a bird's-eye view of all your tasks with the interactive
|
||||
calendar. See priority-coded dots for each day, click to view
|
||||
detailed task lists, and easily track your upcoming
|
||||
responsibilities. The color-coded priority system helps you focus on
|
||||
what matters most.
|
||||
</Typography>
|
||||
</Card>
|
||||
</Grid>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DemoCalendar
|
||||
@@ -14,6 +14,7 @@ const DemoMyChore = () => {
|
||||
nextDueDate: moment().add(1, 'days').hour(8).minute(0).toISOString(),
|
||||
isRolling: false,
|
||||
assignedTo: 1,
|
||||
status: 0,
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
@@ -24,6 +25,7 @@ const DemoMyChore = () => {
|
||||
nextDueDate: moment().subtract(7, 'day').toISOString(),
|
||||
isRolling: false,
|
||||
assignedTo: 1,
|
||||
status: 0,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
@@ -93,7 +95,7 @@ const DemoMyChore = () => {
|
||||
// },
|
||||
]
|
||||
|
||||
const users = [{ displayName: 'Me', id: 1 }]
|
||||
const users = [{ displayName: 'Me', id: 1, userId: 1 }]
|
||||
return (
|
||||
<>
|
||||
<Grid item xs={12} sm={5} data-aos-first-tasks-list>
|
||||
|
||||
57
src/views/Landing/DemoNotificationTemplate.jsx
Normal file
57
src/views/Landing/DemoNotificationTemplate.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Card, Grid, Typography } from '@mui/joy'
|
||||
import NotificationTemplate from '../../components/NotificationTemplate'
|
||||
|
||||
const DemoNotificationTemplate = () => {
|
||||
const demoNotifications = [
|
||||
{ value: -3, unit: 'd' }, // 3 days before
|
||||
{ value: 0, unit: 'm' }, // On due
|
||||
{ value: 1, unit: 'd' }, // 1 day after
|
||||
]
|
||||
|
||||
const handleNotificationChange = data => {
|
||||
// Demo handler - doesn't need to do anything
|
||||
console.log('Demo notification change:', data)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid item xs={12} sm={7} data-aos-notification-template-list>
|
||||
<div
|
||||
data-aos-delay={100}
|
||||
data-aos-anchor='[data-aos-notification-template-list]'
|
||||
data-aos='fade-up'
|
||||
>
|
||||
<NotificationTemplate
|
||||
value={{ templates: demoNotifications }}
|
||||
onChange={handleNotificationChange}
|
||||
maxNotifications={5}
|
||||
showTimeline={false}
|
||||
/>
|
||||
</div>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={5} data-aos-notification-demo-section>
|
||||
<Card
|
||||
sx={{
|
||||
p: 4,
|
||||
py: 6,
|
||||
height: 'fit-content',
|
||||
}}
|
||||
data-aos-delay={200}
|
||||
data-aos-anchor='[data-aos-notification-demo-section]'
|
||||
data-aos='fade-left'
|
||||
>
|
||||
<Typography level='h3' textAlign='center' sx={{ mt: 2, mb: 4 }}>
|
||||
Smart Notification Scheduling
|
||||
</Typography>
|
||||
<Typography level='body-lg' textAlign='center' sx={{ mb: 4 }}>
|
||||
Set up intelligent reminders for your tasks with flexible timing
|
||||
options. Get notified before, on, or after due dates with
|
||||
customizable intervals.
|
||||
</Typography>
|
||||
</Card>
|
||||
</Grid>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DemoNotificationTemplate
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user