Rewrite the Swiping Logic and update the Project, Labelm Things,Chore and all swipe card/view
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
270
src/views/Chores/ChoreListView.jsx
Normal file
270
src/views/Chores/ChoreListView.jsx
Normal 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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 || []}
|
||||
userLabels={userLabels}
|
||||
onChipClick={handleLabelFiltering}
|
||||
onAction={handleChoreAction}
|
||||
// Multi-select props
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
isSelected={selectedChores.has(chore.id)}
|
||||
onSelectionToggle={() => toggleChoreSelection(chore.id)}
|
||||
/>
|
||||
))
|
||||
<ChoreListView
|
||||
chores={getChoresForDate(selectedCalendarDate)}
|
||||
viewMode={'compact'}
|
||||
membersData={membersData}
|
||||
userLabels={userLabels}
|
||||
handleLabelFiltering={handleLabelFiltering}
|
||||
handleChoreAction={handleChoreAction}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
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>
|
||||
)
|
||||
|
||||
@@ -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,330 +52,164 @@ const FilterCard = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={filter.id + '-filter-box'}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minHeight: 64,
|
||||
width: '100%',
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
bgcolor: 'background.body',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{/* Filter Icon */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
'&:last-child': {
|
||||
borderBottom: 'none',
|
||||
},
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (hoverTimer) {
|
||||
clearTimeout(hoverTimer)
|
||||
setHoverTimer(null)
|
||||
}
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mr: 2,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{/* Action buttons underneath (revealed on swipe) */}
|
||||
<Box
|
||||
<Avatar
|
||||
size='sm'
|
||||
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,
|
||||
width: 32,
|
||||
height: 32,
|
||||
bgcolor: filter.color || 'neutral.500',
|
||||
border: '2px solid',
|
||||
borderColor: filter.isPinned ? 'warning.300' : 'background.surface',
|
||||
boxShadow: filter.isPinned
|
||||
? '0 0 0 1px var(--joy-palette-warning-300)'
|
||||
: 'sm',
|
||||
}}
|
||||
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>
|
||||
{''}
|
||||
</Avatar>
|
||||
</Box>
|
||||
|
||||
<IconButton
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
resetSwipe()
|
||||
onEditClick(filter)
|
||||
}}
|
||||
{/* Content - Center */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Filter Name */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.25 }}>
|
||||
<Typography
|
||||
level='title-sm'
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
mx: 0.5,
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
{filter.name}
|
||||
</Typography>
|
||||
{filter.isPinned && (
|
||||
<Star
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
color: 'warning.500',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Main card content */}
|
||||
{/* Filter Info */}
|
||||
<Box
|
||||
ref={cardRef}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minHeight: 64,
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
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',
|
||||
},
|
||||
gap: 0.5,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
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)
|
||||
}}
|
||||
>
|
||||
{/* Drag indicator dots */}
|
||||
<Box
|
||||
{filter.description && (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.25,
|
||||
color: 'text.tertiary',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: '150px',
|
||||
}}
|
||||
>
|
||||
<MoreVert sx={{ fontSize: 20 }} />
|
||||
</Box>
|
||||
</Box>
|
||||
{filter.description}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Filter Icon */}
|
||||
<Box
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
startDecorator={<Task />}
|
||||
color={overdueCount > 0 ? 'danger' : 'primary'}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mr: 2,
|
||||
flexShrink: 0,
|
||||
fontSize: 10,
|
||||
height: 18,
|
||||
px: 0.75,
|
||||
bgcolor: overdueCount > 0 ? 'danger.softBg' : 'primary.softBg',
|
||||
color: overdueCount > 0 ? 'danger.500' : 'primary.500',
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
{taskCount} tasks
|
||||
</Chip>
|
||||
|
||||
{overdueCount > 0 && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='danger'
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
bgcolor: filter.color || 'neutral.500',
|
||||
border: '2px solid',
|
||||
borderColor: filter.isPinned
|
||||
? 'warning.300'
|
||||
: 'background.surface',
|
||||
boxShadow: filter.isPinned
|
||||
? '0 0 0 1px var(--joy-palette-warning-300)'
|
||||
: 'sm',
|
||||
fontSize: 10,
|
||||
height: 18,
|
||||
px: 0.75,
|
||||
}}
|
||||
>
|
||||
{''}
|
||||
</Avatar>
|
||||
</Box>
|
||||
{overdueCount} overdue
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
{/* Content - Center */}
|
||||
<Box
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
startDecorator={<FilterAlt />}
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
fontSize: 10,
|
||||
height: 18,
|
||||
px: 0.75,
|
||||
bgcolor: 'neutral.softBg',
|
||||
color: 'neutral.600',
|
||||
}}
|
||||
>
|
||||
{/* Filter Name */}
|
||||
<Box
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.25 }}
|
||||
>
|
||||
<Typography
|
||||
level='title-sm'
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{filter.name}
|
||||
</Typography>
|
||||
{filter.isPinned && (
|
||||
<Star
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
color: 'warning.500',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
{getConditionSummary()}
|
||||
</Chip>
|
||||
|
||||
{/* Filter Info */}
|
||||
<Box
|
||||
{filter.usageCount > 0 && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
flexWrap: 'wrap',
|
||||
fontSize: 10,
|
||||
height: 18,
|
||||
px: 0.75,
|
||||
bgcolor: 'success.softBg',
|
||||
color: 'success.600',
|
||||
}}
|
||||
>
|
||||
{filter.description && (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
color: 'text.tertiary',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: '150px',
|
||||
}}
|
||||
>
|
||||
{filter.description}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
startDecorator={<Task />}
|
||||
color={overdueCount > 0 ? 'danger' : 'primary'}
|
||||
sx={{
|
||||
fontSize: 10,
|
||||
height: 18,
|
||||
px: 0.75,
|
||||
bgcolor:
|
||||
overdueCount > 0 ? 'danger.softBg' : 'primary.softBg',
|
||||
color: overdueCount > 0 ? 'danger.500' : 'primary.500',
|
||||
}}
|
||||
>
|
||||
{taskCount} tasks
|
||||
</Chip>
|
||||
|
||||
{overdueCount > 0 && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='danger'
|
||||
sx={{
|
||||
fontSize: 10,
|
||||
height: 18,
|
||||
px: 0.75,
|
||||
}}
|
||||
>
|
||||
{overdueCount} overdue
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
startDecorator={<FilterAlt />}
|
||||
sx={{
|
||||
fontSize: 10,
|
||||
height: 18,
|
||||
px: 0.75,
|
||||
bgcolor: 'neutral.softBg',
|
||||
color: 'neutral.600',
|
||||
}}
|
||||
>
|
||||
{getConditionSummary()}
|
||||
</Chip>
|
||||
|
||||
{filter.usageCount > 0 && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
sx={{
|
||||
fontSize: 10,
|
||||
height: 18,
|
||||
px: 0.75,
|
||||
bgcolor: 'success.softBg',
|
||||
color: 'success.600',
|
||||
}}
|
||||
>
|
||||
Used {filter.usageCount}x
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
Used {filter.usageCount}x
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -545,6 +217,7 @@ const FilterCard = ({
|
||||
}
|
||||
|
||||
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
|
||||
key={filter.id}
|
||||
filter={filter}
|
||||
onEditClick={handleEditFilter}
|
||||
onDeleteClick={handleDeleteClicked}
|
||||
onPinClick={handlePinFilter}
|
||||
taskCount={filterCounts[filter.id]?.count || 0}
|
||||
overdueCount={filterCounts[filter.id]?.overdueCount || 0}
|
||||
/>
|
||||
))
|
||||
<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}
|
||||
taskCount={filterCounts[filter.id]?.count || 0}
|
||||
overdueCount={filterCounts[filter.id]?.overdueCount || 0}
|
||||
/>
|
||||
</SwipeableListItem>
|
||||
))}
|
||||
</SwipeableList>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -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) => (
|
||||
<HistoryCard
|
||||
onClick={() => handleEdit(historyEntry)}
|
||||
onEditClick={handleEdit}
|
||||
onDeleteClick={handleDelete}
|
||||
historyEntry={historyEntry}
|
||||
performers={performers}
|
||||
allHistory={choreHistory}
|
||||
key={index}
|
||||
index={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
|
||||
historyEntry={historyEntry}
|
||||
performers={performers}
|
||||
allHistory={choreHistory}
|
||||
index={index}
|
||||
/>
|
||||
</SwipeableListItem>
|
||||
))}
|
||||
</List>
|
||||
</SwipeableList>
|
||||
</Sheet>
|
||||
<EditHistoryModal
|
||||
config={{
|
||||
|
||||
@@ -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,477 +135,154 @@ 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,
|
||||
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',
|
||||
}}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
>
|
||||
<ListItemContent>
|
||||
<Grid container spacing={1} alignItems='center'>
|
||||
{/* First Row/Column: Status and Time Info */}
|
||||
<Grid xs={12} sm={8}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
{getStatusAvatar()}
|
||||
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
fontWeight: 'md',
|
||||
}}
|
||||
>
|
||||
{historyEntry.status === 0
|
||||
? 'In Progress'
|
||||
: historyEntry.status === 1
|
||||
? 'Completed'
|
||||
: historyEntry.status === 2
|
||||
? 'Skipped'
|
||||
: historyEntry.status === 3
|
||||
? 'Pending Approval'
|
||||
: historyEntry.status === 4
|
||||
? 'Rejected'
|
||||
: 'Completed'}
|
||||
</Typography>
|
||||
|
||||
<Chip size='sm' startDecorator={<EventNote />}>
|
||||
{moment(
|
||||
historyEntry.performedAt || historyEntry.updatedAt,
|
||||
).format('MMM DD, h:mm A')}
|
||||
</Chip>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
{getCompletedChip(historyEntry)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Second Row/Column: Completion Status (right side on desktop) */}
|
||||
<Grid xs={12} sm={4}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: { xs: 'flex-start', sm: 'flex-end' },
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{historyEntry.dueDate && (
|
||||
<Chip size='sm' startDecorator={<CalendarMonth />}>
|
||||
{moment(historyEntry.dueDate).format('MMM DD h:mm A')}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Third Row: Performer and Assignment Info */}
|
||||
<Grid xs={12}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flexWrap: 'wrap',
|
||||
mt: 0.5,
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='success'
|
||||
startDecorator={<CheckCircle />}
|
||||
>
|
||||
Done by {performer?.displayName || 'Unknown'}
|
||||
</Chip>
|
||||
|
||||
{historyEntry.completedBy !== historyEntry.assignedTo &&
|
||||
assignedTo && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<Person />}
|
||||
>
|
||||
Assigned to {assignedTo.displayName}
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
{historyEntry.notes && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
startDecorator={<EventNote />}
|
||||
sx={{ maxWidth: '120px', overflow: 'hidden' }}
|
||||
>
|
||||
Note
|
||||
</Chip>
|
||||
)}
|
||||
{/* add a duration chip if we have duration */}
|
||||
{historyEntry?.duration > 0 && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='primary'
|
||||
startDecorator={<AccessTime />}
|
||||
>
|
||||
{formatTime(historyEntry.duration)}
|
||||
</Chip>
|
||||
)}
|
||||
{historyEntry?.points > 0 && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='success'
|
||||
startDecorator={<Toll />}
|
||||
>
|
||||
{historyEntry.points} pt
|
||||
{historyEntry.points > 1 ? 's' : ''}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</ListItemContent>
|
||||
|
||||
{/* Right drag area - only triggers reveal on hover */}
|
||||
{(onEditClick || onDeleteClick) && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minHeight: 64,
|
||||
minWidth: '100%',
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
bgcolor: 'background.body',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Grid container spacing={1} alignItems='center'>
|
||||
{/* First Row/Column: Status and Time Info */}
|
||||
<Grid xs={12} sm={8}>
|
||||
<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',
|
||||
},
|
||||
gap: 1,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{/* Drag indicator dots */}
|
||||
<Box
|
||||
{getStatusAvatar()}
|
||||
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.25,
|
||||
color: 'text.secondary',
|
||||
fontWeight: 'md',
|
||||
}}
|
||||
>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
width: 3,
|
||||
height: 3,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'text.tertiary',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{historyEntry.status === 0
|
||||
? 'In Progress'
|
||||
: historyEntry.status === 1
|
||||
? 'Completed'
|
||||
: historyEntry.status === 2
|
||||
? 'Skipped'
|
||||
: historyEntry.status === 3
|
||||
? 'Pending Approval'
|
||||
: historyEntry.status === 4
|
||||
? 'Rejected'
|
||||
: 'Completed'}
|
||||
</Typography>
|
||||
|
||||
<Chip size='sm' startDecorator={<EventNote />}>
|
||||
{moment(
|
||||
historyEntry.performedAt || historyEntry.updatedAt,
|
||||
).format('MMM DD, h:mm A')}
|
||||
</Chip>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
{getCompletedChip(historyEntry)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</ListItem>
|
||||
</Grid>
|
||||
|
||||
{/* Compact Divider with Time Difference */}
|
||||
{index < allHistory.length - 1 && allHistory[index + 1].performedAt && (
|
||||
<ListDivider
|
||||
component='li'
|
||||
sx={{
|
||||
my: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
{/* Second Row/Column: Completion Status (right side on desktop) */}
|
||||
<Grid xs={12} sm={4}>
|
||||
<Box
|
||||
sx={{
|
||||
color: 'text.tertiary',
|
||||
backgroundColor: 'background.surface',
|
||||
px: 1,
|
||||
fontSize: '0.75rem',
|
||||
display: 'flex',
|
||||
justifyContent: { xs: 'flex-start', sm: 'flex-end' },
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{formatTimeDifference(
|
||||
historyEntry.performedAt || historyEntry.updatedAt,
|
||||
allHistory[index + 1].performedAt,
|
||||
)}{' '}
|
||||
before
|
||||
</Typography>
|
||||
</ListDivider>
|
||||
)}
|
||||
{historyEntry.dueDate && (
|
||||
<Chip size='sm' startDecorator={<CalendarMonth />}>
|
||||
{moment(historyEntry.dueDate).format('MMM DD h:mm A')}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Third Row: Performer and Assignment Info */}
|
||||
<Grid xs={12}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flexWrap: 'wrap',
|
||||
mt: 0.5,
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='success'
|
||||
startDecorator={<CheckCircle />}
|
||||
>
|
||||
Done by {performer?.displayName || 'Unknown'}
|
||||
</Chip>
|
||||
|
||||
{historyEntry.completedBy !== historyEntry.assignedTo &&
|
||||
assignedTo && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<Person />}
|
||||
>
|
||||
Assigned to {assignedTo.displayName}
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
{historyEntry.notes && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
startDecorator={<EventNote />}
|
||||
sx={{ maxWidth: '120px', overflow: 'hidden' }}
|
||||
>
|
||||
Note
|
||||
</Chip>
|
||||
)}
|
||||
{/* add a duration chip if we have duration */}
|
||||
{historyEntry?.duration > 0 && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='primary'
|
||||
startDecorator={<AccessTime />}
|
||||
>
|
||||
{formatTime(historyEntry.duration)}
|
||||
</Chip>
|
||||
)}
|
||||
{historyEntry?.points > 0 && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='success'
|
||||
startDecorator={<Toll />}
|
||||
>
|
||||
{historyEntry.points} pt
|
||||
{historyEntry.points > 1 ? 's' : ''}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,429 +10,122 @@ 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={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minHeight: 64,
|
||||
width: '100%',
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
bgcolor: 'background.body',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
{/* Color Avatar */}
|
||||
<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)
|
||||
}
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mr: 2,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{/* Action buttons underneath (revealed on swipe) */}
|
||||
<Box
|
||||
<Avatar
|
||||
size='sm'
|
||||
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,
|
||||
width: 32,
|
||||
height: 32,
|
||||
bgcolor: label.color,
|
||||
border: '2px solid',
|
||||
borderColor: isOwnedByCurrentUser
|
||||
? 'background.surface'
|
||||
: 'warning.300',
|
||||
boxShadow: isOwnedByCurrentUser
|
||||
? 'sm'
|
||||
: '0 0 0 1px var(--joy-palette-warning-300)',
|
||||
}}
|
||||
onMouseEnter={handleActionAreaMouseEnter}
|
||||
onMouseLeave={handleActionAreaMouseLeave}
|
||||
>
|
||||
<IconButton
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
resetSwipe()
|
||||
onEditClick(label)
|
||||
}}
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
mx: 1,
|
||||
color: getTextColorFromBackgroundColor(label.color),
|
||||
fontWeight: 'bold',
|
||||
fontSize: 10,
|
||||
}}
|
||||
>
|
||||
<EditIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
{label.name.charAt(0).toUpperCase()}
|
||||
</Typography>
|
||||
</Avatar>
|
||||
</Box>
|
||||
|
||||
<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}
|
||||
{/* Content - Center */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Label Name */}
|
||||
<Typography
|
||||
level='title-sm'
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minHeight: 64,
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
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',
|
||||
},
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
mb: 0.25,
|
||||
}}
|
||||
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,
|
||||
}}
|
||||
>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
width: 3,
|
||||
height: 3,
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'text.tertiary',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
{/* Color Avatar */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mr: 2,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
{label.name}
|
||||
</Typography>
|
||||
|
||||
{/* Color Info */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{!isOwnedByCurrentUser && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='warning'
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
bgcolor: label.color,
|
||||
border: '2px solid',
|
||||
borderColor: isOwnedByCurrentUser
|
||||
? 'background.surface'
|
||||
: 'warning.300',
|
||||
boxShadow: isOwnedByCurrentUser
|
||||
? 'sm'
|
||||
: '0 0 0 1px var(--joy-palette-warning-300)',
|
||||
fontSize: 9,
|
||||
height: 16,
|
||||
px: 0.5,
|
||||
fontWeight: 'md',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
color: getTextColorFromBackgroundColor(label.color),
|
||||
fontWeight: 'bold',
|
||||
fontSize: 10,
|
||||
}}
|
||||
>
|
||||
{label.name.charAt(0).toUpperCase()}
|
||||
</Typography>
|
||||
</Avatar>
|
||||
</Box>
|
||||
|
||||
{/* Content - Center */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Label Name */}
|
||||
<Typography
|
||||
level='title-sm'
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
mb: 0.25,
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</Typography>
|
||||
|
||||
{/* Color Info */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{!isOwnedByCurrentUser && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='warning'
|
||||
sx={{
|
||||
fontSize: 9,
|
||||
height: 16,
|
||||
px: 0.5,
|
||||
fontWeight: 'md',
|
||||
}}
|
||||
>
|
||||
Shared
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
Shared
|
||||
</Chip>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
{userLabels.map(label => (
|
||||
<LabelCard
|
||||
key={label.id}
|
||||
label={label}
|
||||
onEditClick={handleEditLabel}
|
||||
onDeleteClick={handleDeleteClicked}
|
||||
currentUserId={userProfile?.id}
|
||||
/>
|
||||
))}
|
||||
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
||||
{userLabels.map(label => (
|
||||
<SwipeableListItem
|
||||
key={label.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 && (
|
||||
|
||||
@@ -10,506 +10,191 @@ 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={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minHeight: 64,
|
||||
width: '100%',
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
bgcolor: 'background.body',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={onCardClick}
|
||||
>
|
||||
{/* Project Avatar */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
'&:last-child': {
|
||||
borderBottom: 'none',
|
||||
},
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (hoverTimer) {
|
||||
clearTimeout(hoverTimer)
|
||||
setHoverTimer(null)
|
||||
}
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mr: 2,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{/* 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}
|
||||
<Avatar
|
||||
size='sm'
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minHeight: 64,
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
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
|
||||
width: 32,
|
||||
height: 32,
|
||||
bgcolor: project.color || 'primary.500',
|
||||
border: '2px solid',
|
||||
borderColor: isDefaultProject
|
||||
? 'primary.300'
|
||||
: isOwnedByCurrentUser
|
||||
? 'background.surface'
|
||||
: 'background.level1',
|
||||
boxShadow: isSwipeRevealed ? 'none' : 'sm',
|
||||
},
|
||||
: 'warning.300',
|
||||
boxShadow: isDefaultProject
|
||||
? '0 0 0 1px var(--joy-palette-primary-300)'
|
||||
: isOwnedByCurrentUser
|
||||
? 'sm'
|
||||
: '0 0 0 1px var(--joy-palette-warning-300)',
|
||||
}}
|
||||
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}
|
||||
>
|
||||
{/* 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.icon ? (
|
||||
(() => {
|
||||
const IconComponent = getIconComponent(project.icon)
|
||||
return (
|
||||
<IconComponent
|
||||
sx={{
|
||||
fontSize: 16,
|
||||
color: getTextColorFromBackgroundColor(
|
||||
project.color || '#1976d2',
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})()
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</Avatar>
|
||||
</Box>
|
||||
|
||||
{/* Project Avatar */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mr: 2,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
{/* Content - Center */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Project Name */}
|
||||
<Typography
|
||||
level='title-sm'
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
mb: 0.25,
|
||||
}}
|
||||
>
|
||||
{project.name}
|
||||
{isDefaultProject && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='primary'
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
bgcolor: project.color || 'primary.500',
|
||||
border: '2px solid',
|
||||
borderColor: isDefaultProject
|
||||
? 'primary.300'
|
||||
: isOwnedByCurrentUser
|
||||
? 'background.surface'
|
||||
: 'warning.300',
|
||||
boxShadow: isDefaultProject
|
||||
? '0 0 0 1px var(--joy-palette-primary-300)'
|
||||
: isOwnedByCurrentUser
|
||||
? 'sm'
|
||||
: '0 0 0 1px var(--joy-palette-warning-300)',
|
||||
fontSize: 9,
|
||||
height: 16,
|
||||
px: 0.5,
|
||||
ml: 1,
|
||||
fontWeight: 'md',
|
||||
}}
|
||||
>
|
||||
{project.icon ? (
|
||||
(() => {
|
||||
const IconComponent = getIconComponent(project.icon)
|
||||
return (
|
||||
<IconComponent
|
||||
sx={{
|
||||
fontSize: 16,
|
||||
color: getTextColorFromBackgroundColor(
|
||||
project.color || '#1976d2',
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})()
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</Avatar>
|
||||
</Box>
|
||||
Default
|
||||
</Chip>
|
||||
)}
|
||||
</Typography>
|
||||
|
||||
{/* Content - Center */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Project Name */}
|
||||
{/* Project Info */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{project.description && (
|
||||
<Typography
|
||||
level='title-sm'
|
||||
level='body-xs'
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
color: 'text.tertiary',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
mb: 0.25,
|
||||
maxWidth: '200px',
|
||||
}}
|
||||
>
|
||||
{project.name}
|
||||
{isDefaultProject && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='primary'
|
||||
sx={{
|
||||
fontSize: 9,
|
||||
height: 16,
|
||||
px: 0.5,
|
||||
ml: 1,
|
||||
fontWeight: 'md',
|
||||
}}
|
||||
>
|
||||
Default
|
||||
</Chip>
|
||||
)}
|
||||
{project.description}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Project Info */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{project.description && (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
color: 'text.tertiary',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: '200px',
|
||||
}}
|
||||
>
|
||||
{project.description}
|
||||
</Typography>
|
||||
)}
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
startDecorator={<Task />}
|
||||
sx={{
|
||||
fontSize: 10,
|
||||
height: 18,
|
||||
px: 0.75,
|
||||
bgcolor: 'primary.softBg',
|
||||
color: 'primary.500',
|
||||
}}
|
||||
>
|
||||
{taskCount} tasks
|
||||
</Chip>
|
||||
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
startDecorator={<Task />}
|
||||
sx={{
|
||||
fontSize: 10,
|
||||
height: 18,
|
||||
px: 0.75,
|
||||
bgcolor: 'primary.softBg',
|
||||
color: 'primary.500',
|
||||
}}
|
||||
>
|
||||
{taskCount} tasks
|
||||
</Chip>
|
||||
|
||||
{!isOwnedByCurrentUser && !isDefaultProject && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='warning'
|
||||
sx={{
|
||||
fontSize: 9,
|
||||
height: 16,
|
||||
px: 0.5,
|
||||
fontWeight: 'md',
|
||||
}}
|
||||
>
|
||||
Shared
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
{!isOwnedByCurrentUser && !isDefaultProject && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='warning'
|
||||
sx={{
|
||||
fontSize: 9,
|
||||
height: 16,
|
||||
px: 0.5,
|
||||
fontWeight: 'md',
|
||||
}}
|
||||
>
|
||||
Shared
|
||||
</Chip>
|
||||
)}
|
||||
</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',
|
||||
})
|
||||
}
|
||||
/>
|
||||
{userProjects.map(project => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
project={project}
|
||||
onEditClick={handleEditProject}
|
||||
onDeleteClick={handleDeleteClicked}
|
||||
currentUserId={userProfile?.id}
|
||||
taskCounts={taskCounts}
|
||||
isEditable={true}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* User projects - swipeable */}
|
||||
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
||||
{userProjects.map(project => (
|
||||
<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}
|
||||
currentUserId={userProfile?.id}
|
||||
taskCounts={taskCounts}
|
||||
// onCardClick={}
|
||||
/>
|
||||
</SwipeableListItem>
|
||||
))}
|
||||
</SwipeableList>
|
||||
</Box>
|
||||
|
||||
{modalOpen && (
|
||||
|
||||
@@ -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,425 +84,103 @@ 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={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minHeight: 64,
|
||||
width: '100%',
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
bgcolor: 'background.body',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={onCardClick}
|
||||
>
|
||||
{/* Avatar and Primary Action */}
|
||||
<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)
|
||||
}
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mr: 2,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{/* Action buttons underneath (revealed on swipe) */}
|
||||
{getThingAvatar()}
|
||||
</Box>
|
||||
|
||||
{/* Content - Center */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Line 1: Name + State */}
|
||||
<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',
|
||||
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',
|
||||
},
|
||||
justifyContent: 'space-between',
|
||||
mb: 0.5,
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isSwipeRevealed) {
|
||||
resetSwipe()
|
||||
return
|
||||
}
|
||||
Navigate(`/things/${thing?.id}`)
|
||||
}}
|
||||
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,
|
||||
}}
|
||||
>
|
||||
{[...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={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mr: 2,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{getThingAvatar()}
|
||||
</Box>
|
||||
|
||||
{/* Content - Center */}
|
||||
<Box
|
||||
<Typography
|
||||
level='title-sm'
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
mr: 1,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Line 1: Name + State */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
level='title-sm'
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
mr: 1,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{thing?.name}
|
||||
</Typography>
|
||||
{thing?.name}
|
||||
</Typography>
|
||||
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color={
|
||||
thing?.type === 'boolean' && thing?.state === 'true'
|
||||
? 'success'
|
||||
: 'primary'
|
||||
}
|
||||
sx={{
|
||||
fontSize: 11,
|
||||
height: 20,
|
||||
px: 1,
|
||||
fontWeight: 'md',
|
||||
flexShrink: 0,
|
||||
ml: 1,
|
||||
}}
|
||||
>
|
||||
{thing?.state}
|
||||
</Chip>
|
||||
</Box>
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color={
|
||||
thing?.type === 'boolean' && thing?.state === 'true'
|
||||
? 'success'
|
||||
: 'primary'
|
||||
}
|
||||
sx={{
|
||||
fontSize: 11,
|
||||
height: 20,
|
||||
px: 1,
|
||||
fontWeight: 'md',
|
||||
flexShrink: 0,
|
||||
ml: 1,
|
||||
}}
|
||||
>
|
||||
{thing?.state}
|
||||
</Chip>
|
||||
</Box>
|
||||
|
||||
{/* Line 2: Type */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
sx={{
|
||||
fontSize: 10,
|
||||
height: 18,
|
||||
px: 0.75,
|
||||
}}
|
||||
>
|
||||
{thing?.type}
|
||||
</Chip>
|
||||
</Box>
|
||||
</Box>
|
||||
{/* Line 2: Type */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
sx={{
|
||||
fontSize: 10,
|
||||
height: 18,
|
||||
px: 0.75,
|
||||
}}
|
||||
>
|
||||
{thing?.type}
|
||||
</Chip>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -519,6 +188,7 @@ const ThingCard = ({
|
||||
}
|
||||
|
||||
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>
|
||||
)}
|
||||
{things.map(thing => (
|
||||
<ThingCard
|
||||
key={thing?.id}
|
||||
thing={thing}
|
||||
onEditClick={handleEditClick}
|
||||
onDeleteClick={handleDeleteClick}
|
||||
onStateChangeRequest={handleStateChangeRequest}
|
||||
/>
|
||||
))}
|
||||
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
||||
{things.map(thing => (
|
||||
<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'
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
trailingActions={
|
||||
<TrailingActions>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
boxShadow:
|
||||
'inset 2px 0 4px rgba(0,0,0,0.06)',
|
||||
zIndex: 0,
|
||||
}}
|
||||
>
|
||||
<SwipeAction
|
||||
onClick={() => handleEditSession()}
|
||||
>
|
||||
<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={() =>
|
||||
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>
|
||||
}
|
||||
>
|
||||
{/* Action buttons underneath (revealed on swipe) */}
|
||||
<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)',
|
||||
zIndex: 0,
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
variant='soft'
|
||||
color='primary'
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleEditSession()
|
||||
}}
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
mx: 1,
|
||||
}}
|
||||
>
|
||||
<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,
|
||||
}}
|
||||
>
|
||||
<Delete sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Session Card */}
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user