From 7c8fa27aaf1eead9ebe11f28a00af54af157792a Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sat, 17 Jan 2026 19:35:36 -0500 Subject: [PATCH] feat: Add unarchive functionality to ChoreView and enhance project management features - Implemented unarchive button for archived chores in ChoreView. - Updated chore status handling to disable actions for archived chores. - Enhanced MyChores component to filter tasks based on project selection, including a default project. - Improved ProjectModal to streamline project creation and editing with a new footer layout. - Refactored ProjectView to accurately count tasks for default and user projects. - Added project selection dropdown in AddTaskModal with default project handling. - Updated NavBar to handle logout using apiClient. - Enhanced ProjectSelector to manage projects with a new menu item for project management. --- src/components/UserProfileAvatar.jsx | 5 +- src/components/common/BottomSheetModal.jsx | 24 +- src/hooks/useAuth.jsx | 56 ++- src/hooks/useSSE.js | 7 + src/queries/UserQueries.jsx | 15 +- src/service/NotificationProvider.jsx | 29 +- src/utils/ApiClient.js | 42 ++- src/utils/Fetcher.jsx | 9 + src/utils/TokenStorage.js | 188 ++++++++++ src/views/Authorization/LoginView.jsx | 29 +- src/views/ChoreEdit/ChoreEdit.jsx | 417 +++++++++++++++++---- src/views/ChoreEdit/ChoreView.jsx | 314 +++++++++------- src/views/Chores/ArchivedTasks.jsx | 1 - src/views/Chores/MyChores.jsx | 40 +- src/views/Modals/Inputs/ProjectModal.jsx | 108 ++---- src/views/Projects/ProjectView.jsx | 43 +-- src/views/components/AddTaskModal.jsx | 171 +++++++-- src/views/components/NavBar.jsx | 7 +- src/views/components/ProjectSelector.jsx | 89 ++++- 19 files changed, 1132 insertions(+), 462 deletions(-) create mode 100644 src/utils/TokenStorage.js diff --git a/src/components/UserProfileAvatar.jsx b/src/components/UserProfileAvatar.jsx index 0b31008..4065356 100644 --- a/src/components/UserProfileAvatar.jsx +++ b/src/components/UserProfileAvatar.jsx @@ -31,6 +31,7 @@ import { useNavigate } from 'react-router-dom' import { useImpersonateUser } from '../contexts/ImpersonateUserContext' import useStickyState from '../hooks/useStickyState' import { useCircleMembers, useUserProfile } from '../queries/UserQueries' +import { apiClient } from '../utils/apiClient' import { isPlusAccount } from '../utils/Helpers' import UserModal from '../views/Modals/Inputs/UserModal' import SubscriptionModal from './SubscriptionModal' @@ -76,9 +77,7 @@ const UserProfileAvatar = () => { } const handleLogout = () => { - localStorage.removeItem('access_token') - localStorage.removeItem('ca_expiration') - window.location.href = '/login' + apiClient.handleLogout() } const handleSupportEmail = () => { diff --git a/src/components/common/BottomSheetModal.jsx b/src/components/common/BottomSheetModal.jsx index 51a3b12..029475d 100644 --- a/src/components/common/BottomSheetModal.jsx +++ b/src/components/common/BottomSheetModal.jsx @@ -1,5 +1,5 @@ import { Close } from '@mui/icons-material' -import { IconButton, Modal, Sheet, Typography } from '@mui/joy' +import { Divider, IconButton, Modal, Sheet, Typography } from '@mui/joy' import { forwardRef, useEffect, useState } from 'react' import { Z_INDEX } from '../../constants/zIndex' @@ -10,6 +10,7 @@ const BottomSheetModal = forwardRef( onClose, children, title, + footer, height = 'auto', maxHeight = '90vh', expandedHeight = '95vh', @@ -79,7 +80,11 @@ const BottomSheetModal = forwardRef( const currentHeight = isExpanded ? expandedHeight : height // Filter out DOM props that shouldn't be passed to Modal - const { fullWidth: _fullWidth, unmountDelay: _unmountDelay, ...modalProps } = props + const { + fullWidth: _fullWidth, + unmountDelay: _unmountDelay, + ...modalProps + } = props return ( {children} + + {footer && ( + <> + +
+ {footer} +
+ + )}
) diff --git a/src/hooks/useAuth.jsx b/src/hooks/useAuth.jsx index 4dc41b8..3551708 100644 --- a/src/hooks/useAuth.jsx +++ b/src/hooks/useAuth.jsx @@ -2,6 +2,7 @@ import { createContext, useContext, useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' import { API_URL } from '../Config' import { apiClient } from '../utils/ApiClient' +import { saveTokens, clearAllTokens } from '../utils/TokenStorage' const AuthContext = createContext(null) @@ -28,14 +29,10 @@ export const AuthProvider = ({ children }) => { return new Date() >= new Date(expiry) } - const clearAuth = () => { + const clearAuth = async () => { setToken(null) setUser(null) - localStorage.removeItem('token') - localStorage.removeItem('token_expiry') - localStorage.removeItem('ca_token') - localStorage.removeItem('ca_expiration') - localStorage.removeItem('access_token') + await clearAllTokens() } const login = async credentials => { @@ -58,14 +55,14 @@ export const AuthProvider = ({ children }) => { if (userToken) { setToken(userToken) - localStorage.setItem('token', userToken) - if (data.expire || data.access_token_expiry) { - localStorage.setItem( - 'token_expiry', - data.expire || data.access_token_expiry, - ) - } + // Use centralized token storage + await saveTokens({ + accessToken: userToken, + accessTokenExpiry: data.expire || data.access_token_expiry, + refreshToken: data.refresh_token, + refreshTokenExpiry: data.refresh_token_expiry, + }) } setIsLoading(false) @@ -86,7 +83,7 @@ export const AuthProvider = ({ children }) => { } catch (error) { console.warn('Logout API call failed:', error) } finally { - clearAuth() + await clearAuth() setIsLoading(false) navigate('/login') } @@ -112,22 +109,21 @@ export const AuthProvider = ({ children }) => { } useEffect(() => { - const initAuth = async () => { - if (token && !isTokenExpired()) { - await fetchUser() - } else if (token && isTokenExpired()) { - // Token is expired, but don't refresh here - // Let the first API call handle refresh via ApiClient - // Just try to fetch user - if it fails, ApiClient will handle refresh - await fetchUser() - } else { - clearAuth() - navigate('/login') - } - setIsLoading(false) - } - - initAuth() + // const initAuth = async () => { + // if (token && !isTokenExpired()) { + // await fetchUser() + // } else if (token && isTokenExpired()) { + // // Token is expired, but don't refresh here + // // Let the first API call handle refresh via ApiClient + // // Just try to fetch user - if it fails, ApiClient will handle refresh + // await fetchUser() + // } else { + // clearAuth() + // navigate('/login') + // } + // setIsLoading(false) + // } + // initAuth() }, [token, navigate]) const value = { diff --git a/src/hooks/useSSE.js b/src/hooks/useSSE.js index 2729d06..5b21273 100644 --- a/src/hooks/useSSE.js +++ b/src/hooks/useSSE.js @@ -443,6 +443,13 @@ export const useSSE = () => { return // Exit early, don't use exponential backoff for 401 errors } else { + // Check if refresh token expired + if (refreshResult.error === 'Refresh token expired') { + console.error('Refresh token expired, user must login again') + setError('Session expired - please log in again') + return // Don't attempt reconnection + } + console.error('Token refresh failed:', refreshResult.error) setError('Authentication failed - please log in again') // Don't attempt reconnection if token refresh failed diff --git a/src/queries/UserQueries.jsx b/src/queries/UserQueries.jsx index c9a97d8..9ffe727 100644 --- a/src/queries/UserQueries.jsx +++ b/src/queries/UserQueries.jsx @@ -46,24 +46,11 @@ export const useUserProfile = () => { const { data, error, isLoading } = useQuery({ queryKey: ['userProfile'], queryFn: async () => { - // the below code deleted because it cause issue when token expire and screen is off - // and then user comes back to the app after long time. the user profile fetch would fail - // and there is no retry for some reason. remove this seem to fix the issue. - - if (!isTokenValid()) { - throw new Error('Invalid or expired token, cannot fetch user profile') - } const resp = await GetUserProfile() const result = await resp.json() // if we got 403 then user probably deleted their account and token is still valid. navigate to login - if (resp.status === 403) { - localStorage.removeItem('access_token') - localStorage.removeItem('ca_expiration') - window.location.href = '/login' - throw new Error('User account deleted or access forbidden') - } - return result.res // Return the actual user profile data + return result.res || null }, staleTime: 30 * 60 * 1000, // 30 minutes in milliseconds gcTime: 30 * 60 * 1000, // 30 minutes in milliseconds diff --git a/src/service/NotificationProvider.jsx b/src/service/NotificationProvider.jsx index 9d6dc39..2a736a2 100644 --- a/src/service/NotificationProvider.jsx +++ b/src/service/NotificationProvider.jsx @@ -1,4 +1,4 @@ -import { CheckCircle, Error, Info, Warning } from '@mui/icons-material' +import { CheckCircle, Error, Info, Undo, Warning } from '@mui/icons-material' import { Box, Button, Snackbar, Typography } from '@mui/joy' import React, { createContext, useContext, useState } from 'react' @@ -24,10 +24,17 @@ const NOTIFICATION_TYPES = { success: { color: 'success', icon: , - autoHideDuration: 3000, + autoHideDuration: 5000, showDismissButton: false, defaultTitle: 'Success', }, + undo: { + color: 'success', + icon: , + autoHideDuration: null, + showDismissButton: false, + defaultTitle: 'Undone Successfully', + }, warning: { color: 'warning', icon: , @@ -134,6 +141,10 @@ export const NotificationProvider = ({ children }) => { return addNotification(normalizeNotification(error, 'error')) } + const showUndo = message => { + return addNotification(normalizeNotification(message, 'undo')) + } + const showSuccess = message => { return addNotification(normalizeNotification(message, 'success')) } @@ -189,7 +200,18 @@ export const NotificationProvider = ({ children }) => { onClose={() => removeNotification(notification.id)} startDecorator={notificationIcon} endDecorator={ - config.showDismissButton ? ( + notification.undoAction ? ( + + ) : config.showDismissButton ? ( diff --git a/src/views/ChoreEdit/ChoreView.jsx b/src/views/ChoreEdit/ChoreView.jsx index 6b6923d..0a003c5 100644 --- a/src/views/ChoreEdit/ChoreView.jsx +++ b/src/views/ChoreEdit/ChoreView.jsx @@ -1,4 +1,5 @@ import { + Archive, CalendarMonth, CancelScheduleSend, Check, @@ -15,6 +16,7 @@ import { SwitchAccessShortcut, ThumbDown, ThumbUp, + Unarchive, } from '@mui/icons-material' import { Box, @@ -60,6 +62,7 @@ import { MarkChoreComplete, RejectChore, SkipChore, + UnArchiveChore, UpdateChorePriority, } from '../../utils/Fetcher' import Priorities from '../../utils/Priorities' @@ -354,6 +357,18 @@ const ChoreView = () => { }) } + const handleUnarchiveChore = () => { + UnArchiveChore(choreId).then(response => { + if (response.ok) { + response.json().then(data => { + setChore({ ...chore, isActive: true }) + // Invalidate chores cache to refetch data + queryClient.invalidateQueries(['chores']) + }) + } + }) + } + // Check if the current user can approve/reject (admin, manager, or task owner) const canApproveReject = () => { if (!circleMembersData?.res || !chore) return false @@ -408,6 +423,16 @@ const ChoreView = () => { > {chore.name} + {chore.isActive === false && ( + } + size='md' + color='warning' + sx={{ mb: 1 }} + > + Archived + + )} } size='md' sx={{ mb: 1 }}> {chore.nextDueDate ? `Due at ${moment(chore.nextDueDate).format('MM/DD/YYYY hh:mm A')}` @@ -531,6 +556,7 @@ const ChoreView = () => { > { color='neutral' variant='plain' fullWidth + disabled={chore.isActive === false} onClick={() => { navigate(`/chores/${choreId}/history`) }} @@ -609,6 +636,7 @@ const ChoreView = () => { color='neutral' variant='plain' fullWidth + disabled={chore.isActive === false} sx={{ // top right of the card: flexDirection: 'column', @@ -725,6 +753,7 @@ const ChoreView = () => { { if (e.target.checked) { setNote('') @@ -764,6 +793,7 @@ const ChoreView = () => { { if (e.target.checked) { setCompletedDate( @@ -804,164 +834,188 @@ const ChoreView = () => { /> )} - + {chore.isActive === false ? ( + // Archived chore - only show unarchive button - {chore.status === 3 ? ( - // Pending approval: Show approve/reject for admins/managers/owners, grayed out button for others - canApproveReject() ? ( + + + ) : ( + // Active chore - show all normal actions + + + {chore.status === 3 ? ( + // Pending approval: Show approve/reject for admins/managers/owners, grayed out button for others + canApproveReject() ? ( + <> + + + + ) : ( + + ) + ) : ( + // Normal completion flow <> + - ) : ( - - ) + )} + + {/* Timer Button - Show split button when timer is active, regular button otherwise */} + {[ChoreStatus.ACTIVE, ChoreStatus.PAUSED].includes(chore.status) ? ( + { + if (action === 'pause') { + handleChorePause() + } else if (action === 'resume') { + handleChoreStart() + } + }} + onShowDetails={() => navigate(`/chores/${choreId}/timer`)} + onResetTimer={handleResetTimer} + onClearAllTime={handleClearAllTime} + fullWidth + /> + ) : chore.status === ChoreStatus.PENDING_APPROVAL ? ( + <> ) : ( - // Normal completion flow - <> - - - - + )} - {/* Timer Button - Show split button when timer is active, regular button otherwise */} - {[ChoreStatus.ACTIVE, ChoreStatus.PAUSED].includes(chore.status) ? ( - { - if (action === 'pause') { - handleChorePause() - } else if (action === 'resume') { - handleChoreStart() - } - }} - onShowDetails={() => navigate(`/chores/${choreId}/timer`)} - onResetTimer={handleResetTimer} - onClearAllTime={handleClearAllTime} - fullWidth - /> - ) : chore.status === ChoreStatus.PENDING_APPROVAL ? ( - <> - ) : ( - - )} - + )} { performers={performers} viewOnly={false} showActions={false} - // onAction={handleChoreAction} // Multi-select props isMultiSelectMode={isMultiSelectMode} isSelected={selectedChores.has(chore.id)} diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx index 19701dd..09819e9 100644 --- a/src/views/Chores/MyChores.jsx +++ b/src/views/Chores/MyChores.jsx @@ -209,9 +209,16 @@ const MyChores = () => { } // Use project-filtered chores for section grouping - const choresToGroup = selectedProject - ? filterByProject(chores, selectedProject.id) - : chores + let choresToGroup = chores + if (selectedProject) { + if (selectedProject.id === 'default') { + // Default project: only show tasks without a projectId + choresToGroup = chores.filter(chore => !chore.projectId) + } else { + // Other projects: use the existing filter function + choresToGroup = filterByProject(chores, selectedProject.id) + } + } const sections = ChoresGrouper( selectedChoreSection, @@ -1084,8 +1091,8 @@ const MyChores = () => { } const setSelectedProjectWithCache = project => { - // Handle the case where project might be null (clearing selection) - const finalProject = project?.id === 'default' || !project ? null : project + // Keep the project as-is, including the default project object + const finalProject = project || null setSelectedProject(finalProject) console.log('final project', finalProject) @@ -1157,6 +1164,13 @@ const MyChores = () => { if (!selectedProject) { return chores } + + // Special case: Default project shows only tasks without a projectId + if (selectedProject.id === 'default') { + return chores.filter(chore => !chore.projectId) + } + + // Other projects: use the existing filter function return filterByProject(chores, selectedProject.id) }, [chores, selectedProject]) @@ -1183,11 +1197,17 @@ const MyChores = () => { .search(searchTerm.toLowerCase()) .map(result => result.item) } else { - result = filteredChores.filter( - chore => - !selectedProject || - filterByProject([chore], selectedProject).length > 0, - ) + result = filteredChores.filter(chore => { + if (!selectedProject) return true + + // Default project: only show tasks without projectId + if (selectedProject.id === 'default') { + return !chore.projectId + } + + // Other projects: use existing filter + return filterByProject([chore], selectedProject.id).length > 0 + }) } } else { let choresToFilter = baseChores diff --git a/src/views/Modals/Inputs/ProjectModal.jsx b/src/views/Modals/Inputs/ProjectModal.jsx index 8e67def..94ac505 100644 --- a/src/views/Modals/Inputs/ProjectModal.jsx +++ b/src/views/Modals/Inputs/ProjectModal.jsx @@ -125,12 +125,32 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => { size='md' unmountDelay={250} fullWidth={true} + title={project ? 'Edit Project' : 'Create New Project'} + footer={ + + + + + } > - - {project ? 'Edit Project' : 'Create New Project'} - - -
+ {/* Project Name */} @@ -238,62 +258,6 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => { - {/* Project Preview */} - - Preview - - - {(() => { - const IconComponent = getIconComponent(projectIcon) - return ( - - ) - })()} - - - - {projectName || 'Project Name'} - - {projectDescription && ( - - {projectDescription} - - )} - - - - {/* Error Message */} {error && ( @@ -301,29 +265,7 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => { )} - - - - -
- setIsIconPickerOpen(false)} diff --git a/src/views/Projects/ProjectView.jsx b/src/views/Projects/ProjectView.jsx index 6d9ad52..20e1053 100644 --- a/src/views/Projects/ProjectView.jsx +++ b/src/views/Projects/ProjectView.jsx @@ -413,18 +413,7 @@ const ProjectCard = ({ ) })() ) : ( - - {project.name.charAt(0).toUpperCase()} - + <> )} @@ -545,7 +534,7 @@ const ProjectCard = ({ const ProjectView = () => { const { data: projects, isProjectsLoading, isError } = useProjects() const { data: userProfile } = useUserProfile() - const { data: chores = [] } = useChores(false) // false to exclude archived + const { data: chores = { res: [] } } = useChores(false) // false to exclude archived const [userProjects, setUserProjects] = useState([]) const [modalOpen, setModalOpen] = useState(false) @@ -602,23 +591,27 @@ const ProjectView = () => { // Calculate real task counts from chores data useEffect(() => { - if (chores && chores.res && userProjects.length > 0) { + if (chores && chores.res) { const choresList = chores.res const realCounts = {} + // First, count tasks for the default project (tasks without a projectId) + const defaultProjectCount = choresList.filter(chore => { + const choreProjectId = chore.projectId || chore.project_id + return ( + !choreProjectId || + choreProjectId === '' || + choreProjectId === 'default' || + choreProjectId === null + ) + }).length + realCounts['default'] = defaultProjectCount + + // Then count tasks for each user project userProjects.forEach(project => { - // Count chores for this project const choreCount = choresList.filter(chore => { - // Handle default project (projectId is null, undefined, empty string, or 'default') - if (project.id === 'default') { - return ( - !chore.projectId || - chore.projectId === '' || - chore.projectId === 'default' - ) - } - // Handle custom projects - exact match with project ID - return chore.projectId === project.id + const choreProjectId = chore.projectId || chore.project_id + return choreProjectId === project.id }).length realCounts[project.id] = choreCount diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx index c726dfd..9361ce7 100644 --- a/src/views/components/AddTaskModal.jsx +++ b/src/views/components/AddTaskModal.jsx @@ -1,5 +1,13 @@ import { Add, EditNotifications } from '@mui/icons-material' -import { Box, Button, Input, Option, Select, Typography } from '@mui/joy' +import { + Avatar, + Box, + Button, + Input, + Option, + Select, + Typography, +} from '@mui/joy' import { FormControl } from '@mui/material' import * as chrono from 'chrono-node' import moment from 'moment' @@ -7,8 +15,11 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useResponsiveModal } from '../../hooks/useResponsiveModal' import { useCreateChore } from '../../queries/ChoreQueries' import { useCircleMembers, useUserProfile } from '../../queries/UserQueries' +import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx' import { isPlusAccount } from '../../utils/Helpers' +import { getIconComponent } from '../../utils/ProjectIcons' import { useLabels } from '../Labels/LabelQueries' +import { useProjects } from '../Projects/ProjectQueries' import { parseAssignees, parseDueDate, @@ -47,10 +58,25 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { const { data: userLabels, isLoading: userLabelsLoading } = useLabels() const { data: circleMembers, isLoading: isCircleMembersLoading } = useCircleMembers() + const { data: projects = [], isLoading: isProjectsLoading } = useProjects() const createChoreMutation = useCreateChore() const { data: userProfile } = useUserProfile() + // Get initial project from localStorage (current active project) + const getInitialProject = () => { + const saved = localStorage.getItem('selectedProject') + if (saved) { + try { + const project = JSON.parse(saved) + return project?.id || 'default' + } catch { + return 'default' + } + } + return 'default' + } + const [taskText, setTaskText] = useState('') const [taskTitle, setTaskTitle] = useState('') const [renderedParts, setRenderedParts] = useState([]) @@ -75,6 +101,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { const [hasSubTasks, setHasSubTasks] = useState(false) const [hasNotifications, setHasNotifications] = useState(false) const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false) + const [projectId, setProjectId] = useState(getInitialProject()) // set showKeyboardShortcuts true as soon as the user hold ctrl or cmd key: useEffect(() => { @@ -473,6 +500,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { setHasSubTasks(false) setLabelsV2([]) setAssignees([]) + setProjectId(getInitialProject()) } const createChore = () => { @@ -516,6 +544,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { frequencyMetadata: {}, notificationMetadata: {}, subTasks: subTasks?.length > 0 ? subTasks : null, + projectId: projectId === 'default' ? null : projectId, } if (frequency) { @@ -560,7 +589,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { }) handleCloseModal(false) } - if (userLabelsLoading || isCircleMembersLoading) { + if (userLabelsLoading || isCircleMembersLoading || isProjectsLoading) { return <> } @@ -571,6 +600,44 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { size='lg' fullWidth={true} title='Create new task' + footer={ + + + + + } > { )} + {projects.length >= 1 && ( + + Project + + + )} { )} - - - - ) } diff --git a/src/views/components/NavBar.jsx b/src/views/components/NavBar.jsx index 8a4fc41..a5d8057 100644 --- a/src/views/components/NavBar.jsx +++ b/src/views/components/NavBar.jsx @@ -94,13 +94,12 @@ const links = [ import { SafeArea } from 'capacitor-plugin-safe-area' import Z_INDEX from '../../constants/zIndex' -import { useAuth } from '../../hooks/useAuth.jsx' import { useResource } from '../../queries/ResourceQueries' +import { apiClient } from '../../utils/apiClient' const publicPages = ['/landing', '/privacy', '/terms'] const NavBar = () => { const { data: resource } = useResource() - const { logout } = useAuth() const navigate = useNavigate() const [drawerOpen, setDrawerOpen] = useState(false) @@ -272,7 +271,9 @@ const NavBar = () => { Upgrade to Plus */} { + apiClient.handleLogout() + }} sx={{ py: 1.2, }} diff --git a/src/views/components/ProjectSelector.jsx b/src/views/components/ProjectSelector.jsx index 266d5b2..89c4754 100644 --- a/src/views/components/ProjectSelector.jsx +++ b/src/views/components/ProjectSelector.jsx @@ -1,4 +1,4 @@ -import { Add, Check, FolderOpen } from '@mui/icons-material' +import { Add, Check, FolderOpen, Settings } from '@mui/icons-material' import { Avatar, Box, @@ -11,6 +11,7 @@ import { Typography, } from '@mui/joy' import { useEffect, useRef, useState } from 'react' +import { useNavigate } from 'react-router-dom' import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint' import LABEL_COLORS, { getTextColorFromBackgroundColor, @@ -25,9 +26,11 @@ const ProjectSelector = ({ showKeyboardShortcuts = false, }) => { const { data: projects = [], isLoading } = useProjects() + const navigate = useNavigate() const [anchorEl, setAnchorEl] = useState(null) const [selectedIndex, setSelectedIndex] = useState(0) + const [isKeyboardNavigating, setIsKeyboardNavigating] = useState(false) const [isProjectModalOpen, setIsProjectModalOpen] = useState(false) const menuRef = useRef(null) const buttonRef = useRef(null) @@ -56,6 +59,7 @@ const ProjectSelector = ({ const handleMenuOpen = event => { setAnchorEl(event.currentTarget) + setIsKeyboardNavigating(false) } const handleMenuClose = () => { @@ -76,6 +80,11 @@ const ProjectSelector = ({ handleProjectSelect(project) } + const handleManageProjects = () => { + navigate('/projects') + handleMenuClose() + } + useEffect(() => { const handleMenuOutsideClick = event => { if (menuRef.current && !menuRef.current.contains(event.target)) { @@ -100,6 +109,7 @@ const ProjectSelector = ({ if (!anchorEl) { setAnchorEl(buttonRef.current) setSelectedIndex(0) + setIsKeyboardNavigating(true) } else { handleMenuClose() } @@ -112,20 +122,33 @@ const ProjectSelector = ({ switch (event.key) { case 'ArrowDown': event.preventDefault() + setIsKeyboardNavigating(true) setSelectedIndex(prev => - prev < defaultProjects.length ? prev + 1 : prev, + prev < defaultProjects.length + 2 ? prev + 1 : prev, ) break case 'ArrowUp': event.preventDefault() + setIsKeyboardNavigating(true) setSelectedIndex(prev => (prev > 0 ? prev - 1 : prev)) break case 'Enter': event.preventDefault() - if (selectedIndex < defaultProjects.length) { - handleProjectSelect(defaultProjects[selectedIndex]) - } else { + if (selectedIndex === 0) { + // Hardcoded Default Project + handleProjectSelect({ + id: 'default', + name: 'Default Project', + color: LABEL_COLORS[0].value, + icon: 'FolderOpen', + }) + } else if (selectedIndex <= defaultProjects.length) { + // Projects from the array (offset by 1) + handleProjectSelect(defaultProjects[selectedIndex - 1]) + } else if (selectedIndex === defaultProjects.length + 1) { handleAddProjectClick() + } else { + handleManageProjects() } break case 'Escape': @@ -139,7 +162,7 @@ const ProjectSelector = ({ return () => { document.removeEventListener('keydown', handleKeyDown) } - }, [anchorEl, selectedIndex, defaultProjects]) + }, [anchorEl, selectedIndex, defaultProjects, isKeyboardNavigating]) // Reset selected index when menu opens useEffect(() => { @@ -233,7 +256,6 @@ const ProjectSelector = ({ disabled sx={{ borderRadius: 'var(--joy-radius-sm)', - mb: 1, cursor: 'default', opacity: 1, }} @@ -243,13 +265,7 @@ const ProjectSelector = ({ - Select Project - - - Choose or create a project workspace + Projects @@ -266,12 +282,13 @@ const ProjectSelector = ({ icon: 'FolderOpen', }) } + onMouseEnter={() => setIsKeyboardNavigating(false)} sx={{ borderRadius: 'var(--joy-radius-sm)', backgroundColor: effectiveSelectedProject === 'Default Project' ? 'var(--joy-palette-primary-softBg)' - : selectedIndex === 0 && anchorEl + : selectedIndex === 0 && anchorEl && isKeyboardNavigating ? 'var(--joy-palette-neutral-softHoverBg)' : 'transparent', '&:hover': { @@ -345,12 +362,13 @@ const ProjectSelector = ({ handleProjectSelect(project)} + onMouseEnter={() => setIsKeyboardNavigating(false)} sx={{ borderRadius: 'var(--joy-radius-sm)', backgroundColor: effectiveSelectedProject === project.name ? 'var(--joy-palette-primary-softBg)' - : selectedIndex === index && anchorEl + : selectedIndex === index + 1 && anchorEl && isKeyboardNavigating ? 'var(--joy-palette-neutral-softHoverBg)' : 'transparent', '&:hover': { @@ -437,10 +455,11 @@ const ProjectSelector = ({ setIsKeyboardNavigating(false)} sx={{ borderRadius: 'var(--joy-radius-sm)', backgroundColor: - selectedIndex === defaultProjects.length && anchorEl + selectedIndex === defaultProjects.length + 1 && anchorEl && isKeyboardNavigating ? 'var(--joy-palette-success-softHoverBg)' : 'transparent', '&:hover': { @@ -456,7 +475,6 @@ const ProjectSelector = ({ level='body-sm' sx={{ fontWeight: 500, - color: 'var(--joy-palette-success-600)', }} > Create New Project @@ -469,6 +487,41 @@ const ProjectSelector = ({ + + setIsKeyboardNavigating(false)} + sx={{ + borderRadius: 'var(--joy-radius-sm)', + backgroundColor: + selectedIndex === defaultProjects.length + 2 && anchorEl && isKeyboardNavigating + ? 'var(--joy-palette-neutral-softHoverBg)' + : 'transparent', + '&:hover': { + backgroundColor: 'var(--joy-palette-neutral-softHoverBg)', + }, + }} + > + + + + + + Manage Projects + + + View, edit, and organize all projects + + +