Rewrite the Swiping Logic and update the Project, Labelm Things,Chore and all swipe card/view

This commit is contained in:
Mo Tarbin
2026-02-07 23:26:06 -05:00
parent c9baf04505
commit 373096b339
13 changed files with 1816 additions and 3844 deletions

View File

@@ -79,6 +79,7 @@
"react-dom": "^18.2.0",
"react-easy-crop": "^5.4.2",
"react-router-dom": "^6.21.1",
"react-swipeable-list": "^1.10.0",
"react-transition-group": "^4.4.5",
"reactjs-social-login": "^2.6.3",
"recharts": "^2.15.0",

View File

@@ -32,6 +32,7 @@ import { DeleteChore, GetArchivedChores } from '../../utils/Fetcher'
import LoadingComponent from '../components/Loading'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import ChoreCard from './ChoreCard'
import ChoreListView from './ChoreListView.jsx'
import CompactChoreCard from './CompactChoreCard'
import MultiSelectHelp from './MultiSelectHelp'
@@ -851,9 +852,15 @@ const ArchivedTasks = () => {
</Typography>
<List sx={{ gap: viewMode === 'compact' ? 0 : 1 }}>
{filteredChores.map(chore =>
renderChoreCard(chore, `archived-${chore.id}`),
)}
<ChoreListView
chores={filteredChores}
// viewOnly={true}
showActions={false}
viewMode={viewMode}
membersData={membersData}
isMultiSelectMode={isMultiSelectMode}
selectedChores={selectedChores}
/>
</List>
</Box>
)}

View File

