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 { Outlet, useNavigate } from 'react-router-dom'
|
||||||
import { useRegisterSW } from 'virtual:pwa-register/react'
|
import { useRegisterSW } from 'virtual:pwa-register/react'
|
||||||
import { registerCapacitorListeners } from './CapacitorListener'
|
import { registerCapacitorListeners } from './CapacitorListener'
|
||||||
|
import PageTransition from './components/animations/PageTransition'
|
||||||
import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext'
|
import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext'
|
||||||
import { AuthenticationProvider } from './service/AuthenticationService'
|
import { AuthenticationProvider } from './service/AuthenticationService'
|
||||||
import { useNotification } from './service/NotificationProvider'
|
import { useNotification } from './service/NotificationProvider'
|
||||||
@@ -74,7 +75,9 @@ const AppContent = () => {
|
|||||||
<>
|
<>
|
||||||
<ImpersonateUserProvider>
|
<ImpersonateUserProvider>
|
||||||
<NavBar />
|
<NavBar />
|
||||||
<Outlet />
|
<PageTransition>
|
||||||
|
<Outlet />
|
||||||
|
</PageTransition>
|
||||||
</ImpersonateUserProvider>
|
</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 base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@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 moment from 'moment'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
|
import { LoadingScreen, SmoothCard } from '../../components/animations'
|
||||||
import {
|
import {
|
||||||
DeleteChoreHistory,
|
DeleteChoreHistory,
|
||||||
GetAllCircleMembers,
|
GetAllCircleMembers,
|
||||||
GetChoreHistory,
|
GetChoreHistory,
|
||||||
UpdateChoreHistory,
|
UpdateChoreHistory,
|
||||||
} from '../../utils/Fetcher'
|
} from '../../utils/Fetcher'
|
||||||
import LoadingComponent from '../components/Loading'
|
|
||||||
import EditHistoryModal from '../Modals/EditHistoryModal'
|
import EditHistoryModal from '../Modals/EditHistoryModal'
|
||||||
import HistoryCard from './HistoryCard'
|
import HistoryCard from './HistoryCard'
|
||||||
|
|
||||||
@@ -143,7 +143,7 @@ const ChoreHistory = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <LoadingComponent />
|
return <LoadingScreen message='Loading task history...' />
|
||||||
}
|
}
|
||||||
if (!choreHistory.length) {
|
if (!choreHistory.length) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user