unifying the empty state across components (#184)
This commit is contained in:
231
src/components/common/EmptyState.jsx
Normal file
231
src/components/common/EmptyState.jsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import { Box, Button, Typography } from '@mui/joy'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
/**
|
||||
* The single empty/error surface for the app.
|
||||
*
|
||||
* variant drives the tone, the icon tile color and the a11y role:
|
||||
* - 'empty' nothing exists yet. Teach the feature, offer the way in.
|
||||
* - 'no-results' something exists, the current search/filter hides it.
|
||||
* - 'error' we failed to load. Say what happened, offer a retry.
|
||||
*
|
||||
* Actions are objects instead of nodes so every call site gets the same
|
||||
* button vocabulary (solid primary lead, plain neutral follow).
|
||||
*/
|
||||
|
||||
const TONES = {
|
||||
empty: {
|
||||
tileBg: 'primary.softHoverBg',
|
||||
halo: 'primary.softBg',
|
||||
iconColor: 'primary.softColor',
|
||||
role: 'status',
|
||||
},
|
||||
'no-results': {
|
||||
tileBg: 'neutral.softHoverBg',
|
||||
halo: 'neutral.softBg',
|
||||
iconColor: 'neutral.softColor',
|
||||
role: 'status',
|
||||
},
|
||||
error: {
|
||||
tileBg: 'danger.softHoverBg',
|
||||
halo: 'danger.softBg',
|
||||
iconColor: 'danger.softColor',
|
||||
role: 'alert',
|
||||
},
|
||||
}
|
||||
|
||||
const SIZES = {
|
||||
sm: {
|
||||
tile: 48,
|
||||
icon: '1.375rem',
|
||||
halo: 6,
|
||||
py: 5,
|
||||
title: 'title-sm',
|
||||
titleSize: '1rem',
|
||||
},
|
||||
md: {
|
||||
tile: 68,
|
||||
icon: '1.875rem',
|
||||
halo: 10,
|
||||
py: 8,
|
||||
title: 'title-md',
|
||||
titleSize: '1.25rem',
|
||||
},
|
||||
}
|
||||
|
||||
const ActionButton = ({ action, ...buttonProps }) => {
|
||||
const { label, to, onClick, ...rest } = action
|
||||
return (
|
||||
<Button
|
||||
{...buttonProps}
|
||||
{...rest}
|
||||
onClick={onClick}
|
||||
{...(to ? { component: Link, to } : {})}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
ActionButton.propTypes = {
|
||||
action: PropTypes.object.isRequired,
|
||||
}
|
||||
|
||||
const EmptyState = ({
|
||||
variant = 'empty',
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
primaryAction,
|
||||
secondaryAction,
|
||||
size = 'md',
|
||||
fullHeight = false,
|
||||
sx,
|
||||
...rest
|
||||
}) => {
|
||||
const tone = TONES[variant] || TONES.empty
|
||||
const dimensions = SIZES[size] || SIZES.md
|
||||
const buttonSize = size === 'sm' ? 'sm' : 'md'
|
||||
|
||||
return (
|
||||
<Box
|
||||
role={tone.role}
|
||||
aria-live={variant === 'error' ? 'assertive' : 'polite'}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center',
|
||||
gap: 0.75,
|
||||
px: 3,
|
||||
py: dimensions.py,
|
||||
...(fullHeight && { minHeight: '55vh' }),
|
||||
animation: 'dt-empty-state-in 200ms cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
'@keyframes dt-empty-state-in': {
|
||||
from: { opacity: 0, transform: 'translateY(4px)' },
|
||||
to: { opacity: 1, transform: 'none' },
|
||||
},
|
||||
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
|
||||
...sx,
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{icon && (
|
||||
<Box
|
||||
aria-hidden='true'
|
||||
sx={{
|
||||
// Two concentric tints: a wide, pale halo with a deeper medallion
|
||||
// inside it, so the icon reads as an object rather than a chip.
|
||||
width: dimensions.tile + dimensions.halo * 2,
|
||||
height: dimensions.tile + dimensions.halo * 2,
|
||||
mb: 1.5,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '50%',
|
||||
bgcolor: tone.halo,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: dimensions.tile,
|
||||
height: dimensions.tile,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '50%',
|
||||
bgcolor: tone.tileBg,
|
||||
color: tone.iconColor,
|
||||
'& > svg': { fontSize: dimensions.icon },
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Typography
|
||||
level={dimensions.title}
|
||||
sx={{
|
||||
color: 'text.primary',
|
||||
fontSize: dimensions.titleSize,
|
||||
fontWeight: 'lg',
|
||||
letterSpacing: '-0.01em',
|
||||
textWrap: 'balance',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
|
||||
{description && (
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
maxWidth: '38ch',
|
||||
lineHeight: 1.55,
|
||||
textWrap: 'pretty',
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{(primaryAction || secondaryAction) && (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 1.5,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{primaryAction && (
|
||||
<ActionButton
|
||||
action={primaryAction}
|
||||
variant='solid'
|
||||
color='primary'
|
||||
size={buttonSize}
|
||||
sx={{ minWidth: size === 'sm' ? 0 : 148 }}
|
||||
/>
|
||||
)}
|
||||
{secondaryAction && (
|
||||
<ActionButton
|
||||
action={secondaryAction}
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
size={buttonSize}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
EmptyState.propTypes = {
|
||||
variant: PropTypes.oneOf(['empty', 'no-results', 'error']),
|
||||
icon: PropTypes.node,
|
||||
title: PropTypes.node.isRequired,
|
||||
description: PropTypes.node,
|
||||
primaryAction: PropTypes.shape({
|
||||
label: PropTypes.node.isRequired,
|
||||
onClick: PropTypes.func,
|
||||
to: PropTypes.string,
|
||||
startDecorator: PropTypes.node,
|
||||
}),
|
||||
secondaryAction: PropTypes.shape({
|
||||
label: PropTypes.node.isRequired,
|
||||
onClick: PropTypes.func,
|
||||
to: PropTypes.string,
|
||||
startDecorator: PropTypes.node,
|
||||
}),
|
||||
size: PropTypes.oneOf(['sm', 'md']),
|
||||
fullHeight: PropTypes.bool,
|
||||
sx: PropTypes.object,
|
||||
}
|
||||
|
||||
export default EmptyState
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Label,
|
||||
Person,
|
||||
PriorityHigh,
|
||||
SearchOff,
|
||||
SelectAll,
|
||||
Unarchive,
|
||||
ViewAgenda,
|
||||
@@ -27,6 +28,7 @@ import { useQueryClient } from '@tanstack/react-query'
|
||||
import Fuse from 'fuse.js'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import EmptyState from '../../components/common/EmptyState'
|
||||
import FilterBar from '../../components/common/FilterBar'
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||
@@ -1004,45 +1006,37 @@ const ArchivedTasks = () => {
|
||||
|
||||
{/* Content */}
|
||||
{finalChores.length === 0 ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
height: '50vh',
|
||||
}}
|
||||
>
|
||||
<Archive sx={{ fontSize: '4rem', mb: 1, color: 'text.tertiary' }} />
|
||||
<Typography level='title-md' gutterBottom>
|
||||
{searchTerm || hasActiveFilters
|
||||
? 'No archived tasks found'
|
||||
: 'No archived tasks'}
|
||||
</Typography>
|
||||
<Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}>
|
||||
{searchTerm || hasActiveFilters
|
||||
? 'Try adjusting your search or filters'
|
||||
: 'Archived tasks will appear here when you archive them from the main task list'}
|
||||
</Typography>
|
||||
{(searchTerm || hasActiveFilters) && (
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
{searchTerm && (
|
||||
<Button
|
||||
onClick={handleSearchClose}
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
>
|
||||
Clear search
|
||||
</Button>
|
||||
)}
|
||||
{hasActiveFilters && (
|
||||
<Button onClick={clearAll} variant='outlined' color='neutral'>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
searchTerm || hasActiveFilters ? (
|
||||
<EmptyState
|
||||
variant='no-results'
|
||||
fullHeight
|
||||
icon={<SearchOff />}
|
||||
title='No archived tasks match'
|
||||
description={
|
||||
searchTerm
|
||||
? `Nothing in the archive matches "${searchTerm}".`
|
||||
: 'There are archived tasks, but none fit the filters that are currently on.'
|
||||
}
|
||||
primaryAction={
|
||||
searchTerm
|
||||
? { label: 'Clear search', onClick: handleSearchClose }
|
||||
: { label: 'Clear filters', onClick: clearAll }
|
||||
}
|
||||
secondaryAction={
|
||||
searchTerm && hasActiveFilters
|
||||
? { label: 'Clear filters', onClick: clearAll }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
fullHeight
|
||||
icon={<Archive />}
|
||||
title='Nothing archived'
|
||||
description='Archiving hides a task without deleting it. Anything you archive from your task list shows up here, ready to restore.'
|
||||
primaryAction={{ label: 'Back to tasks', to: '/chores' }}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Box>
|
||||
<Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}>
|
||||
|
||||
@@ -2,18 +2,18 @@ import {
|
||||
Add,
|
||||
Bolt,
|
||||
CalendarMonth,
|
||||
CloudOff,
|
||||
EditCalendar,
|
||||
SearchOff,
|
||||
ExpandCircleDown,
|
||||
PriorityHigh,
|
||||
Style,
|
||||
} from '@mui/icons-material'
|
||||
import Logo from '../../Logo'
|
||||
import {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
AccordionGroup,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Container,
|
||||
Divider,
|
||||
@@ -33,6 +33,7 @@ import IconButtonWithMenu from './IconButtonWithMenu'
|
||||
|
||||
import { useMediaQuery } from '@mui/material'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import EmptyState from '../../components/common/EmptyState'
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import { useFilter } from '../../hooks/useFilter'
|
||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||
@@ -217,8 +218,7 @@ const MyChores = () => {
|
||||
)
|
||||
case 'Due Later':
|
||||
return (
|
||||
d !== null &&
|
||||
d > new Date(now.getTime() + 24 * 60 * 60 * 1000)
|
||||
d !== null && d > new Date(now.getTime() + 24 * 60 * 60 * 1000)
|
||||
)
|
||||
case 'No Due Date':
|
||||
return item.nextDueDate === null
|
||||
@@ -637,7 +637,8 @@ const MyChores = () => {
|
||||
selectedChores,
|
||||
addTaskModalOpen,
|
||||
searchTerm,
|
||||
searchFilter: hasQuickFilters || searchTerm?.length > 0 ? 'filtered' : 'All',
|
||||
searchFilter:
|
||||
hasQuickFilters || searchTerm?.length > 0 ? 'filtered' : 'All',
|
||||
filteredChores: getFilteredChores,
|
||||
choreSections,
|
||||
openChoreSections,
|
||||
@@ -826,10 +827,12 @@ const MyChores = () => {
|
||||
}
|
||||
|
||||
const toggleViewMode = value => {
|
||||
const newMode = value ?? (() => {
|
||||
const modes = ['default', 'compact', 'calendar']
|
||||
return modes[(modes.indexOf(viewMode) + 1) % modes.length]
|
||||
})()
|
||||
const newMode =
|
||||
value ??
|
||||
(() => {
|
||||
const modes = ['default', 'compact', 'calendar']
|
||||
return modes[(modes.indexOf(viewMode) + 1) % modes.length]
|
||||
})()
|
||||
setViewMode(newMode)
|
||||
localStorage.setItem('choreCardViewMode', newMode)
|
||||
if (newMode !== 'calendar') {
|
||||
@@ -905,9 +908,28 @@ const MyChores = () => {
|
||||
[getFilteredChores],
|
||||
)
|
||||
|
||||
|
||||
// "Narrowed" means the user actively cut the list down (search, quick
|
||||
// filters, a saved filter). Picking a project is not narrowing: an empty
|
||||
// project is an empty place, not a filtered-away result.
|
||||
const isNarrowed = Boolean(
|
||||
searchTerm?.length > 0 || hasQuickFilters || activeFilterId,
|
||||
)
|
||||
const isCustomProjectSelected = Boolean(
|
||||
selectedProject && selectedProject.id !== 'default',
|
||||
)
|
||||
|
||||
const clearNarrowing = () => {
|
||||
clearQuickFilters()
|
||||
setSearchTerm('')
|
||||
clearActiveFilter()
|
||||
updateFilterUrl(null, null)
|
||||
}
|
||||
|
||||
const appendChore = (prev, newChore) => {
|
||||
let newChores = [...prev, newChore]
|
||||
|
||||
|
||||
if (impersonatedUser) {
|
||||
newChores = newChores.filter(
|
||||
chore => chore.assignedTo === impersonatedUser.userId,
|
||||
@@ -930,40 +952,23 @@ const MyChores = () => {
|
||||
if (choresError || membersError) {
|
||||
return (
|
||||
<Container maxWidth='md'>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
height: '70vh',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ mb: 2, opacity: 0.7 }}>
|
||||
<Logo />
|
||||
</Box>
|
||||
<Typography level='h4' color='danger'>
|
||||
Unable to communicate with server
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-md'
|
||||
sx={{ textAlign: 'center', maxWidth: 400 }}
|
||||
>
|
||||
{choresErrorDetails?.message ||
|
||||
'The server is currently unavailable. Please check your connection and try again.'}
|
||||
</Typography>
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
onClick={() => {
|
||||
<EmptyState
|
||||
variant='error'
|
||||
fullHeight
|
||||
icon={<CloudOff />}
|
||||
title={"Can't reach Donetick"}
|
||||
description={
|
||||
choresErrorDetails?.message ||
|
||||
'Your tasks are safe. We just could not load them right now, check your connection and try again.'
|
||||
}
|
||||
primaryAction={{
|
||||
label: 'Try again',
|
||||
onClick: () => {
|
||||
refetchChores()
|
||||
queryClient.invalidateQueries(['circleMembers'])
|
||||
}}
|
||||
>
|
||||
Retry Connection
|
||||
</Button>
|
||||
</Box>
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -1118,50 +1123,79 @@ const MyChores = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Show "Nothing scheduled" when appropriate based on current view mode */}
|
||||
{(searchTerm?.length > 0 || hasQuickFilters || activeFilterId
|
||||
{/* Empty state. Three different situations, three different messages:
|
||||
nothing created yet, nothing left after narrowing, or an empty
|
||||
project. Only the middle one is about filters. */}
|
||||
{(isNarrowed
|
||||
? getFilteredChores.length === 0
|
||||
: projectFilteredChores.length === 0) &&
|
||||
// only if not in calendar view:
|
||||
viewMode !== 'calendar' && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
height: '50vh',
|
||||
viewMode !== 'calendar' &&
|
||||
(chores.length === 0 ? (
|
||||
<EmptyState
|
||||
variant='empty'
|
||||
fullHeight
|
||||
icon={<EditCalendar />}
|
||||
title='No tasks yet'
|
||||
description='Create your first task and Donetick keeps track of when it is due, whose turn it is, and what comes next.'
|
||||
primaryAction={{
|
||||
label: 'Create a task',
|
||||
startDecorator: <Add />,
|
||||
onClick: () => setAddTaskModalOpen(true),
|
||||
}}
|
||||
>
|
||||
<EditCalendar
|
||||
sx={{
|
||||
fontSize: '4rem',
|
||||
// color: 'text.disabled',
|
||||
mb: 1,
|
||||
}}
|
||||
/>
|
||||
<Typography level='title-md' gutterBottom>
|
||||
Nothing scheduled
|
||||
</Typography>
|
||||
{chores.length > 0 && (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
clearQuickFilters()
|
||||
setSearchTerm('')
|
||||
clearActiveFilter()
|
||||
setSelectedProjectWithCache(null)
|
||||
updateFilterUrl(null, null)
|
||||
}}
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
>
|
||||
Reset filters
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
secondaryAction={{
|
||||
label: 'More options',
|
||||
onClick: () => Navigate('/chores/create'),
|
||||
}}
|
||||
/>
|
||||
) : isNarrowed ? (
|
||||
<EmptyState
|
||||
variant='no-results'
|
||||
fullHeight
|
||||
icon={<SearchOff />}
|
||||
title='No tasks match this view'
|
||||
description={
|
||||
searchTerm?.length > 0
|
||||
? `Nothing matches "${searchTerm}". Try a different search, or clear what is narrowing the list.`
|
||||
: 'You have tasks, but none of them fit the filters that are currently on.'
|
||||
}
|
||||
primaryAction={{
|
||||
label:
|
||||
searchTerm?.length > 0 ? 'Clear search' : 'Clear filters',
|
||||
onClick: clearNarrowing,
|
||||
}}
|
||||
/>
|
||||
) : isCustomProjectSelected ? (
|
||||
<EmptyState
|
||||
variant='empty'
|
||||
fullHeight
|
||||
icon={<EditCalendar />}
|
||||
title={`Nothing in ${selectedProject.name} yet`}
|
||||
description='Tasks you add to this project show up here. Your other tasks are still where you left them.'
|
||||
primaryAction={{
|
||||
label: 'Add a task here',
|
||||
startDecorator: <Add />,
|
||||
onClick: () => setAddTaskModalOpen(true),
|
||||
}}
|
||||
secondaryAction={{
|
||||
label: 'See tasks outside projects',
|
||||
onClick: () => setSelectedProjectWithCache(null),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
variant='empty'
|
||||
fullHeight
|
||||
icon={<EditCalendar />}
|
||||
title='No tasks here yet'
|
||||
description='Tasks that do not belong to a project live here. Add one, or switch projects to see what is in them.'
|
||||
primaryAction={{
|
||||
label: 'Create a task',
|
||||
startDecorator: <Add />,
|
||||
onClick: () => setAddTaskModalOpen(true),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{searchTerm?.length > 0 && viewMode !== 'calendar' && (
|
||||
<ChoreListView
|
||||
chores={getFilteredChores}
|
||||
@@ -1330,16 +1364,17 @@ const MyChores = () => {
|
||||
}}
|
||||
>
|
||||
{getChoresForDate(selectedCalendarDate).length === 0 ? (
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
py: 2,
|
||||
color: 'text.tertiary',
|
||||
<EmptyState
|
||||
size='sm'
|
||||
icon={<EditCalendar />}
|
||||
title='Nothing scheduled'
|
||||
description='This day is free. Add a task if you want something to land here.'
|
||||
primaryAction={{
|
||||
label: 'Add task',
|
||||
startDecorator: <Add />,
|
||||
onClick: () => setAddTaskModalOpen(true),
|
||||
}}
|
||||
>
|
||||
No tasks scheduled for this date
|
||||
</Typography>
|
||||
/>
|
||||
) : (
|
||||
<ChoreListView
|
||||
chores={getChoresForDate(selectedCalendarDate)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BarChart, Person } from '@mui/icons-material'
|
||||
import { Avatar, Box, Sheet, Typography } from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import EmptyState from '../../components/common/EmptyState'
|
||||
import { useCircleMembers } from '../../queries/UserQueries'
|
||||
import { TASK_COLOR } from '../../utils/Colors'
|
||||
import { resolvePhotoURL } from '../../utils/Helpers'
|
||||
@@ -127,10 +128,13 @@ const TasksByAssigneeCard = ({ chores = [] }) => {
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Person sx={{ fontSize: 48, opacity: 0.3, mb: 1 }} />
|
||||
<Typography level='body-sm' color='neutral'>
|
||||
No assigned tasks found
|
||||
</Typography>
|
||||
<EmptyState
|
||||
variant='no-results'
|
||||
size='sm'
|
||||
icon={<Person />}
|
||||
title='No one has tasks yet'
|
||||
description='Assign a task to someone in your circle and their workload shows up here.'
|
||||
/>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
StarBorder,
|
||||
Task,
|
||||
} from '@mui/icons-material'
|
||||
import EmptyState from '../../components/common/EmptyState'
|
||||
import { useChores } from '../../queries/ChoreQueries'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||
import { getFilterCount, getFilterOverdueCount } from '../../utils/FilterEngine'
|
||||
@@ -417,29 +418,17 @@ const FilterView = () => {
|
||||
}}
|
||||
>
|
||||
{savedFilters.length === 0 ? (
|
||||
<Box
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
<EmptyState
|
||||
fullHeight
|
||||
icon={<FilterAlt />}
|
||||
title='No saved filters yet'
|
||||
description='Save a set of conditions once, like "overdue and assigned to me", and jump straight back to it from anywhere.'
|
||||
primaryAction={{
|
||||
label: 'Create a filter',
|
||||
startDecorator: <Add />,
|
||||
onClick: handleAddFilter,
|
||||
}}
|
||||
>
|
||||
<FilterAlt
|
||||
sx={{
|
||||
fontSize: 48,
|
||||
color: 'neutral.300',
|
||||
mb: 2,
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
level='title-lg'
|
||||
sx={{ mb: 1, color: 'text.secondary' }}
|
||||
>
|
||||
No saved filters yet
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ color: 'text.tertiary', mb: 2 }}>
|
||||
Create custom filters to quickly access your most used chore
|
||||
</Typography>
|
||||
</Box>
|
||||
/>
|
||||
) : (
|
||||
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
||||
{savedFilters.map(filter => {
|
||||
|
||||
@@ -28,10 +28,11 @@ import {
|
||||
} from '@mui/icons-material'
|
||||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import EditIcon from '@mui/icons-material/Edit'
|
||||
import { Box, Button, Card, Container, Grid, Sheet, Typography } from '@mui/joy'
|
||||
import { Box, Card, Container, Grid, Sheet, Typography } from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import EmptyState from '../../components/common/EmptyState'
|
||||
import FilterBar from '../../components/common/FilterBar'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import useConfirmationModal from '../../hooks/useConfirmationModal'
|
||||
@@ -303,36 +304,14 @@ const ChoreHistory = () => {
|
||||
}
|
||||
if (!choreHistory.length) {
|
||||
return (
|
||||
<Container
|
||||
maxWidth='md'
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
// make sure the content is centered vertically:
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
height: '50vh',
|
||||
}}
|
||||
>
|
||||
<EventBusy
|
||||
sx={{
|
||||
fontSize: '6rem',
|
||||
// color: 'text.disabled',
|
||||
mb: 1,
|
||||
}}
|
||||
<Container maxWidth='md'>
|
||||
<EmptyState
|
||||
fullHeight
|
||||
icon={<EventBusy />}
|
||||
title='No history yet'
|
||||
description='Every time this task gets completed or skipped, it lands here with who did it and when. Nothing has happened yet.'
|
||||
primaryAction={{ label: 'Back to tasks', to: '/chores' }}
|
||||
/>
|
||||
|
||||
<Typography level='h3' gutterBottom>
|
||||
No History Yet
|
||||
</Typography>
|
||||
<Typography level='body1'>
|
||||
You haven't completed any tasks. Once you start finishing tasks,
|
||||
they'll show up here.
|
||||
</Typography>
|
||||
<Button variant='soft' sx={{ mt: 2 }}>
|
||||
<Link to='/chores'>Go back to chores</Link>
|
||||
</Button>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -441,27 +420,13 @@ const ChoreHistory = () => {
|
||||
/>
|
||||
</Box>
|
||||
{sortedHistory.length === 0 && activeFilterCount > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
py: 6,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<FilterList sx={{ fontSize: '3rem', color: 'text.tertiary' }} />
|
||||
<Typography level='title-md' sx={{ color: 'text.secondary' }}>
|
||||
No results match your filters
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ color: 'text.tertiary' }}>
|
||||
Try adjusting or clearing the active filters.
|
||||
</Typography>
|
||||
<Button variant='soft' size='sm' onClick={clearAll} sx={{ mt: 0.5 }}>
|
||||
Clear filters
|
||||
</Button>
|
||||
</Box>
|
||||
<EmptyState
|
||||
variant='no-results'
|
||||
icon={<FilterList />}
|
||||
title='No history matches these filters'
|
||||
description='There is history here, but none of it fits the filters that are currently on.'
|
||||
primaryAction={{ label: 'Clear filters', onClick: clearAll }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{sortedHistory.length > 0 && (
|
||||
|
||||
@@ -21,7 +21,8 @@ import {
|
||||
TrailingActions,
|
||||
} from '@meauxt/react-swipeable-list'
|
||||
import '@meauxt/react-swipeable-list/dist/styles.css'
|
||||
import { Add, MoreVert } from '@mui/icons-material'
|
||||
import { Add, MoreVert, Style } from '@mui/icons-material'
|
||||
import EmptyState from '../../components/common/EmptyState'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors'
|
||||
@@ -258,19 +259,17 @@ const LabelView = () => {
|
||||
}}
|
||||
>
|
||||
{userLabels.length === 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
height: '50vh',
|
||||
<EmptyState
|
||||
fullHeight
|
||||
icon={<Style />}
|
||||
title='No labels yet'
|
||||
description='Labels group tasks across your circle, like "kitchen" or "bills", so you can filter down to them in one tap.'
|
||||
primaryAction={{
|
||||
label: 'Create a label',
|
||||
startDecorator: <Add />,
|
||||
onClick: handleAddLabel,
|
||||
}}
|
||||
>
|
||||
<Typography level='title-md' gutterBottom>
|
||||
No labels available. Add a new label to get started.
|
||||
</Typography>
|
||||
</Box>
|
||||
/>
|
||||
)}
|
||||
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
||||
{userLabels.map(label => (
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import EmptyState from '../../../components/common/EmptyState'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { GetChoreAttachments } from '../../../utils/Fetcher'
|
||||
@@ -95,12 +96,12 @@ function AttachmentBrowserModal({ choreId, isOpen, onClose }) {
|
||||
<CircularProgress size='md' />
|
||||
</Box>
|
||||
) : attachments.length === 0 ? (
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ color: 'text.secondary', py: 2, textAlign: 'center' }}
|
||||
>
|
||||
No attachments found.
|
||||
</Typography>
|
||||
<EmptyState
|
||||
size='sm'
|
||||
icon={<AttachFile />}
|
||||
title='No attachments'
|
||||
description='Photos and files added to this task will show up here.'
|
||||
/>
|
||||
) : (
|
||||
<List sx={{ '--ListItem-paddingX': '0px' }}>
|
||||
{attachments.map((attachment, index) => (
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Analytics,
|
||||
BarChart,
|
||||
CallReceived,
|
||||
CloudOff,
|
||||
EventBusy,
|
||||
Schedule,
|
||||
Speed,
|
||||
@@ -25,7 +26,7 @@ import {
|
||||
} from '@mui/joy'
|
||||
import { useTheme } from '@mui/joy/styles'
|
||||
import moment from 'moment'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import {
|
||||
Line,
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts'
|
||||
import EmptyState from '../../components/common/EmptyState'
|
||||
import { useThingHistory } from '../../queries/ThingQueries'
|
||||
import LoadingComponent from '../components/Loading'
|
||||
|
||||
@@ -49,6 +51,7 @@ const ThingsHistory = () => {
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
refetch,
|
||||
} = useThingHistory(id)
|
||||
|
||||
// Flatten all pages of history data
|
||||
@@ -152,35 +155,23 @@ const ThingsHistory = () => {
|
||||
|
||||
if (error || !thingsHistory || thingsHistory.length === 0) {
|
||||
return (
|
||||
<Container
|
||||
maxWidth='md'
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
// make sure the content is centered vertically:
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
height: '50vh',
|
||||
}}
|
||||
>
|
||||
<EventBusy
|
||||
sx={{
|
||||
fontSize: '6rem',
|
||||
// color: 'text.disabled',
|
||||
mb: 1,
|
||||
}}
|
||||
<Container maxWidth='md'>
|
||||
<EmptyState
|
||||
variant={error ? 'error' : 'empty'}
|
||||
fullHeight
|
||||
icon={error ? <CloudOff /> : <EventBusy />}
|
||||
title={error ? "Couldn't load this history" : 'No history yet'}
|
||||
description={
|
||||
error
|
||||
? 'We could not reach the server. Check your connection and try again.'
|
||||
: "Each time this thing's value changes, the change is recorded here."
|
||||
}
|
||||
primaryAction={
|
||||
error
|
||||
? { label: 'Try again', onClick: () => refetch() }
|
||||
: { label: 'Back to things', to: '/things' }
|
||||
}
|
||||
/>
|
||||
|
||||
<Typography level='h3' gutterBottom>
|
||||
No history found
|
||||
</Typography>
|
||||
<Typography level='body1'>
|
||||
It looks like there is no history for this thing yet.
|
||||
</Typography>
|
||||
<Button variant='soft' sx={{ mt: 2 }}>
|
||||
<Link to='/things'>Go back to things</Link>
|
||||
</Button>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import EmptyState from '../../components/common/EmptyState'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import {
|
||||
CreateThing,
|
||||
@@ -405,25 +406,20 @@ const ThingsView = () => {
|
||||
}}
|
||||
>
|
||||
{things.length === 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
height: '50vh',
|
||||
<EmptyState
|
||||
fullHeight
|
||||
icon={<Widgets />}
|
||||
title='No things yet'
|
||||
description='A thing tracks a value, like a counter or a switch, that other tasks can react to. Create one to trigger tasks automatically.'
|
||||
primaryAction={{
|
||||
label: 'Create a thing',
|
||||
startDecorator: <Add />,
|
||||
onClick: () => {
|
||||
setCreateModalThing(null)
|
||||
setIsShowCreateThingModal(true)
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Widgets
|
||||
sx={{
|
||||
fontSize: '4rem',
|
||||
mb: 1,
|
||||
}}
|
||||
/>
|
||||
<Typography level='title-md' gutterBottom>
|
||||
No things has been created/found
|
||||
</Typography>
|
||||
</Box>
|
||||
/>
|
||||
)}
|
||||
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
||||
{things.map(thing => (
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
import moment from 'moment'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import EmptyState from '../../components/common/EmptyState'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import {
|
||||
useChoreTimer,
|
||||
@@ -1204,9 +1205,12 @@ const TimerDetails = () => {
|
||||
)}
|
||||
|
||||
{(!timerData.pauseLog || timerData.pauseLog.length === 0) && (
|
||||
<Alert color='neutral'>
|
||||
No work sessions found for this timer.
|
||||
</Alert>
|
||||
<EmptyState
|
||||
size='sm'
|
||||
icon={<AccessTime />}
|
||||
title='No work sessions yet'
|
||||
description='Start the timer on this task and each session lands here.'
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
|
||||
@@ -20,17 +20,16 @@ import {
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Chip,
|
||||
Container,
|
||||
Divider,
|
||||
Grid,
|
||||
Link,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import EmptyState from '../../components/common/EmptyState'
|
||||
import FilterBar from '../../components/common/FilterBar'
|
||||
import { useFilter } from '../../hooks/useFilter'
|
||||
|
||||
@@ -992,54 +991,21 @@ const UserActivites = () => {
|
||||
|
||||
{/* Conditional Content Based on Data Availability */}
|
||||
{!choresData.res?.length > 0 || !choresHistory?.length > 0 ? (
|
||||
<Container
|
||||
maxWidth='md'
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
height: '50vh',
|
||||
}}
|
||||
>
|
||||
<EventBusy
|
||||
sx={{
|
||||
fontSize: '6rem',
|
||||
mb: 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Typography level='h3' gutterBottom>
|
||||
No activities found
|
||||
</Typography>
|
||||
<Typography level='body1' sx={{ mb: 1 }}>
|
||||
No activities found for{' '}
|
||||
<Typography
|
||||
component='span'
|
||||
sx={{ fontWeight: 600, color: 'primary.500' }}
|
||||
>
|
||||
{selectedUser === undefined || selectedUser === 'all'
|
||||
? 'All Users'
|
||||
: circleUsers.find(user => user.userId === selectedUser)
|
||||
?.displayName || 'Unknown User'}
|
||||
</Typography>{' '}
|
||||
in the{' '}
|
||||
<Typography
|
||||
component='span'
|
||||
sx={{ fontWeight: 600, color: 'primary.500' }}
|
||||
>
|
||||
{tabValue === 365 ? 'All Time' : `Last ${tabValue} Days`}
|
||||
</Typography>
|
||||
.
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary', mb: 2 }}>
|
||||
Try selecting a different time period or user filter above.
|
||||
</Typography>
|
||||
<Button variant='soft' sx={{ mt: 2 }}>
|
||||
<Link to='/chores'>Go back to chores</Link>
|
||||
</Button>
|
||||
</Container>
|
||||
<EmptyState
|
||||
variant='no-results'
|
||||
fullHeight
|
||||
icon={<EventBusy />}
|
||||
title='No activity in this range'
|
||||
description={`Nothing was completed by ${
|
||||
selectedUser === undefined || selectedUser === 'all'
|
||||
? 'anyone in your circle'
|
||||
: circleUsers.find(user => user.userId === selectedUser)
|
||||
?.displayName || 'this member'
|
||||
} ${
|
||||
tabValue === 365 ? 'so far' : `in the last ${tabValue} days`
|
||||
}. Try a wider time range or a different member.`}
|
||||
primaryAction={{ label: 'Back to tasks', to: '/chores' }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Main Content Area - Mobile: Stack vertically, Desktop: Side by side */}
|
||||
|
||||
@@ -1,61 +1,23 @@
|
||||
import { HomeRounded, Login } from '@mui/icons-material'
|
||||
import { Box, Button, CircularProgress, Container } from '@mui/joy'
|
||||
import { Typography } from '@mui/material'
|
||||
import { Link } from 'react-router-dom' // Assuming you are using React Router
|
||||
import Logo from '../../Logo'
|
||||
import { Explore, HomeRounded } from '@mui/icons-material'
|
||||
import { Container } from '@mui/joy'
|
||||
import EmptyState from '../../components/common/EmptyState'
|
||||
|
||||
const NotFound = () => {
|
||||
return (
|
||||
<Container className='flex h-full items-center justify-center'>
|
||||
<Box
|
||||
className='flex flex-col items-center justify-center'
|
||||
sx={{
|
||||
minHeight: '80vh',
|
||||
<Container maxWidth='sm'>
|
||||
<EmptyState
|
||||
variant='no-results'
|
||||
fullHeight
|
||||
icon={<Explore />}
|
||||
title='Page not found'
|
||||
description='This link does not lead anywhere. It may have moved, or the address has a typo in it.'
|
||||
primaryAction={{
|
||||
label: 'Go to my tasks',
|
||||
to: '/chores',
|
||||
startDecorator: <HomeRounded />,
|
||||
}}
|
||||
>
|
||||
<CircularProgress
|
||||
value={100}
|
||||
color='danger' // Set the color to 'error' for danger color
|
||||
sx={{ '--CircularProgress-size': '200px' }}
|
||||
>
|
||||
<Logo />
|
||||
</CircularProgress>
|
||||
<Box
|
||||
className='flex items-center gap-2'
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: 24,
|
||||
mt: 2,
|
||||
}}
|
||||
>
|
||||
Page Not Found
|
||||
</Box>
|
||||
<Typography level='h2' fontWeight={500} textAlign={'center'}>
|
||||
Sorry, I could be wrong but I think you are lost.
|
||||
</Typography>
|
||||
<Button
|
||||
component={Link}
|
||||
to='/chores'
|
||||
variant='outlined'
|
||||
color='primary'
|
||||
sx={{ mt: 4 }}
|
||||
size='lg'
|
||||
startDecorator={<HomeRounded />}
|
||||
>
|
||||
Home
|
||||
</Button>
|
||||
<Button
|
||||
component={Link}
|
||||
to='/login'
|
||||
variant='outlined'
|
||||
color='primary'
|
||||
sx={{ mt: 1 }}
|
||||
size='lg'
|
||||
startDecorator={<Login />}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</Box>
|
||||
secondaryAction={{ label: 'Log in', to: '/login' }}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user