diff --git a/src/components/UserProfileAvatar.jsx b/src/components/UserProfileAvatar.jsx
index 0801636..7e1566d 100644
--- a/src/components/UserProfileAvatar.jsx
+++ b/src/components/UserProfileAvatar.jsx
@@ -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 && (
+ {isLargeScreen && (
+
+ )}
)}
-
+ */}
@@ -500,7 +523,7 @@ const UserProfileAvatar = () => {
isOpen={isModalOpen}
performers={circleMembersData?.res}
onSelect={user => {
- setImpersonatedUser(user)
+ startImpersonation(user, userProfile)
setIsModalOpen(false)
}}
onClose={() => setIsModalOpen(false)}
diff --git a/src/contexts/ImpersonateUserContext.jsx b/src/contexts/ImpersonateUserContext.jsx
index 23f5503..2964a37 100644
--- a/src/contexts/ImpersonateUserContext.jsx
+++ b/src/contexts/ImpersonateUserContext.jsx
@@ -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 (
-
+
{children}
)
diff --git a/src/utils/TokenManager.jsx b/src/utils/TokenManager.jsx
index 01d2ecb..98d0065 100644
--- a/src/utils/TokenManager.jsx
+++ b/src/utils/TokenManager.jsx
@@ -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 }),
}
}
diff --git a/src/views/Authorization/LoginView.jsx b/src/views/Authorization/LoginView.jsx
index b2ee9f8..b205649 100644
--- a/src/views/Authorization/LoginView.jsx
+++ b/src/views/Authorization/LoginView.jsx
@@ -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
diff --git a/src/views/Chores/UserSwitcher.jsx b/src/views/Chores/UserSwitcher.jsx
index c48956e..6483435 100644
--- a/src/views/Chores/UserSwitcher.jsx
+++ b/src/views/Chores/UserSwitcher.jsx
@@ -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 (
{
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={() => {
diff --git a/src/views/History/ChoreHistory.jsx b/src/views/History/ChoreHistory.jsx
index d40b61a..0005566 100644
--- a/src/views/History/ChoreHistory.jsx
+++ b/src/views/History/ChoreHistory.jsx
@@ -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 = () => {
{choreHistory.map((historyEntry, index) => (
{
- 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}
/>
+
)
}
diff --git a/src/views/History/HistoryCard.jsx b/src/views/History/HistoryCard.jsx
index 68b524e..c105772 100644
--- a/src/views/History/HistoryCard.jsx
+++ b/src/views/History/HistoryCard.jsx
@@ -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 (
<>
- {
+ // Only clear timers, don't auto-hide
+ if (hoverTimer) {
+ clearTimeout(hoverTimer)
+ setHoverTimer(null)
+ }
}}
>
+ {/* Action buttons underneath (revealed on swipe) */}
+ {(onEditClick || onDeleteClick) && (
+
+ {onEditClick && (
+ {
+ e.stopPropagation()
+ resetSwipe()
+ onEditClick(historyEntry)
+ }}
+ sx={{
+ width: 40,
+ height: 40,
+ mx: 1,
+ }}
+ >
+
+
+ )}
+
+ {onDeleteClick && (
+ {
+ e.stopPropagation()
+ resetSwipe()
+ onDeleteClick(historyEntry)
+ }}
+ sx={{
+ width: 40,
+ height: 40,
+ mx: 1,
+ }}
+ >
+
+
+ )}
+
+ )}
+
+ {/* Main card content */}
+ {
+ 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}
+ >
{/* First Row/Column: Status and Time Info */}
@@ -303,6 +561,56 @@ const HistoryCard = ({
+
+ {/* Right drag area - only triggers reveal on hover */}
+ {(onEditClick || onDeleteClick) && (
+
+ {/* Drag indicator dots */}
+
+ {[...Array(3)].map((_, i) => (
+
+ ))}
+
+
+ )}
{/* Compact Divider with Time Difference */}
@@ -330,6 +638,7 @@ const HistoryCard = ({
)}
+
>
)
}
diff --git a/src/views/Labels/LabelView.jsx b/src/views/Labels/LabelView.jsx
index b60f7ab..125962b 100644
--- a/src/views/Labels/LabelView.jsx
+++ b/src/views/Labels/LabelView.jsx
@@ -232,7 +232,7 @@ const LabelCard = ({ label, onEditClick, onDeleteClick, currentUserId }) => {
onMouseLeave={handleActionAreaMouseLeave}
>
{
@@ -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',
- },
}}
>
{
@@ -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',
- },
}}
>
diff --git a/src/views/Modals/EditHistoryModal.jsx b/src/views/Modals/EditHistoryModal.jsx
index 4995753..0047321 100644
--- a/src/views/Modals/EditHistoryModal.jsx
+++ b/src/views/Modals/EditHistoryModal.jsx
@@ -25,7 +25,12 @@ function EditHistoryModal({ config, historyRecord }) {
const [notes, setNotes] = useState(historyRecord.notes)
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
return (
-
+
Edit History
@@ -82,19 +87,9 @@ function EditHistoryModal({ config, historyRecord }) {
>
Save
-