enhance impersonation context and user profile handling; improve UI interactions in chore history and timer details
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
AdminPanelSettings,
|
||||
DarkModeOutlined,
|
||||
Email,
|
||||
GroupAdd,
|
||||
LightModeOutlined,
|
||||
Logout,
|
||||
Person,
|
||||
@@ -24,8 +24,9 @@ import {
|
||||
Typography,
|
||||
useColorScheme,
|
||||
} from '@mui/joy'
|
||||
import { useMediaQuery } from '@mui/material'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useImpersonateUser } from '../contexts/ImpersonateUserContext'
|
||||
import useStickyState from '../hooks/useStickyState'
|
||||
@@ -38,29 +39,23 @@ const UserProfileAvatar = () => {
|
||||
const navigate = useNavigate()
|
||||
const { mode, setMode } = useColorScheme()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const { impersonatedUser, setImpersonatedUser } = useImpersonateUser()
|
||||
const {
|
||||
isImpersonating,
|
||||
startImpersonation,
|
||||
stopImpersonation,
|
||||
canImpersonate,
|
||||
getEffectiveUser,
|
||||
} = useImpersonateUser()
|
||||
const { data: circleMembersData } = useCircleMembers()
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [isSubscriptionModalOpen, setIsSubscriptionModalOpen] = useState(false)
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
const [themeMode, setThemeMode] = useStickyState(mode, 'themeMode')
|
||||
|
||||
useEffect(() => {
|
||||
if (userProfile && userProfile?.id) {
|
||||
const members = circleMembersData?.res || []
|
||||
const isUserAdmin = members.some(
|
||||
member =>
|
||||
member.userId === userProfile?.id &&
|
||||
(member.role === 'admin' || member.role === 'manager'),
|
||||
)
|
||||
setIsAdmin(isUserAdmin)
|
||||
}
|
||||
}, [userProfile, circleMembersData])
|
||||
const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('lg'))
|
||||
|
||||
if (!userProfile) return null
|
||||
|
||||
const currentUser = impersonatedUser || userProfile
|
||||
const isImpersonating = !!impersonatedUser
|
||||
const currentUser = getEffectiveUser(userProfile)
|
||||
const isAdmin = canImpersonate(userProfile, circleMembersData?.res)
|
||||
const isPlusUser = isPlusAccount(userProfile)
|
||||
|
||||
const getSubscriptionStatus = () => {
|
||||
@@ -310,7 +305,7 @@ const UserProfileAvatar = () => {
|
||||
|
||||
{isImpersonating && (
|
||||
<MenuItem
|
||||
onClick={() => setImpersonatedUser(null)}
|
||||
onClick={() => stopImpersonation()}
|
||||
sx={{
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
'&:hover': {
|
||||
@@ -367,7 +362,7 @@ const UserProfileAvatar = () => {
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem
|
||||
onClick={() => navigate('/settings/detailed#sidepanel')}
|
||||
onClick={() => navigate('/settings/circle')}
|
||||
sx={{
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
'&:hover': {
|
||||
@@ -375,21 +370,49 @@ const UserProfileAvatar = () => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator sx={{ color: 'var(--joy-palette-neutral-500)' }}>
|
||||
<Tune />
|
||||
<ListItemDecorator sx={{ color: 'var(--joy-palette-primary-500)' }}>
|
||||
<GroupAdd />
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
|
||||
Sidepanel Settings
|
||||
Invite People
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Customize layout & cards
|
||||
Add members to your circle
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
{isLargeScreen && (
|
||||
<MenuItem
|
||||
onClick={() => navigate('/settings/detailed#sidepanel')}
|
||||
sx={{
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator
|
||||
sx={{ color: 'var(--joy-palette-neutral-500)' }}
|
||||
>
|
||||
<Tune />
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
|
||||
Side Panel Settings
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Customize layout & cards
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
<MenuItem
|
||||
onClick={handleThemeToggle}
|
||||
@@ -445,7 +468,7 @@ const UserProfileAvatar = () => {
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
<MenuItem
|
||||
{/* <MenuItem
|
||||
onClick={handleSupportEmail}
|
||||
sx={{
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
@@ -468,7 +491,7 @@ const UserProfileAvatar = () => {
|
||||
support@donetick.com
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
</MenuItem> */}
|
||||
|
||||
<Divider sx={{ my: 1 }} />
|
||||
|
||||
@@ -500,7 +523,7 @@ const UserProfileAvatar = () => {
|
||||
isOpen={isModalOpen}
|
||||
performers={circleMembersData?.res}
|
||||
onSelect={user => {
|
||||
setImpersonatedUser(user)
|
||||
startImpersonation(user, userProfile)
|
||||
setIsModalOpen(false)
|
||||
}}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
|
||||
@@ -1,15 +1,150 @@
|
||||
import { createContext, useContext, useState } from 'react'
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
const ImpersonateUserContext = createContext()
|
||||
|
||||
export const useImpersonateUser = () => useContext(ImpersonateUserContext)
|
||||
|
||||
export const ImpersonateUserProvider = ({ children }) => {
|
||||
const [impersonatedUser, setImpersonatedUser] = useState(null)
|
||||
const [impersonationState, setImpersonationState] = useState({
|
||||
isImpersonating: false,
|
||||
impersonatedUser: null,
|
||||
originalUser: null,
|
||||
})
|
||||
|
||||
// Start impersonation
|
||||
const startImpersonation = useCallback((userToImpersonate, currentUser) => {
|
||||
console.log('Starting impersonation:', { userToImpersonate, currentUser })
|
||||
const newState = {
|
||||
isImpersonating: true,
|
||||
impersonatedUser: userToImpersonate,
|
||||
originalUser: currentUser,
|
||||
}
|
||||
|
||||
setImpersonationState(newState)
|
||||
|
||||
// Store in localStorage for persistence across page refreshes
|
||||
localStorage.setItem('impersonation', JSON.stringify(newState))
|
||||
localStorage.setItem('impersonatedUserId', userToImpersonate.userId)
|
||||
}, [])
|
||||
|
||||
// Stop impersonation
|
||||
const stopImpersonation = useCallback(() => {
|
||||
console.log('Stopping impersonation')
|
||||
setImpersonationState({
|
||||
isImpersonating: false,
|
||||
impersonatedUser: null,
|
||||
originalUser: null,
|
||||
})
|
||||
|
||||
// Remove from localStorage
|
||||
localStorage.removeItem('impersonation')
|
||||
localStorage.removeItem('impersonatedUserId')
|
||||
}, [])
|
||||
|
||||
// Get effective user (impersonated user if impersonating, otherwise current user)
|
||||
const getEffectiveUser = useCallback(
|
||||
currentUser => {
|
||||
if (
|
||||
impersonationState.isImpersonating &&
|
||||
impersonationState.impersonatedUser
|
||||
) {
|
||||
return impersonationState.impersonatedUser
|
||||
}
|
||||
return currentUser
|
||||
},
|
||||
[impersonationState],
|
||||
)
|
||||
|
||||
// Get impersonation headers for API calls
|
||||
const getImpersonationHeaders = useCallback(() => {
|
||||
if (
|
||||
impersonationState.isImpersonating &&
|
||||
impersonationState.impersonatedUser
|
||||
) {
|
||||
return {
|
||||
'X-Impersonate-User-ID':
|
||||
impersonationState.impersonatedUser.id.toString(),
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}, [impersonationState])
|
||||
|
||||
// Check if user can impersonate (admin or manager)
|
||||
// Note: This is a basic check. The component using this should also check circle membership
|
||||
const canImpersonate = useCallback((user, circleMembers = []) => {
|
||||
if (!user?.id) return false
|
||||
|
||||
// If circleMembers is provided, check role from there
|
||||
if (circleMembers.length > 0) {
|
||||
const member = circleMembers.find(m => m.userId === user.id)
|
||||
return member?.role === 'admin' || member?.role === 'manager'
|
||||
}
|
||||
|
||||
// Fallback to user.role property (if available)
|
||||
return user?.role === 'admin' || user?.role === 'manager'
|
||||
}, [])
|
||||
|
||||
// Restore impersonation state from localStorage on mount
|
||||
useEffect(() => {
|
||||
const storedImpersonation = localStorage.getItem('impersonation')
|
||||
if (storedImpersonation) {
|
||||
try {
|
||||
const parsed = JSON.parse(storedImpersonation)
|
||||
if (
|
||||
parsed.isImpersonating &&
|
||||
parsed.impersonatedUser &&
|
||||
parsed.originalUser
|
||||
) {
|
||||
setImpersonationState(parsed)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to restore impersonation state:', error)
|
||||
localStorage.removeItem('impersonation')
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const value = {
|
||||
// State
|
||||
...impersonationState,
|
||||
|
||||
// Actions
|
||||
startImpersonation,
|
||||
stopImpersonation,
|
||||
|
||||
getEffectiveUser,
|
||||
getImpersonationHeaders,
|
||||
canImpersonate,
|
||||
|
||||
// Computed properties
|
||||
isImpersonating: impersonationState.isImpersonating,
|
||||
impersonatedUser: impersonationState.impersonatedUser,
|
||||
originalUser: impersonationState.originalUser,
|
||||
|
||||
// Legacy support
|
||||
setImpersonatedUser: user => {
|
||||
if (user) {
|
||||
// If setting a user, assume we're starting impersonation
|
||||
// Note: This won't have originalUser, so it's for backward compatibility only
|
||||
setImpersonationState(prev => ({
|
||||
isImpersonating: true,
|
||||
impersonatedUser: user,
|
||||
originalUser: prev.originalUser,
|
||||
}))
|
||||
} else {
|
||||
stopImpersonation()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<ImpersonateUserContext.Provider
|
||||
value={{ impersonatedUser, setImpersonatedUser }}
|
||||
>
|
||||
<ImpersonateUserContext.Provider value={value}>
|
||||
{children}
|
||||
</ImpersonateUserContext.Provider>
|
||||
)
|
||||
|
||||
@@ -137,9 +137,13 @@ export async function Fetch(url, options) {
|
||||
}
|
||||
|
||||
export const HEADERS = () => {
|
||||
// Import here to avoid circular dependency issues
|
||||
const impersonateUserId = localStorage.getItem('impersonatedUserId')
|
||||
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer ' + localStorage.getItem('ca_token'),
|
||||
...(impersonateUserId && { 'X-Impersonate-User-ID': impersonateUserId }),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -648,7 +648,7 @@ const LoginView = () => {
|
||||
variant='plain'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
window.open('https://donetick.com/privacy-policy', '_blank')
|
||||
window.open('https://donetick.com/privacy', '_blank')
|
||||
}}
|
||||
>
|
||||
Privacy Policy
|
||||
|
||||
@@ -6,30 +6,24 @@ import { useImpersonateUser } from '../../contexts/ImpersonateUserContext'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||
import UserModal from '../Modals/Inputs/UserModal'
|
||||
const UserSwitcher = () => {
|
||||
const { impersonatedUser, setImpersonatedUser } = useImpersonateUser()
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
const {
|
||||
impersonatedUser,
|
||||
isImpersonating,
|
||||
startImpersonation,
|
||||
stopImpersonation,
|
||||
canImpersonate
|
||||
} = useImpersonateUser()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
|
||||
const { data: circleMembersData, isLoading: isCircleMembersLoading } =
|
||||
useCircleMembers()
|
||||
|
||||
useEffect(() => {
|
||||
if (userProfile && userProfile?.id) {
|
||||
const members = circleMembersData?.res || []
|
||||
const isUserAdmin = members.some(
|
||||
member =>
|
||||
member.userId === userProfile?.id &&
|
||||
(member.role === 'admin' || member.role === 'manager'),
|
||||
)
|
||||
|
||||
setIsAdmin(isUserAdmin)
|
||||
}
|
||||
}, [userProfile, circleMembersData])
|
||||
// Check if current user can impersonate
|
||||
const isAdmin = canImpersonate(userProfile, circleMembersData?.res)
|
||||
if (!isAdmin) {
|
||||
return null
|
||||
} else if (isCircleMembersLoading || impersonatedUser === null) {
|
||||
} else if (isCircleMembersLoading || !isImpersonating) {
|
||||
return (
|
||||
<Sheet
|
||||
variant='plain'
|
||||
@@ -81,7 +75,7 @@ const UserSwitcher = () => {
|
||||
isOpen={isModalOpen}
|
||||
performers={circleMembersData?.res}
|
||||
onSelect={user => {
|
||||
setImpersonatedUser(user)
|
||||
startImpersonation(user, userProfile)
|
||||
setIsModalOpen(false)
|
||||
}}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
@@ -167,7 +161,7 @@ const UserSwitcher = () => {
|
||||
size='sm'
|
||||
sx={{ ml: 0.5 }}
|
||||
onClick={() => {
|
||||
setImpersonatedUser(null)
|
||||
stopImpersonation()
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
@@ -180,7 +174,7 @@ const UserSwitcher = () => {
|
||||
isOpen={isModalOpen}
|
||||
performers={circleMembersData?.res}
|
||||
onSelect={user => {
|
||||
setImpersonatedUser(user)
|
||||
startImpersonation(user, userProfile)
|
||||
setIsModalOpen(false)
|
||||
}}
|
||||
onClose={() => {
|
||||
|
||||
@@ -22,6 +22,7 @@ import moment from 'moment'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { LoadingScreen } from '../../components/animations'
|
||||
import useConfirmationModal from '../../hooks/useConfirmationModal'
|
||||
import { ChoreHistoryStatus } from '../../utils/Chores'
|
||||
import {
|
||||
DeleteChoreHistory,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
GetChoreHistory,
|
||||
UpdateChoreHistory,
|
||||
} from '../../utils/Fetcher'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import EditHistoryModal from '../Modals/EditHistoryModal'
|
||||
import HistoryCard from './HistoryCard'
|
||||
|
||||
@@ -42,6 +44,31 @@ const ChoreHistory = () => {
|
||||
const { choreId } = useParams()
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
|
||||
const [editHistory, setEditHistory] = useState({})
|
||||
const { confirmModalConfig, showConfirmation } = useConfirmationModal()
|
||||
|
||||
const handleDelete = historyEntry => {
|
||||
showConfirmation(
|
||||
`Are you sure you want to delete this history record?`,
|
||||
'Delete History Record',
|
||||
() => {
|
||||
DeleteChoreHistory(choreId, historyEntry.id).then(() => {
|
||||
const newHistory = choreHistory.filter(
|
||||
record => record.id !== historyEntry.id,
|
||||
)
|
||||
setChoresHistory(newHistory)
|
||||
updateHistoryInfo(newHistory, userHistory, performers)
|
||||
})
|
||||
},
|
||||
'Delete',
|
||||
'Cancel',
|
||||
'danger',
|
||||
)
|
||||
}
|
||||
|
||||
const handleEdit = historyEntry => {
|
||||
setIsEditModalOpen(true)
|
||||
setEditHistory(historyEntry)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true) // Start loading
|
||||
@@ -281,10 +308,9 @@ const ChoreHistory = () => {
|
||||
<List sx={{ p: 0 }}>
|
||||
{choreHistory.map((historyEntry, index) => (
|
||||
<HistoryCard
|
||||
onClick={() => {
|
||||
setIsEditModalOpen(true)
|
||||
setEditHistory(historyEntry)
|
||||
}}
|
||||
onClick={() => handleEdit(historyEntry)}
|
||||
onEditClick={handleEdit}
|
||||
onDeleteClick={handleDelete}
|
||||
historyEntry={historyEntry}
|
||||
performers={performers}
|
||||
allHistory={choreHistory}
|
||||
@@ -334,6 +360,7 @@ const ChoreHistory = () => {
|
||||
}}
|
||||
historyRecord={editHistory}
|
||||
/>
|
||||
<ConfirmationModal config={confirmModalConfig} />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
CalendarMonth,
|
||||
Check,
|
||||
CheckCircle,
|
||||
Delete,
|
||||
Edit,
|
||||
EventNote,
|
||||
HourglassEmpty,
|
||||
Person,
|
||||
@@ -16,12 +18,14 @@ import {
|
||||
Box,
|
||||
Chip,
|
||||
Grid,
|
||||
IconButton,
|
||||
ListDivider,
|
||||
ListItem,
|
||||
ListItemContent,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { TASK_COLOR } from '../../utils/Colors.jsx'
|
||||
|
||||
const getCompletedChip = historyEntry => {
|
||||
@@ -100,10 +104,22 @@ const HistoryCard = ({
|
||||
historyEntry,
|
||||
index,
|
||||
onClick,
|
||||
onEditClick,
|
||||
onDeleteClick,
|
||||
}) => {
|
||||
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
|
||||
@@ -150,23 +166,265 @@ 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 (
|
||||
<>
|
||||
<ListItem
|
||||
onClick={onClick}
|
||||
<Box
|
||||
sx={{
|
||||
cursor: onClick ? 'pointer' : 'default',
|
||||
py: 1.5,
|
||||
px: 2,
|
||||
'&:hover': onClick
|
||||
? {
|
||||
backgroundColor: 'background.level1',
|
||||
}
|
||||
: {},
|
||||
borderRadius: 'sm',
|
||||
transition: 'background-color 0.2s',
|
||||
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 */}
|
||||
@@ -303,6 +561,56 @@ const HistoryCard = ({
|
||||
</Grid>
|
||||
</Grid>
|
||||
</ListItemContent>
|
||||
|
||||
{/* Right drag area - only triggers reveal on hover */}
|
||||
{(onEditClick || onDeleteClick) && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '20px',
|
||||
cursor: 'grab',
|
||||
zIndex: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
opacity: isSwipeRevealed ? 0 : 0.3, // Hide when action area is revealed
|
||||
transition: 'opacity 0.2s ease',
|
||||
pointerEvents: isSwipeRevealed ? 'none' : 'auto', // Disable pointer events when revealed
|
||||
'&:hover': {
|
||||
opacity: isSwipeRevealed ? 0 : 0.7,
|
||||
},
|
||||
'&:active': {
|
||||
cursor: 'grabbing',
|
||||
},
|
||||
}}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{/* Drag indicator dots */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.25,
|
||||
}}
|
||||
>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
width: 3,
|
||||
height: 3,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'text.tertiary',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</ListItem>
|
||||
|
||||
{/* Compact Divider with Time Difference */}
|
||||
@@ -330,6 +638,7 @@ const HistoryCard = ({
|
||||
</Typography>
|
||||
</ListDivider>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -232,7 +232,7 @@ const LabelCard = ({ label, onEditClick, onDeleteClick, currentUserId }) => {
|
||||
onMouseLeave={handleActionAreaMouseLeave}
|
||||
>
|
||||
<IconButton
|
||||
variant='plain'
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
@@ -244,18 +244,13 @@ const LabelCard = ({ label, onEditClick, onDeleteClick, currentUserId }) => {
|
||||
width: 40,
|
||||
height: 40,
|
||||
mx: 1,
|
||||
bgcolor: 'primary.100',
|
||||
color: 'primary.600',
|
||||
'&:hover': {
|
||||
bgcolor: 'primary.200',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<EditIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
variant='plain'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
@@ -267,11 +262,6 @@ const LabelCard = ({ label, onEditClick, onDeleteClick, currentUserId }) => {
|
||||
width: 40,
|
||||
height: 40,
|
||||
mx: 1,
|
||||
bgcolor: 'danger.100',
|
||||
color: 'danger.600',
|
||||
'&:hover': {
|
||||
bgcolor: 'danger.200',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DeleteIcon sx={{ fontSize: 16 }} />
|
||||
|
||||
@@ -25,7 +25,12 @@ function EditHistoryModal({ config, historyRecord }) {
|
||||
const [notes, setNotes] = useState(historyRecord.notes)
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||
return (
|
||||
<ResponsiveModal open={config?.isOpen} onClose={config?.onClose}>
|
||||
<ResponsiveModal
|
||||
open={config?.isOpen}
|
||||
onClose={config?.onClose}
|
||||
size='lg'
|
||||
// fullWidth={true}
|
||||
>
|
||||
<Typography level='h4' mb={1}>
|
||||
Edit History
|
||||
</Typography>
|
||||
@@ -82,19 +87,9 @@ function EditHistoryModal({ config, historyRecord }) {
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button size='' onClick={config.onClose} variant='outlined'>
|
||||
<Button fullWidth size='lg' onClick={config.onClose} variant='outlined'>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size='lg'
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true)
|
||||
}}
|
||||
variant='outlined'
|
||||
color='danger'
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Box>
|
||||
<ConfirmationModal
|
||||
config={{
|
||||
|
||||
@@ -5,10 +5,12 @@ import {
|
||||
Delete,
|
||||
Edit,
|
||||
PauseCircle,
|
||||
Person,
|
||||
PlayArrow,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
@@ -25,6 +27,7 @@ import {
|
||||
import moment from 'moment'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useCircleMembers } from '../../queries/UserQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import {
|
||||
GetChoreTimer,
|
||||
@@ -32,6 +35,7 @@ import {
|
||||
StartChore,
|
||||
UpdateTimeSession,
|
||||
} from '../../utils/Fetcher'
|
||||
import { resolvePhotoURL } from '../../utils/Helpers'
|
||||
|
||||
const TimerDetails = () => {
|
||||
const { choreId } = useParams()
|
||||
@@ -50,6 +54,17 @@ const TimerDetails = () => {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [isTouchDevice, setIsTouchDevice] = useState(false)
|
||||
|
||||
// Fetch circle members data
|
||||
const { data: circleMembersData, isLoading: isCircleMembersLoading } =
|
||||
useCircleMembers()
|
||||
|
||||
const members = circleMembersData?.res || []
|
||||
|
||||
// Helper function to find member by user ID
|
||||
const getMemberById = userId => {
|
||||
return members?.find(member => member.userId === userId)
|
||||
}
|
||||
|
||||
// Detect if device supports touch
|
||||
useEffect(() => {
|
||||
const checkTouchDevice = () => {
|
||||
@@ -1230,16 +1245,56 @@ const TimerDetails = () => {
|
||||
>
|
||||
{formatDuration(realTimeDuration)}
|
||||
</Typography>
|
||||
{isOngoing && (
|
||||
<Chip
|
||||
size='sm'
|
||||
color='success'
|
||||
variant='soft'
|
||||
sx={{ fontSize: '0.7rem' }}
|
||||
>
|
||||
Live
|
||||
</Chip>
|
||||
)}
|
||||
<Box
|
||||
sx={{ display: 'flex', gap: 0.5, mt: 0.5 }}
|
||||
>
|
||||
{isOngoing && (
|
||||
<Chip
|
||||
size='sm'
|
||||
color='success'
|
||||
variant='soft'
|
||||
sx={{ fontSize: '0.7rem' }}
|
||||
>
|
||||
Live
|
||||
</Chip>
|
||||
)}
|
||||
{/* User chip showing who started the session */}
|
||||
{pause.updatedBy &&
|
||||
pause.updatedBy !== 0 &&
|
||||
(() => {
|
||||
const sessionUser = getMemberById(
|
||||
pause.updatedBy,
|
||||
)
|
||||
return sessionUser ? (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={
|
||||
<Avatar
|
||||
size='sm'
|
||||
src={resolvePhotoURL(
|
||||
sessionUser?.image,
|
||||
)}
|
||||
sx={{ width: 16, height: 16 }}
|
||||
>
|
||||
{sessionUser?.displayName?.charAt(
|
||||
0,
|
||||
) ||
|
||||
sessionUser?.name?.charAt(
|
||||
0,
|
||||
) || <Person />}
|
||||
</Avatar>
|
||||
}
|
||||
sx={{ fontSize: '0.7rem' }}
|
||||
>
|
||||
{sessionUser?.displayName ||
|
||||
sessionUser?.name ||
|
||||
'Unknown'}
|
||||
</Chip>
|
||||
) : null
|
||||
})()}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Session details */}
|
||||
|
||||
Reference in New Issue
Block a user