Merge branch 'dev'
This commit is contained in:
@@ -5,6 +5,7 @@ import { useCallback, useEffect } from 'react'
|
||||
import { Outlet, useNavigate } from 'react-router-dom'
|
||||
import { useRegisterSW } from 'virtual:pwa-register/react'
|
||||
import { registerCapacitorListeners } from './CapacitorListener'
|
||||
import PageTransition from './components/animations/PageTransition'
|
||||
import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext'
|
||||
import { AuthenticationProvider } from './service/AuthenticationService'
|
||||
import { useNotification } from './service/NotificationProvider'
|
||||
@@ -74,7 +75,9 @@ const AppContent = () => {
|
||||
<>
|
||||
<ImpersonateUserProvider>
|
||||
<NavBar />
|
||||
<Outlet />
|
||||
<PageTransition>
|
||||
<Outlet />
|
||||
</PageTransition>
|
||||
</ImpersonateUserProvider>
|
||||
</>
|
||||
)
|
||||
|
||||
88
src/components/animations/AnimatedList.jsx
Normal file
88
src/components/animations/AnimatedList.jsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React from 'react'
|
||||
import { Box } from '@mui/joy'
|
||||
import { CSSTransition, TransitionGroup } from 'react-transition-group'
|
||||
import { useStaggeredAnimation, useReducedMotion } from '../../hooks/useAnimations'
|
||||
import './PageTransition.css'
|
||||
|
||||
const AnimatedList = ({
|
||||
children,
|
||||
staggerDelay = 50,
|
||||
animationType = 'stagger', // 'stagger', 'fade', 'slide'
|
||||
direction = 'up', // 'up', 'down', 'left', 'right'
|
||||
renderItem,
|
||||
keyExtractor,
|
||||
items,
|
||||
...boxProps
|
||||
}) => {
|
||||
// Handle both children and items patterns
|
||||
let childrenArray
|
||||
if (items && renderItem) {
|
||||
childrenArray = items.map((item, index) =>
|
||||
React.cloneElement(renderItem(item, index), {
|
||||
key: keyExtractor ? keyExtractor(item, index) : index
|
||||
})
|
||||
)
|
||||
} else {
|
||||
childrenArray = React.Children.toArray(children)
|
||||
}
|
||||
|
||||
const visibleItems = useStaggeredAnimation(childrenArray.length, staggerDelay)
|
||||
const prefersReducedMotion = useReducedMotion()
|
||||
|
||||
// If user prefers reduced motion, render without animations
|
||||
if (prefersReducedMotion) {
|
||||
return (
|
||||
<Box {...boxProps}>
|
||||
{items && renderItem ? childrenArray : children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const getAnimationClass = () => {
|
||||
switch (animationType) {
|
||||
case 'fade':
|
||||
return 'fade'
|
||||
case 'slide':
|
||||
return direction === 'up' ? 'slide-up' : 'page'
|
||||
case 'stagger':
|
||||
default:
|
||||
return 'stagger'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box {...boxProps}>
|
||||
<TransitionGroup component={null}>
|
||||
{childrenArray.map((child, index) => {
|
||||
const isVisible = visibleItems.has(index)
|
||||
|
||||
return (
|
||||
<CSSTransition
|
||||
key={child.key || index}
|
||||
in={isVisible}
|
||||
timeout={{
|
||||
enter: 300,
|
||||
exit: 200,
|
||||
}}
|
||||
classNames={getAnimationClass()}
|
||||
unmountOnExit={false}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? 'none' : 'translateY(20px)',
|
||||
transition: 'opacity 0.3s ease, transform 0.3s ease',
|
||||
transitionDelay: `${index * staggerDelay}ms`,
|
||||
}}
|
||||
>
|
||||
{child}
|
||||
</Box>
|
||||
</CSSTransition>
|
||||
)
|
||||
})}
|
||||
</TransitionGroup>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default AnimatedList
|
||||
105
src/components/animations/LoadingScreen.jsx
Normal file
105
src/components/animations/LoadingScreen.jsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Box, CircularProgress, Typography } from '@mui/joy'
|
||||
import { styled } from '@mui/joy/styles'
|
||||
|
||||
// Styled components with keyframe animations
|
||||
const LoadingContainer = styled(Box)({
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
animation: 'fadeIn 0.3s ease-out',
|
||||
'@keyframes fadeIn': {
|
||||
from: {
|
||||
opacity: 0,
|
||||
},
|
||||
to: {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const LoadingContent = styled(Box)({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '24px',
|
||||
animation: 'slideUp 0.5s ease-out 0.2s both',
|
||||
'@keyframes slideUp': {
|
||||
from: {
|
||||
opacity: 0,
|
||||
transform: 'translateY(20px)',
|
||||
},
|
||||
to: {
|
||||
opacity: 1,
|
||||
transform: 'translateY(0)',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const PulsingText = styled(Typography)({
|
||||
animation: 'pulse 2s ease-in-out infinite',
|
||||
'@keyframes pulse': {
|
||||
'0%, 100%': {
|
||||
opacity: 1,
|
||||
},
|
||||
'50%': {
|
||||
opacity: 0.5,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const LogoContainer = styled(Box)({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
marginBottom: '24px',
|
||||
})
|
||||
|
||||
const LoadingScreen = ({
|
||||
message = 'Loading...',
|
||||
showLogo = true,
|
||||
size = 'lg',
|
||||
}) => {
|
||||
return (
|
||||
<LoadingContainer>
|
||||
<LoadingContent>
|
||||
{showLogo && (
|
||||
<LogoContainer>
|
||||
<Typography
|
||||
level='h1'
|
||||
sx={{
|
||||
fontSize: { xs: '2rem', sm: '2.5rem' },
|
||||
fontWeight: 700,
|
||||
color: 'primary.500',
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
Done
|
||||
<span style={{ color: '#06b6d4' }}>tick</span>
|
||||
</Typography>
|
||||
</LogoContainer>
|
||||
)}
|
||||
|
||||
<CircularProgress
|
||||
size={size}
|
||||
sx={{
|
||||
'--CircularProgress-size': size === 'lg' ? '60px' : '40px',
|
||||
mb: 2,
|
||||
}}
|
||||
/>
|
||||
|
||||
<PulsingText level='body-md'>{message}</PulsingText>
|
||||
</LoadingContent>
|
||||
</LoadingContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoadingScreen
|
||||
208
src/components/animations/PageTransition.css
Normal file
208
src/components/animations/PageTransition.css
Normal file
@@ -0,0 +1,208 @@
|
||||
/* Page Transition Animations */
|
||||
.page-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Enter animations */
|
||||
.page-enter {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.page-enter-active {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
transition: opacity 300ms ease-out, transform 300ms ease-out;
|
||||
}
|
||||
|
||||
/* Exit animations */
|
||||
.page-exit {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.page-exit-active {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
transition: opacity 200ms ease-in, transform 200ms ease-in;
|
||||
}
|
||||
|
||||
/* Specific animation for back navigation */
|
||||
.page-back-enter {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
|
||||
.page-back-enter-active {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
transition: opacity 300ms ease-out, transform 300ms ease-out;
|
||||
}
|
||||
|
||||
.page-back-exit {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.page-back-exit-active {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
transition: opacity 200ms ease-in, transform 200ms ease-in;
|
||||
}
|
||||
|
||||
/* Modal/overlay animations */
|
||||
.modal-enter {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
}
|
||||
|
||||
.modal-enter-active {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
transition: opacity 250ms ease-out, transform 250ms ease-out;
|
||||
}
|
||||
|
||||
.modal-exit {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
|
||||
.modal-exit-active {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
transition: opacity 200ms ease-in, transform 200ms ease-in;
|
||||
}
|
||||
|
||||
/* Fade animation for simple transitions */
|
||||
.fade-enter {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.fade-enter-active {
|
||||
opacity: 1;
|
||||
transition: opacity 250ms ease-out;
|
||||
}
|
||||
|
||||
.fade-exit {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.fade-exit-active {
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease-in;
|
||||
}
|
||||
|
||||
/* Slide up animation for bottom sheets/panels */
|
||||
.slide-up-enter {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
.slide-up-enter-active {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition: opacity 300ms ease-out, transform 300ms ease-out;
|
||||
}
|
||||
|
||||
.slide-up-exit {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.slide-up-exit-active {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
transition: opacity 250ms ease-in, transform 250ms ease-in;
|
||||
}
|
||||
|
||||
/* Loading spinner animation */
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* Skeleton loading animations */
|
||||
@keyframes skeleton-loading {
|
||||
0% {
|
||||
background-position: -200px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: calc(200px + 100%) 0;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200px 100%;
|
||||
animation: skeleton-loading 1.5s infinite;
|
||||
}
|
||||
|
||||
/* Micro-interactions */
|
||||
.interactive-element {
|
||||
transition: all 0.2s ease-out;
|
||||
transform: translateZ(0); /* Enable GPU acceleration */
|
||||
}
|
||||
|
||||
.interactive-element:hover {
|
||||
transform: translateY(-2px) translateZ(0);
|
||||
}
|
||||
|
||||
.interactive-element:active {
|
||||
transform: translateY(0) translateZ(0);
|
||||
transition: all 0.1s ease-out;
|
||||
}
|
||||
|
||||
/* Stagger animation for lists */
|
||||
.stagger-enter {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
.stagger-enter-active {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition: opacity 300ms ease-out, transform 300ms ease-out;
|
||||
}
|
||||
|
||||
/* Add animation delays for staggered effects */
|
||||
.stagger-enter-active:nth-child(1) { transition-delay: 0ms; }
|
||||
.stagger-enter-active:nth-child(2) { transition-delay: 50ms; }
|
||||
.stagger-enter-active:nth-child(3) { transition-delay: 100ms; }
|
||||
.stagger-enter-active:nth-child(4) { transition-delay: 150ms; }
|
||||
.stagger-enter-active:nth-child(5) { transition-delay: 200ms; }
|
||||
.stagger-enter-active:nth-child(6) { transition-delay: 250ms; }
|
||||
.stagger-enter-active:nth-child(7) { transition-delay: 300ms; }
|
||||
.stagger-enter-active:nth-child(8) { transition-delay: 350ms; }
|
||||
|
||||
/* Reduced motion for accessibility */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.page-enter,
|
||||
.page-enter-active,
|
||||
.page-exit,
|
||||
.page-exit-active,
|
||||
.modal-enter,
|
||||
.modal-enter-active,
|
||||
.modal-exit,
|
||||
.modal-exit-active,
|
||||
.fade-enter,
|
||||
.fade-enter-active,
|
||||
.fade-exit,
|
||||
.fade-exit-active,
|
||||
.interactive-element,
|
||||
.stagger-enter,
|
||||
.stagger-enter-active {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
88
src/components/animations/PageTransition.jsx
Normal file
88
src/components/animations/PageTransition.jsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { CSSTransition, TransitionGroup } from 'react-transition-group'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import './PageTransition.css'
|
||||
|
||||
// Route hierarchy for determining navigation direction
|
||||
const routeHierarchy = {
|
||||
'/': 0,
|
||||
'/my/chores': 0,
|
||||
'/chores': 1,
|
||||
'/chores/create': 2,
|
||||
'/settings': 1,
|
||||
'/things': 1,
|
||||
'/activities': 1,
|
||||
'/points': 1,
|
||||
'/labels': 1,
|
||||
'/login': 0,
|
||||
'/signup': 1,
|
||||
'/landing': 0,
|
||||
}
|
||||
|
||||
const getRouteLevel = pathname => {
|
||||
// Check for exact matches first
|
||||
if (routeHierarchy[pathname] !== undefined) {
|
||||
return routeHierarchy[pathname]
|
||||
}
|
||||
|
||||
// Check for dynamic routes (e.g., /chores/123/edit)
|
||||
if (pathname.includes('/chores/') && pathname.includes('/edit')) {
|
||||
return 3
|
||||
}
|
||||
if (pathname.includes('/chores/') && pathname.includes('/history')) {
|
||||
return 3
|
||||
}
|
||||
if (pathname.includes('/chores/') && !pathname.includes('/edit') && !pathname.includes('/history')) {
|
||||
return 2
|
||||
}
|
||||
if (pathname.includes('/things/')) {
|
||||
return 2
|
||||
}
|
||||
|
||||
// Default level
|
||||
return 1
|
||||
}
|
||||
|
||||
const PageTransition = ({ children }) => {
|
||||
const location = useLocation()
|
||||
const prevLocation = useRef(location)
|
||||
const isNavigatingBack = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const currentLevel = getRouteLevel(location.pathname)
|
||||
const previousLevel = getRouteLevel(prevLocation.current.pathname)
|
||||
|
||||
// Determine if we're navigating back (to a higher level in hierarchy)
|
||||
isNavigatingBack.current = currentLevel < previousLevel
|
||||
|
||||
prevLocation.current = location
|
||||
}, [location])
|
||||
|
||||
const getTransitionClasses = () => {
|
||||
if (location.pathname.includes('/login') || location.pathname.includes('/signup') || location.pathname.includes('/landing')) {
|
||||
return 'fade' // Use fade for auth pages
|
||||
}
|
||||
|
||||
return isNavigatingBack.current ? 'page-back' : 'page'
|
||||
}
|
||||
|
||||
return (
|
||||
<TransitionGroup component={null}>
|
||||
<CSSTransition
|
||||
key={location.pathname}
|
||||
classNames={getTransitionClasses()}
|
||||
timeout={{
|
||||
enter: 300,
|
||||
exit: 200,
|
||||
}}
|
||||
unmountOnExit
|
||||
>
|
||||
<div className="page-wrapper">
|
||||
{children}
|
||||
</div>
|
||||
</CSSTransition>
|
||||
</TransitionGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export default PageTransition
|
||||
138
src/components/animations/SkeletonLoader.jsx
Normal file
138
src/components/animations/SkeletonLoader.jsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { Box, Skeleton } from '@mui/joy'
|
||||
|
||||
const SkeletonLoader = ({
|
||||
type = 'card',
|
||||
count = 1,
|
||||
height = 100,
|
||||
width = '100%',
|
||||
variant = 'rectangular',
|
||||
...props
|
||||
}) => {
|
||||
const renderSkeleton = () => {
|
||||
switch (type) {
|
||||
case 'card':
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 'md',
|
||||
}}
|
||||
>
|
||||
<Skeleton variant='text' height={24} width='60%' sx={{ mb: 1 }} />
|
||||
<Skeleton
|
||||
variant='text'
|
||||
height={16}
|
||||
width='100%'
|
||||
sx={{ mb: 0.5 }}
|
||||
/>
|
||||
<Skeleton variant='text' height={16} width='80%' sx={{ mb: 2 }} />
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Skeleton variant='circular' width={32} height={32} />
|
||||
<Skeleton variant='rectangular' height={32} width={80} />
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
case 'list':
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, p: 1.5 }}>
|
||||
<Skeleton variant='circular' width={40} height={40} />
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Skeleton
|
||||
variant='text'
|
||||
height={20}
|
||||
width='70%'
|
||||
sx={{ mb: 0.5 }}
|
||||
/>
|
||||
<Skeleton variant='text' height={16} width='50%' />
|
||||
</Box>
|
||||
<Skeleton variant='rectangular' width={60} height={24} />
|
||||
</Box>
|
||||
)
|
||||
|
||||
case 'chore':
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 'md',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Skeleton variant='text' height={24} width='50%' />
|
||||
<Skeleton variant='circular' width={24} height={24} />
|
||||
</Box>
|
||||
<Skeleton variant='text' height={16} width='80%' sx={{ mb: 1 }} />
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
||||
<Skeleton variant='rectangular' height={20} width={60} />
|
||||
<Skeleton variant='rectangular' height={20} width={40} />
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Skeleton variant='circular' width={32} height={32} />
|
||||
<Skeleton variant='text' height={16} width='30%' />
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
case 'profile':
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
p: 3,
|
||||
}}
|
||||
>
|
||||
<Skeleton
|
||||
variant='circular'
|
||||
width={80}
|
||||
height={80}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<Skeleton variant='text' height={24} width={150} sx={{ mb: 1 }} />
|
||||
<Skeleton variant='text' height={16} width={100} />
|
||||
</Box>
|
||||
)
|
||||
|
||||
default:
|
||||
return (
|
||||
<Skeleton
|
||||
variant={variant}
|
||||
height={height}
|
||||
width={width}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: count }, (_, index) => (
|
||||
<Box key={index} sx={{ mb: type === 'list' ? 0 : 2 }}>
|
||||
{renderSkeleton()}
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SkeletonLoader
|
||||
107
src/components/animations/SmoothButton.jsx
Normal file
107
src/components/animations/SmoothButton.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Button } from '@mui/joy'
|
||||
import { styled } from '@mui/joy/styles'
|
||||
|
||||
const AnimatedButton = styled(Button)(({ theme }) => ({
|
||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
transform: 'translateZ(0)', // Enable GPU acceleration
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
|
||||
'&:hover:not(:disabled)': {
|
||||
transform: 'translateY(-2px) translateZ(0)',
|
||||
boxShadow: theme.shadow.lg,
|
||||
},
|
||||
|
||||
'&:active:not(:disabled)': {
|
||||
transform: 'translateY(0) translateZ(0)',
|
||||
transition: 'all 0.1s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
},
|
||||
|
||||
'&:focus-visible': {
|
||||
outline: '2px solid',
|
||||
outlineColor: theme.palette.primary[500],
|
||||
outlineOffset: '2px',
|
||||
},
|
||||
|
||||
// Ripple effect
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
width: '0',
|
||||
height: '0',
|
||||
borderRadius: '50%',
|
||||
background: 'currentColor',
|
||||
opacity: 0.1,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
transition: 'width 0.6s, height 0.6s',
|
||||
},
|
||||
|
||||
'&:active::before': {
|
||||
width: '300px',
|
||||
height: '300px',
|
||||
},
|
||||
|
||||
// Loading state
|
||||
'&[data-loading="true"]': {
|
||||
pointerEvents: 'none',
|
||||
position: 'relative',
|
||||
|
||||
'& > *': {
|
||||
opacity: 0.6,
|
||||
},
|
||||
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
border: '2px solid currentColor',
|
||||
borderTop: '2px solid transparent',
|
||||
borderRadius: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
animation: 'spin 1s linear infinite',
|
||||
},
|
||||
},
|
||||
|
||||
// Reduced motion support
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transition: 'none',
|
||||
transform: 'none !important',
|
||||
|
||||
'&:hover:not(:disabled)': {
|
||||
transform: 'none',
|
||||
},
|
||||
|
||||
'&:active:not(:disabled)': {
|
||||
transform: 'none',
|
||||
},
|
||||
|
||||
'&::before': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const SmoothButton = ({ children, loading = false, onClick, ...props }) => {
|
||||
const handleClick = event => {
|
||||
if (loading) return
|
||||
onClick?.(event)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatedButton
|
||||
{...props}
|
||||
onClick={handleClick}
|
||||
data-loading={loading}
|
||||
disabled={props.disabled || loading}
|
||||
>
|
||||
{children}
|
||||
</AnimatedButton>
|
||||
)
|
||||
}
|
||||
|
||||
export default SmoothButton
|
||||
88
src/components/animations/SmoothCard.jsx
Normal file
88
src/components/animations/SmoothCard.jsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React from 'react'
|
||||
import { Card } from '@mui/joy'
|
||||
import { styled } from '@mui/joy/styles'
|
||||
|
||||
const AnimatedCard = styled(Card)(({ theme }) => ({
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
transform: 'translateZ(0)', // Enable GPU acceleration
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px) translateZ(0)',
|
||||
boxShadow: theme.shadow.lg,
|
||||
},
|
||||
|
||||
'&:active': {
|
||||
transform: 'translateY(-2px) translateZ(0)',
|
||||
transition: 'all 0.1s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
},
|
||||
|
||||
// Subtle background animation on hover
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'linear-gradient(45deg, transparent, rgba(255,255,255,0.1), transparent)',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.3s ease',
|
||||
pointerEvents: 'none',
|
||||
borderRadius: 'inherit',
|
||||
},
|
||||
|
||||
'&:hover::before': {
|
||||
opacity: 1,
|
||||
},
|
||||
|
||||
// Focus states for accessibility
|
||||
'&:focus-visible': {
|
||||
outline: '2px solid',
|
||||
outlineColor: theme.palette.primary[500],
|
||||
outlineOffset: '2px',
|
||||
},
|
||||
|
||||
// Reduced motion support
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transition: 'none',
|
||||
transform: 'none !important',
|
||||
|
||||
'&:hover': {
|
||||
transform: 'none',
|
||||
boxShadow: theme.shadow.md, // Still provide visual feedback
|
||||
},
|
||||
|
||||
'&:active': {
|
||||
transform: 'none',
|
||||
},
|
||||
|
||||
'&::before': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const SmoothCard = ({
|
||||
children,
|
||||
onClick,
|
||||
animationDisabled = false,
|
||||
...props
|
||||
}) => {
|
||||
if (animationDisabled) {
|
||||
return (
|
||||
<Card {...props} onClick={onClick}>
|
||||
{children}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatedCard {...props} onClick={onClick}>
|
||||
{children}
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
||||
export default SmoothCard
|
||||
54
src/components/animations/StaggeredList.jsx
Normal file
54
src/components/animations/StaggeredList.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Box } from '@mui/joy'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { CSSTransition, TransitionGroup } from 'react-transition-group'
|
||||
import './PageTransition.css'
|
||||
|
||||
const StaggeredList = ({
|
||||
children,
|
||||
staggerDelay = 50,
|
||||
initialDelay = 0,
|
||||
animate = true,
|
||||
}) => {
|
||||
const [isVisible, setIsVisible] = useState(!animate)
|
||||
|
||||
useEffect(() => {
|
||||
if (animate) {
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(true)
|
||||
}, initialDelay)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [animate, initialDelay])
|
||||
|
||||
if (!animate) {
|
||||
return <Box>{children}</Box>
|
||||
}
|
||||
|
||||
const childrenArray = React.Children.toArray(children)
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<TransitionGroup component={null}>
|
||||
{isVisible &&
|
||||
childrenArray.map((child, index) => (
|
||||
<CSSTransition
|
||||
key={child.key || index}
|
||||
classNames='stagger'
|
||||
timeout={{
|
||||
enter: 300 + index * staggerDelay,
|
||||
exit: 200,
|
||||
}}
|
||||
style={{
|
||||
transitionDelay: `${index * staggerDelay}ms`,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ mb: 1 }}>{child}</Box>
|
||||
</CSSTransition>
|
||||
))}
|
||||
</TransitionGroup>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default StaggeredList
|
||||
10
src/components/animations/index.js
Normal file
10
src/components/animations/index.js
Normal file
@@ -0,0 +1,10 @@
|
||||
// Animation Components
|
||||
export { default as AnimatedList } from './AnimatedList'
|
||||
export { default as LoadingScreen } from './LoadingScreen'
|
||||
export { default as PageTransition } from './PageTransition'
|
||||
export { default as SmoothButton } from './SmoothButton'
|
||||
export { default as SmoothCard } from './SmoothCard'
|
||||
export { default as StaggeredList } from './StaggeredList'
|
||||
|
||||
// Animation Styles
|
||||
import './PageTransition.css'
|
||||
90
src/hooks/useAnimations.js
Normal file
90
src/hooks/useAnimations.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
// Hook to detect user's motion preferences
|
||||
export const useReducedMotion = () => {
|
||||
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
setPrefersReducedMotion(mediaQuery.matches)
|
||||
|
||||
const handleChange = (event) => {
|
||||
setPrefersReducedMotion(event.matches)
|
||||
}
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange)
|
||||
return () => mediaQuery.removeEventListener('change', handleChange)
|
||||
}, [])
|
||||
|
||||
return prefersReducedMotion
|
||||
}
|
||||
|
||||
// Hook for staggered animations
|
||||
export const useStaggeredAnimation = (itemCount, delay = 50) => {
|
||||
const [visibleItems, setVisibleItems] = useState(new Set())
|
||||
const prefersReducedMotion = useReducedMotion()
|
||||
|
||||
useEffect(() => {
|
||||
if (prefersReducedMotion) {
|
||||
// Show all items immediately if reduced motion is preferred
|
||||
setVisibleItems(new Set(Array.from({ length: itemCount }, (_, i) => i)))
|
||||
return
|
||||
}
|
||||
|
||||
const timeouts = []
|
||||
|
||||
// Stagger the appearance of items
|
||||
for (let i = 0; i < itemCount; i++) {
|
||||
const timeout = setTimeout(() => {
|
||||
setVisibleItems(prev => new Set([...prev, i]))
|
||||
}, i * delay)
|
||||
|
||||
timeouts.push(timeout)
|
||||
}
|
||||
|
||||
return () => {
|
||||
timeouts.forEach(clearTimeout)
|
||||
}
|
||||
}, [itemCount, delay, prefersReducedMotion])
|
||||
|
||||
return visibleItems
|
||||
}
|
||||
|
||||
// Hook for intersection observer animations
|
||||
export const useInViewAnimation = (threshold = 0.1) => {
|
||||
const [isInView, setIsInView] = useState(false)
|
||||
const [element, setElement] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!element) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
setIsInView(entry.isIntersecting)
|
||||
},
|
||||
{ threshold }
|
||||
)
|
||||
|
||||
observer.observe(element)
|
||||
|
||||
return () => {
|
||||
observer.unobserve(element)
|
||||
}
|
||||
}, [element, threshold])
|
||||
|
||||
return [setElement, isInView]
|
||||
}
|
||||
|
||||
// Hook for page transition context
|
||||
export const usePageTransition = () => {
|
||||
const [isTransitioning, setIsTransitioning] = useState(false)
|
||||
|
||||
const startTransition = () => setIsTransitioning(true)
|
||||
const endTransition = () => setIsTransitioning(false)
|
||||
|
||||
return {
|
||||
isTransitioning,
|
||||
startTransition,
|
||||
endTransition,
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,51 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Global animations for smooth interactions */
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth scrolling for better UX */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Global focus styles for better accessibility */
|
||||
*:focus-visible {
|
||||
outline: 2px solid #0ea5e9;
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Improve button and interactive element performance */
|
||||
button, a, [role="button"] {
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
/* Ensure smooth transitions for dynamic content */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Optimize animations for performance */
|
||||
.animate-optimized {
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.animate-optimized.animation-complete {
|
||||
will-change: auto;
|
||||
}
|
||||
|
||||
@@ -14,13 +14,13 @@ import {
|
||||
import moment from 'moment'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { LoadingScreen, SmoothCard } from '../../components/animations'
|
||||
import {
|
||||
DeleteChoreHistory,
|
||||
GetAllCircleMembers,
|
||||
GetChoreHistory,
|
||||
UpdateChoreHistory,
|
||||
} from '../../utils/Fetcher'
|
||||
import LoadingComponent from '../components/Loading'
|
||||
import EditHistoryModal from '../Modals/EditHistoryModal'
|
||||
import HistoryCard from './HistoryCard'
|
||||
|
||||
@@ -143,7 +143,7 @@ const ChoreHistory = () => {
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingComponent />
|
||||
return <LoadingScreen message='Loading task history...' />
|
||||
}
|
||||
if (!choreHistory.length) {
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user