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:
Mo Tarbin
2025-08-29 00:50:54 -04:00
parent 6b5a874db5
commit cf4d0565c5
19 changed files with 2077 additions and 188 deletions

View File

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

View 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

View File

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

View File

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

View File

@@ -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 />,
},
{

View File

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

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View 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

View 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

View File

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