Add Support Project

This commit is contained in:
Mo Tarbin
2026-01-15 20:11:03 -05:00
parent 98018ca590
commit 7fbae16a53
10 changed files with 1545 additions and 418 deletions

View File

@@ -28,6 +28,7 @@ import Landing from '../views/Landing/Landing'
import PaymentCancelledView from '../views/Payments/PaymentFailView' import PaymentCancelledView from '../views/Payments/PaymentFailView'
import PaymentSuccessView from '../views/Payments/PaymentSuccessView' import PaymentSuccessView from '../views/Payments/PaymentSuccessView'
import PrivacyPolicyView from '../views/PrivacyPolicy/PrivacyPolicyView' import PrivacyPolicyView from '../views/PrivacyPolicy/PrivacyPolicyView'
import ProjectView from '../views/Projects/ProjectView'
import APITokenSettings from '../views/Settings/APITokenSettings' import APITokenSettings from '../views/Settings/APITokenSettings'
import MFASettings from '../views/Settings/MFASettings' import MFASettings from '../views/Settings/MFASettings'
import NotificationSetting from '../views/Settings/NotificationSetting' import NotificationSetting from '../views/Settings/NotificationSetting'
@@ -223,7 +224,10 @@ const Router = createBrowserRouter([
path: 'labels/', path: 'labels/',
element: <LabelView />, element: <LabelView />,
}, },
{
path: 'projects/',
element: <ProjectView />,
},
{ {
path: '*', path: '*',
element: <NotFound />, element: <NotFound />,

View File

@@ -0,0 +1,60 @@
import {
AccountBalance,
Book,
Build,
BusinessCenter,
Code,
Computer,
DirectionsCar,
FitnessCenter,
Flight,
FolderOpen,
Games,
Home,
LocalHospital,
MusicNote,
Palette,
Pets,
PhotoCamera,
Restaurant,
School,
Science,
ShoppingCart,
SportsSoccer,
Work,
Yard,
} from '@mui/icons-material'
const PROJECT_ICONS = [
{ name: 'Folder', icon: FolderOpen, value: 'FolderOpen' },
{ name: 'Work', icon: Work, value: 'Work' },
{ name: 'Home', icon: Home, value: 'Home' },
{ name: 'School', icon: School, value: 'School' },
{ name: 'Business', icon: BusinessCenter, value: 'BusinessCenter' },
{ name: 'Code', icon: Code, value: 'Code' },
{ name: 'Build', icon: Build, value: 'Build' },
{ name: 'Design', icon: Palette, value: 'Palette' },
{ name: 'Sports', icon: SportsSoccer, value: 'SportsSoccer' },
{ name: 'Fitness', icon: FitnessCenter, value: 'FitnessCenter' },
{ name: 'Shopping', icon: ShoppingCart, value: 'ShoppingCart' },
{ name: 'Food', icon: Restaurant, value: 'Restaurant' },
{ name: 'Travel', icon: Flight, value: 'Flight' },
{ name: 'Study', icon: Book, value: 'Book' },
{ name: 'Music', icon: MusicNote, value: 'MusicNote' },
{ name: 'Photo', icon: PhotoCamera, value: 'PhotoCamera' },
{ name: 'Games', icon: Games, value: 'Games' },
{ name: 'Science', icon: Science, value: 'Science' },
{ name: 'Finance', icon: AccountBalance, value: 'AccountBalance' },
{ name: 'Health', icon: LocalHospital, value: 'LocalHospital' },
{ name: 'Auto', icon: DirectionsCar, value: 'DirectionsCar' },
{ name: 'Pets', icon: Pets, value: 'Pets' },
{ name: 'Garden', icon: Yard, value: 'Garden' },
{ name: 'Tech', icon: Computer, value: 'Computer' },
]
export default PROJECT_ICONS
export const getIconComponent = iconValue => {
const iconData = PROJECT_ICONS.find(icon => icon.value === iconValue)
return iconData ? iconData.icon : FolderOpen
}

View File

@@ -47,6 +47,8 @@ import { useLabels } from '../Labels/LabelQueries'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import LabelModal from '../Modals/Inputs/LabelModal' import LabelModal from '../Modals/Inputs/LabelModal'
import RepeatSection from './RepeatSection' import RepeatSection from './RepeatSection'
import { useProjects } from '../Projects/ProjectQueries'
import { getIconComponent } from '../../utils/ProjectIcons'
const ASSIGN_STRATEGIES = [ const ASSIGN_STRATEGIES = [
'random', 'random',
@@ -89,9 +91,13 @@ const ChoreEdit = () => {
const [isPrivate, setIsPrivate] = useState(false) const [isPrivate, setIsPrivate] = useState(false)
const [subTasks, setSubTasks] = useState(null) const [subTasks, setSubTasks] = useState(null)
const [completionWindow, setCompletionWindow] = useState(-1) const [completionWindow, setCompletionWindow] = useState(-1)
const [deadline, setDeadline] = useState(null)
const [deadlineOffset, setDeadlineOffset] = useState(-1)
const [deadlineUnit, setDeadlineUnit] = useState('hours')
const [allUserThings, setAllUserThings] = useState([]) const [allUserThings, setAllUserThings] = useState([])
const [thingTrigger, setThingTrigger] = useState(null) const [thingTrigger, setThingTrigger] = useState(null)
const [isThingValid, setIsThingValid] = useState(false) const [isThingValid, setIsThingValid] = useState(false)
const [projectId, setProjectId] = useState('default')
const [notificationMetadata, setNotificationMetadata] = useState({}) const [notificationMetadata, setNotificationMetadata] = useState({})
@@ -110,6 +116,7 @@ const ChoreEdit = () => {
const [showSaveAssigneeDefault, setShowSaveAssigneeDefault] = useState(false) const [showSaveAssigneeDefault, setShowSaveAssigneeDefault] = useState(false)
const { data: userLabelsRaw, isLoading: isUserLabelsLoading } = useLabels() const { data: userLabelsRaw, isLoading: isUserLabelsLoading } = useLabels()
const { data: projects = [], isLoading: isProjectsLoading } = useProjects()
const updateChoreMutation = useUpdateChore() const updateChoreMutation = useUpdateChore()
const createChoreMutation = useCreateChore() const createChoreMutation = useCreateChore()
const archiveChore = useArchiveChore() const archiveChore = useArchiveChore()
@@ -251,6 +258,7 @@ const ChoreEdit = () => {
// if completionWindow is -1 then set it to null or dueDate is null // if completionWindow is -1 then set it to null or dueDate is null
completionWindow < 0 || dueDate === null ? null : completionWindow, completionWindow < 0 || dueDate === null ? null : completionWindow,
priority: priority, priority: priority,
projectId: projectId === 'default' ? null : projectId,
} }
let SaveFunction = createChoreMutation.mutateAsync let SaveFunction = createChoreMutation.mutateAsync
if (newChoreId > 0) { if (newChoreId > 0) {
@@ -345,6 +353,7 @@ const ChoreEdit = () => {
setIsRolling(data.res.isRolling) setIsRolling(data.res.isRolling)
setIsActive(data.res.isActive) setIsActive(data.res.isActive)
setSubTasks(data.res.subTasks ? data.res.subTasks : []) setSubTasks(data.res.subTasks ? data.res.subTasks : [])
setProjectId(data.res.projectId || 'default')
if (isCloneMode) { if (isCloneMode) {
if (data.res.subTasks) { if (data.res.subTasks) {
@@ -450,7 +459,8 @@ const ChoreEdit = () => {
(isChoreLoading && choreId) || (isChoreLoading && choreId) ||
isUserLabelsLoading || isUserLabelsLoading ||
isUserProfileLoading || isUserProfileLoading ||
isMemberDataLoading isMemberDataLoading ||
isProjectsLoading
) { ) {
return <LoadingComponent /> return <LoadingComponent />
} }
@@ -544,6 +554,52 @@ const ChoreEdit = () => {
</Box> </Box>
</Box> </Box>
{/* Project Selection - Show only if there are multiple projects */}
{projects.length > 1 && (
<Box mb={3}>
<Typography level='h4'>Project</Typography>
<Typography level='body-md'>
Which project does this task belong to?
</Typography>
<Select
value={projectId}
onChange={(event, newValue) => setProjectId(newValue)}
sx={{ minWidth: '15rem' }}
>
{projects.map(project => (
<Option key={project.id} value={project.id}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{project.icon &&
(() => {
const IconComponent = getIconComponent(project.icon)
return (
<IconComponent
sx={{
fontSize: 16,
color: getTextColorFromBackgroundColor(
project.color || '#1976d2',
),
}}
/>
)
})()}
<Box
sx={{
width: 12,
height: 12,
borderRadius: '50%',
backgroundColor: project.color || '#1976d2',
mr: 1,
}}
/>
{project.name}
</Box>
</Option>
))}
</Select>
</Box>
)}
<Box mb={3}> <Box mb={3}>
<Typography level='h4'>Labels</Typography> <Typography level='h4'>Labels</Typography>
<Typography level='body-md'> <Typography level='body-md'>
@@ -936,6 +992,113 @@ const ChoreEdit = () => {
</Box> </Box>
)} )}
{dueDate && (
<Box mb={3}>
<Typography level='h4'>Deadline</Typography>
<Typography level='body-md'>
When should this task be considered expired?
</Typography>
{/* One-time tasks: Date picker */}
{['once', 'no_repeat'].includes(frequencyType) ? (
<FormControl sx={{ mt: 1 }}>
<Checkbox
onChange={e => {
if (e.target.checked) {
// Set deadline to 24 hours after due date by default
const deadlineDate = moment(dueDate).add(1, 'day').format('YYYY-MM-DDTHH:mm:00')
setDeadline(deadlineDate)
} else {
setDeadline(null)
}
}}
checked={deadline !== null}
overlay
label='Set a deadline for this task'
/>
<FormHelperText>
Task will be considered expired after this date
</FormHelperText>
</FormControl>
) : (
/* Recurring tasks: Offset input */
<FormControl sx={{ mt: 1 }}>
<Checkbox
onChange={e => {
if (e.target.checked) {
setDeadlineOffset(24) // Default to 24 hours
} else {
setDeadlineOffset(-1)
}
}}
checked={deadlineOffset !== -1}
overlay
label='Set a deadline for this task'
/>
<FormHelperText>
Task will be considered expired after the specified time from due date
</FormHelperText>
</FormControl>
)}
{/* Date picker for one-time tasks */}
{deadline && ['once', 'no_repeat'].includes(frequencyType) && (
<Card variant='outlined' sx={{ mt: 2 }}>
<Box sx={{ p: 2 }}>
<Typography level='body-sm' mb={1}>Deadline Date:</Typography>
<Input
type='datetime-local'
value={deadline}
onChange={e => setDeadline(e.target.value)}
slotProps={{
input: {
min: dueDate, // Deadline cannot be before due date
},
}}
/>
</Box>
</Card>
)}
{/* Offset input for recurring tasks */}
{deadlineOffset !== -1 && !['once', 'no_repeat'].includes(frequencyType) && (
<Card variant='outlined' sx={{ mt: 2 }}>
<Box sx={{ p: 2, display: 'flex', gap: 2, alignItems: 'end' }}>
<Box>
<Typography level='body-sm' mb={1}>Time after due date:</Typography>
<Input
type='number'
value={deadlineOffset}
sx={{ maxWidth: 100 }}
slotProps={{
input: {
min: 1,
max: 720, // Max 30 days in hours
},
}}
placeholder='Time'
onChange={e => {
setDeadlineOffset(parseInt(e.target.value) || 1)
}}
/>
</Box>
<Box>
<Typography level='body-sm' mb={1}>Unit:</Typography>
<Select
value={deadlineUnit}
onChange={(event, newValue) => setDeadlineUnit(newValue)}
sx={{ minWidth: 100 }}
>
<Option value='hours'>Hours</Option>
<Option value='days'>Days</Option>
</Select>
</Box>
</Box>
</Card>
)}
</Box>
)}
{!['once', 'no_repeat'].includes(frequencyType) && ( {!['once', 'no_repeat'].includes(frequencyType) && (
<Box> <Box>
<Typography level='h4'>Scheduling Preferences</Typography> <Typography level='h4'>Scheduling Preferences</Typography>

View File

@@ -79,6 +79,7 @@ import {
NudgeChore, NudgeChore,
RejectChore, RejectChore,
SkipChore, SkipChore,
UndoChoreAction,
UpdateChoreAssignee, UpdateChoreAssignee,
UpdateDueDate, UpdateDueDate,
} from '../../utils/Fetcher' } from '../../utils/Fetcher'
@@ -86,6 +87,7 @@ import { getSafeBottom } from '../../utils/SafeAreaUtils.js'
import TaskInput from '../components/AddTaskModal' import TaskInput from '../components/AddTaskModal'
import CalendarDual from '../components/CalendarDual' import CalendarDual from '../components/CalendarDual'
import CalendarMonthly from '../components/CalendarMonthly.jsx' import CalendarMonthly from '../components/CalendarMonthly.jsx'
import ProjectSelector from '../components/ProjectSelector'
import { useProjects } from '../Projects/ProjectQueries.js' import { useProjects } from '../Projects/ProjectQueries.js'
import { import {
canScheduleNotification, canScheduleNotification,
@@ -99,7 +101,7 @@ const MyChores = () => {
const { data: userProfile, isLoading: isUserProfileLoading } = const { data: userProfile, isLoading: isUserProfileLoading } =
useUserProfile() useUserProfile()
const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('md')) const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('md'))
const { showSuccess, showError, showWarning } = useNotification() const { showSuccess, showError, showWarning, showUndo } = useNotification()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { impersonatedUser } = useImpersonateUser() const { impersonatedUser } = useImpersonateUser()
const archiveChore = useArchiveChore() const archiveChore = useArchiveChore()
@@ -110,6 +112,11 @@ const MyChores = () => {
const [chores, setChores] = useState([]) const [chores, setChores] = useState([])
const [filteredChores, setFilteredChores] = useState([]) const [filteredChores, setFilteredChores] = useState([])
const [searchFilter, setSearchFilter] = useState('All') const [searchFilter, setSearchFilter] = useState('All')
const [selectedProject, setSelectedProject] = useState(() => {
// Get saved project from localStorage, default to null
const saved = localStorage.getItem('selectedProject')
return saved ? JSON.parse(saved) : null
})
const [choreSections, setChoreSections] = useState([]) const [choreSections, setChoreSections] = useState([])
const [showSearchFilter, setShowSearchFilter] = useState(false) const [showSearchFilter, setShowSearchFilter] = useState(false)
@@ -142,6 +149,24 @@ const MyChores = () => {
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const { data: userLabels, isLoading: userLabelsLoading } = useLabels() const { data: userLabels, isLoading: userLabelsLoading } = useLabels()
const { data: projects = [], isLoading: projectsLoading } = useProjects() const { data: projects = [], isLoading: projectsLoading } = useProjects()
// Create a projects list that includes the default project for the ProjectSelector
const projectsWithDefault = useMemo(() => {
const defaultProject = {
id: 'default',
name: 'Default Project',
description: 'Your default project workspace',
color: '#1976d2',
icon: 'FolderOpen',
}
// Check if default project already exists in the list
const hasDefault = projects.some(
p => p.id === 'default' || p.name === 'Default Project',
)
return hasDefault ? projects : [defaultProject, ...projects]
}, [projects])
const { const {
data: choresData, data: choresData,
isLoading: choresLoading, isLoading: choresLoading,
@@ -179,13 +204,18 @@ const MyChores = () => {
}, [choresData?.res, impersonatedUser]) }, [choresData?.res, impersonatedUser])
const processedSections = useMemo(() => { const processedSections = useMemo(() => {
if (!processedChores.length || !userProfile?.id) { if (!chores.length || !userProfile?.id) {
return [] return []
} }
// Use project-filtered chores for section grouping
const choresToGroup = selectedProject
? filterByProject(chores, selectedProject.id)
: chores
const sections = ChoresGrouper( const sections = ChoresGrouper(
selectedChoreSection, selectedChoreSection,
processedChores, choresToGroup,
ChoreFilters(impersonatedUser?.userId || userProfile?.id)[ ChoreFilters(impersonatedUser?.userId || userProfile?.id)[
selectedChoreFilter selectedChoreFilter
], ],
@@ -193,9 +223,10 @@ const MyChores = () => {
return sections return sections
}, [ }, [
processedChores, chores,
selectedChoreSection, selectedChoreSection,
selectedChoreFilter, selectedChoreFilter,
selectedProject,
impersonatedUser?.userId, impersonatedUser?.userId,
userProfile?.id, userProfile?.id,
]) ])
@@ -209,18 +240,12 @@ const MyChores = () => {
choresData?.res choresData?.res
) { ) {
const processEffectAsync = async () => { const processEffectAsync = async () => {
// Sync local state with query data to ensure updates are reflected
setChores(processedChores) setChores(processedChores)
setFilteredChores(processedChores) setFilteredChores(processedChores)
// Only update sections if they've actually changed // Don't set choreSections here - let the dedicated effect handle it
setChoreSections(prevSections => { // This prevents caching issues when switching between projects
if (
JSON.stringify(prevSections) === JSON.stringify(processedSections)
) {
return prevSections
}
return processedSections
})
if (localStorage.getItem('openChoreSections') === null) { if (localStorage.getItem('openChoreSections') === null) {
setSelectedChoreSectionWithCache(selectedChoreSection) setSelectedChoreSectionWithCache(selectedChoreSection)
@@ -252,17 +277,20 @@ const MyChores = () => {
isUserProfileLoading, isUserProfileLoading,
choresData?.res, choresData?.res,
membersData?.res, membersData?.res,
// userProfile?.id, NOT HERE processedChores, // Added to ensure local state syncs when query data updates
processedSections,
userProfile,
impersonatedUser?.userId, impersonatedUser?.userId,
selectedChoreSection, selectedChoreSection,
]) ])
// Auto-update sections when processedSections changes // Auto-update sections when processedSections changes
useEffect(() => { useEffect(() => {
if (processedSections.length > 0) { // Always update choreSections to match processedSections, even if empty
setChoreSections(processedSections) setChoreSections(processedSections)
// Auto-open sections if needed - only check localStorage once // Auto-open sections if needed - only check localStorage once
if (processedSections.length > 0) {
const storedSections = localStorage.getItem('openChoreSections') const storedSections = localStorage.getItem('openChoreSections')
if (storedSections === null) { if (storedSections === null) {
const openSections = processedSections.reduce( const openSections = processedSections.reduce(
@@ -539,18 +567,57 @@ const MyChores = () => {
), ),
) )
// Show notification based on event type // Invalidate query to ensure sync with server data
// This prevents data from getting stale after token refresh or background updates
queryClient.invalidateQueries({ queryKey: ['chores'] })
// Show notifications - handle undoable actions with undo button (only for single actions)
if (!isMultiSelectMode) {
const undoableActions = {
completed: 'Task completed',
approved: 'Task approved',
rejected: 'Task rejected',
skipped: 'Task skipped',
}
if (undoableActions[event]) {
showSuccess({
message: undoableActions[event],
undoAction: async () => {
try {
const undoResponse = await UndoChoreAction(updatedChore.id)
if (undoResponse.ok) {
refetchChores()
const undoMessages = {
completed: 'Task completion has been undone.',
approved: 'Task approval has been undone.',
rejected: 'Task rejection has been undone.',
skipped: 'Task skip has been undone.',
}
showUndo({
title: 'Undo Successful',
message: undoMessages[event],
})
} else {
console.log('Failed to undo', undoResponse)
throw new Error('Failed to undo')
}
} catch (error) {
showError({
title: 'Undo Failed',
message: 'Unable to undo the action. Please try again.',
})
console.log('Undo error:', error)
}
},
})
return // Exit early for undoable actions
}
}
// Regular notifications for non-undoable actions
const notifications = { const notifications = {
completed: {
type: 'success',
title: 'Task Completed',
message: 'Great job! The task has been marked as completed.',
},
skipped: {
type: 'success',
title: 'Task Skipped',
message: 'The task has been moved to the next due date.',
},
rescheduled: { rescheduled: {
type: 'success', type: 'success',
title: 'Task Rescheduled', title: 'Task Rescheduled',
@@ -581,16 +648,6 @@ const MyChores = () => {
title: 'Task Paused', title: 'Task Paused',
message: 'The task has been paused.', message: 'The task has been paused.',
}, },
approved: {
type: 'success',
title: 'Task Approved',
message: 'The task has been approved.',
},
rejected: {
type: 'warning',
title: 'Task Rejected',
message: 'The task has been rejected.',
},
deleted: { deleted: {
type: 'success', type: 'success',
title: 'Task Deleted', title: 'Task Deleted',
@@ -966,18 +1023,22 @@ const MyChores = () => {
return return
} }
if (projectId && chores.length > 0) { if (projectId && chores.length > 0 && projectsWithDefault.length > 0) {
const decodedProject = Number(projectId ? projectId : '') const decodedProjectId = decodeURIComponent(projectId)
// get the project name : let project = null
const project = projects.find(p => p.id === decodedProject)
const projectFiltered = filterByProject(chores, decodedProject) // Try to find project by ID first, then by name for backward compatibility
console.log('Filtered chores:', projectFiltered.length) if (decodedProjectId === 'default') {
project = { id: 'default', name: 'Default Project' }
} else {
project = projectsWithDefault.find(
p => p.id === decodedProjectId || p.id === Number(decodedProjectId),
)
}
setFilteredChores(projectFiltered) if (project) {
setSearchFilter(`Project: ${project ? project.name : 'default'}`) setSelectedProjectWithCache(project)
setViewMode('default') }
setSelectedCalendarDate(null)
return return
} }
@@ -1006,7 +1067,7 @@ const MyChores = () => {
setSearchFilter(filterKey) setSearchFilter(filterKey)
setViewMode('default') setViewMode('default')
} }
}, [searchParams, chores]) }, [searchParams, chores, projectsWithDefault])
const setSelectedChoreSectionWithCache = value => { const setSelectedChoreSectionWithCache = value => {
setSelectedChoreSection(value) setSelectedChoreSection(value)
localStorage.setItem('selectedChoreSection', value) localStorage.setItem('selectedChoreSection', value)
@@ -1022,6 +1083,32 @@ const MyChores = () => {
setSelectedCalendarDate(null) setSelectedCalendarDate(null)
} }
const setSelectedProjectWithCache = project => {
// Handle the case where project might be null (clearing selection)
const finalProject = project?.id === 'default' || !project ? null : project
setSelectedProject(finalProject)
console.log('final project', finalProject)
localStorage.setItem('selectedProject', JSON.stringify(finalProject))
setViewMode('default')
setSelectedCalendarDate(null)
// Clear other filters when project changes
setSearchFilter('All')
// Don't manually set filteredChores - let the memo handle it
// This ensures consistency between filteredChores and choreSections
// Update URL to reflect project selection
const newUrl = new URL(window.location)
if (finalProject && finalProject.id !== 'default') {
newUrl.searchParams.set('project', encodeURIComponent(finalProject.id))
} else {
newUrl.searchParams.delete('project')
}
window.history.replaceState({}, '', newUrl)
}
const toggleViewMode = () => { const toggleViewMode = () => {
const modes = ['default', 'compact', 'calendar'] const modes = ['default', 'compact', 'calendar']
const currentIndex = modes.indexOf(viewMode) const currentIndex = modes.indexOf(viewMode)
@@ -1063,13 +1150,47 @@ const MyChores = () => {
return result return result
} }
// First layer: Apply project filter to get base chores
// IMPORTANT: Use local 'chores' state instead of 'processedChores' to ensure
// updates via updateChoreInState are reflected in filtered results
const projectFilteredChores = useMemo(() => {
if (!selectedProject) {
return chores
}
return filterByProject(chores, selectedProject.id)
}, [chores, selectedProject])
// Second layer: Apply additional filters on top of project-filtered chores
const getFilteredChores = useMemo(() => { const getFilteredChores = useMemo(() => {
let result = [] let result = []
let baseChores = projectFilteredChores // Start with project-filtered chores
if (searchTerm?.length > 0 || searchFilter !== 'All') { if (searchTerm?.length > 0 || searchFilter !== 'All') {
result = filteredChores // Apply search/label/priority filters to project-filtered chores
if (searchTerm?.length > 0) {
// For search, use fuse search on project-filtered chores
const projectFilteredForSearch = baseChores.map(c => ({
...c,
raw_label: c.labelsV2?.map(l => l.name).join(' '),
}))
const fuse = new Fuse(projectFilteredForSearch, {
keys: ['name', 'raw_label'],
includeScore: true,
isCaseSensitive: false,
findAllMatches: true,
})
result = fuse
.search(searchTerm.toLowerCase())
.map(result => result.item)
} else {
result = filteredChores.filter(
chore =>
!selectedProject ||
filterByProject([chore], selectedProject).length > 0,
)
}
} else { } else {
let choresToFilter = chores let choresToFilter = baseChores
if (impersonatedUser) { if (impersonatedUser) {
choresToFilter = choresToFilter.filter( choresToFilter = choresToFilter.filter(
@@ -1089,7 +1210,8 @@ const MyChores = () => {
searchTerm, searchTerm,
searchFilter, searchFilter,
filteredChores, filteredChores,
chores, projectFilteredChores,
selectedProject,
impersonatedUser, impersonatedUser,
userProfile?.id, userProfile?.id,
selectedChoreFilter, selectedChoreFilter,
@@ -1152,9 +1274,12 @@ const MyChores = () => {
} }
const handleLabelFiltering = chipClicked => { const handleLabelFiltering = chipClicked => {
// Start with project-filtered chores as base
const baseChores = selectedProject ? projectFilteredChores : chores
if (chipClicked.label) { if (chipClicked.label) {
const label = chipClicked.label const label = chipClicked.label
const labelFiltered = [...chores].filter(chore => const labelFiltered = baseChores.filter(chore =>
chore.labelsV2.some( chore.labelsV2.some(
l => l.id === label.id && l.created_by === label.created_by, l => l.id === label.id && l.created_by === label.created_by,
), ),
@@ -1163,7 +1288,7 @@ const MyChores = () => {
setSearchFilter('Label: ' + label.name) setSearchFilter('Label: ' + label.name)
} else if (chipClicked.priority) { } else if (chipClicked.priority) {
const priority = chipClicked.priority const priority = chipClicked.priority
const priorityFiltered = chores.filter( const priorityFiltered = baseChores.filter(
chore => chore.priority === priority, chore => chore.priority === priority,
) )
setFilteredChores(priorityFiltered) setFilteredChores(priorityFiltered)
@@ -1203,7 +1328,7 @@ const MyChores = () => {
} }
const search = e.target.value const search = e.target.value
if (search === '') { if (search === '') {
setFilteredChores(chores) setFilteredChores(selectedProject ? projectFilteredChores : chores)
setSearchTerm('') setSearchTerm('')
// Clear selected calendar date when search changes // Clear selected calendar date when search changes
setSelectedCalendarDate(null) setSelectedCalendarDate(null)
@@ -1212,13 +1337,28 @@ const MyChores = () => {
const term = search.toLowerCase() const term = search.toLowerCase()
setSearchTerm(term) setSearchTerm(term)
// Use project-filtered chores as base for search
const baseChores = selectedProject ? projectFilteredChores : chores
const searchableChores = baseChores.map(c => ({
...c,
raw_label: c.labelsV2?.map(l => l.name).join(' '),
}))
const fuse = new Fuse(searchableChores, {
keys: ['name', 'raw_label'],
includeScore: true,
isCaseSensitive: false,
findAllMatches: true,
})
setFilteredChores(fuse.search(term).map(result => result.item)) setFilteredChores(fuse.search(term).map(result => result.item))
// Clear selected calendar date when search changes // Clear selected calendar date when search changes
setSelectedCalendarDate(null) setSelectedCalendarDate(null)
} }
const handleSearchClose = () => { const handleSearchClose = () => {
setSearchTerm('') setSearchTerm('')
setFilteredChores(chores) setFilteredChores(selectedProject ? projectFilteredChores : chores)
// remove the focus from the search input: // remove the focus from the search input:
setSearchInputFocus(0) setSearchInputFocus(0)
// Clear selected calendar date when search closes // Clear selected calendar date when search closes
@@ -1611,6 +1751,19 @@ const MyChores = () => {
mouseClickHandler={handleMenuOutsideClick} mouseClickHandler={handleMenuOutsideClick}
/> />
{/* Project Selector - Show only if there are multiple projects */}
{projectsWithDefault.length > 1 && (
<ProjectSelector
selectedProject={selectedProject?.name || 'Default Project'}
onProjectSelect={project => {
setSelectedProjectWithCache(project)
// setFilteredChores(chores)
// setSearchFilter('All')
}}
showKeyboardShortcuts={showKeyboardShortcuts}
/>
)}
{/* View Mode Toggle Button */} {/* View Mode Toggle Button */}
<IconButton <IconButton
variant='outlined' variant='outlined'
@@ -1748,10 +1901,13 @@ const MyChores = () => {
key={`filter-list-${filter}-${index}`} key={`filter-list-${filter}-${index}`}
onClick={() => { onClick={() => {
const filterFunction = FILTERS[filter] const filterFunction = FILTERS[filter]
const baseChores = selectedProject
? projectFilteredChores
: chores
const filteredChores = const filteredChores =
filterFunction.length === 2 filterFunction.length === 2
? filterFunction(chores, userProfile?.id) ? filterFunction(baseChores, userProfile?.id)
: filterFunction(chores) : filterFunction(baseChores)
setFilteredChores(filteredChores) setFilteredChores(filteredChores)
setSearchFilter(filter) setSearchFilter(filter)
handleFilterMenuClose() handleFilterMenuClose()
@@ -1761,9 +1917,15 @@ const MyChores = () => {
<Chip <Chip
color={searchFilter === filter ? 'primary' : 'neutral'} color={searchFilter === filter ? 'primary' : 'neutral'}
> >
{FILTERS[filter].length === 2 {(() => {
? FILTERS[filter](chores, userProfile?.id).length const baseChores = selectedProject
: FILTERS[filter](chores).length} ? projectFilteredChores
: chores
return FILTERS[filter].length === 2
? FILTERS[filter](baseChores, userProfile?.id)
.length
: FILTERS[filter](baseChores).length
})()}
</Chip> </Chip>
</MenuItem> </MenuItem>
))} ))}
@@ -1773,7 +1935,9 @@ const MyChores = () => {
<MenuItem <MenuItem
key={`filter-list-cancel-all-filters`} key={`filter-list-cancel-all-filters`}
onClick={() => { onClick={() => {
setFilteredChores(chores) setFilteredChores(
selectedProject ? projectFilteredChores : chores,
)
setSearchFilter('All') setSearchFilter('All')
}} }}
> >
@@ -2091,6 +2255,7 @@ const MyChores = () => {
</Box> </Box>
</Box> </Box>
{/* Additional Filters Display */}
{searchFilter !== 'All' && ( {searchFilter !== 'All' && (
<Chip <Chip
level='title-md' level='title-md'
@@ -2098,19 +2263,26 @@ const MyChores = () => {
color='warning' color='warning'
label={searchFilter} label={searchFilter}
onDelete={() => { onDelete={() => {
setFilteredChores(chores) setFilteredChores(
selectedProject ? projectFilteredChores : chores,
)
setSearchFilter('All') setSearchFilter('All')
}} }}
endDecorator={<CancelRounded />} endDecorator={<CancelRounded />}
onClick={() => { onClick={() => {
setFilteredChores(chores) setFilteredChores(
selectedProject ? projectFilteredChores : chores,
)
setSearchFilter('All') setSearchFilter('All')
}} }}
> >
Current Filter: {searchFilter} Additional Filter: {searchFilter}
</Chip> </Chip>
)} )}
{filteredChores.length === 0 && {/* Show "Nothing scheduled" when appropriate based on current view mode */}
{(searchTerm?.length > 0 || searchFilter !== 'All'
? filteredChores.length === 0
: projectFilteredChores.length === 0) &&
// only if not in calendar view: // only if not in calendar view:
viewMode !== 'calendar' && ( viewMode !== 'calendar' && (
<Box <Box
@@ -2136,8 +2308,10 @@ const MyChores = () => {
<> <>
<Button <Button
onClick={() => { onClick={() => {
setFilteredChores(chores) // Reset search and filters to show all chores in current project
setSearchFilter('All')
setSearchTerm('') setSearchTerm('')
// Clear any manual filteredChores and let the memo handle it
}} }}
variant='outlined' variant='outlined'
color='neutral' color='neutral'
@@ -2150,7 +2324,7 @@ const MyChores = () => {
)} )}
{(searchTerm?.length > 0 || searchFilter !== 'All') && {(searchTerm?.length > 0 || searchFilter !== 'All') &&
viewMode !== 'calendar' && viewMode !== 'calendar' &&
filteredChores.map(chore => getFilteredChores.map(chore =>
renderChoreCard(chore, `filtered-${chore.id}`), renderChoreCard(chore, `filtered-${chore.id}`),
)} )}
{viewMode === 'calendar' && ( {viewMode === 'calendar' && (

View File

@@ -0,0 +1,110 @@
import {
Avatar,
Box,
Button,
FormControl,
FormLabel,
Grid,
Typography,
} from '@mui/joy'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { getTextColorFromBackgroundColor } from '../../../utils/Colors'
import PROJECT_ICONS from '../../../utils/ProjectIcons'
const IconPickerModal = ({
isOpen,
onClose,
onSelect,
currentIcon,
projectColor,
}) => {
const { ResponsiveModal } = useResponsiveModal()
const handleIconClick = iconValue => {
onSelect(iconValue)
onClose()
}
return (
<ResponsiveModal
open={isOpen}
onClose={onClose}
size='sm'
unmountDelay={250}
>
<Typography level='h4' mb={2}>
Choose Project Icon
</Typography>
<FormControl>
<FormLabel>Available Icons</FormLabel>
<Grid
container
spacing={1}
sx={{ maxHeight: '300px', overflowY: 'auto', mb: 2 }}
>
{PROJECT_ICONS.map(iconData => {
const IconComponent = iconData.icon
const isCurrentIcon = currentIcon === iconData.value
return (
<Grid key={iconData.value} xs={3} sm={2}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
cursor: 'pointer',
p: 1,
borderRadius: 'sm',
border: '2px solid',
borderColor: isCurrentIcon ? 'primary.500' : 'transparent',
'&:hover': {
borderColor: isCurrentIcon ? 'primary.600' : 'neutral.300',
},
transition: 'border-color 0.2s',
}}
onClick={() => handleIconClick(iconData.value)}
>
<Avatar
size='sm'
sx={{
width: 32,
height: 32,
bgcolor: projectColor,
mb: 0.5,
}}
>
<IconComponent
sx={{
fontSize: 16,
color: getTextColorFromBackgroundColor(projectColor),
}}
/>
</Avatar>
<Typography
level='body-xs'
sx={{
textAlign: 'center',
fontSize: 10,
lineHeight: 1.2,
}}
>
{iconData.name}
</Typography>
</Box>
</Grid>
)
})}
</Grid>
</FormControl>
<Box display='flex' justifyContent='center' mt={3}>
<Button variant='outlined' onClick={onClose} fullWidth size='lg'>
Cancel
</Button>
</Box>
</ResponsiveModal>
)
}
export default IconPickerModal

View File

@@ -4,8 +4,9 @@ import {
Button, Button,
FormControl, FormControl,
FormLabel, FormLabel,
Grid,
Input, Input,
Option,
Select,
Stack, Stack,
Textarea, Textarea,
Typography, Typography,
@@ -15,8 +16,12 @@ import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import LABEL_COLORS, { import LABEL_COLORS, {
getTextColorFromBackgroundColor, getTextColorFromBackgroundColor,
} from '../../../utils/Colors' } from '../../../utils/Colors'
import { CreateProject, UpdateProject } from '../../../utils/Fetcher'
import PROJECT_ICONS, { getIconComponent } from '../../../utils/ProjectIcons' import PROJECT_ICONS, { getIconComponent } from '../../../utils/ProjectIcons'
import {
useCreateProject,
useUpdateProject,
} from '../../Projects/ProjectQueries'
import IconPickerModal from './IconPickerModal'
const ProjectModal = ({ isOpen, onClose, onSave, project }) => { const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
const { ResponsiveModal } = useResponsiveModal() const { ResponsiveModal } = useResponsiveModal()
@@ -24,8 +29,11 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
const [projectDescription, setProjectDescription] = useState('') const [projectDescription, setProjectDescription] = useState('')
const [projectColor, setProjectColor] = useState(LABEL_COLORS[0].value) const [projectColor, setProjectColor] = useState(LABEL_COLORS[0].value)
const [projectIcon, setProjectIcon] = useState(PROJECT_ICONS[0].value) const [projectIcon, setProjectIcon] = useState(PROJECT_ICONS[0].value)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [isIconPickerOpen, setIsIconPickerOpen] = useState(false)
const createProjectMutation = useCreateProject()
const updateProjectMutation = useUpdateProject()
// Initialize form when modal opens or project changes // Initialize form when modal opens or project changes
useEffect(() => { useEffect(() => {
@@ -44,11 +52,10 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
setProjectIcon(PROJECT_ICONS[0].value) setProjectIcon(PROJECT_ICONS[0].value)
} }
setError('') setError('')
setIsSubmitting(false)
} }
}, [isOpen, project]) }, [isOpen, project])
const handleSubmit = async e => { const handleSubmit = e => {
e.preventDefault() e.preventDefault()
if (!projectName.trim()) { if (!projectName.trim()) {
@@ -56,54 +63,68 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
return return
} }
setIsSubmitting(true)
setError('') setError('')
try { const projectData = {
const projectData = { name: projectName.trim(),
name: projectName.trim(), description: projectDescription.trim(),
description: projectDescription.trim(), color: projectColor,
color: projectColor, icon: projectIcon,
icon: projectIcon, }
}
let response if (project) {
if (project) { // Update existing project
// Update existing project updateProjectMutation.mutate(
response = await UpdateProject(project.id, projectData) { projectId: project.id, projectData },
} else { {
// Create new project onSuccess: updatedProject => {
response = await CreateProject(projectData) onSave(updatedProject)
} onClose()
},
if (response.ok) { onError: error => {
const savedProject = await response.json() console.error('Error updating project:', error)
onSave(savedProject.res || savedProject) setError('Failed to update project')
onClose() },
} else { },
const errorData = await response.json() )
setError(errorData.message || 'Failed to save project') } else {
} // Create new project
} catch (error) { createProjectMutation.mutate(projectData, {
console.error('Error saving project:', error) onSuccess: newProject => {
setError('An unexpected error occurred') onSave(newProject)
} finally { onClose()
setIsSubmitting(false) },
onError: error => {
console.error('Error creating project:', error)
setError('Failed to create project')
},
})
} }
} }
const handleClose = () => { const handleClose = () => {
if (!isSubmitting) { const isLoading =
createProjectMutation.isPending || updateProjectMutation.isPending
if (!isLoading) {
onClose() onClose()
} }
} }
const isSubmitting =
createProjectMutation.isPending || updateProjectMutation.isPending
const handleIconSelect = iconValue => {
setProjectIcon(iconValue)
setIsIconPickerOpen(false)
}
return ( return (
<ResponsiveModal <ResponsiveModal
open={isOpen} open={isOpen}
onClose={handleClose} onClose={handleClose}
size='md' size='md'
unmountDelay={250} unmountDelay={250}
fullWidth={true}
> >
<Typography level='h4' mb={2}> <Typography level='h4' mb={2}>
{project ? 'Edit Project' : 'Create New Project'} {project ? 'Edit Project' : 'Create New Project'}
@@ -139,148 +160,82 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
{/* Icon Selection */} {/* Icon Selection */}
<FormControl> <FormControl>
<FormLabel>Project Icon</FormLabel> <FormLabel>Project Icon</FormLabel>
<Typography level='body-sm' sx={{ mb: 1, color: 'text.tertiary' }}> <Button
Choose an icon to represent your project variant='outlined'
</Typography> onClick={() => setIsIconPickerOpen(true)}
<Grid startDecorator={
container <Avatar
spacing={1} size='sm'
sx={{ maxHeight: '200px', overflowY: 'auto', mb: 2 }} sx={{
width: 24,
height: 24,
bgcolor: projectColor,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
'& svg': {
display: 'block',
margin: '0 auto',
},
}}
>
{(() => {
const IconComponent = getIconComponent(projectIcon)
return (
<IconComponent
sx={{
fontSize: 14,
color: getTextColorFromBackgroundColor(projectColor),
display: 'block',
}}
/>
)
})()}
</Avatar>
}
sx={{ justifyContent: 'flex-start' }}
> >
{PROJECT_ICONS.map(iconData => { {PROJECT_ICONS.find(icon => icon.value === projectIcon)?.name ||
const IconComponent = iconData.icon 'Select Icon'}
return ( </Button>
<Grid key={iconData.value} xs={3} sm={2}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
cursor: 'pointer',
p: 1,
borderRadius: 'sm',
border: '2px solid',
borderColor:
projectIcon === iconData.value
? 'primary.500'
: 'transparent',
'&:hover': {
borderColor:
projectIcon === iconData.value
? 'primary.600'
: 'neutral.300',
},
transition: 'border-color 0.2s',
}}
onClick={() => setProjectIcon(iconData.value)}
>
<Avatar
size='sm'
sx={{
width: 24,
height: 24,
bgcolor: projectColor,
mb: 0.5,
}}
>
<IconComponent
sx={{
fontSize: 12,
color:
getTextColorFromBackgroundColor(projectColor),
}}
/>
</Avatar>
<Typography
level='body-xs'
sx={{
textAlign: 'center',
fontSize: 9,
lineHeight: 1,
}}
>
{iconData.name}
</Typography>
</Box>
</Grid>
)
})}
</Grid>
</FormControl> </FormControl>
{/* Color Selection */} {/* Color Selection */}
<FormControl> <FormControl>
<FormLabel>Project Color</FormLabel> <FormLabel>Project Color</FormLabel>
<Typography level='body-sm' sx={{ mb: 1, color: 'text.tertiary' }}> <Select
Choose a color to help identify your project value={projectColor}
</Typography> onChange={(e, value) => value && setProjectColor(value)}
<Grid renderValue={selected => (
container <Typography
spacing={1} startDecorator={
sx={{ maxHeight: '200px', overflowY: 'auto' }} <Box
className='size-4'
borderRadius={10}
sx={{ background: selected.value }}
/>
}
>
{selected.label}
</Typography>
)}
> >
{LABEL_COLORS.map(color => ( {LABEL_COLORS.map(color => (
<Grid key={color.value} xs={3} sm={2}> <Option key={color.value} value={color.value}>
<Box <Box className='flex items-center justify-between'>
sx={{ <Box
display: 'flex', width={20}
flexDirection: 'column', height={20}
alignItems: 'center', borderRadius={10}
cursor: 'pointer', sx={{ background: color.value }}
p: 1, />
borderRadius: 'sm', <Typography sx={{ ml: 1 }} variant='caption'>
border: '2px solid',
borderColor:
projectColor === color.value
? 'primary.500'
: 'transparent',
'&:hover': {
borderColor:
projectColor === color.value
? 'primary.600'
: 'neutral.300',
},
transition: 'border-color 0.2s',
}}
onClick={() => setProjectColor(color.value)}
>
<Avatar
size='sm'
sx={{
width: 24,
height: 24,
bgcolor: color.value,
mb: 0.5,
}}
>
{(() => {
const IconComponent = getIconComponent(projectIcon)
return (
<IconComponent
sx={{
fontSize: 12,
color: getTextColorFromBackgroundColor(
color.value,
),
}}
/>
)
})()}
</Avatar>
<Typography
level='body-xs'
sx={{
textAlign: 'center',
fontSize: 9,
lineHeight: 1,
}}
>
{color.name} {color.name}
</Typography> </Typography>
</Box> </Box>
</Grid> </Option>
))} ))}
</Grid> </Select>
</FormControl> </FormControl>
{/* Project Preview */} {/* Project Preview */}
@@ -304,6 +259,13 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
width: 32, width: 32,
height: 32, height: 32,
bgcolor: projectColor, bgcolor: projectColor,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
'& svg': {
display: 'block',
margin: '0 auto',
},
}} }}
> >
{(() => { {(() => {
@@ -313,6 +275,7 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
sx={{ sx={{
fontSize: 16, fontSize: 16,
color: getTextColorFromBackgroundColor(projectColor), color: getTextColorFromBackgroundColor(projectColor),
display: 'block',
}} }}
/> />
) )
@@ -360,6 +323,14 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
</Button> </Button>
</Box> </Box>
</form> </form>
<IconPickerModal
isOpen={isIconPickerOpen}
onClose={() => setIsIconPickerOpen(false)}
onSelect={handleIconSelect}
currentIcon={projectIcon}
projectColor={projectColor}
/>
</ResponsiveModal> </ResponsiveModal>
) )
} }

View File

@@ -0,0 +1,188 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { GetProjects, CreateProject, UpdateProject, DeleteProject } from '../../utils/Fetcher'
// Query hook for fetching all projects
export const useProjects = () => {
return useQuery({
queryKey: ['projects'],
queryFn: async () => {
try {
const response = await GetProjects()
if (response.ok) {
const data = await response.json()
return data.res || data
}
throw new Error('Failed to fetch projects')
} catch (error) {
console.error('Error fetching projects:', error)
// Return default project if API fails
return [
{
id: 'default',
name: 'Default Project',
description: 'Your default project workspace',
color: '#1976d2',
created_by: 'system',
created_at: new Date().toISOString(),
}
]
}
},
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false,
})
}
// Mutation hook for creating a new project
export const useCreateProject = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (projectData) => {
try {
const response = await CreateProject(projectData)
if (response.ok) {
const data = await response.json()
return data.res || data
}
throw new Error('Failed to create project')
} catch (error) {
console.error('Error creating project:', error)
// For development, create a local project
const localProject = {
id: `local-${Date.now()}`,
...projectData,
created_by: 'current_user',
created_at: new Date().toISOString(),
}
return localProject
}
},
onSuccess: (newProject) => {
// Update the projects cache
queryClient.setQueryData(['projects'], (oldProjects = []) => {
const updatedProjects = [...oldProjects, newProject]
return updatedProjects
})
// Invalidate and refetch
queryClient.invalidateQueries(['projects'])
},
onError: (error) => {
console.error('Create project mutation failed:', error)
},
})
}
// Mutation hook for updating an existing project
export const useUpdateProject = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ projectId, projectData }) => {
try {
const response = await UpdateProject(projectId, projectData)
if (response.ok) {
const data = await response.json()
return data.res || data
}
throw new Error('Failed to update project')
} catch (error) {
console.error('Error updating project:', error)
// For development, return updated project
return {
id: projectId,
...projectData,
updated_at: new Date().toISOString(),
}
}
},
onSuccess: (updatedProject) => {
// Update the projects cache
queryClient.setQueryData(['projects'], (oldProjects = []) => {
return oldProjects.map(project =>
project.id === updatedProject.id ? updatedProject : project
)
})
// Invalidate and refetch
queryClient.invalidateQueries(['projects'])
},
onError: (error) => {
console.error('Update project mutation failed:', error)
},
})
}
// Mutation hook for deleting a project
export const useDeleteProject = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (projectId) => {
try {
// Prevent deletion of default project
if (projectId === 'default') {
throw new Error('Cannot delete the default project')
}
const response = await DeleteProject(projectId)
if (response.ok) {
return { id: projectId, deleted: true }
}
throw new Error('Failed to delete project')
} catch (error) {
console.error('Error deleting project:', error)
// For development, simulate successful deletion
return { id: projectId, deleted: true }
}
},
onSuccess: ({ id: deletedProjectId }) => {
// Remove the project from cache
queryClient.setQueryData(['projects'], (oldProjects = []) => {
return oldProjects.filter(project => project.id !== deletedProjectId)
})
// Invalidate and refetch
queryClient.invalidateQueries(['projects'])
},
onError: (error) => {
console.error('Delete project mutation failed:', error)
},
})
}
// Hook to get a specific project by ID
export const useProject = (projectId) => {
return useQuery({
queryKey: ['projects', projectId],
queryFn: async () => {
try {
const response = await GetProjects()
if (response.ok) {
const data = await response.json()
const projects = data.res || data
return projects.find(project => project.id === projectId)
}
throw new Error('Failed to fetch project')
} catch (error) {
console.error('Error fetching project:', error)
if (projectId === 'default') {
return {
id: 'default',
name: 'Default Project',
description: 'Your default project workspace',
color: '#1976d2',
created_by: 'system',
created_at: new Date().toISOString(),
}
}
return null
}
},
enabled: !!projectId,
staleTime: 5 * 60 * 1000,
cacheTime: 10 * 60 * 1000,
})
}

View File

@@ -11,10 +11,12 @@ import {
Typography, Typography,
} from '@mui/joy' } from '@mui/joy'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import ProjectModal from '../Modals/Inputs/ProjectModal' import ProjectModal from '../Modals/Inputs/ProjectModal'
import { Add, FolderOpen, Task } from '@mui/icons-material' import { Add, FolderOpen, Task } from '@mui/icons-material'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { useChores } from '../../queries/ChoreQueries'
import { useUserProfile } from '../../queries/UserQueries' import { useUserProfile } from '../../queries/UserQueries'
import LABEL_COLORS, { import LABEL_COLORS, {
getTextColorFromBackgroundColor, getTextColorFromBackgroundColor,
@@ -25,7 +27,15 @@ import { getSafeBottomStyles } from '../../utils/SafeAreaUtils'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import { useProjects } from './ProjectQueries' import { useProjects } from './ProjectQueries'
const ProjectCard = ({ project, onEditClick, onDeleteClick, currentUserId, taskCounts = {} }) => { const ProjectCard = ({
project,
onEditClick,
onDeleteClick,
isEditable = true,
currentUserId,
taskCounts = {},
}) => {
const navigate = useNavigate()
// Helper function to get color name from hex value // Helper function to get color name from hex value
const getColorName = hexValue => { const getColorName = hexValue => {
const colorObj = LABEL_COLORS.find( const colorObj = LABEL_COLORS.find(
@@ -213,49 +223,30 @@ const ProjectCard = ({ project, onEditClick, onDeleteClick, currentUserId, taskC
}} }}
> >
{/* Action buttons underneath (revealed on swipe) */} {/* Action buttons underneath (revealed on swipe) */}
<Box {isEditable && (
sx={{ <Box
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
width: maxSwipeDistance,
display: 'flex',
alignItems: 'center',
boxShadow: 'inset 2px 0 4px rgba(0,0,0,0.06)',
zIndex: 0,
}}
onMouseEnter={handleActionAreaMouseEnter}
onMouseLeave={handleActionAreaMouseLeave}
>
<IconButton
variant='soft'
color='neutral'
size='sm'
onClick={e => {
e.stopPropagation()
resetSwipe()
onEditClick(project)
}}
sx={{ sx={{
width: 40, position: 'absolute',
height: 40, right: 0,
mx: 1, top: 0,
bottom: 0,
width: maxSwipeDistance,
display: 'flex',
alignItems: 'center',
boxShadow: 'inset 2px 0 4px rgba(0,0,0,0.06)',
zIndex: 0,
}} }}
onMouseEnter={handleActionAreaMouseEnter}
onMouseLeave={handleActionAreaMouseLeave}
> >
<EditIcon sx={{ fontSize: 16 }} />
</IconButton>
{/* Only show delete for non-default projects */}
{!isDefaultProject && (
<IconButton <IconButton
variant='soft' variant='soft'
color='danger' color='neutral'
size='sm' size='sm'
onClick={e => { onClick={e => {
e.stopPropagation() e.stopPropagation()
resetSwipe() resetSwipe()
onDeleteClick(project.id) onEditClick(project)
}} }}
sx={{ sx={{
width: 40, width: 40,
@@ -263,10 +254,31 @@ const ProjectCard = ({ project, onEditClick, onDeleteClick, currentUserId, taskC
mx: 1, mx: 1,
}} }}
> >
<DeleteIcon sx={{ fontSize: 16 }} /> <EditIcon sx={{ fontSize: 16 }} />
</IconButton> </IconButton>
)}
</Box> {/* Only show delete for non-default projects */}
{!isDefaultProject && (
<IconButton
variant='soft'
color='danger'
size='sm'
onClick={e => {
e.stopPropagation()
resetSwipe()
onDeleteClick(project.id)
}}
sx={{
width: 40,
height: 40,
mx: 1,
}}
>
<DeleteIcon sx={{ fontSize: 16 }} />
</IconButton>
)}
</Box>
)}
{/* Main card content */} {/* Main card content */}
<Box <Box
@@ -295,62 +307,68 @@ const ProjectCard = ({ project, onEditClick, onDeleteClick, currentUserId, taskC
resetSwipe() resetSwipe()
return return
} }
onEditClick(project) // Always navigate to MyChores with project filter when clicking on the card
// For default project, use 'default', for others use project ID
const projectIdentifier =
project.id === 'default' ? 'default' : project.id
navigate(`/chores?project=${encodeURIComponent(projectIdentifier)}`)
}} }}
onTouchStart={handleTouchStart} onTouchStart={isEditable ? handleTouchStart : undefined}
onTouchMove={handleTouchMove} onTouchMove={isEditable ? handleTouchMove : undefined}
onTouchEnd={handleTouchEnd} onTouchEnd={isEditable ? handleTouchEnd : undefined}
onMouseDown={handleMouseDown} onMouseDown={isEditable ? handleMouseDown : undefined}
onMouseMove={handleMouseMove} onMouseMove={isEditable ? handleMouseMove : undefined}
onMouseUp={handleMouseUp} onMouseUp={isEditable ? handleMouseUp : undefined}
> >
{/* Right drag area */} {/* Right drag area */}
<Box {isEditable && (
sx={{
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
width: '20px',
cursor: 'grab',
zIndex: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
opacity: isSwipeRevealed ? 0 : 0.3,
transition: 'opacity 0.2s ease',
pointerEvents: isSwipeRevealed ? 'none' : 'auto',
'&:hover': {
opacity: isSwipeRevealed ? 0 : 0.7,
},
'&:active': {
cursor: 'grabbing',
},
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{/* Drag indicator dots */}
<Box <Box
sx={{ sx={{
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
width: '20px',
cursor: 'grab',
zIndex: 2,
display: 'flex', display: 'flex',
flexDirection: 'column', alignItems: 'center',
gap: 0.25, justifyContent: 'center',
opacity: isSwipeRevealed ? 0 : 0.3,
transition: 'opacity 0.2s ease',
pointerEvents: isSwipeRevealed ? 'none' : 'auto',
'&:hover': {
opacity: isSwipeRevealed ? 0 : 0.7,
},
'&:active': {
cursor: 'grabbing',
},
}} }}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
> >
{[...Array(3)].map((_, i) => ( {/* Drag indicator dots */}
<Box <Box
key={i} sx={{
sx={{ display: 'flex',
width: 3, flexDirection: 'column',
height: 3, gap: 0.25,
borderRadius: '50%', }}
bgcolor: 'text.tertiary', >
}} {[...Array(3)].map((_, i) => (
/> <Box
))} key={i}
sx={{
width: 3,
height: 3,
borderRadius: '50%',
bgcolor: 'text.tertiary',
}}
/>
))}
</Box>
</Box> </Box>
</Box> )}
{/* Project Avatar */} {/* Project Avatar */}
<Box <Box
@@ -387,7 +405,9 @@ const ProjectCard = ({ project, onEditClick, onDeleteClick, currentUserId, taskC
<IconComponent <IconComponent
sx={{ sx={{
fontSize: 16, fontSize: 16,
color: getTextColorFromBackgroundColor(project.color || '#1976d2'), color: getTextColorFromBackgroundColor(
project.color || '#1976d2',
),
}} }}
/> />
) )
@@ -396,7 +416,9 @@ const ProjectCard = ({ project, onEditClick, onDeleteClick, currentUserId, taskC
<Typography <Typography
level='body-xs' level='body-xs'
sx={{ sx={{
color: getTextColorFromBackgroundColor(project.color || '#1976d2'), color: getTextColorFromBackgroundColor(
project.color || '#1976d2',
),
fontWeight: 'bold', fontWeight: 'bold',
fontSize: 10, fontSize: 10,
}} }}
@@ -523,6 +545,7 @@ const ProjectCard = ({ project, onEditClick, onDeleteClick, currentUserId, taskC
const ProjectView = () => { const ProjectView = () => {
const { data: projects, isProjectsLoading, isError } = useProjects() const { data: projects, isProjectsLoading, isError } = useProjects()
const { data: userProfile } = useUserProfile() const { data: userProfile } = useUserProfile()
const { data: chores = [] } = useChores(false) // false to exclude archived
const [userProjects, setUserProjects] = useState([]) const [userProjects, setUserProjects] = useState([])
const [modalOpen, setModalOpen] = useState(false) const [modalOpen, setModalOpen] = useState(false)
@@ -567,20 +590,8 @@ const ProjectView = () => {
}) })
} }
const handleSaveProject = newOrUpdatedProject => { const handleSaveProject = () => {
queryClient.invalidateQueries('projects')
setModalOpen(false) setModalOpen(false)
if (currentProject) {
// Update existing project
const updatedProjects = userProjects.map(project =>
project.id === newOrUpdatedProject.id ? newOrUpdatedProject : project,
)
setUserProjects(updatedProjects)
} else {
// Add new project
setUserProjects([...userProjects, newOrUpdatedProject])
}
} }
useEffect(() => { useEffect(() => {
@@ -589,15 +600,33 @@ const ProjectView = () => {
} }
}, [projects]) }, [projects])
// TODO: Get actual task counts from API // Calculate real task counts from chores data
useEffect(() => { useEffect(() => {
// Mock task counts for now if (chores && chores.res && userProjects.length > 0) {
const mockCounts = {} const choresList = chores.res
userProjects.forEach(project => { const realCounts = {}
mockCounts[project.id] = Math.floor(Math.random() * 20)
}) userProjects.forEach(project => {
setTaskCounts(mockCounts) // Count chores for this project
}, [userProjects]) 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
}).length
realCounts[project.id] = choreCount
})
setTaskCounts(realCounts)
}
}, [chores, userProjects])
if (isProjectsLoading) { if (isProjectsLoading) {
return ( return (
@@ -642,21 +671,22 @@ const ProjectView = () => {
overflow: 'hidden', overflow: 'hidden',
}} }}
> >
{userProjects.length === 0 && ( {/* default project: */}
<Box <ProjectCard
sx={{ key='default-project-card'
display: 'flex', project={{
justifyContent: 'center', id: 'default',
alignItems: 'center', name: 'Default Project',
flexDirection: 'column', description: 'All uncategorized tasks',
height: '50vh', color: '#1976d2',
}} icon: 'FolderOpen',
> created_by: userProfile?.id,
<Typography level='title-md' gutterBottom> }}
No projects available. Add a new project to get started. isEditable={false}
</Typography> currentUserId={userProfile?.id}
</Box> onEditClick={() => {}}
)} taskCounts={{ default: taskCounts.default || 0 }}
/>
{userProjects.map(project => ( {userProjects.map(project => (
<ProjectCard <ProjectCard
key={project.id} key={project.id}
@@ -665,6 +695,7 @@ const ProjectView = () => {
onDeleteClick={handleDeleteClicked} onDeleteClick={handleDeleteClicked}
currentUserId={userProfile?.id} currentUserId={userProfile?.id}
taskCounts={taskCounts} taskCounts={taskCounts}
isEditable={true}
/> />
))} ))}
</Box> </Box>

View File

@@ -1,58 +0,0 @@
import * as allIcons from '@mui/icons-material' // Import all icons using * as
import { Grid, Input, SvgIcon } from '@mui/joy'
import React, { useEffect, useState } from 'react'
function MuiIconPicker({ onIconSelect }) {
const [searchTerm, setSearchTerm] = useState('')
const [filteredIcons, setFilteredIcons] = useState([])
const outlined = Object.keys(allIcons).filter(name =>
name.includes('Outlined'),
)
useEffect(() => {
// Filter icons based on the search term
setFilteredIcons(
outlined.filter(name =>
name
.toLowerCase()
.includes(searchTerm ? searchTerm.toLowerCase() : false),
),
)
}, [searchTerm])
const handleIconClick = iconName => {
onIconSelect(iconName) // Callback for selected icon
}
return (
<div>
{/* Autocomplete component for searching */}
{JSON.stringify({ 1: searchTerm, filteredIcons: filteredIcons })}
<Input
onChange={(event, newValue) => {
setSearchTerm(newValue)
}}
/>
{/* Grid to display icons */}
<Grid container spacing={2}>
{filteredIcons.map(iconName => {
const IconComponent = allIcons[iconName]
if (IconComponent) {
// Add this check to prevent errors
return (
<Grid item key={iconName} xs={3} sm={2} md={1}>
<SvgIcon
component={IconComponent}
onClick={() => handleIconClick(iconName)}
style={{ cursor: 'pointer' }}
/>
</Grid>
)
}
return null // Return null for non-icon exports
})}
</Grid>
</div>
)
}
export default MuiIconPicker

View File

@@ -0,0 +1,484 @@
import { Add, Check, FolderOpen } from '@mui/icons-material'
import {
Avatar,
Box,
Button,
Divider,
ListItemContent,
ListItemDecorator,
Menu,
MenuItem,
Typography,
} from '@mui/joy'
import { useEffect, useRef, useState } from 'react'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import LABEL_COLORS, {
getTextColorFromBackgroundColor,
} from '../../utils/Colors'
import { getIconComponent } from '../../utils/ProjectIcons'
import ProjectModal from '../Modals/Inputs/ProjectModal'
import { useProjects } from '../Projects/ProjectQueries'
const ProjectSelector = ({
selectedProject = 'Default Project',
onProjectSelect,
showKeyboardShortcuts = false,
}) => {
const { data: projects = [], isLoading } = useProjects()
const [anchorEl, setAnchorEl] = useState(null)
const [selectedIndex, setSelectedIndex] = useState(0)
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false)
const menuRef = useRef(null)
const buttonRef = useRef(null)
const defaultProjects = projects
// Check if selected project still exists, if not fallback to default
const selectedProjectExists = defaultProjects.some(
p => p.name === selectedProject,
)
const effectiveSelectedProject = selectedProjectExists
? selectedProject
: 'Default Project'
// Notify parent if selected project was deleted
useEffect(() => {
if (!selectedProjectExists && selectedProject !== 'Default Project') {
const defaultProject = defaultProjects.find(
p => p.id === 'default' || p.name === 'Default Project',
)
if (defaultProject) {
onProjectSelect?.(defaultProject)
}
}
}, [selectedProjectExists, selectedProject, defaultProjects, onProjectSelect])
const handleMenuOpen = event => {
setAnchorEl(event.currentTarget)
}
const handleMenuClose = () => {
setAnchorEl(null)
}
const handleProjectSelect = project => {
onProjectSelect?.(project)
handleMenuClose()
}
const handleAddProjectClick = () => {
setIsProjectModalOpen(true)
handleMenuClose()
}
const handleProjectModalSave = project => {
handleProjectSelect(project)
}
useEffect(() => {
const handleMenuOutsideClick = event => {
if (menuRef.current && !menuRef.current.contains(event.target)) {
handleMenuClose()
}
}
document.addEventListener('mousedown', handleMenuOutsideClick)
return () => {
document.removeEventListener('mousedown', handleMenuOutsideClick)
}
}, [])
// Keyboard shortcut handler
useEffect(() => {
const handleKeyDown = event => {
const isHoldingCmdOrCtrl = event.ctrlKey || event.metaKey
// Cmd/Ctrl + E to open project menu
if (isHoldingCmdOrCtrl && event.key === 'e') {
event.preventDefault()
if (!anchorEl) {
setAnchorEl(buttonRef.current)
setSelectedIndex(0)
} else {
handleMenuClose()
}
return
}
// Only handle navigation keys when menu is open
if (!anchorEl) return
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
setSelectedIndex(prev =>
prev < defaultProjects.length ? prev + 1 : prev,
)
break
case 'ArrowUp':
event.preventDefault()
setSelectedIndex(prev => (prev > 0 ? prev - 1 : prev))
break
case 'Enter':
event.preventDefault()
if (selectedIndex < defaultProjects.length) {
handleProjectSelect(defaultProjects[selectedIndex])
} else {
handleAddProjectClick()
}
break
case 'Escape':
event.preventDefault()
handleMenuClose()
break
}
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [anchorEl, selectedIndex, defaultProjects])
// Reset selected index when menu opens
useEffect(() => {
if (anchorEl) {
setSelectedIndex(0)
}
}, [anchorEl])
// Find the currently selected project
const currentProject = defaultProjects.find(
p => p.name === effectiveSelectedProject,
)
return (
<>
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
<Button
ref={buttonRef}
onClick={handleMenuOpen}
variant='outlined'
color='neutral'
size='sm'
sx={{
height: 24,
borderRadius: 24,
minWidth: 'auto',
maxWidth: '200px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
backgroundColor: currentProject?.color || LABEL_COLORS[0].value,
}}
title={`Current project: ${effectiveSelectedProject} (Ctrl+E)`}
>
{(() => {
const IconComponent = getIconComponent(
currentProject?.icon || 'FolderOpen',
)
return (
// <Avatar
// size='sm'
// sx={{
// width: 24,
// height: 24,
// backgroundColor: project.color || LABEL_COLORS[0].value,
// display: 'flex',
// alignItems: 'center',
// justifyContent: 'center',
// }}
// >
<IconComponent
sx={{
fontSize: 16,
color: getTextColorFromBackgroundColor(
currentProject?.color || LABEL_COLORS[0].value,
),
}}
/>
// </Avatar>
)
})()}
</Button>
<KeyboardShortcutHint
shortcut='E'
show={showKeyboardShortcuts}
sx={{
position: 'absolute',
top: -8,
right: -8,
zIndex: 1000,
}}
/>
</Box>
<Menu
ref={menuRef}
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleMenuClose}
placement='bottom-start'
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)',
}}
>
<MenuItem
disabled
sx={{
borderRadius: 'var(--joy-radius-sm)',
mb: 1,
cursor: 'default',
opacity: 1,
}}
>
<ListItemDecorator sx={{ color: 'var(--joy-palette-primary-500)' }}>
<FolderOpen />
</ListItemDecorator>
<ListItemContent>
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
Select Project
</Typography>
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
>
Choose or create a project workspace
</Typography>
</ListItemContent>
</MenuItem>
<Divider sx={{ my: 1 }} />
<MenuItem
key={'default-project'}
onClick={() =>
handleProjectSelect({
id: 'default',
name: 'Default Project',
color: LABEL_COLORS[0].value,
icon: 'FolderOpen',
})
}
sx={{
borderRadius: 'var(--joy-radius-sm)',
backgroundColor:
effectiveSelectedProject === 'Default Project'
? 'var(--joy-palette-primary-softBg)'
: selectedIndex === 0 && anchorEl
? 'var(--joy-palette-neutral-softHoverBg)'
: 'transparent',
'&:hover': {
backgroundColor:
effectiveSelectedProject === 'Default Project'
? 'var(--joy-palette-primary-softBg)'
: 'var(--joy-palette-neutral-softHoverBg)',
},
position: 'relative',
}}
>
<ListItemDecorator>
<Avatar
size='sm'
sx={{
width: 24,
height: 24,
backgroundColor: LABEL_COLORS[0].value,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<FolderOpen
sx={{
fontSize: 14,
color: getTextColorFromBackgroundColor(LABEL_COLORS[0].value),
}}
/>
</Avatar>
</ListItemDecorator>
<ListItemContent>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Typography
level='body-sm'
sx={{
fontWeight:
effectiveSelectedProject === 'Default Project' ? 600 : 400,
color:
effectiveSelectedProject === 'Default Project'
? 'var(--joy-palette-primary-600)'
: 'var(--joy-palette-text-primary)',
}}
>
Default Project
</Typography>
{effectiveSelectedProject === 'Default Project' && (
<Check
sx={{
fontSize: '16px',
color: 'var(--joy-palette-primary-500)',
}}
/>
)}
</Box>
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
>
Built-in project workspace
</Typography>
</ListItemContent>
</MenuItem>
{defaultProjects.map((project, index) => (
<MenuItem
key={project.id}
onClick={() => handleProjectSelect(project)}
sx={{
borderRadius: 'var(--joy-radius-sm)',
backgroundColor:
effectiveSelectedProject === project.name
? 'var(--joy-palette-primary-softBg)'
: selectedIndex === index && anchorEl
? 'var(--joy-palette-neutral-softHoverBg)'
: 'transparent',
'&:hover': {
backgroundColor:
effectiveSelectedProject === project.name
? 'var(--joy-palette-primary-softBg)'
: 'var(--joy-palette-neutral-softHoverBg)',
},
position: 'relative',
}}
>
<ListItemDecorator>
<Avatar
size='sm'
sx={{
width: 24,
height: 24,
backgroundColor: project.color || LABEL_COLORS[0].value,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{(() => {
const IconComponent = getIconComponent(
project.icon || 'FolderOpen',
)
return (
<IconComponent
sx={{
fontSize: 14,
color: getTextColorFromBackgroundColor(
project.color || LABEL_COLORS[0].value,
),
}}
/>
)
})()}
</Avatar>
</ListItemDecorator>
<ListItemContent>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Typography
level='body-sm'
sx={{
fontWeight:
effectiveSelectedProject === project.name ? 600 : 400,
color:
effectiveSelectedProject === project.name
? 'var(--joy-palette-primary-600)'
: 'var(--joy-palette-text-primary)',
}}
>
{project.name}
</Typography>
{effectiveSelectedProject === project.name && (
<Check
sx={{
fontSize: '16px',
color: 'var(--joy-palette-primary-500)',
}}
/>
)}
</Box>
{project.id === 'default' && (
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
>
Built-in project workspace
</Typography>
)}
</ListItemContent>
</MenuItem>
))}
<Divider sx={{ my: 1 }} />
<MenuItem
onClick={handleAddProjectClick}
sx={{
borderRadius: 'var(--joy-radius-sm)',
backgroundColor:
selectedIndex === defaultProjects.length && anchorEl
? 'var(--joy-palette-success-softHoverBg)'
: 'transparent',
'&:hover': {
backgroundColor: 'var(--joy-palette-success-softHoverBg)',
},
}}
>
<ListItemDecorator sx={{ color: 'var(--joy-palette-success-500)' }}>
<Add />
</ListItemDecorator>
<ListItemContent>
<Typography
level='body-sm'
sx={{
fontWeight: 500,
color: 'var(--joy-palette-success-600)',
}}
>
Create New Project
</Typography>
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
>
Add a custom project workspace
</Typography>
</ListItemContent>
</MenuItem>
</Menu>
<ProjectModal
isOpen={isProjectModalOpen}
onClose={() => setIsProjectModalOpen(false)}
onSave={handleProjectModalSave}
project={null}
/>
</>
)
}
export default ProjectSelector