feat: Enhance Signup and Chores functionality
- Added query invalidation on signup to refresh user profile data. - Improved MyChores component to ensure data is loaded before rendering. - Introduced dynamic sidepanel configuration with drag-and-drop functionality. - Created TasksByAssigneeCard to visualize tasks assigned to users. - Updated MFASettings and Settings components for better structure and usability. - Implemented SettingsOverview for a comprehensive settings navigation experience. - Added SidepanelSettings for customizing sidepanel card visibility and order. - Refactored NavBar to include user profile avatar and improved layout.
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
ext {
|
||||
minSdkVersion = 22
|
||||
compileSdkVersion = 34
|
||||
targetSdkVersion = 34
|
||||
androidxActivityVersion = '1.8.0'
|
||||
androidxAppCompatVersion = '1.6.1'
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 35
|
||||
targetSdkVersion = 35
|
||||
androidxActivityVersion = '1.9.2'
|
||||
androidxAppCompatVersion = '1.7.0'
|
||||
androidxCoordinatorLayoutVersion = '1.2.0'
|
||||
androidxCoreVersion = '1.12.0'
|
||||
androidxFragmentVersion = '1.6.2'
|
||||
|
||||
517
src/components/UserProfileAvatar.jsx
Normal file
517
src/components/UserProfileAvatar.jsx
Normal file
@@ -0,0 +1,517 @@
|
||||
import {
|
||||
AdminPanelSettings,
|
||||
DarkModeOutlined,
|
||||
Email,
|
||||
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 moment from 'moment'
|
||||
import { useEffect, 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 { 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 { impersonatedUser, setImpersonatedUser } = useImpersonateUser()
|
||||
const { data: circleMembersData } = useCircleMembers()
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [isSubscriptionModalOpen, setIsSubscriptionModalOpen] = useState(false)
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
const [themeMode, setThemeMode] = useStickyState(mode, 'themeMode')
|
||||
|
||||
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])
|
||||
|
||||
if (!userProfile) return null
|
||||
|
||||
const currentUser = impersonatedUser || userProfile
|
||||
const isImpersonating = !!impersonatedUser
|
||||
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 = () => {
|
||||
localStorage.removeItem('ca_token')
|
||||
localStorage.removeItem('ca_expiration')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
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={() => setImpersonatedUser(null)}
|
||||
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/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 }}>
|
||||
Sidepanel 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 => {
|
||||
setImpersonatedUser(user)
|
||||
setIsModalOpen(false)
|
||||
}}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
/>
|
||||
|
||||
<SubscriptionModal
|
||||
isOpen={isSubscriptionModalOpen}
|
||||
onClose={() => setIsSubscriptionModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserProfileAvatar
|
||||
@@ -86,7 +86,14 @@ const PageTransition = ({ children }) => {
|
||||
}}
|
||||
unmountOnExit
|
||||
>
|
||||
<div className='page-wrapper'>{children}</div>
|
||||
<div
|
||||
className='page-wrapper'
|
||||
style={{
|
||||
paddingBottom: `var(--safe-area-inset-bottom, 0px)`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CSSTransition>
|
||||
</TransitionGroup>
|
||||
)
|
||||
|
||||
@@ -41,7 +41,7 @@ const FadeModal = ({
|
||||
size={size}
|
||||
sx={{
|
||||
zIndex: Z_INDEX.MODAL_CONTENT,
|
||||
minWidth: fullWidth ? '100%' : 'auto',
|
||||
minWidth: fullWidth ? '90%' : 'auto',
|
||||
animation: open
|
||||
? 'modalFadeIn 0.35s forwards'
|
||||
: 'modalFadeOut 0.25s forwards',
|
||||
|
||||
@@ -2,6 +2,7 @@ import App from '@/App'
|
||||
import ChoreEdit from '@/views/ChoreEdit/ChoreEdit'
|
||||
import Error from '@/views/Error'
|
||||
import Settings from '@/views/Settings/Settings'
|
||||
import SettingsOverview from '@/views/Settings/SettingsOverview'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { RouterProvider, createBrowserRouter } from 'react-router-dom'
|
||||
import AuthenticationLoading from '../views/Authorization/Authenticating'
|
||||
@@ -49,6 +50,10 @@ const Router = createBrowserRouter([
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
element: <SettingsOverview />,
|
||||
},
|
||||
{
|
||||
path: '/settings/detailed',
|
||||
element: <Settings />,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -60,6 +60,11 @@ 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',
|
||||
|
||||
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',
|
||||
name: 'Welcome Card',
|
||||
description: 'Shows greeting and quick stats',
|
||||
iconName: 'WavingHand',
|
||||
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
|
||||
}
|
||||
218
src/utils/StatusBarManager.js
Normal file
218
src/utils/StatusBarManager.js
Normal file
@@ -0,0 +1,218 @@
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,5 +1,5 @@
|
||||
// create boilerplate for ResetPasswordView:
|
||||
import LogoSVG from '@/assets/logo.svg'
|
||||
import Logo from '../../Logo'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -80,16 +80,12 @@ const ForgotPasswordView = () => {
|
||||
<Container
|
||||
component='main'
|
||||
maxWidth='xs'
|
||||
|
||||
// make content center in the middle of the page:
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
@@ -104,102 +100,83 @@ 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={{ textAlign: 'center', mt: 2, mb: 3 }}>
|
||||
Enter your email, and we'll send you a link to get into your
|
||||
account.
|
||||
</Typography>
|
||||
<FormControl error={emailError !== null} sx={{ width: '100%', mb: 2 }}>
|
||||
<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>
|
||||
|
||||
<Button
|
||||
variant='soft'
|
||||
variant='solid'
|
||||
size='lg'
|
||||
sx={{ position: 'relative', bottom: '0' }}
|
||||
fullWidth
|
||||
sx={{ mb: 2 }}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Reset Password
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='plain'
|
||||
sx={{
|
||||
width: '100%',
|
||||
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>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Browser } from '@capacitor/browser'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { Device } from '@capacitor/device'
|
||||
// import { GoogleAuth } from '@codetrix-studio/capacitor-google-auth'
|
||||
@@ -251,19 +252,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 (
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@mui/joy'
|
||||
import React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import Logo from '../../Logo'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { login, signUp } from '../../utils/Fetcher'
|
||||
@@ -19,6 +20,7 @@ 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('')
|
||||
@@ -32,11 +34,11 @@ const SignupView = () => {
|
||||
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('/chores')
|
||||
}, 500)
|
||||
|
||||
// Invalidate user profile queries to ensure fresh data
|
||||
queryClient.invalidateQueries(['userProfile'])
|
||||
|
||||
Navigate('/chores')
|
||||
})
|
||||
} else {
|
||||
console.log('Login failed', response)
|
||||
|
||||
@@ -117,7 +117,13 @@ const MyChores = () => {
|
||||
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
|
||||
useEffect(() => {
|
||||
;(async () => {
|
||||
if (!choresLoading && !membersLoading && userProfile) {
|
||||
if (
|
||||
!choresLoading &&
|
||||
!membersLoading &&
|
||||
userProfile &&
|
||||
membersData?.res &&
|
||||
choresData?.res
|
||||
) {
|
||||
setPerformers(membersData.res)
|
||||
const sortedChores = choresData.res.sort(ChoreSorter)
|
||||
setChores(sortedChores)
|
||||
|
||||
@@ -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 { getSidepanelConfig } from '../../utils/SidepanelConfig'
|
||||
import CalendarView from '../components/CalendarView'
|
||||
import ActivitiesCard from './ActivitesCard'
|
||||
import TasksByAssigneeCard from './TasksByAssigneeCard'
|
||||
import WelcomeCard from './WelcomeCard'
|
||||
|
||||
const Sidepanel = ({ chores }) => {
|
||||
const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('md'))
|
||||
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 <WelcomeCard 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' }}
|
||||
>
|
||||
<CalendarView 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', overflowX: '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
|
||||
|
||||
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
|
||||
@@ -210,7 +210,7 @@ const MFASettings = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='grid gap-4 py-4'>
|
||||
<div className='grid gap-4 py-4' id='mfa'>
|
||||
<Typography level='h3'>Multi-Factor Authentication</Typography>
|
||||
<Divider />
|
||||
<Typography level='body-md'>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { Refresh } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
import { Purchases } from '@revenuecat/purchases-capacitor'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import RealTimeSettings from '../../components/RealTimeSettings'
|
||||
import SubscriptionModal from '../../components/SubscriptionModal'
|
||||
import Logo from '../../Logo'
|
||||
@@ -45,12 +47,14 @@ import APITokenSettings from './APITokenSettings'
|
||||
import MFASettings from './MFASettings'
|
||||
import NotificationSetting from './NotificationSetting'
|
||||
import ProfileSettings from './ProfileSettings'
|
||||
import SidepanelSettings from './SidepanelSettings'
|
||||
import StorageSettings from './StorageSettings'
|
||||
import ThemeToggle from './ThemeToggle'
|
||||
|
||||
const Settings = () => {
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const { showNotification } = useNotification()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [userCircles, setUserCircles] = useState([])
|
||||
const [circleMemberRequests, setCircleMemberRequests] = useState([])
|
||||
@@ -59,6 +63,8 @@ const Settings = () => {
|
||||
const [webhookURL, setWebhookURL] = useState(null)
|
||||
const [webhookError, setWebhookError] = useState(null)
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
const [lastRefresh, setLastRefresh] = useState(null)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
|
||||
const [changePasswordModal, setChangePasswordModal] = useState(false)
|
||||
const [subscriptionModal, setSubscriptionModal] = useState(false)
|
||||
@@ -88,6 +94,23 @@ const Settings = () => {
|
||||
},
|
||||
})
|
||||
}
|
||||
const refreshMemberRequests = async () => {
|
||||
setIsRefreshing(true)
|
||||
try {
|
||||
const resp = await GetCircleMemberRequests()
|
||||
const data = await resp.json()
|
||||
setCircleMemberRequests(data.res ? data.res : [])
|
||||
setLastRefresh(new Date())
|
||||
} catch (error) {
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message: 'Failed to refresh member requests',
|
||||
})
|
||||
} finally {
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
GetUserCircle().then(resp => {
|
||||
resp.json().then(data => {
|
||||
@@ -98,6 +121,7 @@ const Settings = () => {
|
||||
GetCircleMemberRequests().then(resp => {
|
||||
resp.json().then(data => {
|
||||
setCircleMemberRequests(data.res ? data.res : [])
|
||||
setLastRefresh(new Date())
|
||||
})
|
||||
})
|
||||
GetAllCircleMembers().then(data => {
|
||||
@@ -116,15 +140,35 @@ const Settings = () => {
|
||||
}, [circleMembers, userProfile])
|
||||
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash
|
||||
if (hash) {
|
||||
const sharingSection = document.getElementById(
|
||||
window.location.hash.slice(1),
|
||||
)
|
||||
if (sharingSection) {
|
||||
sharingSection.scrollIntoView({ behavior: 'smooth' })
|
||||
const handleHashChange = () => {
|
||||
const hash = window.location.hash
|
||||
if (hash) {
|
||||
// Small delay to ensure the component is fully rendered before scrolling
|
||||
setTimeout(() => {
|
||||
const section = document.getElementById(hash.slice(1))
|
||||
if (section) {
|
||||
// Get the element position and scroll with some offset for the title
|
||||
const elementPosition = section.offsetTop
|
||||
const offsetPosition = elementPosition - 20 // 20px padding above the title
|
||||
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior: 'instant', // Use 'smooth' for smooth scrolling
|
||||
})
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle initial hash on mount
|
||||
handleHashChange()
|
||||
|
||||
// Listen for hash changes
|
||||
window.addEventListener('hashchange', handleHashChange)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('hashchange', handleHashChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const getSubscriptionDetails = () => {
|
||||
@@ -172,10 +216,11 @@ const Settings = () => {
|
||||
if (!userProfile) {
|
||||
return <LoadingComponent />
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<ProfileSettings />
|
||||
<div className='grid gap-4 py-4' id='sharing'>
|
||||
<div className='grid gap-4 py-4' id='circle'>
|
||||
<Typography level='h3'>Circle settings</Typography>
|
||||
<Divider />
|
||||
<Typography level='body-md'>
|
||||
@@ -406,9 +451,35 @@ const Settings = () => {
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{circleMemberRequests.length > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Typography level='title-md'>Circle Member Requests</Typography>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{lastRefresh && (
|
||||
<Typography level='body-sm' color='neutral'>
|
||||
Last updated: {moment(lastRefresh).format('MMM DD, HH:mm')}
|
||||
</Typography>
|
||||
)}
|
||||
<Button
|
||||
size='sm'
|
||||
variant='soft'
|
||||
onClick={refreshMemberRequests}
|
||||
disabled={isRefreshing}
|
||||
startDecorator={
|
||||
isRefreshing ? <CircularProgress size='sm' /> : <Refresh />
|
||||
}
|
||||
>
|
||||
{isRefreshing ? 'Refreshing...' : 'Refresh'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{circleMemberRequests.map(request => (
|
||||
<Card key={request.id} className='p-4'>
|
||||
<Typography level='body-md'>
|
||||
@@ -729,7 +800,18 @@ const Settings = () => {
|
||||
<MFASettings />
|
||||
<APITokenSettings />
|
||||
<StorageSettings />
|
||||
<div className='grid gap-4 py-4'>
|
||||
<div className='grid gap-4 py-4' id='sidepanel'>
|
||||
<Typography level='h3'>Sidepanel Customization</Typography>
|
||||
<Divider />
|
||||
<Typography level='body-md'>
|
||||
Customize the layout and visibility of cards in the sidepanel. the
|
||||
section only available on large screen devices such as tablets and
|
||||
desktops..
|
||||
</Typography>
|
||||
<SidepanelSettings />
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 py-4' id='theme'>
|
||||
<Typography level='h3'>Theme preferences</Typography>
|
||||
<Divider />
|
||||
<Typography level='body-md'>
|
||||
|
||||
300
src/views/Settings/SettingsOverview.jsx
Normal file
300
src/views/Settings/SettingsOverview.jsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import {
|
||||
AccountCircle,
|
||||
Api,
|
||||
Circle,
|
||||
Notifications,
|
||||
Palette,
|
||||
Person,
|
||||
Security,
|
||||
Star,
|
||||
Storage,
|
||||
ViewSidebar,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Container,
|
||||
Grid,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { isPlusAccount } from '../../utils/Helpers'
|
||||
|
||||
const SettingsOverview = () => {
|
||||
const navigate = useNavigate()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
|
||||
const settingsCards = [
|
||||
{
|
||||
id: 'profile',
|
||||
title: 'Profile Settings',
|
||||
description:
|
||||
'Update your profile information, photo, display name, and timezone preferences.',
|
||||
icon: <Person />,
|
||||
color: 'primary',
|
||||
},
|
||||
{
|
||||
id: 'circle',
|
||||
title: 'Circle Settings',
|
||||
description:
|
||||
'Manage your circle, invite members, handle join requests, and configure webhooks.',
|
||||
icon: <Circle />,
|
||||
color: 'success',
|
||||
},
|
||||
{
|
||||
id: 'account',
|
||||
title: 'Account Settings',
|
||||
description:
|
||||
'Manage your subscription, change password, and account deletion options.',
|
||||
icon: <AccountCircle />,
|
||||
color: 'warning',
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
title: 'Notifications',
|
||||
description:
|
||||
'Configure push notifications, email alerts, and notification targets for tasks.',
|
||||
icon: <Notifications />,
|
||||
color: 'info',
|
||||
},
|
||||
{
|
||||
id: 'mfa',
|
||||
title: 'Multi-Factor Authentication',
|
||||
description:
|
||||
'Add an extra layer of security with MFA using authenticator apps.',
|
||||
icon: <Security />,
|
||||
color: 'danger',
|
||||
},
|
||||
{
|
||||
id: 'apitokens',
|
||||
title: 'API Tokens',
|
||||
description:
|
||||
'Generate and manage access tokens for third-party integrations and API access.',
|
||||
icon: <Api />,
|
||||
color: 'neutral',
|
||||
},
|
||||
{
|
||||
id: 'storage',
|
||||
title: 'Storage Settings',
|
||||
description:
|
||||
'Backup and restore your data, manage local storage and sync preferences.',
|
||||
icon: <Storage />,
|
||||
color: 'primary',
|
||||
},
|
||||
{
|
||||
id: 'sidepanel',
|
||||
title: 'Sidepanel Customization',
|
||||
description:
|
||||
'Customize the layout and visibility of cards in the sidepanel interface.',
|
||||
icon: <ViewSidebar />,
|
||||
color: 'success',
|
||||
},
|
||||
{
|
||||
id: 'theme',
|
||||
title: 'Theme Preferences',
|
||||
description:
|
||||
'Choose your preferred theme and configure dark/light mode settings.',
|
||||
icon: <Palette />,
|
||||
color: 'warning',
|
||||
},
|
||||
]
|
||||
|
||||
const handleCardClick = settingId => {
|
||||
navigate(`/settings/detailed#${settingId}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth='lg' sx={{ py: 4 }}>
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography level='h2' sx={{ mb: 1 }}>
|
||||
Settings
|
||||
</Typography>
|
||||
<Typography level='body-lg' color='neutral'>
|
||||
Customize your experience and manage your account preferences
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Upgrade Card - Only show if user is not a Plus member */}
|
||||
{userProfile && !isPlusAccount(userProfile) && (
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
<Grid xs={12}>
|
||||
<Card
|
||||
variant='outlined'
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
background: 'linear-gradient(135deg, #0891b2 0%, #0e7490 100%)',
|
||||
border: 'none',
|
||||
color: 'white',
|
||||
'&:hover': {
|
||||
boxShadow: 'xl',
|
||||
transform: 'translateY(-3px)',
|
||||
},
|
||||
}}
|
||||
onClick={() => navigate('/settings/detailed#account')}
|
||||
>
|
||||
<CardContent sx={{ p: { xs: 1.5, md: 3 } }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
flexDirection: { xs: 'column', md: 'row' },
|
||||
gap: { xs: 1.5, md: 2 },
|
||||
textAlign: { xs: 'center', md: 'left' },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexDirection: { xs: 'column', md: 'row' },
|
||||
gap: { xs: 1, md: 3 },
|
||||
width: { xs: '100%', md: 'auto' },
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
sx={{
|
||||
'--Avatar-size': { xs: '36px', md: '60px' },
|
||||
background: 'rgba(255, 255, 255, 0.2)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
}}
|
||||
>
|
||||
<Star
|
||||
sx={{ fontSize: { xs: 18, md: 30 }, color: 'white' }}
|
||||
/>
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Typography
|
||||
level='title-lg'
|
||||
sx={{
|
||||
color: 'white',
|
||||
mb: { xs: 0.25, md: 0.5 },
|
||||
fontWeight: 'bold',
|
||||
fontSize: { xs: '0.95rem', md: '1.25rem' },
|
||||
}}
|
||||
>
|
||||
Upgrade to Plus
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-md'
|
||||
sx={{
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
mb: { xs: 0.5, md: 1 },
|
||||
fontSize: { xs: '0.75rem', md: '1rem' },
|
||||
lineHeight: { xs: 1.3, md: 1.5 },
|
||||
}}
|
||||
>
|
||||
Unlock powerful features to enhance your productivity
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: { xs: 'none', sm: 'flex' },
|
||||
flexWrap: 'wrap',
|
||||
gap: 1,
|
||||
fontSize: '0.875rem',
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
}}
|
||||
>
|
||||
<span>• Rich text descriptions</span>
|
||||
<span>• Task notifications</span>
|
||||
<span>• API integrations</span>
|
||||
<span>• Advanced automation</span>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Button
|
||||
variant='solid'
|
||||
size='sm'
|
||||
sx={{
|
||||
bgcolor: 'rgba(255, 255, 255, 0.2)',
|
||||
color: 'white',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
px: { xs: 1.5, md: 3 },
|
||||
py: { xs: 0.5, md: 1.5 },
|
||||
fontWeight: 'bold',
|
||||
minWidth: { xs: '80px', md: '120px' },
|
||||
width: { xs: '100%', md: 'auto' },
|
||||
fontSize: { xs: '0.75rem', md: '0.875rem' },
|
||||
'&:hover': {
|
||||
bgcolor: 'rgba(255, 255, 255, 0.3)',
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
}}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
navigate('/settings/detailed#account')
|
||||
}}
|
||||
>
|
||||
Upgrade Now
|
||||
</Button>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{settingsCards.map(setting => (
|
||||
<Grid key={setting.id} xs={4} sm={4} md={4}>
|
||||
<Card
|
||||
variant='outlined'
|
||||
sx={{
|
||||
height: '100%',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
boxShadow: 'md',
|
||||
transform: 'translateY(-2px)',
|
||||
borderColor: `${setting.color}.500`,
|
||||
},
|
||||
}}
|
||||
onClick={() => handleCardClick(setting.id)}
|
||||
>
|
||||
<CardContent sx={{ p: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
mb: 1.5,
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
variant='soft'
|
||||
color={setting.color}
|
||||
sx={{ mb: 1, '--Avatar-size': '40px' }}
|
||||
>
|
||||
{setting.icon}
|
||||
</Avatar>
|
||||
<Typography level='title-sm' component='h3' sx={{ mb: 1 }}>
|
||||
{setting.title}
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
color='neutral'
|
||||
sx={{
|
||||
lineHeight: 1.4,
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
}}
|
||||
>
|
||||
{setting.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsOverview
|
||||
258
src/views/Settings/SidepanelSettings.jsx
Normal file
258
src/views/Settings/SidepanelSettings.jsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd'
|
||||
import {
|
||||
CalendarMonth,
|
||||
DragIndicator,
|
||||
History,
|
||||
Person,
|
||||
Visibility,
|
||||
VisibilityOff,
|
||||
WavingHand,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemContent,
|
||||
ListItemDecorator,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const DEFAULT_SIDEPANEL_CONFIG = [
|
||||
{
|
||||
id: 'welcome',
|
||||
name: 'Welcome Card',
|
||||
description: 'Shows greeting and quick stats',
|
||||
iconName: 'WavingHand',
|
||||
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,
|
||||
},
|
||||
]
|
||||
|
||||
const SidepanelSettings = () => {
|
||||
const [config, setConfig] = useState(DEFAULT_SIDEPANEL_CONFIG)
|
||||
|
||||
const getIcon = iconName => {
|
||||
switch (iconName) {
|
||||
case 'WavingHand':
|
||||
return <WavingHand />
|
||||
case 'Person':
|
||||
return <Person />
|
||||
case 'CalendarMonth':
|
||||
return <CalendarMonth />
|
||||
case 'History':
|
||||
return <History />
|
||||
default:
|
||||
return <Person />
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('sidepanelConfig')
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved)
|
||||
setConfig(parsed)
|
||||
} catch (error) {
|
||||
console.error('Error parsing sidepanel config:', error)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const saveConfig = newConfig => {
|
||||
setConfig(newConfig)
|
||||
localStorage.setItem('sidepanelConfig', JSON.stringify(newConfig))
|
||||
window.dispatchEvent(new Event('sidepanelConfigChanged'))
|
||||
}
|
||||
|
||||
const handleToggleEnabled = (id, enabled) => {
|
||||
const newConfig = config.map(item =>
|
||||
item.id === id ? { ...item, enabled } : item,
|
||||
)
|
||||
saveConfig(newConfig)
|
||||
}
|
||||
|
||||
const handleDragEnd = result => {
|
||||
if (!result.destination) return
|
||||
|
||||
const newConfig = Array.from(config)
|
||||
const [reorderedItem] = newConfig.splice(result.source.index, 1)
|
||||
newConfig.splice(result.destination.index, 0, reorderedItem)
|
||||
|
||||
const updatedConfig = newConfig.map((item, index) => ({
|
||||
...item,
|
||||
order: index,
|
||||
}))
|
||||
|
||||
saveConfig(updatedConfig)
|
||||
}
|
||||
|
||||
const resetToDefaults = () => {
|
||||
saveConfig(DEFAULT_SIDEPANEL_CONFIG)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography level='h4' sx={{ mb: 2 }}>
|
||||
Sidepanel Settings
|
||||
</Typography>
|
||||
<Typography level='body-md' sx={{ mb: 3 }}>
|
||||
Customize which cards appear in the sidepanel and their order. Drag and
|
||||
drop to reorder, or toggle visibility for each card.
|
||||
</Typography>
|
||||
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
<Droppable droppableId='sidepanel-cards'>
|
||||
{provided => (
|
||||
<List
|
||||
{...provided.droppableProps}
|
||||
ref={provided.innerRef}
|
||||
sx={{ gap: 1 }}
|
||||
>
|
||||
{config.map((item, index) => (
|
||||
<Draggable key={item.id} draggableId={item.id} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
<ListItem
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
sx={{
|
||||
p: 0,
|
||||
backgroundColor: snapshot.isDragging
|
||||
? 'var(--joy-palette-neutral-softBg)'
|
||||
: 'transparent',
|
||||
borderRadius: 'var(--joy-radius-md)',
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 2,
|
||||
p: 2,
|
||||
opacity: item.enabled ? 1 : 0.6,
|
||||
border: snapshot.isDragging
|
||||
? '2px solid var(--joy-palette-primary-400)'
|
||||
: '1px solid var(--joy-palette-divider)',
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
<IconButton
|
||||
{...provided.dragHandleProps}
|
||||
variant='plain'
|
||||
size='sm'
|
||||
sx={{
|
||||
cursor: 'grab',
|
||||
'&:active': { cursor: 'grabbing' },
|
||||
}}
|
||||
>
|
||||
<DragIndicator />
|
||||
</IconButton>
|
||||
</ListItemDecorator>
|
||||
|
||||
<IconButton
|
||||
sx={{ color: 'var(--joy-palette-primary-500)' }}
|
||||
>
|
||||
{getIcon(item.iconName)}
|
||||
</IconButton>
|
||||
|
||||
<ListItemContent sx={{ flex: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
level='title-sm'
|
||||
sx={{ fontWeight: 600 }}
|
||||
>
|
||||
{item.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
color: 'var(--joy-palette-text-tertiary)',
|
||||
}}
|
||||
>
|
||||
- {item.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
</ListItemContent>
|
||||
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={item.enabled}
|
||||
onChange={e =>
|
||||
handleToggleEnabled(item.id, e.target.checked)
|
||||
}
|
||||
overlay
|
||||
variant='plain'
|
||||
size='lg'
|
||||
checkedIcon={<Visibility />}
|
||||
uncheckedIcon={<VisibilityOff />}
|
||||
/>
|
||||
</FormControl>
|
||||
</Card>
|
||||
</ListItem>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</List>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
|
||||
<Box
|
||||
sx={{ mt: 3, pt: 2, borderTop: '1px solid var(--joy-palette-divider)' }}
|
||||
>
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
onClick={resetToDefaults}
|
||||
size='sm'
|
||||
>
|
||||
Reset to Defaults
|
||||
</Button>
|
||||
<FormHelperText sx={{ mt: 1 }}>
|
||||
This will restore all cards to their default visibility and order.
|
||||
</FormHelperText>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default SidepanelSettings
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { version } from '../../../package.json'
|
||||
import UserProfileAvatar from '../../components/UserProfileAvatar'
|
||||
import ThemeToggleButton from '../Settings/ThemeToggleButton'
|
||||
import NavBarLink from './NavBarLink'
|
||||
const links = [
|
||||
@@ -113,9 +114,9 @@ const NavBar = () => {
|
||||
|
||||
return (
|
||||
<nav
|
||||
className='mt-2 flex gap-2 p-3 pt-5'
|
||||
className='flex gap-2 p-3'
|
||||
style={{
|
||||
paddingTop: `calc( env(safe-area-inset-top, 0px))`,
|
||||
paddingTop: `calc(var(--safe-area-inset-top, 0px) + 12px)`,
|
||||
position: 'sticky',
|
||||
zIndex: Z_INDEX.NAVBAR,
|
||||
top: 0,
|
||||
@@ -136,37 +137,10 @@ const NavBar = () => {
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
)}
|
||||
<Box
|
||||
className='flex items-center gap-2'
|
||||
onClick={() => {
|
||||
navigate('/chores')
|
||||
}}
|
||||
>
|
||||
<img src={Logo} width='25' alt='Logo' />
|
||||
<Typography
|
||||
level='title-lg'
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: 20,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Done
|
||||
<span
|
||||
style={{
|
||||
color: '#06b6d4',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
tick✓
|
||||
</span>
|
||||
</Typography>
|
||||
<ThemeToggleButton
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: 10,
|
||||
}}
|
||||
/>
|
||||
<Box className='flex-1' />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<UserProfileAvatar />
|
||||
<ThemeToggleButton />
|
||||
</Box>
|
||||
<Drawer
|
||||
open={drawerOpen}
|
||||
@@ -176,11 +150,11 @@ const NavBar = () => {
|
||||
sx={{
|
||||
'& .MuiDrawer-content': {
|
||||
position: 'fixed',
|
||||
pt: 'calc(env(safe-area-inset-top, 0px))',
|
||||
// pt: 'calc(var(--safe-area-inset-top, 0px))',
|
||||
left: 0,
|
||||
pb: 'calc(env(safe-area-inset-bottom, 0px))',
|
||||
// pb: 'calc(var(--safe-area-inset-bottom, 0px))',
|
||||
// height:
|
||||
// 'calc(100vh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))',
|
||||
// 'calc(100vh - var(--safe-area-inset-top, 0px) - var(--safe-area-inset-bottom, 0px))',
|
||||
overflow: 'auto',
|
||||
zIndex: Z_INDEX.DRAWER,
|
||||
},
|
||||
@@ -254,7 +228,7 @@ const NavBar = () => {
|
||||
p: 1,
|
||||
color: 'text.tertiary',
|
||||
textAlign: 'center',
|
||||
mb: 'calc(env(safe-area-inset-bottom, 0px) + 45px)',
|
||||
mb: 'calc(var(--safe-area-inset-bottom, 0px) )',
|
||||
// mb: -2,
|
||||
}}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user