unifying the empty state across components (#184)

This commit is contained in:
Mohamad Tarbin
2026-08-02 23:16:32 -04:00
committed by GitHub
parent 5da8724a7b
commit dc88fe9704
13 changed files with 517 additions and 380 deletions

View 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

View File

@@ -7,6 +7,7 @@ import {
Label, Label,
Person, Person,
PriorityHigh, PriorityHigh,
SearchOff,
SelectAll, SelectAll,
Unarchive, Unarchive,
ViewAgenda, ViewAgenda,
@@ -27,6 +28,7 @@ import { useQueryClient } from '@tanstack/react-query'
import Fuse from 'fuse.js' import Fuse from 'fuse.js'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import EmptyState from '../../components/common/EmptyState'
import FilterBar from '../../components/common/FilterBar' import FilterBar from '../../components/common/FilterBar'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint' import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx' import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
@@ -1004,45 +1006,37 @@ const ArchivedTasks = () => {
{/* Content */} {/* Content */}
{finalChores.length === 0 ? ( {finalChores.length === 0 ? (
<Box searchTerm || hasActiveFilters ? (
sx={{ <EmptyState
display: 'flex', variant='no-results'
justifyContent: 'center', fullHeight
alignItems: 'center', icon={<SearchOff />}
flexDirection: 'column', title='No archived tasks match'
height: '50vh', description={
}} searchTerm
> ? `Nothing in the archive matches "${searchTerm}".`
<Archive sx={{ fontSize: '4rem', mb: 1, color: 'text.tertiary' }} /> : 'There are archived tasks, but none fit the filters that are currently on.'
<Typography level='title-md' gutterBottom> }
{searchTerm || hasActiveFilters primaryAction={
? 'No archived tasks found' searchTerm
: 'No archived tasks'} ? { label: 'Clear search', onClick: handleSearchClose }
</Typography> : { label: 'Clear filters', onClick: clearAll }
<Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}> }
{searchTerm || hasActiveFilters secondaryAction={
? 'Try adjusting your search or filters' searchTerm && hasActiveFilters
: 'Archived tasks will appear here when you archive them from the main task list'} ? { label: 'Clear filters', onClick: clearAll }
</Typography> : undefined
{(searchTerm || hasActiveFilters) && ( }
<Box sx={{ display: 'flex', gap: 1 }}> />
{searchTerm && ( ) : (
<Button <EmptyState
onClick={handleSearchClose} fullHeight
variant='outlined' icon={<Archive />}
color='neutral' title='Nothing archived'
> description='Archiving hides a task without deleting it. Anything you archive from your task list shows up here, ready to restore.'
Clear search primaryAction={{ label: 'Back to tasks', to: '/chores' }}
</Button> />
)} )
{hasActiveFilters && (
<Button onClick={clearAll} variant='outlined' color='neutral'>
Clear filters
</Button>
)}
</Box>
)}
</Box>
) : ( ) : (
<Box> <Box>
<Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}> <Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}>

View File

@@ -2,18 +2,18 @@ import {
Add, Add,
Bolt, Bolt,
CalendarMonth, CalendarMonth,
CloudOff,
EditCalendar, EditCalendar,
SearchOff,
ExpandCircleDown, ExpandCircleDown,
PriorityHigh, PriorityHigh,
Style, Style,
} from '@mui/icons-material' } from '@mui/icons-material'
import Logo from '../../Logo'
import { import {
Accordion, Accordion,
AccordionDetails, AccordionDetails,
AccordionGroup, AccordionGroup,
Box, Box,
Button,
Chip, Chip,
Container, Container,
Divider, Divider,
@@ -33,6 +33,7 @@ import IconButtonWithMenu from './IconButtonWithMenu'
import { useMediaQuery } from '@mui/material' import { useMediaQuery } from '@mui/material'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import EmptyState from '../../components/common/EmptyState'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint' import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import { useFilter } from '../../hooks/useFilter' import { useFilter } from '../../hooks/useFilter'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx' import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
@@ -217,8 +218,7 @@ const MyChores = () => {
) )
case 'Due Later': case 'Due Later':
return ( return (
d !== null && d !== null && d > new Date(now.getTime() + 24 * 60 * 60 * 1000)
d > new Date(now.getTime() + 24 * 60 * 60 * 1000)
) )
case 'No Due Date': case 'No Due Date':
return item.nextDueDate === null return item.nextDueDate === null
@@ -637,7 +637,8 @@ const MyChores = () => {
selectedChores, selectedChores,
addTaskModalOpen, addTaskModalOpen,
searchTerm, searchTerm,
searchFilter: hasQuickFilters || searchTerm?.length > 0 ? 'filtered' : 'All', searchFilter:
hasQuickFilters || searchTerm?.length > 0 ? 'filtered' : 'All',
filteredChores: getFilteredChores, filteredChores: getFilteredChores,
choreSections, choreSections,
openChoreSections, openChoreSections,
@@ -826,10 +827,12 @@ const MyChores = () => {
} }
const toggleViewMode = value => { const toggleViewMode = value => {
const newMode = value ?? (() => { const newMode =
const modes = ['default', 'compact', 'calendar'] value ??
return modes[(modes.indexOf(viewMode) + 1) % modes.length] (() => {
})() const modes = ['default', 'compact', 'calendar']
return modes[(modes.indexOf(viewMode) + 1) % modes.length]
})()
setViewMode(newMode) setViewMode(newMode)
localStorage.setItem('choreCardViewMode', newMode) localStorage.setItem('choreCardViewMode', newMode)
if (newMode !== 'calendar') { if (newMode !== 'calendar') {
@@ -905,9 +908,28 @@ const MyChores = () => {
[getFilteredChores], [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) => { const appendChore = (prev, newChore) => {
let newChores = [...prev, newChore] let newChores = [...prev, newChore]
if (impersonatedUser) { if (impersonatedUser) {
newChores = newChores.filter( newChores = newChores.filter(
chore => chore.assignedTo === impersonatedUser.userId, chore => chore.assignedTo === impersonatedUser.userId,
@@ -930,40 +952,23 @@ const MyChores = () => {
if (choresError || membersError) { if (choresError || membersError) {
return ( return (
<Container maxWidth='md'> <Container maxWidth='md'>
<Box <EmptyState
sx={{ variant='error'
display: 'flex', fullHeight
justifyContent: 'center', icon={<CloudOff />}
alignItems: 'center', title={"Can't reach Donetick"}
flexDirection: 'column', description={
height: '70vh', choresErrorDetails?.message ||
gap: 2, 'Your tasks are safe. We just could not load them right now, check your connection and try again.'
}} }
> primaryAction={{
<Box sx={{ mb: 2, opacity: 0.7 }}> label: 'Try again',
<Logo /> onClick: () => {
</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={() => {
refetchChores() refetchChores()
queryClient.invalidateQueries(['circleMembers']) queryClient.invalidateQueries(['circleMembers'])
}} },
> }}
Retry Connection />
</Button>
</Box>
</Container> </Container>
) )
} }
@@ -1118,50 +1123,79 @@ const MyChores = () => {
} }
/> />
{/* Show "Nothing scheduled" when appropriate based on current view mode */} {/* Empty state. Three different situations, three different messages:
{(searchTerm?.length > 0 || hasQuickFilters || activeFilterId nothing created yet, nothing left after narrowing, or an empty
project. Only the middle one is about filters. */}
{(isNarrowed
? getFilteredChores.length === 0 ? getFilteredChores.length === 0
: projectFilteredChores.length === 0) && : projectFilteredChores.length === 0) &&
// only if not in calendar view: // only if not in calendar view:
viewMode !== 'calendar' && ( viewMode !== 'calendar' &&
<Box (chores.length === 0 ? (
sx={{ <EmptyState
display: 'flex', variant='empty'
justifyContent: 'center', fullHeight
alignItems: 'center', icon={<EditCalendar />}
flexDirection: 'column', title='No tasks yet'
height: '50vh', 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),
}} }}
> secondaryAction={{
<EditCalendar label: 'More options',
sx={{ onClick: () => Navigate('/chores/create'),
fontSize: '4rem', }}
// color: 'text.disabled', />
mb: 1, ) : isNarrowed ? (
}} <EmptyState
/> variant='no-results'
<Typography level='title-md' gutterBottom> fullHeight
Nothing scheduled icon={<SearchOff />}
</Typography> title='No tasks match this view'
{chores.length > 0 && ( description={
<> searchTerm?.length > 0
<Button ? `Nothing matches "${searchTerm}". Try a different search, or clear what is narrowing the list.`
onClick={() => { : 'You have tasks, but none of them fit the filters that are currently on.'
clearQuickFilters() }
setSearchTerm('') primaryAction={{
clearActiveFilter() label:
setSelectedProjectWithCache(null) searchTerm?.length > 0 ? 'Clear search' : 'Clear filters',
updateFilterUrl(null, null) onClick: clearNarrowing,
}} }}
variant='outlined' />
color='neutral' ) : isCustomProjectSelected ? (
> <EmptyState
Reset filters variant='empty'
</Button> fullHeight
</> icon={<EditCalendar />}
)} title={`Nothing in ${selectedProject.name} yet`}
</Box> 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' && ( {searchTerm?.length > 0 && viewMode !== 'calendar' && (
<ChoreListView <ChoreListView
chores={getFilteredChores} chores={getFilteredChores}
@@ -1330,16 +1364,17 @@ const MyChores = () => {
}} }}
> >
{getChoresForDate(selectedCalendarDate).length === 0 ? ( {getChoresForDate(selectedCalendarDate).length === 0 ? (
<Typography <EmptyState
level='body-sm' size='sm'
sx={{ icon={<EditCalendar />}
textAlign: 'center', title='Nothing scheduled'
py: 2, description='This day is free. Add a task if you want something to land here.'
color: 'text.tertiary', primaryAction={{
label: 'Add task',
startDecorator: <Add />,
onClick: () => setAddTaskModalOpen(true),
}} }}
> />
No tasks scheduled for this date
</Typography>
) : ( ) : (
<ChoreListView <ChoreListView
chores={getChoresForDate(selectedCalendarDate)} chores={getChoresForDate(selectedCalendarDate)}

View File

@@ -1,6 +1,7 @@
import { BarChart, Person } from '@mui/icons-material' import { BarChart, Person } from '@mui/icons-material'
import { Avatar, Box, Sheet, Typography } from '@mui/joy' import { Avatar, Box, Sheet, Typography } from '@mui/joy'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import EmptyState from '../../components/common/EmptyState'
import { useCircleMembers } from '../../queries/UserQueries' import { useCircleMembers } from '../../queries/UserQueries'
import { TASK_COLOR } from '../../utils/Colors' import { TASK_COLOR } from '../../utils/Colors'
import { resolvePhotoURL } from '../../utils/Helpers' import { resolvePhotoURL } from '../../utils/Helpers'
@@ -127,10 +128,13 @@ const TasksByAssigneeCard = ({ chores = [] }) => {
mb: 1, mb: 1,
}} }}
> >
<Person sx={{ fontSize: 48, opacity: 0.3, mb: 1 }} /> <EmptyState
<Typography level='body-sm' color='neutral'> variant='no-results'
No assigned tasks found size='sm'
</Typography> icon={<Person />}
title='No one has tasks yet'
description='Assign a task to someone in your circle and their workload shows up here.'
/>
</Sheet> </Sheet>
) )
} }

View File

@@ -29,6 +29,7 @@ import {
StarBorder, StarBorder,
Task, Task,
} from '@mui/icons-material' } from '@mui/icons-material'
import EmptyState from '../../components/common/EmptyState'
import { useChores } from '../../queries/ChoreQueries' import { useChores } from '../../queries/ChoreQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries' import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import { getFilterCount, getFilterOverdueCount } from '../../utils/FilterEngine' import { getFilterCount, getFilterOverdueCount } from '../../utils/FilterEngine'
@@ -417,29 +418,17 @@ const FilterView = () => {
}} }}
> >
{savedFilters.length === 0 ? ( {savedFilters.length === 0 ? (
<Box <EmptyState
sx={{ fullHeight
p: 4, icon={<FilterAlt />}
textAlign: 'center', 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}> <SwipeableList type={ListType.IOS} fullSwipe={false}>
{savedFilters.map(filter => { {savedFilters.map(filter => {

View File

@@ -28,10 +28,11 @@ import {
} from '@mui/icons-material' } from '@mui/icons-material'
import DeleteIcon from '@mui/icons-material/Delete' import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit' 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 moment from 'moment'
import { useEffect, useMemo, useState } from 'react' 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 FilterBar from '../../components/common/FilterBar'
import { useLocalization } from '../../contexts/LocalizationContext' import { useLocalization } from '../../contexts/LocalizationContext'
import useConfirmationModal from '../../hooks/useConfirmationModal' import useConfirmationModal from '../../hooks/useConfirmationModal'
@@ -303,36 +304,14 @@ const ChoreHistory = () => {
} }
if (!choreHistory.length) { if (!choreHistory.length) {
return ( return (
<Container <Container maxWidth='md'>
maxWidth='md' <EmptyState
sx={{ fullHeight
textAlign: 'center', icon={<EventBusy />}
display: 'flex', title='No history yet'
// make sure the content is centered vertically: description='Every time this task gets completed or skipped, it lands here with who did it and when. Nothing has happened yet.'
alignItems: 'center', primaryAction={{ label: 'Back to tasks', to: '/chores' }}
justifyContent: 'center',
flexDirection: 'column',
height: '50vh',
}}
>
<EventBusy
sx={{
fontSize: '6rem',
// color: 'text.disabled',
mb: 1,
}}
/> />
<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> </Container>
) )
} }
@@ -441,27 +420,13 @@ const ChoreHistory = () => {
/> />
</Box> </Box>
{sortedHistory.length === 0 && activeFilterCount > 0 && ( {sortedHistory.length === 0 && activeFilterCount > 0 && (
<Box <EmptyState
sx={{ variant='no-results'
textAlign: 'center', icon={<FilterList />}
py: 6, title='No history matches these filters'
display: 'flex', description='There is history here, but none of it fits the filters that are currently on.'
flexDirection: 'column', primaryAction={{ label: 'Clear filters', onClick: clearAll }}
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>
)} )}
{sortedHistory.length > 0 && ( {sortedHistory.length > 0 && (

View File

@@ -21,7 +21,8 @@ import {
TrailingActions, TrailingActions,
} from '@meauxt/react-swipeable-list' } from '@meauxt/react-swipeable-list'
import '@meauxt/react-swipeable-list/dist/styles.css' 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 { useQueryClient } from '@tanstack/react-query'
import { useUserProfile } from '../../queries/UserQueries' import { useUserProfile } from '../../queries/UserQueries'
import { getTextColorFromBackgroundColor } from '../../utils/Colors' import { getTextColorFromBackgroundColor } from '../../utils/Colors'
@@ -258,19 +259,17 @@ const LabelView = () => {
}} }}
> >
{userLabels.length === 0 && ( {userLabels.length === 0 && (
<Box <EmptyState
sx={{ fullHeight
display: 'flex', icon={<Style />}
justifyContent: 'center', title='No labels yet'
alignItems: 'center', description='Labels group tasks across your circle, like "kitchen" or "bills", so you can filter down to them in one tap.'
flexDirection: 'column', primaryAction={{
height: '50vh', 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}> <SwipeableList type={ListType.IOS} fullSwipe={false}>
{userLabels.map(label => ( {userLabels.map(label => (

View File

@@ -8,6 +8,7 @@ import {
Typography, Typography,
} from '@mui/joy' } from '@mui/joy'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import EmptyState from '../../../components/common/EmptyState'
import ModalActions from '../../../components/common/ModalActions' import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal' import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { GetChoreAttachments } from '../../../utils/Fetcher' import { GetChoreAttachments } from '../../../utils/Fetcher'
@@ -95,12 +96,12 @@ function AttachmentBrowserModal({ choreId, isOpen, onClose }) {
<CircularProgress size='md' /> <CircularProgress size='md' />
</Box> </Box>
) : attachments.length === 0 ? ( ) : attachments.length === 0 ? (
<Typography <EmptyState
level='body-sm' size='sm'
sx={{ color: 'text.secondary', py: 2, textAlign: 'center' }} icon={<AttachFile />}
> title='No attachments'
No attachments found. description='Photos and files added to this task will show up here.'
</Typography> />
) : ( ) : (
<List sx={{ '--ListItem-paddingX': '0px' }}> <List sx={{ '--ListItem-paddingX': '0px' }}>
{attachments.map((attachment, index) => ( {attachments.map((attachment, index) => (

View File

@@ -2,6 +2,7 @@ import {
Analytics, Analytics,
BarChart, BarChart,
CallReceived, CallReceived,
CloudOff,
EventBusy, EventBusy,
Schedule, Schedule,
Speed, Speed,
@@ -25,7 +26,7 @@ import {
} from '@mui/joy' } from '@mui/joy'
import { useTheme } from '@mui/joy/styles' import { useTheme } from '@mui/joy/styles'
import moment from 'moment' import moment from 'moment'
import { Link, useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { useLocalization } from '../../contexts/LocalizationContext' import { useLocalization } from '../../contexts/LocalizationContext'
import { import {
Line, Line,
@@ -35,6 +36,7 @@ import {
XAxis, XAxis,
YAxis, YAxis,
} from 'recharts' } from 'recharts'
import EmptyState from '../../components/common/EmptyState'
import { useThingHistory } from '../../queries/ThingQueries' import { useThingHistory } from '../../queries/ThingQueries'
import LoadingComponent from '../components/Loading' import LoadingComponent from '../components/Loading'
@@ -49,6 +51,7 @@ const ThingsHistory = () => {
fetchNextPage, fetchNextPage,
hasNextPage, hasNextPage,
isFetchingNextPage, isFetchingNextPage,
refetch,
} = useThingHistory(id) } = useThingHistory(id)
// Flatten all pages of history data // Flatten all pages of history data
@@ -152,35 +155,23 @@ const ThingsHistory = () => {
if (error || !thingsHistory || thingsHistory.length === 0) { if (error || !thingsHistory || thingsHistory.length === 0) {
return ( return (
<Container <Container maxWidth='md'>
maxWidth='md' <EmptyState
sx={{ variant={error ? 'error' : 'empty'}
textAlign: 'center', fullHeight
display: 'flex', icon={error ? <CloudOff /> : <EventBusy />}
// make sure the content is centered vertically: title={error ? "Couldn't load this history" : 'No history yet'}
alignItems: 'center', description={
justifyContent: 'center', error
flexDirection: 'column', ? 'We could not reach the server. Check your connection and try again.'
height: '50vh', : "Each time this thing's value changes, the change is recorded here."
}} }
> primaryAction={
<EventBusy error
sx={{ ? { label: 'Try again', onClick: () => refetch() }
fontSize: '6rem', : { label: 'Back to things', to: '/things' }
// color: 'text.disabled', }
mb: 1,
}}
/> />
<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> </Container>
) )
} }

View File

@@ -28,6 +28,7 @@ import {
} from '@mui/joy' } from '@mui/joy'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import EmptyState from '../../components/common/EmptyState'
import { useNotification } from '../../service/NotificationProvider' import { useNotification } from '../../service/NotificationProvider'
import { import {
CreateThing, CreateThing,
@@ -405,25 +406,20 @@ const ThingsView = () => {
}} }}
> >
{things.length === 0 && ( {things.length === 0 && (
<Box <EmptyState
sx={{ fullHeight
display: 'flex', icon={<Widgets />}
justifyContent: 'center', title='No things yet'
alignItems: 'center', description='A thing tracks a value, like a counter or a switch, that other tasks can react to. Create one to trigger tasks automatically.'
flexDirection: 'column', primaryAction={{
height: '50vh', 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}> <SwipeableList type={ListType.IOS} fullSwipe={false}>
{things.map(thing => ( {things.map(thing => (

View File

@@ -38,6 +38,7 @@ import {
import moment from 'moment' import moment from 'moment'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import EmptyState from '../../components/common/EmptyState'
import { useLocalization } from '../../contexts/LocalizationContext' import { useLocalization } from '../../contexts/LocalizationContext'
import { import {
useChoreTimer, useChoreTimer,
@@ -1204,9 +1205,12 @@ const TimerDetails = () => {
)} )}
{(!timerData.pauseLog || timerData.pauseLog.length === 0) && ( {(!timerData.pauseLog || timerData.pauseLog.length === 0) && (
<Alert color='neutral'> <EmptyState
No work sessions found for this timer. size='sm'
</Alert> icon={<AccessTime />}
title='No work sessions yet'
description='Start the timer on this task and each session lands here.'
/>
)} )}
</Box> </Box>
) : ( ) : (

View File

@@ -20,17 +20,16 @@ import {
import { import {
Avatar, Avatar,
Box, Box,
Button,
Card, Card,
Chip, Chip,
Container, Container,
Divider, Divider,
Grid, Grid,
Link,
Stack, Stack,
Typography, Typography,
} from '@mui/joy' } from '@mui/joy'
import React, { useEffect, useMemo, useState } from 'react' import React, { useEffect, useMemo, useState } from 'react'
import EmptyState from '../../components/common/EmptyState'
import FilterBar from '../../components/common/FilterBar' import FilterBar from '../../components/common/FilterBar'
import { useFilter } from '../../hooks/useFilter' import { useFilter } from '../../hooks/useFilter'
@@ -992,54 +991,21 @@ const UserActivites = () => {
{/* Conditional Content Based on Data Availability */} {/* Conditional Content Based on Data Availability */}
{!choresData.res?.length > 0 || !choresHistory?.length > 0 ? ( {!choresData.res?.length > 0 || !choresHistory?.length > 0 ? (
<Container <EmptyState
maxWidth='md' variant='no-results'
sx={{ fullHeight
textAlign: 'center', icon={<EventBusy />}
display: 'flex', title='No activity in this range'
alignItems: 'center', description={`Nothing was completed by ${
justifyContent: 'center', selectedUser === undefined || selectedUser === 'all'
flexDirection: 'column', ? 'anyone in your circle'
height: '50vh', : circleUsers.find(user => user.userId === selectedUser)
}} ?.displayName || 'this member'
> } ${
<EventBusy tabValue === 365 ? 'so far' : `in the last ${tabValue} days`
sx={{ }. Try a wider time range or a different member.`}
fontSize: '6rem', primaryAction={{ label: 'Back to tasks', to: '/chores' }}
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>
) : ( ) : (
<> <>
{/* Main Content Area - Mobile: Stack vertically, Desktop: Side by side */} {/* Main Content Area - Mobile: Stack vertically, Desktop: Side by side */}

View File

@@ -1,61 +1,23 @@
import { HomeRounded, Login } from '@mui/icons-material' import { Explore, HomeRounded } from '@mui/icons-material'
import { Box, Button, CircularProgress, Container } from '@mui/joy' import { Container } from '@mui/joy'
import { Typography } from '@mui/material' import EmptyState from '../../components/common/EmptyState'
import { Link } from 'react-router-dom' // Assuming you are using React Router
import Logo from '../../Logo'
const NotFound = () => { const NotFound = () => {
return ( return (
<Container className='flex h-full items-center justify-center'> <Container maxWidth='sm'>
<Box <EmptyState
className='flex flex-col items-center justify-center' variant='no-results'
sx={{ fullHeight
minHeight: '80vh', 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 />,
}} }}
> secondaryAction={{ label: 'Log in', to: '/login' }}
<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>
</Container> </Container>
) )
} }