@@ -1,15 +1,10 @@
import {
Check,
Delete,
Edit,
Group,
HourglassEmpty,
Notifications,
Pause,
PlayArrow,
Repeat,
Schedule,
ThumbDown,
ThumbUp,
TimesOneMobiledata,
Toll,
@@ -26,13 +21,10 @@ import {
Typography,
} from '@mui/joy'
import moment from 'moment'
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useUserProfile } from '../../queries/UserQueries.jsx'
import { notInCompletionWindow } from '../../utils/Chores.jsx'
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
import { isOfficialDonetickInstanceSync } from '../../utils/FeatureToggle'
import Priorities from '../../utils/Priorities'
import ChoreActionMenu from '../components/ChoreActionMenu'
const ChoreCard = ({
@@ -48,40 +40,10 @@ const ChoreCard = ({
isSelected = false,
onSelectionToggle,
}) => {
const [isOfficialInstance, setIsOfficialInstance] = React.useState(false)
const navigate = useNavigate()
const { data: userProfile } = useUserProfile()
const { impersonatedUser } = useImpersonateUser()
// Swipe functionality state
const [swipeTranslateX, setSwipeTranslateX] = React.useState(0)
const [isDragging, setIsDragging] = React.useState(false)
const [isSwipeRevealed, setIsSwipeRevealed] = React.useState(false)
const [hoverTimer, setHoverTimer] = React.useState(null)
const [isTouchDevice, setIsTouchDevice] = React.useState(false)
const swipeThreshold = 80 // Minimum swipe distance to reveal actions
const maxSwipeDistance = 260 // Maximum swipe distance
const dragStartX = React.useRef(0)
const cardRef = React.useRef(null)
// Detect if device supports touch
React.useEffect(() => {
const checkTouchDevice = () => {
setIsTouchDevice('ontouchstart' in window || navigator.maxTouchPoints > 0)
}
checkTouchDevice()
// Check if this is the official donetick.com instance
try {
setIsOfficialInstance(isOfficialDonetickInstanceSync())
} catch (error) {
console.warn('Error checking instance type:', error)
setIsOfficialInstance(false)
}
}, [])
// Check if the current user can approve/reject (admin, manager, or task owner)
const canApproveReject = () => {
if (!performers || !chore) return false
@@ -100,177 +62,6 @@ const ChoreCard = ({
)
}
// Swipe gesture handlers
const handleTouchStart = e => {
if (isMultiSelectMode || viewOnly) return
dragStartX.current = e.touches[0].clientX
setIsDragging(true)
}
const handleTouchMove = e => {
if (isMultiSelectMode || viewOnly || !isDragging) return
const currentX = e.touches[0].clientX
const deltaX = currentX - dragStartX.current
if (isSwipeRevealed) {
// When actions are revealed, allow right swipe to hide
if (deltaX > 0) {
const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0)
setSwipeTranslateX(clampedDelta)
}
} else {
// When actions are hidden, allow left swipe to reveal
if (deltaX < 0) {
const clampedDelta = Math.max(deltaX, -maxSwipeDistance)
setSwipeTranslateX(clampedDelta)
}
}
}
const handleTouchEnd = () => {
if (isMultiSelectMode || viewOnly || !isDragging) return
setIsDragging(false)
if (isSwipeRevealed) {
// When actions are revealed, check if user swiped right enough to hide
if (swipeTranslateX > -swipeThreshold) {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
} else {
// Snap back to revealed position
setSwipeTranslateX(-maxSwipeDistance)
}
} else {
// When actions are hidden, check if user swiped left enough to reveal
if (Math.abs(swipeTranslateX) > swipeThreshold) {
setSwipeTranslateX(-maxSwipeDistance)
setIsSwipeRevealed(true)
} else {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
}
}
}
const handleMouseDown = e => {
if (isMultiSelectMode || viewOnly) return
dragStartX.current = e.clientX
setIsDragging(true)
}
const handleMouseMove = e => {
if (isMultiSelectMode || viewOnly || !isDragging) return
const currentX = e.clientX
const deltaX = currentX - dragStartX.current
if (isSwipeRevealed) {
// When actions are revealed, allow right swipe to hide
if (deltaX > 0) {
const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0)
setSwipeTranslateX(clampedDelta)
}
} else {
// When actions are hidden, allow left swipe to reveal
if (deltaX < 0) {
const clampedDelta = Math.max(deltaX, -maxSwipeDistance)
setSwipeTranslateX(clampedDelta)
}
}
}
const handleMouseUp = () => {
if (isMultiSelectMode || viewOnly || !isDragging) return
setIsDragging(false)
if (isSwipeRevealed) {
// When actions are revealed, check if user swiped right enough to hide
if (swipeTranslateX > -swipeThreshold) {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
} else {
// Snap back to revealed position
setSwipeTranslateX(-maxSwipeDistance)
}
} else {
// When actions are hidden, check if user swiped left enough to reveal
if (Math.abs(swipeTranslateX) > swipeThreshold) {
setSwipeTranslateX(-maxSwipeDistance)
setIsSwipeRevealed(true)
} else {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
}
}
}
const resetSwipe = () => {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
}
// Hover functionality for desktop - only trigger from action menu
const handleMouseEnter = () => {
if (isMultiSelectMode || viewOnly || isSwipeRevealed || isTouchDevice)
return
const timer = setTimeout(() => {
setSwipeTranslateX(-maxSwipeDistance)
setIsSwipeRevealed(true)
setHoverTimer(null)
}, 1500) // Match CompactChoreCard delay
setHoverTimer(timer)
}
const handleMouseLeave = () => {
if (isTouchDevice) return
if (hoverTimer) {
clearTimeout(hoverTimer)
setHoverTimer(null)
}
// Add a small delay before hiding to allow moving to action area
if (isSwipeRevealed) {
const hideTimer = setTimeout(() => {
resetSwipe()
}, 300) // Match CompactChoreCard delay
setHoverTimer(hideTimer)
}
}
const handleActionAreaMouseEnter = () => {
if (isTouchDevice) return
// Clear any pending timer when entering action area (both show and hide timers)
if (hoverTimer) {
clearTimeout(hoverTimer)
setHoverTimer(null)
}
}
const handleActionAreaMouseLeave = () => {
if (isTouchDevice) return
// Hide immediately when leaving action area (like CompactChoreCard)
if (isSwipeRevealed) {
resetSwipe()
}
}
// Clean up timer on unmount
React.useEffect(() => {
return () => {
if (hoverTimer) {
clearTimeout(hoverTimer)
}
}
}, [hoverTimer])
const getDueDateChipText = nextDueDate => {
if (chore.nextDueDate === null) return 'No Due Date'
// if due in next 48 hours, we should it in this format : Tomorrow 11:00 AM
@@ -416,7 +207,7 @@ const ChoreCard = ({
return name
}
return (
<Box key={chore.id + '-box'}>
<Box key={chore.id + '-box'} minWidth={'100%'}>
<Chip
variant='soft'
sx={{
@@ -458,187 +249,8 @@ const ChoreCard = ({
overflow: 'hidden',
borderRadius: 20,
}}
onMouseLeave={handleMouseLeave}
>
{/* Action buttons underneath (revealed on swipe) */}
<Box
sx={{
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
width: maxSwipeDistance,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: 'inset 2px 0 4px rgba(0,0,0,0.06)',
zIndex: 0,
borderTopRightRadius: 20,
borderBottomRightRadius: 20,
}}
onMouseEnter={handleActionAreaMouseEnter}
onMouseLeave={handleActionAreaMouseLeave}
>
{chore.status === 3 ? (
// Pending approval: Show approve/reject for admins/managers/owners
canApproveReject() ? (
<>
{/* <IconButton
variant='soft'
color='success'
size='md'
onClick={e => {
e.stopPropagation()
resetSwipe()
onAction('approve', chore)
}}
sx={{
width: 40,
height: 40,
mx: 1,
}}
>
<ThumbUp sx={{ fontSize: 20 }} />
</IconButton> */}
<IconButton
variant='soft'
color='danger'
size='md'
onClick={e => {
e.stopPropagation()
resetSwipe()
onAction('reject', chore)
}}
sx={{
width: 40,
height: 40,
mx: 1,
}}
>
<ThumbDown sx={{ fontSize: 20 }} />
</IconButton>
</>
) : (
<IconButton
variant='soft'
color='neutral'
size='md'
disabled={true}
sx={{
width: 40,
height: 40,
mx: 1,
}}
>
<HourglassEmpty sx={{ fontSize: 20 }} />
</IconButton>
)
) : (
<IconButton
variant='soft'
color='success'
size='md'
onClick={e => {
e.stopPropagation()
resetSwipe()
if (chore.status !== 0) {
onAction('complete', chore)
} else {
onAction('start', chore)
}
}}
sx={{
width: 40,
height: 40,
mx: 1,
}}
>
{chore.status !== 0 ? (
<Check sx={{ fontSize: 20 }} />
) : (
<PlayArrow sx={{ fontSize: 20 }} />
)}
</IconButton>
)}
<IconButton
variant='soft'
color='warning'
size='md'
onClick={e => {
e.stopPropagation()
resetSwipe()
onAction('changeDueDate', chore)
}}
sx={{
width: 40,
height: 40,
mx: 1,
}}
>
<Schedule sx={{ fontSize: 20 }} />
</IconButton>
<IconButton
variant='soft'
color='neutral'
size='md'
onClick={e => {
e.stopPropagation()
resetSwipe()
navigate(`/chores/${chore.id}/edit`)
}}
sx={{
width: 40,
height: 40,
mx: 1,
}}
>
<Edit sx={{ fontSize: 20 }} />
</IconButton>
{isOfficialInstance && (
<IconButton
variant='soft'
color='warning'
size='md'
onClick={e => {
e.stopPropagation()
resetSwipe()
onAction('nudge', chore)
}}
sx={{
width: 40,
height: 40,
mx: 1,
}}
>
<Notifications sx={{ fontSize: 20 }} />
</IconButton>
)}
<IconButton
variant='soft'
color='danger'
size='md'
onClick={e => {
e.stopPropagation()
resetSwipe()
onAction('delete', chore)
}}
sx={{
width: 40,
height: 40,
mx: 1,
}}
>
<Delete sx={{ fontSize: 20 }} />
</IconButton>
</Box>
<Card
ref={cardRef}
style={viewOnly ? { pointerEvents: 'none' } : {}}
variant='plain'
sx={{
@@ -654,12 +266,9 @@ const ChoreCard = ({
backgroundColor: 'background.surface',
border: '1px solid',
borderColor: 'divider',
transform: `translateX(${swipeTranslateX}px)`,
transition: isDragging ? 'none' : 'transform 0.3s ease-out',
zIndex: 1,
cursor: isMultiSelectMode ? 'pointer' : 'default',
'&:hover': {
boxShadow: isSwipeRevealed ? 'sm' : 'md',
boxShadow: 'md',
borderColor: isMultiSelectMode ? 'primary.500' : 'primary.300',
},
// Add padding when in multi-select mode to account for checkbox
@@ -672,12 +281,6 @@ const ChoreCard = ({
boxShadow: 'sm',
}),
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
>
{/* Multi-select checkbox */}
{isMultiSelectMode && (
@@ -714,13 +317,13 @@ const ChoreCard = ({
<Grid
xs={9}
sx={{ cursor: 'pointer' }}
onClick={() => {
if (isMultiSelectMode) {
onSelectionToggle()
} else {
navigate(`/chores/${chore.id}`)
}
}}
// onClick={() => {
// if (isMultiSelectMode) {
// onSelectionToggle()
// } else {
// navigate(`/chores/${chore.id}`)
// }
// }}
>
{/* Box in top right with Chip showing next due date */}
<Box display='flex' justifyContent='start' alignItems='center'>
@@ -1007,14 +610,6 @@ const ChoreCard = ({
onWriteNFC={() => onAction('writeNFC', chore)}
onNudge={() => onAction('nudge', chore)}
onDelete={() => onAction('delete', chore)}
onMouseEnter={handleMouseEnter}
onOpen={() => {
// Clear any pending hide timer when menu opens
if (hoverTimer) {
clearTimeout(hoverTimer)
setHoverTimer(null)
}
}}
/>
</Box>
)}

View File

@@ -0,0 +1,270 @@
import {
Check,
Delete,
Edit,
HourglassEmpty,
Notifications,
PlayArrow,
Schedule,
ThumbDown,
} from '@mui/icons-material'
import { Box, Typography } from '@mui/joy'
import { useNavigate } from 'react-router-dom'
import {
Type as ListType,
SwipeableList,
SwipeableListItem,
SwipeAction,
TrailingActions,
} from 'react-swipeable-list'
import 'react-swipeable-list/dist/styles.css'
import ChoreCard from './ChoreCard'
import CompactChoreCard from './CompactChoreCard'
const ChoreListView = ({
chores,
viewMode,
membersData,
userLabels,
handleLabelFiltering,
handleChoreAction,
isMultiSelectMode,
selectedChores,
toggleChoreSelection,
userProfile,
isOfficialInstance,
toggleMultiSelectMode,
showActions = true,
}) => {
const navigate = useNavigate()
const renderChoreCard = (chore, key) => {
const CardComponent = viewMode === 'compact' ? CompactChoreCard : ChoreCard
return (
<CardComponent
key={key || chore.id}
chore={chore}
performers={membersData?.res}
userLabels={userLabels}
onChipClick={handleLabelFiltering}
onAction={handleChoreAction}
isMultiSelectMode={isMultiSelectMode}
isSelected={selectedChores.has(chore.id)}
onSelectionToggle={() => toggleChoreSelection(chore.id)}
showActions={showActions}
/>
)
}
const canApproveReject = chore => {
return userProfile?.role === 1 || chore.createdBy === userProfile?.id
}
const getTrailingActions = chore => {
if (isMultiSelectMode) return null
if (!showActions) return null
const isCompact = viewMode === 'compact'
return (
<TrailingActions>
<Box
sx={{
display: 'flex',
// boxShadow: 'inset 2px 0 4px rgba(0,0,0,0.06)',
zIndex: 0,
// Offset for the floating chips above ChoreCard so swipe actions
// align with the card body only
...(!isCompact && {
mt: '28px',
borderRadius: '8px',
}),
}}
>
{chore.status === 3 ? (
canApproveReject(chore) ? (
<SwipeAction onClick={() => handleChoreAction('reject', chore)}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'danger.softBg',
color: 'danger.600',
px: 3,
height: '100%',
}}
>
<ThumbDown sx={{ fontSize: 20 }} />
<Typography level='body-xs' sx={{ mt: 0.5 }}>
Reject
</Typography>
</Box>
</SwipeAction>
) : (
<SwipeAction onClick={() => {}}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'var(--joy-palette-neutral-100)',
px: 3,
height: '100%',
opacity: 0.5,
}}
>
<HourglassEmpty sx={{ fontSize: 20 }} />
<Typography level='body-xs' sx={{ mt: 0.5 }}>
Pending
</Typography>
</Box>
</SwipeAction>
)
) : (
<SwipeAction
onClick={() => {
if (chore.status === 0 || chore.status === 2) {
handleChoreAction('start', chore)
} else {
handleChoreAction('complete', chore)
}
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'success.softBg',
color: 'success.600',
px: 3,
height: '100%',
}}
>
{chore.status !== 1 ? (
<PlayArrow sx={{ fontSize: 20 }} />
) : (
<Check sx={{ fontSize: 20 }} />
)}
<Typography level='body-xs' sx={{ mt: 0.5 }}>
{chore.status !== 1 ? 'Start' : 'Complete'}
</Typography>
</Box>
</SwipeAction>
)}
<SwipeAction
onClick={() => handleChoreAction('changeDueDate', chore)}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'warning.softBg',
color: 'warning.600',
px: 3,
height: '100%',
}}
>
<Schedule sx={{ fontSize: 20 }} />
<Typography level='body-xs' sx={{ mt: 0.5 }}>
Schedule
</Typography>
</Box>
</SwipeAction>
<SwipeAction onClick={() => navigate(`/chores/${chore.id}/edit`)}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'var(--joy-palette-neutral-100)',
px: 3,
height: '100%',
}}
>
<Edit sx={{ fontSize: 20 }} />
<Typography level='body-xs' sx={{ mt: 0.5 }}>
Edit
</Typography>
</Box>
</SwipeAction>
{isOfficialInstance && (
<SwipeAction onClick={() => handleChoreAction('nudge', chore)}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'warning.softBg',
color: 'warning.600',
px: 3,
height: '100%',
}}
>
<Notifications sx={{ fontSize: 20 }} />
<Typography level='body-xs' sx={{ mt: 0.5 }}>
Nudge
</Typography>
</Box>
</SwipeAction>
)}
<SwipeAction onClick={() => handleChoreAction('delete', chore)}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'danger.softBg',
color: 'danger.600',
px: 3,
height: '100%',
}}
>
<Delete sx={{ fontSize: 20 }} />
<Typography level='body-xs' sx={{ mt: 0.5 }}>
Delete
</Typography>
</Box>
</SwipeAction>
</Box>
</TrailingActions>
)
}
const renderChores = chores => {
return (
<SwipeableList type={ListType.IOS} fullSwipe={false}>
{chores.map(chore => (
<SwipeableListItem
key={chore.id}
trailingActions={getTrailingActions(chore)}
onClick={() => {
if (isMultiSelectMode) {
toggleChoreSelection(chore.id)
} else {
navigate(`/chores/${chore.id}`)
}
}}
>
{renderChoreCard(chore)}
</SwipeableListItem>
))}
</SwipeableList>
)
}
return <>{renderChores(chores)}</>
}
export default ChoreListView

View File

@@ -1,21 +1,15 @@
import {
Check,
Delete,
Edit,
HourglassEmpty,
Notifications,
Pause,
PlayArrow,
Repeat,
Schedule,
ThumbDown,
ThumbUp,
TimesOneMobiledata,
Webhook,
} from '@mui/icons-material'
import { Box, Checkbox, Chip, IconButton, Typography } from '@mui/joy'
import moment from 'moment'
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
@@ -24,7 +18,6 @@ import {
getPriorityColor,
getTextColorFromBackgroundColor,
} from '../../utils/Colors.jsx'
import { isOfficialDonetickInstanceSync } from '../../utils/FeatureToggle'
import ChoreActionMenu from '../components/ChoreActionMenu'
const CompactChoreCard = ({
@@ -39,8 +32,8 @@ const CompactChoreCard = ({
isMultiSelectMode = false,
isSelected = false,
onSelectionToggle,
onlyClickable = false,
}) => {
const [isOfficialInstance, setIsOfficialInstance] = React.useState(false)
const navigate = useNavigate()
const { data: userProfile } = useUserProfile()
@@ -48,204 +41,6 @@ const CompactChoreCard = ({
const { impersonatedUser } = useImpersonateUser()
// Swipe functionality state
const [swipeTranslateX, setSwipeTranslateX] = React.useState(0)
const [isDragging, setIsDragging] = React.useState(false)
const [isSwipeRevealed, setIsSwipeRevealed] = React.useState(false)
const [hoverTimer, setHoverTimer] = React.useState(null)
const [isTouchDevice, setIsTouchDevice] = React.useState(false)
const swipeThreshold = 80 // Minimum swipe distance to reveal actions
const maxSwipeDistance = 260 // Maximum swipe distance
const dragStartX = React.useRef(0)
const cardRef = React.useRef(null)
// Detect if device supports touch
React.useEffect(() => {
const checkTouchDevice = () => {
setIsTouchDevice('ontouchstart' in window || navigator.maxTouchPoints > 0)
}
checkTouchDevice()
// Check if this is the official donetick.com instance
try {
setIsOfficialInstance(isOfficialDonetickInstanceSync())
} catch (error) {
console.warn('Error checking instance type:', error)
setIsOfficialInstance(false)
}
}, [])
// Swipe gesture handlers
const handleTouchStart = e => {
if (isMultiSelectMode || viewOnly) return
dragStartX.current = e.touches[0].clientX
setIsDragging(true)
}
const handleTouchMove = e => {
if (isMultiSelectMode || viewOnly || !isDragging) return
const currentX = e.touches[0].clientX
const deltaX = currentX - dragStartX.current
if (isSwipeRevealed) {
// When actions are revealed, allow right swipe to hide
if (deltaX > 0) {
const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0)
setSwipeTranslateX(clampedDelta)
}
} else {
// When actions are hidden, allow left swipe to reveal
if (deltaX < 0) {
const clampedDelta = Math.max(deltaX, -maxSwipeDistance)
setSwipeTranslateX(clampedDelta)
}
}
}
const handleTouchEnd = () => {
if (isMultiSelectMode || viewOnly || !isDragging) return
setIsDragging(false)
if (isSwipeRevealed) {
// When actions are revealed, check if user swiped right enough to hide
if (swipeTranslateX > -swipeThreshold) {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
} else {
// Snap back to revealed position
setSwipeTranslateX(-maxSwipeDistance)
}
} else {
// When actions are hidden, check if user swiped left enough to reveal
if (Math.abs(swipeTranslateX) > swipeThreshold) {
setSwipeTranslateX(-maxSwipeDistance)
setIsSwipeRevealed(true)
} else {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
}
}
}
const handleMouseDown = e => {
if (isMultiSelectMode || viewOnly) return
dragStartX.current = e.clientX
setIsDragging(true)
}
const handleMouseMove = e => {
if (isMultiSelectMode || viewOnly || !isDragging) return
const currentX = e.clientX
const deltaX = currentX - dragStartX.current
if (isSwipeRevealed) {
// When actions are revealed, allow right swipe to hide
if (deltaX > 0) {
const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0)
setSwipeTranslateX(clampedDelta)
}
} else {
// When actions are hidden, allow left swipe to reveal
if (deltaX < 0) {
const clampedDelta = Math.max(deltaX, -maxSwipeDistance)
setSwipeTranslateX(clampedDelta)
}
}
}
const handleMouseUp = () => {
if (isMultiSelectMode || viewOnly || !isDragging) return
setIsDragging(false)
if (isSwipeRevealed) {
// When actions are revealed, check if user swiped right enough to hide
if (swipeTranslateX > -swipeThreshold) {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
} else {
// Snap back to revealed position
setSwipeTranslateX(-maxSwipeDistance)
}
} else {
// When actions are hidden, check if user swiped left enough to reveal
if (Math.abs(swipeTranslateX) > swipeThreshold) {
setSwipeTranslateX(-maxSwipeDistance)
setIsSwipeRevealed(true)
} else {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
}
}
}
const resetSwipe = () => {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
}
// Hover functionality for desktop
const handleMouseEnter = () => {
if (isMultiSelectMode || viewOnly || isSwipeRevealed || isTouchDevice)
return
const timer = setTimeout(() => {
setSwipeTranslateX(-maxSwipeDistance)
setIsSwipeRevealed(true)
setHoverTimer(null)
}, 1500)
setHoverTimer(timer)
}
const handleMouseLeave = () => {
if (isTouchDevice) return
if (hoverTimer) {
clearTimeout(hoverTimer)
setHoverTimer(null)
}
// Add a small delay before hiding to allow moving to action area
if (isSwipeRevealed) {
const hideTimer = setTimeout(() => {
resetSwipe()
}, 300)
setHoverTimer(hideTimer)
}
}
const handleActionAreaMouseEnter = () => {
if (isTouchDevice) return
// Clear any pending timer when entering action area (both show and hide timers)
if (hoverTimer) {
clearTimeout(hoverTimer)
setHoverTimer(null)
}
}
const handleActionAreaMouseLeave = () => {
if (isTouchDevice) return
// Hide immediately when leaving action area
if (isSwipeRevealed) {
resetSwipe()
}
}
// Clean up timer on unmount
React.useEffect(() => {
return () => {
if (hoverTimer) {
clearTimeout(hoverTimer)
}
}
}, [hoverTimer])
// Check if the current user can approve/reject (admin, manager, or task owner)
const canApproveReject = () => {
if (!circleMembersData?.res || !chore) return false
@@ -429,222 +224,26 @@ const CompactChoreCard = ({
}
return (
<Box key={chore.id + '-compact-box'}>
<Box
sx={{
position: 'relative',
overflow: 'hidden',
borderBottom: '1px solid',
borderColor: 'divider',
'&:last-child': {
borderBottom: 'none',
},
}}
onMouseLeave={handleMouseLeave}
>
{/* Action buttons underneath (revealed on swipe) */}
<Box
sx={{
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
width: maxSwipeDistance,
display: 'flex',
alignItems: 'center',
// soft background color for the swipe area
// bgcolor: 'background.backdrop',
boxShadow: 'inset 2px 0 4px rgba(0,0,0,0.06)',
zIndex: 0,
}}
onMouseEnter={handleActionAreaMouseEnter}
onMouseLeave={handleActionAreaMouseLeave}
>
{chore.status === 3 ? (
// Pending approval: Show approve/reject for admins/managers/owners
canApproveReject() ? (
<>
{/* <IconButton
variant='soft'
color='success'
size='sm'
onClick={handleApproveChore}
sx={{
width: 40,
height: 40,
mx: 1,
}}
>
<ThumbUp sx={{ fontSize: 16 }} />
</IconButton> */}
<IconButton
variant='soft'
color='danger'
size='sm'
onClick={e => {
e.stopPropagation()
resetSwipe()
onAction('reject', chore)
}}
sx={{
width: 40,
height: 40,
mx: 1,
}}
>
<ThumbDown sx={{ fontSize: 16 }} />
</IconButton>
</>
) : (
<IconButton
variant='soft'
color='neutral'
size='sm'
disabled={true}
sx={{
width: 40,
height: 40,
mx: 1,
}}
>
<HourglassEmpty sx={{ fontSize: 16 }} />
</IconButton>
)
) : (
<IconButton
variant='soft'
color='success'
size='sm'
onClick={e => {
e.stopPropagation()
resetSwipe()
if (chore.status === 0 || chore.status === 2) {
onAction('start', chore)
} else {
onAction('complete', chore)
}
}}
sx={{
width: 40,
height: 40,
mx: 1,
}}
>
{chore.status !== 1 ? (
<PlayArrow sx={{ fontSize: 16 }} />
) : (
<Check sx={{ fontSize: 16 }} />
)}
</IconButton>
)}
<IconButton
variant='soft'
color='warning'
size='sm'
onClick={e => {
e.stopPropagation()
resetSwipe()
onAction('changeDueDate', chore)
}}
sx={{
width: 40,
height: 40,
mx: 1,
// bgcolor: 'warning.100',
// color: 'warning.600',
// '&:hover': {
// bgcolor: 'warning.200',
// },
}}
>
<Schedule sx={{ fontSize: 16 }} />
</IconButton>
<IconButton
variant='soft'
color='neutral'
size='sm'
onClick={e => {
e.stopPropagation()
resetSwipe()
navigate(`/chores/${chore.id}/edit`)
}}
sx={{
width: 40,
height: 40,
mx: 1,
// bgcolor: 'neutral.100',
// color: 'neutral.600',
// '&:hover': {
// bgcolor: 'neutral.200',
// },
}}
>
<Edit sx={{ fontSize: 16 }} />
</IconButton>
{isOfficialInstance && (
<IconButton
variant='soft'
color='warning'
size='sm'
onClick={e => {
e.stopPropagation()
resetSwipe()
onAction('nudge', chore)
}}
sx={{
width: 40,
height: 40,
mx: 1,
}}
>
<Notifications sx={{ fontSize: 16 }} />
</IconButton>
)}
<IconButton
variant='soft'
color='danger'
size='sm'
onClick={e => {
e.stopPropagation()
resetSwipe()
onAction('delete', chore)
}}
sx={{
width: 40,
height: 40,
mx: 1,
}}
>
<Delete sx={{ fontSize: 16 }} />
</IconButton>
</Box>
{/* Main card content */}
<Box
ref={cardRef}
style={viewOnly ? { pointerEvents: 'none' } : {}}
sx={{
...sx,
display: 'flex',
alignItems: 'center',
minHeight: 56,
minWidth: '100%',
cursor: 'pointer',
position: 'relative',
pl: '16px',
bgcolor: 'background.body',
transform: `translateX(${swipeTranslateX}px)`,
transition: isDragging ? 'none' : 'transform 0.3s ease-out',
zIndex: 1,
borderBottom: '1px solid',
borderColor: 'divider',
'&:last-child': {
borderBottom: 'none',
},
'&:hover': {
bgcolor: isSwipeRevealed
? 'background.surface'
: 'background.level1',
boxShadow: isSwipeRevealed ? 'none' : 'sm',
bgcolor: 'background.level1',
boxShadow: 'sm',
},
'&::before': {
content: '""',
@@ -657,24 +256,6 @@ const CompactChoreCard = ({
borderRadius: '16px',
},
}}
onClick={() => {
if (isSwipeRevealed) {
resetSwipe()
return
}
if (isMultiSelectMode) {
onSelectionToggle()
} else {
navigate(`/chores/${chore.id}`)
}
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
// onMouseEnter={handleMouseEnter}
>
{/* Priority bar clickable area */}
{chore.priority > 0 && (
@@ -852,8 +433,7 @@ const CompactChoreCard = ({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition:
'opacity 0.3s ease-in-out, transform 0.3s ease-in-out',
transition: 'opacity 0.3s ease-in-out, transform 0.3s ease-in-out',
opacity: isMultiSelectMode ? 1 : 0,
transform: isMultiSelectMode
? 'scale(1) rotate(0deg)'
@@ -1031,8 +611,6 @@ const CompactChoreCard = ({
onWriteNFC={() => onAction('writeNFC', chore)}
onNudge={() => onAction('nudge', chore)}
onDelete={() => onAction('delete', chore)}
onMouseEnter={handleMouseEnter}
// onMouseLeave={handleMouseLeave}
sx={{
width: 32,
height: 32,
@@ -1043,15 +621,10 @@ const CompactChoreCard = ({
bgcolor: 'background.level1',
},
}}
onOpen={() => {
handleMouseLeave()
}}
/>
)}
</Box>
</Box>
</Box>
</Box>
)
}

View File

@@ -38,8 +38,6 @@ import Priorities from '../../utils/Priorities'
import LoadingComponent from '../components/Loading'
import { useLabels } from '../Labels/LabelQueries'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import ChoreCard from './ChoreCard'
import CompactChoreCard from './CompactChoreCard'
import IconButtonWithMenu from './IconButtonWithMenu'
import { useMediaQuery } from '@mui/material'
@@ -60,6 +58,7 @@ import CalendarMonthly from '../components/CalendarMonthly.jsx'
import ProjectSelector from '../components/ProjectSelector'
import AdvancedFilterBuilder from '../Modals/Inputs/AdvancedFilterBuilder'
import { useProjects } from '../Projects/ProjectQueries.js'
import ChoreListView from './ChoreListView.jsx'
import ChoreModals from './components/ChoreModals'
import FilterSection from './components/FilterSection'
import MultiSelectToolbar from './components/MultiSelectToolbar'
@@ -633,22 +632,60 @@ const MyChores = () => {
}
}
const renderChoreCard = (chore, key) => {
const CardComponent = viewMode === 'compact' ? CompactChoreCard : ChoreCard
return (
<CardComponent
key={key || chore.id}
chore={chore}
performers={membersData?.res}
userLabels={userLabels}
onChipClick={handleLabelFiltering}
onAction={handleChoreAction}
isMultiSelectMode={isMultiSelectMode}
isSelected={selectedChores.has(chore.id)}
onSelectionToggle={() => toggleChoreSelection(chore.id)}
/>
)
}
// const renderChoreCard = (chore, key) => {
// const CardComponent = viewMode === 'compact' ? CompactChoreCard : ChoreCard
// return (
// <CardComponent
// key={key || chore.id}
// chore={chore}
// performers={membersData?.res}
// userLabels={userLabels}
// onChipClick={handleLabelFiltering}
// onAction={handleChoreAction}
// isMultiSelectMode={isMultiSelectMode}
// isSelected={selectedChores.has(chore.id)}
// onSelectionToggle={() => toggleChoreSelection(chore.id)}
// />
// )
// }
// const renderChores = chores => {
// return (
// <SwipeableList>
// {chores.map(chore => (
// <SwipeableListItem
// key={chore.id}
// trailingActions={
// <TrailingActions>
// <SwipeAction>
// <Button
// variant='solid'
// color='primary'
// size='sm'
// startIcon={<Add />}
// onClick={() => setAddTaskModalOpen(true)}
// >
// Add Task
// </Button>
// </SwipeAction>
// </TrailingActions>
// }
// >
// {/* <Button
// variant='outlined'
// color='neutral'
// size='sm'
// startIcon={<EditCalendar />}
// onClick={() => setViewMode('calendar')}
// >
// View Calendar
// </Button> */}
// {renderChoreCard(chore)}
// {/* {chores.map(chore => renderChoreCard(chore))} */}
// </SwipeableListItem>
// ))}
// </SwipeableList>
// )
// }
const getFilteredChores = useMemo(() => {
if (activeFilterId || tempFilter) {
@@ -1141,9 +1178,18 @@ const MyChores = () => {
</Box>
)}
{(searchTerm?.length > 0 || searchFilter !== 'All' || activeFilterId) &&
viewMode !== 'calendar' &&
getFilteredChores.map(chore =>
renderChoreCard(chore, `filtered-${chore.id}`),
viewMode !== 'calendar' && (
<ChoreListView
chores={getFilteredChores}
viewMode={viewMode}
membersData={membersData}
userLabels={userLabels}
handleLabelFiltering={handleLabelFiltering}
handleChoreAction={handleChoreAction}
isMultiSelectMode={isMultiSelectMode}
selectedChores={selectedChores}
toggleChoreSelection={toggleChoreSelection}
/>
)}
{viewMode === 'calendar' && (
<>
@@ -1310,20 +1356,17 @@ const MyChores = () => {
No tasks scheduled for this date
</Typography>
) : (
getChoresForDate(selectedCalendarDate).map(chore => (
<CompactChoreCard
key={`calendar-${chore.id}`}
chore={chore}
performers={membersData?.res || []}
<ChoreListView
chores={getChoresForDate(selectedCalendarDate)}
viewMode={'compact'}
membersData={membersData}
userLabels={userLabels}
onChipClick={handleLabelFiltering}
onAction={handleChoreAction}
// Multi-select props
handleLabelFiltering={handleLabelFiltering}
handleChoreAction={handleChoreAction}
isMultiSelectMode={isMultiSelectMode}
isSelected={selectedChores.has(chore.id)}
onSelectionToggle={() => toggleChoreSelection(chore.id)}
selectedChores={selectedChores}
toggleChoreSelection={toggleChoreSelection}
/>
))
)}
</Box>
</Box>
@@ -1396,7 +1439,17 @@ const MyChores = () => {
},
}}
>
{section.content?.map(chore => renderChoreCard(chore))}
<ChoreListView
chores={section.content}
viewMode={viewMode}
membersData={membersData}
userLabels={userLabels}
handleLabelFiltering={handleLabelFiltering}
handleChoreAction={handleChoreAction}
isMultiSelectMode={isMultiSelectMode}
selectedChores={selectedChores}
toggleChoreSelection={toggleChoreSelection}
/>
</AccordionDetails>
</Accordion>
)

View File

@@ -10,17 +10,18 @@ import {
Stack,
Typography,
} from '@mui/joy'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Add,
FilterAlt,
MoreVert,
Star,
StarBorder,
Task,
} from '@mui/icons-material'
Type as ListType,
SwipeableList,
SwipeableListItem,
SwipeAction,
TrailingActions,
} from 'react-swipeable-list'
import 'react-swipeable-list/dist/styles.css'
import { Add, FilterAlt, Star, StarBorder, Task } from '@mui/icons-material'
import { useChores } from '../../queries/ChoreQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import { getFilterCount, getFilterOverdueCount } from '../../utils/FilterEngine'
@@ -31,177 +32,14 @@ import AdvancedFilterBuilder from '../Modals/Inputs/AdvancedFilterBuilder'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import { useProjects } from '../Projects/ProjectQueries'
import {
useFilters,
useCreateFilter,
useUpdateFilter,
useDeleteFilter,
useFilters,
useToggleFilterPin,
useUpdateFilter,
} from './FilterQueries'
const FilterCard = ({
filter,
onEditClick,
onDeleteClick,
onPinClick,
taskCount = 0,
overdueCount = 0,
}) => {
const navigate = useNavigate()
// Swipe functionality state
const [swipeTranslateX, setSwipeTranslateX] = useState(0)
const [isDragging, setIsDragging] = useState(false)
const [isSwipeRevealed, setIsSwipeRevealed] = useState(false)
const [hoverTimer, setHoverTimer] = useState(null)
const swipeThreshold = 80
const maxSwipeDistance = 200 // Increased to fit pin + edit + delete
const dragStartX = useRef(0)
const cardRef = useRef(null)
// Swipe gesture handlers
const handleTouchStart = e => {
dragStartX.current = e.touches[0].clientX
setIsDragging(true)
}
const handleTouchMove = e => {
if (!isDragging) return
const currentX = e.touches[0].clientX
const deltaX = currentX - dragStartX.current
if (isSwipeRevealed) {
if (deltaX > 0) {
const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0)
setSwipeTranslateX(clampedDelta)
}
} else {
if (deltaX < 0) {
const clampedDelta = Math.max(deltaX, -maxSwipeDistance)
setSwipeTranslateX(clampedDelta)
}
}
}
const handleTouchEnd = () => {
if (!isDragging) return
setIsDragging(false)
if (isSwipeRevealed) {
if (swipeTranslateX > -swipeThreshold) {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
} else {
setSwipeTranslateX(-maxSwipeDistance)
}
} else {
if (Math.abs(swipeTranslateX) > swipeThreshold) {
setSwipeTranslateX(-maxSwipeDistance)
setIsSwipeRevealed(true)
} else {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
}
}
}
const handleMouseDown = e => {
dragStartX.current = e.clientX
setIsDragging(true)
}
const handleMouseMove = e => {
if (!isDragging) return
const currentX = e.clientX
const deltaX = currentX - dragStartX.current
if (isSwipeRevealed) {
if (deltaX > 0) {
const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0)
setSwipeTranslateX(clampedDelta)
}
} else {
if (deltaX < 0) {
const clampedDelta = Math.max(deltaX, -maxSwipeDistance)
setSwipeTranslateX(clampedDelta)
}
}
}
const handleMouseUp = () => {
if (!isDragging) return
setIsDragging(false)
if (isSwipeRevealed) {
if (swipeTranslateX > -swipeThreshold) {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
} else {
setSwipeTranslateX(-maxSwipeDistance)
}
} else {
if (Math.abs(swipeTranslateX) > swipeThreshold) {
setSwipeTranslateX(-maxSwipeDistance)
setIsSwipeRevealed(true)
} else {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
}
}
}
const resetSwipe = () => {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
}
// Hover functionality for desktop
const handleMouseEnter = () => {
if (isSwipeRevealed) return
const timer = setTimeout(() => {
setSwipeTranslateX(-maxSwipeDistance)
setIsSwipeRevealed(true)
setHoverTimer(null)
}, 800)
setHoverTimer(timer)
}
const handleMouseLeave = () => {
if (hoverTimer) {
clearTimeout(hoverTimer)
setHoverTimer(null)
}
if (!isSwipeRevealed) {
const hideTimer = setTimeout(() => {
resetSwipe()
}, 300)
setHoverTimer(hideTimer)
}
}
const handleActionAreaMouseEnter = () => {
if (hoverTimer) {
clearTimeout(hoverTimer)
setHoverTimer(null)
}
}
const handleActionAreaMouseLeave = () => {
if (isSwipeRevealed) {
resetSwipe()
}
}
// Clean up timer on unmount
useEffect(() => {
return () => {
if (hoverTimer) {
clearTimeout(hoverTimer)
}
}
}, [hoverTimer])
const FilterCardContent = ({ filter, taskCount = 0, overdueCount = 0 }) => {
// Get condition labels for display
const getConditionSummary = () => {
if (!filter.conditions || filter.conditions.length === 0) {
@@ -214,179 +52,20 @@ const FilterCard = ({
}
return (
<Box key={filter.id + '-filter-box'}>
<Box
sx={{
position: 'relative',
overflow: 'hidden',
borderBottom: '1px solid',
borderColor: 'divider',
'&:last-child': {
borderBottom: 'none',
},
}}
onMouseLeave={() => {
if (hoverTimer) {
clearTimeout(hoverTimer)
setHoverTimer(null)
}
}}
>
{/* Action buttons underneath (revealed on swipe) */}
<Box
sx={{
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='warning'
size='sm'
onClick={e => {
e.stopPropagation()
resetSwipe()
onPinClick(filter.id)
}}
sx={{
width: 40,
height: 40,
mx: 0.5,
}}
>
{filter.isPinned ? (
<Star sx={{ fontSize: 16 }} />
) : (
<StarBorder sx={{ fontSize: 16 }} />
)}
</IconButton>
<IconButton
variant='soft'
color='neutral'
size='sm'
onClick={e => {
e.stopPropagation()
resetSwipe()
onEditClick(filter)
}}
sx={{
width: 40,
height: 40,
mx: 0.5,
}}
>
<EditIcon sx={{ fontSize: 16 }} />
</IconButton>
<IconButton
variant='soft'
color='danger'
size='sm'
onClick={e => {
e.stopPropagation()
resetSwipe()
onDeleteClick(filter.id)
}}
sx={{
width: 40,
height: 40,
mx: 0.5,
}}
>
<DeleteIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
{/* Main card content */}
<Box
ref={cardRef}
sx={{
display: 'flex',
alignItems: 'center',
minHeight: 64,
cursor: 'pointer',
position: 'relative',
width: '100%',
px: 2,
py: 1.5,
bgcolor: 'background.body',
transform: `translateX(${swipeTranslateX}px)`,
transition: isDragging ? 'none' : 'transform 0.3s ease-out',
zIndex: 1,
'&:hover': {
bgcolor: isSwipeRevealed
? 'background.surface'
: 'background.level1',
boxShadow: isSwipeRevealed ? 'none' : 'sm',
},
}}
onClick={() => {
if (isSwipeRevealed) {
resetSwipe()
return
}
// Navigate to MyChores with filter applied via URL param
navigate(`/chores?filterId=${encodeURIComponent(filter.id)}`)
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
>
{/* Right drag area */}
<Box
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}
onClick={e => {
e.stopPropagation()
const clampedDelta = isSwipeRevealed ? 0 : -maxSwipeDistance
setSwipeTranslateX(clampedDelta)
borderBottom: '1px solid',
borderColor: 'divider',
cursor: 'pointer',
}}
>
{/* Drag indicator dots */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 0.25,
}}
>
<MoreVert sx={{ fontSize: 20 }} />
</Box>
</Box>
{/* Filter Icon */}
<Box
sx={{
@@ -403,9 +82,7 @@ const FilterCard = ({
height: 32,
bgcolor: filter.color || 'neutral.500',
border: '2px solid',
borderColor: filter.isPinned
? 'warning.300'
: 'background.surface',
borderColor: filter.isPinned ? 'warning.300' : 'background.surface',
boxShadow: filter.isPinned
? '0 0 0 1px var(--joy-palette-warning-300)'
: 'sm',
@@ -425,9 +102,7 @@ const FilterCard = ({
}}
>
{/* Filter Name */}
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.25 }}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.25 }}>
<Typography
level='title-sm'
sx={{
@@ -483,8 +158,7 @@ const FilterCard = ({
fontSize: 10,
height: 18,
px: 0.75,
bgcolor:
overdueCount > 0 ? 'danger.softBg' : 'primary.softBg',
bgcolor: overdueCount > 0 ? 'danger.softBg' : 'primary.softBg',
color: overdueCount > 0 ? 'danger.500' : 'primary.500',
}}
>
@@ -539,12 +213,11 @@ const FilterCard = ({
</Box>
</Box>
</Box>
</Box>
</Box>
)
}
const FilterView = () => {
const navigate = useNavigate()
const { data: userProfile } = useUserProfile()
const { data: chores = { res: [] } } = useChores(false)
const { data: labels = [] } = useLabels()
@@ -613,14 +286,7 @@ const FilterView = () => {
setFilterCounts(counts)
}
}, [
chores,
filtersData,
userProfile?.id,
labels,
projects,
membersData?.res,
])
}, [chores, filtersData, userProfile?.id, labels, projects, membersData?.res])
const handleAddFilter = () => {
setEditingFilter(null)
@@ -748,17 +414,100 @@ const FilterView = () => {
</Typography>
</Box>
) : (
savedFilters.map(filter => (
<FilterCard
<SwipeableList type={ListType.IOS} fullSwipe={false}>
{savedFilters.map(filter => (
<SwipeableListItem
onClick={() =>
navigate(`/chores?filterId=${encodeURIComponent(filter.id)}`)
}
key={filter.id}
trailingActions={
<TrailingActions>
<Box
sx={{
display: 'flex',
boxShadow: 'inset 2px 0 4px rgba(0,0,0,0.06)',
zIndex: 0,
}}
>
<SwipeAction onClick={() => handlePinFilter(filter.id)}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'warning.softBg',
color: 'warning.600',
px: 3,
height: '100%',
}}
>
{filter.isPinned ? (
<Star sx={{ fontSize: 20 }} />
) : (
<StarBorder sx={{ fontSize: 20 }} />
)}
<Typography level='body-xs' sx={{ mt: 0.5 }}>
{filter.isPinned ? 'Unpin' : 'Pin'}
</Typography>
</Box>
</SwipeAction>
<SwipeAction onClick={() => handleEditFilter(filter)}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'var(--joy-palette-neutral-100)',
px: 3,
height: '100%',
}}
>
<EditIcon sx={{ fontSize: 20 }} />
<Typography level='body-xs' sx={{ mt: 0.5 }}>
Edit
</Typography>
</Box>
</SwipeAction>
<SwipeAction
onClick={() => handleDeleteClicked(filter.id)}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'danger.softBg',
color: 'danger.600',
px: 3,
height: '100%',
}}
>
<DeleteIcon sx={{ fontSize: 20 }} color='danger' />
<Typography
level='body-xs'
sx={{ mt: 0.5 }}
color='danger'
>
Delete
</Typography>
</Box>
</SwipeAction>
</Box>
</TrailingActions>
}
>
<FilterCardContent
filter={filter}
onEditClick={handleEditFilter}
onDeleteClick={handleDeleteClicked}
onPinClick={handlePinFilter}
taskCount={filterCounts[filter.id]?.count || 0}
overdueCount={filterCounts[filter.id]?.overdueCount || 0}
/>
))
</SwipeableListItem>
))}
</SwipeableList>
)}
</Box>

View File

@@ -8,27 +8,28 @@ import {
Timelapse,
TrendingUp,
} from '@mui/icons-material'
import {
Box,
Button,
Card,
Container,
Grid,
List,
Sheet,
Typography,
} from '@mui/joy'
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 moment from 'moment'
import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import {
Type as ListType,
SwipeableList,
SwipeableListItem,
SwipeAction,
TrailingActions,
} from 'react-swipeable-list'
import 'react-swipeable-list/dist/styles.css'
import useConfirmationModal from '../../hooks/useConfirmationModal'
import { ChoreHistoryStatus } from '../../utils/Chores'
import {
useChoreHistory,
useDeleteChoreHistory,
useUpdateChoreHistory,
} from '../../queries/ChoreQueries'
import { useCircleMembers } from '../../queries/UserQueries'
import { ChoreHistoryStatus } from '../../utils/Chores'
import LoadingComponent from '../components/Loading'
import EditHistoryModal from '../Modals/EditHistoryModal'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
@@ -287,23 +288,76 @@ const ChoreHistory = () => {
Task Activity
</Typography>
</Box>
<Sheet variant='plain' sx={{ borderRadius: 'sm', boxShadow: 'md' }}>
<Sheet
variant='plain'
sx={{ borderRadius: 'sm', boxShadow: 'md', overflow: 'hidden' }}
>
{/* Chore History List (Updated Style) */}
<List sx={{ p: 0 }}>
<SwipeableList type={ListType.IOS} fullSwipe={false}>
{choreHistory.map((historyEntry, index) => (
<SwipeableListItem
key={historyEntry.id || index}
trailingActions={
<TrailingActions>
<Box
sx={{
display: 'flex',
boxShadow: 'inset 2px 0 4px rgba(0,0,0,0.06)',
zIndex: 0,
}}
>
<SwipeAction onClick={() => handleEdit(historyEntry)}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'var(--joy-palette-neutral-100)',
px: 3,
height: '100%',
width: '100%',
}}
>
<EditIcon sx={{ fontSize: 20 }} />
<Typography level='body-xs' sx={{ mt: 0.5 }}>
Edit
</Typography>
</Box>
</SwipeAction>
<SwipeAction onClick={() => handleDelete(historyEntry)}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'danger.softBg',
color: 'danger.600',
px: 3,
height: '100%',
}}
>
<DeleteIcon sx={{ fontSize: 20 }} />
<Typography level='body-xs' sx={{ mt: 0.5 }}>
Delete
</Typography>
</Box>
</SwipeAction>
</Box>
</TrailingActions>
}
>
<HistoryCard
onClick={() => handleEdit(historyEntry)}
onEditClick={handleEdit}
onDeleteClick={handleDelete}
historyEntry={historyEntry}
performers={performers}
allHistory={choreHistory}
key={index}
index={index}
/>
</SwipeableListItem>
))}
</List>
</SwipeableList>
</Sheet>
<EditHistoryModal
config={{

View File

@@ -3,8 +3,6 @@ import {
CalendarMonth,
Check,
CheckCircle,
Delete,
Edit,
EventNote,
HourglassEmpty,
Person,
@@ -13,19 +11,8 @@ import {
Timelapse,
Toll,
} from '@mui/icons-material'
import {
Avatar,
Box,
Chip,
Grid,
IconButton,
ListDivider,
ListItem,
ListItemContent,
Typography,
} from '@mui/joy'
import { Avatar, Box, Chip, Grid, Typography } from '@mui/joy'
import moment from 'moment'
import { useEffect, useRef, useState } from 'react'
import { TASK_COLOR } from '../../utils/Colors.jsx'
const getCompletedChip = historyEntry => {
@@ -96,30 +83,12 @@ const formatTime = seconds => {
}
/**
* Compact HistoryCard component with improved UX and 2-row height design
* Compact HistoryCard component - content only
*/
const HistoryCard = ({
allHistory,
performers,
historyEntry,
index,
onClick,
onEditClick,
onDeleteClick,
}) => {
const HistoryCard = ({ allHistory, performers, historyEntry, index }) => {
const performer = performers.find(p => p.userId === historyEntry.completedBy)
const assignedTo = performers.find(p => p.userId === historyEntry.assignedTo)
// Swipe functionality state
const [swipeTranslateX, setSwipeTranslateX] = useState(0)
const [isDragging, setIsDragging] = useState(false)
const [isSwipeRevealed, setIsSwipeRevealed] = useState(false)
const [hoverTimer, setHoverTimer] = useState(null)
const swipeThreshold = 80
const maxSwipeDistance = 200
const dragStartX = useRef(0)
const cardRef = useRef(null)
const formatTimeDifference = (startDate, endDate) => {
const diffInMinutes = moment(startDate).diff(endDate, 'minutes')
let timeValue = diffInMinutes
@@ -166,266 +135,21 @@ const HistoryCard = ({
)
}
// Swipe gesture handlers
const handleTouchStart = e => {
dragStartX.current = e.touches[0].clientX
setIsDragging(true)
}
const handleTouchMove = e => {
if (!isDragging) return
const currentX = e.touches[0].clientX
const deltaX = currentX - dragStartX.current
if (isSwipeRevealed) {
if (deltaX > 0) {
const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0)
setSwipeTranslateX(clampedDelta)
}
} else {
if (deltaX < 0) {
const clampedDelta = Math.max(deltaX, -maxSwipeDistance)
setSwipeTranslateX(clampedDelta)
}
}
}
const handleTouchEnd = () => {
if (!isDragging) return
setIsDragging(false)
if (isSwipeRevealed) {
if (swipeTranslateX > -swipeThreshold) {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
} else {
setSwipeTranslateX(-maxSwipeDistance)
}
} else {
if (Math.abs(swipeTranslateX) > swipeThreshold) {
setSwipeTranslateX(-maxSwipeDistance)
setIsSwipeRevealed(true)
} else {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
}
}
}
const handleMouseDown = e => {
dragStartX.current = e.clientX
setIsDragging(true)
}
const handleMouseMove = e => {
if (!isDragging) return
const currentX = e.clientX
const deltaX = currentX - dragStartX.current
if (isSwipeRevealed) {
if (deltaX > 0) {
const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0)
setSwipeTranslateX(clampedDelta)
}
} else {
if (deltaX < 0) {
const clampedDelta = Math.max(deltaX, -maxSwipeDistance)
setSwipeTranslateX(clampedDelta)
}
}
}
const handleMouseUp = () => {
if (!isDragging) return
setIsDragging(false)
if (isSwipeRevealed) {
if (swipeTranslateX > -swipeThreshold) {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
} else {
setSwipeTranslateX(-maxSwipeDistance)
}
} else {
if (Math.abs(swipeTranslateX) > swipeThreshold) {
setSwipeTranslateX(-maxSwipeDistance)
setIsSwipeRevealed(true)
} else {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
}
}
}
const resetSwipe = () => {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
}
// Hover functionality for desktop - only trigger from drag area
const handleMouseEnter = () => {
if (isSwipeRevealed) return
const timer = setTimeout(() => {
setSwipeTranslateX(-maxSwipeDistance)
setIsSwipeRevealed(true)
setHoverTimer(null)
}, 800) // Shorter delay for drag area
setHoverTimer(timer)
}
const handleMouseLeave = () => {
if (hoverTimer) {
clearTimeout(hoverTimer)
setHoverTimer(null)
}
// Only add hide timer if we're leaving the drag area and actions are NOT revealed
// If actions are revealed, let the action area handle the hiding
if (!isSwipeRevealed) {
// Actions are not revealed, so we can safely hide after delay
const hideTimer = setTimeout(() => {
resetSwipe()
}, 300)
setHoverTimer(hideTimer)
}
}
const handleActionAreaMouseEnter = () => {
// Clear any pending timer when entering action area
if (hoverTimer) {
clearTimeout(hoverTimer)
setHoverTimer(null)
}
}
const handleActionAreaMouseLeave = () => {
// Hide immediately when leaving action area
if (isSwipeRevealed) {
resetSwipe()
}
}
// Clean up timer on unmount
useEffect(() => {
return () => {
if (hoverTimer) {
clearTimeout(hoverTimer)
}
}
}, [hoverTimer])
return (
<>
<Box
sx={{
position: 'relative',
overflow: 'hidden',
}}
onMouseLeave={() => {
// Only clear timers, don't auto-hide
if (hoverTimer) {
clearTimeout(hoverTimer)
setHoverTimer(null)
}
}}
>
{/* Action buttons underneath (revealed on swipe) */}
{(onEditClick || onDeleteClick) && (
<Box
sx={{
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}
>
{onEditClick && (
<IconButton
variant='soft'
color='neutral'
size='sm'
onClick={e => {
e.stopPropagation()
resetSwipe()
onEditClick(historyEntry)
}}
sx={{
width: 40,
height: 40,
mx: 1,
}}
>
<Edit sx={{ fontSize: 16 }} />
</IconButton>
)}
{onDeleteClick && (
<IconButton
variant='soft'
color='danger'
size='sm'
onClick={e => {
e.stopPropagation()
resetSwipe()
onDeleteClick(historyEntry)
}}
sx={{
width: 40,
height: 40,
mx: 1,
}}
>
<Delete sx={{ fontSize: 16 }} />
</IconButton>
)}
</Box>
)}
{/* Main card content */}
<ListItem
ref={cardRef}
onClick={() => {
if (isSwipeRevealed) {
resetSwipe()
return
}
if (onClick) onClick()
}}
sx={{
cursor: onClick ? 'pointer' : 'default',
py: 1.5,
minHeight: 64,
minWidth: '100%',
px: 2,
position: 'relative',
bgcolor: 'background.surface',
transform: `translateX(${swipeTranslateX}px)`,
transition: isDragging ? 'none' : 'transform 0.3s ease-out',
zIndex: 1,
width: '100%',
'&:hover': onClick
? {
bgcolor: isSwipeRevealed
? 'background.surface'
: 'background.level1',
}
: {},
borderRadius: 'sm',
py: 1.5,
bgcolor: 'background.body',
borderBottom: '1px solid',
borderColor: 'divider',
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
>
<ListItemContent>
<Box sx={{ flex: 1 }}>
<Grid container spacing={1} alignItems='center'>
{/* First Row/Column: Status and Time Info */}
<Grid xs={12} sm={8}>
@@ -557,86 +281,8 @@ const HistoryCard = ({
</Box>
</Grid>
</Grid>
</ListItemContent>
{/* Right drag area - only triggers reveal on hover */}
{(onEditClick || onDeleteClick) && (
<Box
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, // Hide when action area is revealed
transition: 'opacity 0.2s ease',
pointerEvents: isSwipeRevealed ? 'none' : 'auto', // Disable pointer events when revealed
'&:hover': {
opacity: isSwipeRevealed ? 0 : 0.7,
},
'&:active': {
cursor: 'grabbing',
},
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{/* Drag indicator dots */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 0.25,
}}
>
{[...Array(3)].map((_, i) => (
<Box
key={i}
sx={{
width: 3,
height: 3,
borderRadius: '50%',
backgroundColor: 'text.tertiary',
}}
/>
))}
</Box>
</Box>
)}
</ListItem>
{/* Compact Divider with Time Difference */}
{index < allHistory.length - 1 && allHistory[index + 1].performedAt && (
<ListDivider
component='li'
sx={{
my: 0.5,
}}
>
<Typography
level='body-xs'
sx={{
color: 'text.tertiary',
backgroundColor: 'background.surface',
px: 1,
fontSize: '0.75rem',
}}
>
{formatTimeDifference(
historyEntry.performedAt || historyEntry.updatedAt,
allHistory[index + 1].performedAt,
)}{' '}
before
</Typography>
</ListDivider>
)}
</Box>
</>
)
}

View File

@@ -10,349 +10,44 @@ import {
Stack,
Typography,
} from '@mui/joy'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useState } from 'react'
import LabelModal from '../Modals/Inputs/LabelModal'
// import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Add } from '@mui/icons-material'
import { useQueryClient } from '@tanstack/react-query'
import {
Type as ListType,
SwipeableList,
SwipeableListItem,
SwipeAction,
TrailingActions,
} from 'react-swipeable-list'
import 'react-swipeable-list/dist/styles.css'
import { useUserProfile } from '../../queries/UserQueries'
import LABEL_COLORS, {
getTextColorFromBackgroundColor,
} from '../../utils/Colors'
import { getTextColorFromBackgroundColor } from '../../utils/Colors'
import { DeleteLabel } from '../../utils/Fetcher'
import { getSafeBottomStyles } from '../../utils/SafeAreaUtils'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import { useLabels } from './LabelQueries'
const LabelCard = ({ label, onEditClick, onDeleteClick, currentUserId }) => {
// Helper function to get color name from hex value
const getColorName = hexValue => {
const colorObj = LABEL_COLORS.find(
color => color.value.toLowerCase() === hexValue.toLowerCase(),
)
return colorObj ? colorObj.name : hexValue
}
const LabelCardContent = ({ label, currentUserId }) => {
// Check if current user owns this label
const isOwnedByCurrentUser = label.created_by === currentUserId
// Swipe functionality state
const [swipeTranslateX, setSwipeTranslateX] = useState(0)
const [isDragging, setIsDragging] = useState(false)
const [isSwipeRevealed, setIsSwipeRevealed] = useState(false)
const [hoverTimer, setHoverTimer] = useState(null)
const swipeThreshold = 80
const maxSwipeDistance = 160
const dragStartX = useRef(0)
const cardRef = useRef(null)
// Swipe gesture handlers
const handleTouchStart = e => {
dragStartX.current = e.touches[0].clientX
setIsDragging(true)
}
const handleTouchMove = e => {
if (!isDragging) return
const currentX = e.touches[0].clientX
const deltaX = currentX - dragStartX.current
if (isSwipeRevealed) {
if (deltaX > 0) {
const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0)
setSwipeTranslateX(clampedDelta)
}
} else {
if (deltaX < 0) {
const clampedDelta = Math.max(deltaX, -maxSwipeDistance)
setSwipeTranslateX(clampedDelta)
}
}
}
const handleTouchEnd = () => {
if (!isDragging) return
setIsDragging(false)
if (isSwipeRevealed) {
if (swipeTranslateX > -swipeThreshold) {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
} else {
setSwipeTranslateX(-maxSwipeDistance)
}
} else {
if (Math.abs(swipeTranslateX) > swipeThreshold) {
setSwipeTranslateX(-maxSwipeDistance)
setIsSwipeRevealed(true)
} else {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
}
}
}
const handleMouseDown = e => {
dragStartX.current = e.clientX
setIsDragging(true)
}
const handleMouseMove = e => {
if (!isDragging) return
const currentX = e.clientX
const deltaX = currentX - dragStartX.current
if (isSwipeRevealed) {
if (deltaX > 0) {
const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0)
setSwipeTranslateX(clampedDelta)
}
} else {
if (deltaX < 0) {
const clampedDelta = Math.max(deltaX, -maxSwipeDistance)
setSwipeTranslateX(clampedDelta)
}
}
}
const handleMouseUp = () => {
if (!isDragging) return
setIsDragging(false)
if (isSwipeRevealed) {
if (swipeTranslateX > -swipeThreshold) {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
} else {
setSwipeTranslateX(-maxSwipeDistance)
}
} else {
if (Math.abs(swipeTranslateX) > swipeThreshold) {
setSwipeTranslateX(-maxSwipeDistance)
setIsSwipeRevealed(true)
} else {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
}
}
}
const resetSwipe = () => {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
}
// Hover functionality for desktop - only trigger from drag area
const handleMouseEnter = () => {
if (isSwipeRevealed) return
const timer = setTimeout(() => {
setSwipeTranslateX(-maxSwipeDistance)
setIsSwipeRevealed(true)
setHoverTimer(null)
}, 800) // Shorter delay for drag area
setHoverTimer(timer)
}
const handleMouseLeave = () => {
if (hoverTimer) {
clearTimeout(hoverTimer)
setHoverTimer(null)
}
// Only add hide timer if we're leaving the drag area and actions are NOT revealed
// If actions are revealed, let the action area handle the hiding
if (!isSwipeRevealed) {
// Actions are not revealed, so we can safely hide after delay
const hideTimer = setTimeout(() => {
resetSwipe()
}, 300)
setHoverTimer(hideTimer)
}
}
const handleActionAreaMouseEnter = () => {
// Clear any pending timer when entering action area
if (hoverTimer) {
clearTimeout(hoverTimer)
setHoverTimer(null)
}
}
const handleActionAreaMouseLeave = () => {
// Hide immediately when leaving action area
if (isSwipeRevealed) {
resetSwipe()
}
}
// Clean up timer on unmount
useEffect(() => {
return () => {
if (hoverTimer) {
clearTimeout(hoverTimer)
}
}
}, [hoverTimer])
return (
<Box key={label.id + '-compact-box'}>
<Box
sx={{
position: 'relative',
overflow: 'hidden',
borderBottom: '1px solid',
borderColor: 'divider',
'&:last-child': {
borderBottom: 'none',
},
}}
onMouseLeave={() => {
// Only clear timers, don't auto-hide
if (hoverTimer) {
clearTimeout(hoverTimer)
setHoverTimer(null)
}
}}
>
{/* Action buttons underneath (revealed on swipe) */}
<Box
sx={{
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(label)
}}
sx={{
width: 40,
height: 40,
mx: 1,
}}
>
<EditIcon sx={{ fontSize: 16 }} />
</IconButton>
<IconButton
variant='soft'
color='danger'
size='sm'
onClick={e => {
e.stopPropagation()
resetSwipe()
onDeleteClick(label.id)
}}
sx={{
width: 40,
height: 40,
mx: 1,
}}
>
<DeleteIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
{/* Main card content */}
<Box
ref={cardRef}
sx={{
display: 'flex',
alignItems: 'center',
minHeight: 64,
cursor: 'pointer',
position: 'relative',
width: '100%',
px: 2,
py: 1.5,
bgcolor: 'background.body',
transform: `translateX(${swipeTranslateX}px)`,
transition: isDragging ? 'none' : 'transform 0.3s ease-out',
zIndex: 1,
'&:hover': {
bgcolor: isSwipeRevealed
? 'background.surface'
: 'background.level1',
boxShadow: isSwipeRevealed ? 'none' : 'sm',
},
}}
onClick={() => {
if (isSwipeRevealed) {
resetSwipe()
return
}
// Optional: Navigate to label details or edit directly
onEditClick(label)
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
>
{/* Right drag area - only triggers reveal on hover */}
<Box
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, // Hide when action area is revealed
transition: 'opacity 0.2s ease',
pointerEvents: isSwipeRevealed ? 'none' : 'auto', // Disable pointer events when revealed
'&:hover': {
opacity: isSwipeRevealed ? 0 : 0.7,
},
'&:active': {
cursor: 'grabbing',
},
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{/* Drag indicator dots */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 0.25,
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
{[...Array(3)].map((_, i) => (
<Box
key={i}
sx={{
width: 3,
height: 3,
borderRadius: '50%',
bgcolor: 'text.tertiary',
}}
/>
))}
</Box>
</Box>
{/* Color Avatar */}
<Box
sx={{
@@ -434,8 +129,6 @@ const LabelCard = ({ label, onEditClick, onDeleteClick, currentUserId }) => {
</Box>
</Box>
</Box>
</Box>
</Box>
)
}
@@ -545,10 +238,6 @@ const LabelView = () => {
</Box>
<Box
sx={{
// bgcolor: 'background.body',
// border: '1px solid',
// borderColor: 'divider',
// borderRadius: 'md',
overflow: 'hidden',
}}
>
@@ -567,15 +256,64 @@ const LabelView = () => {
</Typography>
</Box>
)}
<SwipeableList type={ListType.IOS} fullSwipe={false}>
{userLabels.map(label => (
<LabelCard
<SwipeableListItem
key={label.id}
label={label}
onEditClick={handleEditLabel}
onDeleteClick={handleDeleteClicked}
currentUserId={userProfile?.id}
/>
trailingActions={
<TrailingActions>
<Box
sx={{
display: 'flex',
boxShadow: 'inset 2px 0 4px rgba(0,0,0,0.06)',
zIndex: 0,
}}
>
<SwipeAction onClick={() => handleEditLabel(label)}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'var(--joy-palette-neutral-100)',
px: 3,
height: '100%',
}}
>
<EditIcon sx={{ fontSize: 20 }} />
<Typography level='body-xs' sx={{ mt: 0.5 }}>
Edit
</Typography>
</Box>
</SwipeAction>
<SwipeAction onClick={() => handleDeleteClicked(label.id)}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'danger.softBg',
color: 'danger.600',
px: 3,
height: '100%',
}}
>
<DeleteIcon sx={{ fontSize: 20 }} />
<Typography level='body-xs' sx={{ mt: 0.5 }}>
Delete
</Typography>
</Box>
</SwipeAction>
</Box>
</TrailingActions>
}
>
<LabelCardContent label={label} currentUserId={userProfile?.id} />
</SwipeableListItem>
))}
</SwipeableList>
</Box>
{modalOpen && (

View File

@@ -10,369 +10,56 @@ import {
Stack,
Typography,
} from '@mui/joy'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import ProjectModal from '../Modals/Inputs/ProjectModal'
import { Add, Task } from '@mui/icons-material'
import { useQueryClient } from '@tanstack/react-query'
import {
Type as ListType,
SwipeableList,
SwipeableListItem,
SwipeAction,
TrailingActions,
} from 'react-swipeable-list'
import 'react-swipeable-list/dist/styles.css'
import { useChores } from '../../queries/ChoreQueries'
import { useUserProfile } from '../../queries/UserQueries'
import LABEL_COLORS, {
getTextColorFromBackgroundColor,
} from '../../utils/Colors'
import { getTextColorFromBackgroundColor } from '../../utils/Colors'
import { DeleteProject } from '../../utils/Fetcher'
import { getIconComponent } from '../../utils/ProjectIcons'
import { getSafeBottomStyles } from '../../utils/SafeAreaUtils'
import { useProjectFilter } from '../Chores/hooks/useProjectFilter'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import { useProjects } from './ProjectQueries'
const ProjectCard = ({
const ProjectCardContent = ({
project,
onEditClick,
onDeleteClick,
isEditable = true,
currentUserId,
taskCounts = {},
onCardClick,
}) => {
const navigate = useNavigate()
const { data: projects = [], isLoading: projectsLoading } = useProjects()
// Helper function to get color name from hex value
const { setSelectedProjectWithCache } = useProjectFilter(projects)
const getColorName = hexValue => {
const colorObj = LABEL_COLORS.find(
color => color.value.toLowerCase() === hexValue.toLowerCase(),
)
return colorObj ? colorObj.name : hexValue
}
// Check if current user owns this project
const isOwnedByCurrentUser = project.created_by === currentUserId
const isDefaultProject = project.id === 'default'
const taskCount = taskCounts[project.id] || 0
// Swipe functionality state
const [swipeTranslateX, setSwipeTranslateX] = useState(0)
const [isDragging, setIsDragging] = useState(false)
const [isSwipeRevealed, setIsSwipeRevealed] = useState(false)
const [hoverTimer, setHoverTimer] = useState(null)
const swipeThreshold = 80
const maxSwipeDistance = 160
const dragStartX = useRef(0)
const cardRef = useRef(null)
// Swipe gesture handlers (same as LabelView)
const handleTouchStart = e => {
dragStartX.current = e.touches[0].clientX
setIsDragging(true)
}
const handleTouchMove = e => {
if (!isDragging) return
const currentX = e.touches[0].clientX
const deltaX = currentX - dragStartX.current
if (isSwipeRevealed) {
if (deltaX > 0) {
const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0)
setSwipeTranslateX(clampedDelta)
}
} else {
if (deltaX < 0) {
const clampedDelta = Math.max(deltaX, -maxSwipeDistance)
setSwipeTranslateX(clampedDelta)
}
}
}
const handleTouchEnd = () => {
if (!isDragging) return
setIsDragging(false)
if (isSwipeRevealed) {
if (swipeTranslateX > -swipeThreshold) {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
} else {
setSwipeTranslateX(-maxSwipeDistance)
}
} else {
if (Math.abs(swipeTranslateX) > swipeThreshold) {
setSwipeTranslateX(-maxSwipeDistance)
setIsSwipeRevealed(true)
} else {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
}
}
}
const handleMouseDown = e => {
dragStartX.current = e.clientX
setIsDragging(true)
}
const handleMouseMove = e => {
if (!isDragging) return
const currentX = e.clientX
const deltaX = currentX - dragStartX.current
if (isSwipeRevealed) {
if (deltaX > 0) {
const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0)
setSwipeTranslateX(clampedDelta)
}
} else {
if (deltaX < 0) {
const clampedDelta = Math.max(deltaX, -maxSwipeDistance)
setSwipeTranslateX(clampedDelta)
}
}
}
const handleMouseUp = () => {
if (!isDragging) return
setIsDragging(false)
if (isSwipeRevealed) {
if (swipeTranslateX > -swipeThreshold) {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
} else {
setSwipeTranslateX(-maxSwipeDistance)
}
} else {
if (Math.abs(swipeTranslateX) > swipeThreshold) {
setSwipeTranslateX(-maxSwipeDistance)
setIsSwipeRevealed(true)
} else {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
}
}
}
const resetSwipe = () => {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
}
// Hover functionality for desktop
const handleMouseEnter = () => {
if (isSwipeRevealed) return
const timer = setTimeout(() => {
setSwipeTranslateX(-maxSwipeDistance)
setIsSwipeRevealed(true)
setHoverTimer(null)
}, 800)
setHoverTimer(timer)
}
const handleMouseLeave = () => {
if (hoverTimer) {
clearTimeout(hoverTimer)
setHoverTimer(null)
}
if (!isSwipeRevealed) {
const hideTimer = setTimeout(() => {
resetSwipe()
}, 300)
setHoverTimer(hideTimer)
}
}
const handleActionAreaMouseEnter = () => {
if (hoverTimer) {
clearTimeout(hoverTimer)
setHoverTimer(null)
}
}
const handleActionAreaMouseLeave = () => {
if (isSwipeRevealed) {
resetSwipe()
}
}
// Clean up timer on unmount
useEffect(() => {
return () => {
if (hoverTimer) {
clearTimeout(hoverTimer)
}
}
}, [hoverTimer])
return (
<Box key={project.id + '-project-box'}>
<Box
sx={{
position: 'relative',
overflow: 'hidden',
borderBottom: '1px solid',
borderColor: 'divider',
'&:last-child': {
borderBottom: 'none',
},
}}
onMouseLeave={() => {
if (hoverTimer) {
clearTimeout(hoverTimer)
setHoverTimer(null)
}
}}
>
{/* Action buttons underneath (revealed on swipe) */}
{isEditable && (
<Box
sx={{
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={{
width: 40,
height: 40,
mx: 1,
}}
>
<EditIcon sx={{ fontSize: 16 }} />
</IconButton>
{/* 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 */}
<Box
ref={cardRef}
sx={{
display: 'flex',
alignItems: 'center',
minHeight: 64,
cursor: 'pointer',
position: 'relative',
width: '100%',
px: 2,
py: 1.5,
bgcolor: 'background.body',
transform: `translateX(${swipeTranslateX}px)`,
transition: isDragging ? 'none' : 'transform 0.3s ease-out',
zIndex: 1,
'&:hover': {
bgcolor: isSwipeRevealed
? 'background.surface'
: 'background.level1',
boxShadow: isSwipeRevealed ? 'none' : 'sm',
},
borderBottom: '1px solid',
borderColor: 'divider',
cursor: 'pointer',
}}
onClick={() => {
if (isSwipeRevealed) {
resetSwipe()
return
}
// 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
setSelectedProjectWithCache(project)
navigate(`/chores?project=${encodeURIComponent(projectIdentifier)}`)
}}
onTouchStart={isEditable ? handleTouchStart : undefined}
onTouchMove={isEditable ? handleTouchMove : undefined}
onTouchEnd={isEditable ? handleTouchEnd : undefined}
onMouseDown={isEditable ? handleMouseDown : undefined}
onMouseMove={isEditable ? handleMouseMove : undefined}
onMouseUp={isEditable ? handleMouseUp : undefined}
onClick={onCardClick}
>
{/* Right drag area */}
{isEditable && (
<Box
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
sx={{
display: 'flex',
flexDirection: 'column',
gap: 0.25,
}}
>
{[...Array(3)].map((_, i) => (
<Box
key={i}
sx={{
width: 3,
height: 3,
borderRadius: '50%',
bgcolor: 'text.tertiary',
}}
/>
))}
</Box>
</Box>
)}
{/* Project Avatar */}
<Box
sx={{
@@ -511,8 +198,6 @@ const ProjectCard = ({
</Box>
</Box>
</Box>
</Box>
</Box>
)
}
@@ -520,6 +205,9 @@ const ProjectView = () => {
const { data: projects, isProjectsLoading, isError } = useProjects()
const { data: userProfile } = useUserProfile()
const { data: chores = { res: [] } } = useChores(false) // false to exclude archived
const { data: projectsData = [], isLoading: projectsLoading } = useProjects()
const { setSelectedProjectWithCache } = useProjectFilter(projectsData)
const navigate = useNavigate()
const [userProjects, setUserProjects] = useState([])
const [modalOpen, setModalOpen] = useState(false)
@@ -568,6 +256,14 @@ const ProjectView = () => {
setModalOpen(false)
}
const handleCardClick = 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
setSelectedProjectWithCache(project)
navigate(`/chores?project=${encodeURIComponent(projectIdentifier)}`)
}
useEffect(() => {
if (projects) {
setUserProjects(projects)
@@ -649,32 +345,95 @@ const ProjectView = () => {
overflow: 'hidden',
}}
>
{/* default project: */}
<ProjectCard
key='default-project-card'
{/* Default project - not swipeable */}
<ProjectCardContent
project={{
id: 'default',
name: 'Default Project',
description: 'All tasks without a specific project',
icon: 'FolderOpen',
color: '#1976d2',
created_by: userProfile?.id,
}}
isEditable={false}
currentUserId={userProfile?.id}
onEditClick={() => {}}
taskCounts={{ default: taskCounts.default || 0 }}
onCardClick={() =>
handleCardClick({
id: 'default',
name: 'Default Project',
icon: 'FolderOpen',
color: '#1976d2',
})
}
/>
{/* User projects - swipeable */}
<SwipeableList type={ListType.IOS} fullSwipe={false}>
{userProjects.map(project => (
<ProjectCard
<SwipeableListItem
onClick={() => handleCardClick(project)}
key={project.id}
trailingActions={
<TrailingActions>
<Box
sx={{
display: 'flex',
boxShadow: 'inset 2px 0 4px rgba(0,0,0,0.06)',
zIndex: 0,
}}
>
<SwipeAction onClick={() => handleEditProject(project)}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'var(--joy-palette-neutral-100)',
px: 3,
height: '100%',
}}
>
<EditIcon sx={{ fontSize: 20 }} />
<Typography level='body-xs' sx={{ mt: 0.5 }}>
Edit
</Typography>
</Box>
</SwipeAction>
<SwipeAction
onClick={() => handleDeleteClicked(project.id)}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'danger.softBg',
color: 'danger.600',
px: 3,
height: '100%',
}}
>
<DeleteIcon sx={{ fontSize: 20 }} />
<Typography level='body-xs' sx={{ mt: 0.5 }}>
Delete
</Typography>
</Box>
</SwipeAction>
</Box>
</TrailingActions>
}
>
<ProjectCardContent
project={project}
onEditClick={handleEditProject}
onDeleteClick={handleDeleteClicked}
currentUserId={userProfile?.id}
taskCounts={taskCounts}
isEditable={true}
// onCardClick={}
/>
</SwipeableListItem>
))}
</SwipeableList>
</Box>
{modalOpen && (

View File

@@ -17,8 +17,16 @@ import {
Stack,
Typography,
} from '@mui/joy'
import React, { useEffect, useRef, useState } from 'react'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Type as ListType,
SwipeableList,
SwipeableListItem,
SwipeAction,
TrailingActions,
} from 'react-swipeable-list'
import 'react-swipeable-list/dist/styles.css'
import { useNotification } from '../../service/NotificationProvider'
import {
CreateThing,
@@ -31,25 +39,8 @@ import { getSafeBottomStyles } from '../../utils/SafeAreaUtils'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import CreateThingModal from '../Modals/Inputs/CreateThingModal'
import EditThingStateModal from '../Modals/Inputs/EditThingState'
const ThingCard = ({
thing,
onEditClick,
onStateChangeRequest,
onDeleteClick,
}) => {
const [isDisabled, setIsDisabled] = useState(false)
const Navigate = useNavigate()
// Swipe functionality state
const [swipeTranslateX, setSwipeTranslateX] = useState(0)
const [isDragging, setIsDragging] = useState(false)
const [isSwipeRevealed, setIsSwipeRevealed] = useState(false)
const [hoverTimer, setHoverTimer] = useState(null)
const swipeThreshold = 80
const maxSwipeDistance = 200
const dragStartX = useRef(0)
const cardRef = useRef(null)
const ThingCardContent = ({ thing, onCardClick }) => {
const getThingIcon = type => {
if (type === 'text') {
return <Flip />
@@ -93,342 +84,22 @@ const ThingCard = ({
)
}
const handleRequestChange = thing => {
setIsDisabled(true)
resetSwipe()
onStateChangeRequest(thing)
setTimeout(() => {
setIsDisabled(false)
}, 2000)
}
// Swipe gesture handlers
const handleTouchStart = e => {
dragStartX.current = e.touches[0].clientX
setIsDragging(true)
}
const handleTouchMove = e => {
if (!isDragging) return
const currentX = e.touches[0].clientX
const deltaX = currentX - dragStartX.current
if (isSwipeRevealed) {
if (deltaX > 0) {
const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0)
setSwipeTranslateX(clampedDelta)
}
} else {
if (deltaX < 0) {
const clampedDelta = Math.max(deltaX, -maxSwipeDistance)
setSwipeTranslateX(clampedDelta)
}
}
}
const handleTouchEnd = () => {
if (!isDragging) return
setIsDragging(false)
if (isSwipeRevealed) {
if (swipeTranslateX > -swipeThreshold) {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
} else {
setSwipeTranslateX(-maxSwipeDistance)
}
} else {
if (Math.abs(swipeTranslateX) > swipeThreshold) {
setSwipeTranslateX(-maxSwipeDistance)
setIsSwipeRevealed(true)
} else {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
}
}
}
const handleMouseDown = e => {
dragStartX.current = e.clientX
setIsDragging(true)
}
const handleMouseMove = e => {
if (!isDragging) return
const currentX = e.clientX
const deltaX = currentX - dragStartX.current
if (isSwipeRevealed) {
if (deltaX > 0) {
const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0)
setSwipeTranslateX(clampedDelta)
}
} else {
if (deltaX < 0) {
const clampedDelta = Math.max(deltaX, -maxSwipeDistance)
setSwipeTranslateX(clampedDelta)
}
}
}
const handleMouseUp = () => {
if (!isDragging) return
setIsDragging(false)
if (isSwipeRevealed) {
if (swipeTranslateX > -swipeThreshold) {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
} else {
setSwipeTranslateX(-maxSwipeDistance)
}
} else {
if (Math.abs(swipeTranslateX) > swipeThreshold) {
setSwipeTranslateX(-maxSwipeDistance)
setIsSwipeRevealed(true)
} else {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
}
}
}
const resetSwipe = () => {
setSwipeTranslateX(0)
setIsSwipeRevealed(false)
}
// Hover functionality for desktop - only trigger from drag area
const handleMouseEnter = () => {
if (isSwipeRevealed) return
const timer = setTimeout(() => {
setSwipeTranslateX(-maxSwipeDistance)
setIsSwipeRevealed(true)
setHoverTimer(null)
}, 800) // Shorter delay for drag area
setHoverTimer(timer)
}
const handleMouseLeave = () => {
if (hoverTimer) {
clearTimeout(hoverTimer)
setHoverTimer(null)
}
// Only add hide timer if we're leaving the drag area and actions are NOT revealed
// If actions are revealed, let the action area handle the hiding
if (!isSwipeRevealed) {
// Actions are not revealed, so we can safely hide after delay
const hideTimer = setTimeout(() => {
resetSwipe()
}, 300)
setHoverTimer(hideTimer)
}
}
const handleActionAreaMouseEnter = () => {
// Clear any pending timer when entering action area
if (hoverTimer) {
clearTimeout(hoverTimer)
setHoverTimer(null)
}
}
const handleActionAreaMouseLeave = () => {
// Hide immediately when leaving action area
if (isSwipeRevealed) {
resetSwipe()
}
}
// Clean up timer on unmount
React.useEffect(() => {
return () => {
if (hoverTimer) {
clearTimeout(hoverTimer)
}
}
}, [hoverTimer])
return (
<Box key={thing.id + '-compact-box'}>
<Box
sx={{
position: 'relative',
overflow: 'hidden',
borderBottom: '1px solid',
borderColor: 'divider',
'&:last-child': {
borderBottom: 'none',
},
}}
onMouseLeave={() => {
// Only clear timers, don't auto-hide
if (hoverTimer) {
clearTimeout(hoverTimer)
setHoverTimer(null)
}
}}
>
{/* Action buttons underneath (revealed on swipe) */}
<Box
sx={{
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='success'
size='sm'
onClick={e => {
e.stopPropagation()
if (thing?.type === 'text') {
onEditClick(thing)
} else {
handleRequestChange(thing)
}
}}
disabled={isDisabled}
sx={{
width: 40,
height: 40,
mx: 1,
}}
>
{getThingIcon(thing?.type)}
</IconButton>
<IconButton
variant='soft'
color='neutral'
size='sm'
onClick={e => {
e.stopPropagation()
resetSwipe()
onEditClick(thing)
}}
sx={{
width: 40,
height: 40,
mx: 1,
}}
>
<Edit sx={{ fontSize: 16 }} />
</IconButton>
<IconButton
variant='soft'
color='danger'
size='sm'
onClick={e => {
e.stopPropagation()
resetSwipe()
onDeleteClick(thing)
}}
sx={{
width: 40,
height: 40,
mx: 1,
}}
>
<Delete sx={{ fontSize: 16 }} />
</IconButton>
</Box>
{/* Main card content */}
<Box
ref={cardRef}
sx={{
display: 'flex',
alignItems: 'center',
minHeight: 64,
cursor: 'pointer',
position: 'relative',
width: '100%',
px: 2,
py: 1.5,
bgcolor: 'background.body',
transform: `translateX(${swipeTranslateX}px)`,
transition: isDragging ? 'none' : 'transform 0.3s ease-out',
zIndex: 1,
'&:hover': {
bgcolor: isSwipeRevealed
? 'background.surface'
: 'background.level1',
boxShadow: isSwipeRevealed ? 'none' : 'sm',
},
borderBottom: '1px solid',
borderColor: 'divider',
cursor: 'pointer',
}}
onClick={() => {
if (isSwipeRevealed) {
resetSwipe()
return
}
Navigate(`/things/${thing?.id}`)
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onClick={onCardClick}
>
{/* Right drag area - only triggers reveal on hover */}
<Box
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, // Hide when action area is revealed
transition: 'opacity 0.2s ease',
pointerEvents: isSwipeRevealed ? 'none' : 'auto', // Disable pointer events when revealed
'&:hover': {
opacity: isSwipeRevealed ? 0 : 0.7,
},
'&:active': {
cursor: 'grabbing',
},
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{/* Drag indicator dots */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 0.25,
}}
>
{[...Array(3)].map((_, i) => (
<Box
key={i}
sx={{
width: 3,
height: 3,
borderRadius: '50%',
bgcolor: 'text.tertiary',
}}
/>
))}
</Box>
</Box>
{/* Avatar and Primary Action */}
<Box
sx={{
@@ -513,12 +184,11 @@ const ThingCard = ({
</Box>
</Box>
</Box>
</Box>
</Box>
)
}
const ThingsView = () => {
const navigate = useNavigate()
const [things, setThings] = useState([])
const [isShowCreateThingModal, setIsShowCreateThingModal] = useState(false)
const [isShowEditThingStateModal, setIsShowEditStateModal] = useState(false)
@@ -626,22 +296,23 @@ const ThingsView = () => {
}
const handleStateChangeRequest = thing => {
if (thing?.type === 'number') {
thing.state = Number(thing.state) + 1
} else if (thing?.type === 'boolean') {
if (thing.state === 'true') {
thing.state = 'false'
const updatedThing = { ...thing }
if (updatedThing?.type === 'number') {
updatedThing.state = Number(updatedThing.state) + 1
} else if (updatedThing?.type === 'boolean') {
if (updatedThing.state === 'true') {
updatedThing.state = 'false'
} else {
thing.state = 'true'
updatedThing.state = 'true'
}
}
UpdateThingState(thing)
UpdateThingState(updatedThing)
.then(result => {
result.json().then(data => {
const currentThings = [...things]
const thingIndex = currentThings.findIndex(
currentThing => currentThing.id === thing.id,
currentThing => currentThing.id === updatedThing.id,
)
currentThings[thingIndex] = data.res
setThings(currentThings)
@@ -712,15 +383,100 @@ const ThingsView = () => {
</Typography>
</Box>
)}
<SwipeableList type={ListType.IOS} fullSwipe={false}>
{things.map(thing => (
<ThingCard
key={thing?.id}
thing={thing}
onEditClick={handleEditClick}
onDeleteClick={handleDeleteClick}
onStateChangeRequest={handleStateChangeRequest}
/>
<SwipeableListItem
onClick={() => navigate(`/things/${thing?.id}`)}
key={thing.id}
trailingActions={
<TrailingActions>
<Box
sx={{
display: 'flex',
boxShadow: 'inset 2px 0 4px rgba(0,0,0,0.06)',
zIndex: 0,
}}
>
<SwipeAction
onClick={() => {
if (thing?.type === 'text') {
handleEditClick(thing)
} else {
handleStateChangeRequest(thing)
}
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'success.softBg',
color: 'success.600',
px: 3,
height: '100%',
}}
>
{thing?.type === 'text' ? (
<Flip sx={{ fontSize: 20 }} />
) : thing?.type === 'number' ? (
<PlusOne sx={{ fontSize: 20 }} />
) : thing.state === 'true' ? (
<ToggleOn sx={{ fontSize: 20 }} />
) : (
<ToggleOff sx={{ fontSize: 20 }} />
)}
<Typography level='body-xs' sx={{ mt: 0.5 }}>
{thing?.type === 'text' ? 'Edit' : 'Toggle'}
</Typography>
</Box>
</SwipeAction>
<SwipeAction onClick={() => handleEditClick(thing)}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'var(--joy-palette-neutral-100)',
px: 3,
height: '100%',
}}
>
<Edit sx={{ fontSize: 20 }} />
<Typography level='body-xs' sx={{ mt: 0.5 }}>
Edit
</Typography>
</Box>
</SwipeAction>
<SwipeAction onClick={() => handleDeleteClick(thing)}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'danger.softBg',
color: 'danger.600',
px: 3,
height: '100%',
}}
>
<Delete sx={{ fontSize: 20 }} />
<Typography level='body-xs' sx={{ mt: 0.5 }}>
Delete
</Typography>
</Box>
</SwipeAction>
</Box>
</TrailingActions>
}
>
<ThingCardContent thing={thing} />
</SwipeableListItem>
))}
</SwipeableList>
</Box>
<Box
// variant='outlined'

View File

@@ -8,6 +8,8 @@ import {
Person,
PlayArrow,
} from '@mui/icons-material'
import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import {
Alert,
Avatar,
@@ -25,16 +27,24 @@ import {
Typography,
} from '@mui/joy'
import moment from 'moment'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { useCircleMembers } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import {
Type as ListType,
SwipeableList,
SwipeableListItem,
SwipeAction,
TrailingActions,
} from 'react-swipeable-list'
import 'react-swipeable-list/dist/styles.css'
import {
useChoreTimer,
usePauseChore,
useStartChore,
useUpdateTimeSession,
} from '../../queries/TimeQueries'
import { useCircleMembers } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { resolvePhotoURL } from '../../utils/Helpers'
import { getSafeBottom } from '../../utils/SafeAreaUtils'
import LoadingComponent from '../components/Loading'
@@ -48,14 +58,6 @@ const TimerDetails = () => {
const [timerActionLoading, setTimerActionLoading] = useState(false)
const { showError, showSuccess } = useNotification()
// Swipe functionality state for session cards
const [sessionSwipeStates, setSessionSwipeStates] = useState({})
const swipeThreshold = 80
const maxSwipeDistance = 160
const dragStartX = useRef(0)
const [isDragging, setIsDragging] = useState(false)
const [isTouchDevice, setIsTouchDevice] = useState(false)
// Fetch circle members data
const { data: circleMembersData, isLoading: isCircleMembersLoading } =
useCircleMembers()
@@ -73,14 +75,6 @@ const TimerDetails = () => {
return members?.find(member => member.userId === userId)
}
// Detect if device supports touch
useEffect(() => {
const checkTouchDevice = () => {
setIsTouchDevice('ontouchstart' in window || navigator.maxTouchPoints > 0)
}
checkTouchDevice()
}, [])
// Update timerData when choreTimer data changes
useEffect(() => {
if (choreTimer?.res) {
@@ -102,7 +96,6 @@ const TimerDetails = () => {
}
}, [timerData])
const formatTime = seconds => {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
@@ -341,151 +334,11 @@ const TimerDetails = () => {
return Math.max(0, totalDuration - activeDuration)
}
// Swipe functionality methods
const getSessionSwipeState = sessionIndex => {
return (
sessionSwipeStates[sessionIndex] || {
translateX: 0,
isRevealed: false,
}
)
}
const updateSessionSwipeState = (sessionIndex, newState) => {
setSessionSwipeStates(prev => ({
...prev,
[sessionIndex]: {
...prev[sessionIndex],
...newState,
},
}))
}
const resetSessionSwipe = sessionIndex => {
updateSessionSwipeState(sessionIndex, {
translateX: 0,
isRevealed: false,
})
}
const resetAllSwipes = () => {
setSessionSwipeStates({})
}
// Touch handlers for swipe
const handleSessionTouchStart = e => {
dragStartX.current = e.touches[0].clientX
setIsDragging(true)
}
const handleSessionTouchMove = (e, sessionIndex) => {
if (!isDragging) return
const currentX = e.touches[0].clientX
const deltaX = currentX - dragStartX.current
const currentState = getSessionSwipeState(sessionIndex)
if (currentState.isRevealed) {
if (deltaX > 0) {
const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0)
updateSessionSwipeState(sessionIndex, { translateX: clampedDelta })
}
} else {
if (deltaX < 0) {
const clampedDelta = Math.max(deltaX, -maxSwipeDistance)
updateSessionSwipeState(sessionIndex, { translateX: clampedDelta })
}
}
}
const handleSessionTouchEnd = (e, sessionIndex) => {
if (!isDragging) return
setIsDragging(false)
const currentState = getSessionSwipeState(sessionIndex)
if (currentState.isRevealed) {
if (currentState.translateX > -swipeThreshold) {
resetSessionSwipe(sessionIndex)
} else {
updateSessionSwipeState(sessionIndex, {
translateX: -maxSwipeDistance,
isRevealed: true,
})
}
} else {
if (Math.abs(currentState.translateX) > swipeThreshold) {
updateSessionSwipeState(sessionIndex, {
translateX: -maxSwipeDistance,
isRevealed: true,
})
} else {
resetSessionSwipe(sessionIndex)
}
}
}
// Mouse handlers for swipe (desktop)
const handleSessionMouseDown = e => {
dragStartX.current = e.clientX
setIsDragging(true)
}
const handleSessionMouseMove = (e, sessionIndex) => {
if (!isDragging) return
const currentX = e.clientX
const deltaX = currentX - dragStartX.current
const currentState = getSessionSwipeState(sessionIndex)
if (currentState.isRevealed) {
if (deltaX > 0) {
const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0)
updateSessionSwipeState(sessionIndex, { translateX: clampedDelta })
}
} else {
if (deltaX < 0) {
const clampedDelta = Math.max(deltaX, -maxSwipeDistance)
updateSessionSwipeState(sessionIndex, { translateX: clampedDelta })
}
}
}
const handleSessionMouseUp = (e, sessionIndex) => {
if (!isDragging) return
setIsDragging(false)
const currentState = getSessionSwipeState(sessionIndex)
if (currentState.isRevealed) {
if (currentState.translateX > -swipeThreshold) {
resetSessionSwipe(sessionIndex)
} else {
updateSessionSwipeState(sessionIndex, {
translateX: -maxSwipeDistance,
isRevealed: true,
})
}
} else {
if (Math.abs(currentState.translateX) > swipeThreshold) {
updateSessionSwipeState(sessionIndex, {
translateX: -maxSwipeDistance,
isRevealed: true,
})
} else {
resetSessionSwipe(sessionIndex)
}
}
}
const handleEditSession = () => {
resetAllSwipes()
// Trigger the existing edit functionality
startEditingSession()
}
const handleDeleteSession = sessionIndex => {
resetAllSwipes()
// For now, just show an alert since we'd need to implement session deletion API
showError({
title: 'Delete Session',
@@ -493,11 +346,6 @@ const TimerDetails = () => {
})
}
// Reset swipes when editing mode changes
useEffect(() => {
resetAllSwipes()
}, [editingSessions])
if (loading || isCircleMembersLoading) {
return <LoadingComponent />
}
@@ -1067,13 +915,7 @@ const TimerDetails = () => {
Work Sessions ({timerData.pauseLog.length})
</Typography>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1.5,
}}
>
<SwipeableList type={ListType.IOS} fullSwipe={false}>
{timerData.pauseLog
.sort((a, b) => moment(b.start) - moment(a.start))
.map((pause, pauseIndex) => {
@@ -1095,67 +937,74 @@ const TimerDetails = () => {
)
: pause.duration
const swipeState = getSessionSwipeState(pauseIndex)
return (
<Box
<SwipeableListItem
key={pauseIndex}
sx={{
position: 'relative',
overflow: 'hidden',
borderRadius: 'md',
}}
>
{/* Action buttons underneath (revealed on swipe) */}
trailingActions={
<TrailingActions>
<Box
sx={{
position: 'absolute',
right: 0,
top: 0,
bottom: getSafeBottom(),
width: maxSwipeDistance,
display: 'flex',
alignItems: 'center',
boxShadow: 'inset 2px 0 4px rgba(0,0,0,0.06)',
boxShadow:
'inset 2px 0 4px rgba(0,0,0,0.06)',
zIndex: 0,
}}
>
<IconButton
variant='soft'
color='primary'
size='sm'
onClick={e => {
e.stopPropagation()
handleEditSession()
}}
<SwipeAction
onClick={() => handleEditSession()}
>
<Box
sx={{
width: 40,
height: 40,
mx: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor:
'var(--joy-palette-neutral-100)',
px: 3,
height: '100%',
}}
>
<Edit sx={{ fontSize: 16 }} />
</IconButton>
<IconButton
variant='soft'
color='danger'
size='sm'
onClick={e => {
e.stopPropagation()
handleDeleteSession(pauseIndex)
}}
sx={{
width: 40,
height: 40,
mx: 1,
}}
<EditIcon sx={{ fontSize: 20 }} />
<Typography
level='body-xs'
sx={{ mt: 0.5 }}
>
<Delete sx={{ fontSize: 16 }} />
</IconButton>
Edit
</Typography>
</Box>
{/* Session Card */}
</SwipeAction>
<SwipeAction
onClick={() =>
handleDeleteSession(pauseIndex)
}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'danger.softBg',
color: 'danger.600',
px: 3,
height: '100%',
}}
>
<DeleteIcon sx={{ fontSize: 20 }} />
<Typography
level='body-xs'
sx={{ mt: 0.5 }}
>
Delete
</Typography>
</Box>
</SwipeAction>
</Box>
</TrailingActions>
}
>
{/* Session Card Content */}
<Card
variant='soft'
sx={{
@@ -1168,40 +1017,10 @@ const TimerDetails = () => {
borderColor: isOngoing
? 'success.300'
: 'divider',
position: 'relative',
transform: `translateX(${swipeState.translateX}px)`,
transition: isDragging
? 'none'
: 'transform 0.3s ease-out',
zIndex: 1,
cursor: 'pointer',
'&:hover': {
bgcolor: swipeState.isRevealed
? 'background.surface'
: 'background.level1',
},
borderRadius: 0,
borderBottom: '1px solid',
minWidth: '100%',
}}
onClick={() => {
if (swipeState.isRevealed) {
resetSessionSwipe(pauseIndex)
return
}
// Optional: Navigate to session details
}}
onTouchStart={handleSessionTouchStart}
onTouchMove={e =>
handleSessionTouchMove(e, pauseIndex)
}
onTouchEnd={e =>
handleSessionTouchEnd(e, pauseIndex)
}
onMouseDown={handleSessionMouseDown}
onMouseMove={e =>
handleSessionMouseMove(e, pauseIndex)
}
onMouseUp={e =>
handleSessionMouseUp(e, pauseIndex)
}
>
{/* Session indicator */}
<Box
@@ -1312,59 +1131,11 @@ const TimerDetails = () => {
{endTime ? `${endTime}` : '→ ongoing'}
</Typography>
</Box>
{/* Right drag indicator (desktop only) */}
{!isTouchDevice && (
<Box
sx={{
position: 'absolute',
right: 8,
top: '50%',
transform: 'translateY(-50%)',
width: '20px',
height: '20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
opacity: swipeState.isRevealed ? 0 : 0.3,
transition: 'opacity 0.2s ease',
pointerEvents: swipeState.isRevealed
? 'none'
: 'auto',
'&:hover': {
opacity: swipeState.isRevealed
? 0
: 0.7,
},
}}
>
{/* Drag indicator dots */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 0.25,
}}
>
{[...Array(3)].map((_, i) => (
<Box
key={i}
sx={{
width: 3,
height: 3,
borderRadius: '50%',
bgcolor: 'text.tertiary',
}}
/>
))}
</Box>
</Box>
)}
</Card>
</Box>
</SwipeableListItem>
)
})}
</Box>
</SwipeableList>
</Box>
)}