Add skeleton loading states across all tracking pages
- Replace CircularProgress spinners with content-aware skeleton screens - Add FormSkeleton for form loading states (feeding, sleep, diaper pages) - Add ActivityListSkeleton for recent activities loading - Improves perceived performance with layout-matching placeholders 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
|||||||
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
|
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
|
||||||
import { ErrorBoundary } from '@/components/common/ErrorBoundary';
|
import { ErrorBoundary } from '@/components/common/ErrorBoundary';
|
||||||
import { ChartErrorFallback } from '@/components/common/ErrorFallbacks';
|
import { ChartErrorFallback } from '@/components/common/ErrorFallbacks';
|
||||||
|
import { StatGridSkeleton, ChartSkeleton } from '@/components/common/LoadingSkeletons';
|
||||||
import {
|
import {
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Hotel,
|
Hotel,
|
||||||
@@ -102,15 +103,14 @@ export default function AnalyticsPage() {
|
|||||||
return (
|
return (
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<Box
|
<Box>
|
||||||
sx={{
|
<Typography variant="h4" gutterBottom fontWeight="600" sx={{ mb: 3 }}>
|
||||||
display: 'flex',
|
Analytics & Insights
|
||||||
justifyContent: 'center',
|
</Typography>
|
||||||
alignItems: 'center',
|
<StatGridSkeleton count={3} />
|
||||||
minHeight: '60vh',
|
<Box sx={{ mt: 4 }}>
|
||||||
}}
|
<ChartSkeleton height={350} />
|
||||||
>
|
</Box>
|
||||||
<CircularProgress />
|
|
||||||
</Box>
|
</Box>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { EmailVerificationBanner } from '@/components/common/EmailVerificationBa
|
|||||||
import { ErrorBoundary } from '@/components/common/ErrorBoundary';
|
import { ErrorBoundary } from '@/components/common/ErrorBoundary';
|
||||||
import { DataErrorFallback } from '@/components/common/ErrorFallbacks';
|
import { DataErrorFallback } from '@/components/common/ErrorFallbacks';
|
||||||
import { NetworkStatusIndicator } from '@/components/common/NetworkStatusIndicator';
|
import { NetworkStatusIndicator } from '@/components/common/NetworkStatusIndicator';
|
||||||
|
import { StatGridSkeleton } from '@/components/common/LoadingSkeletons';
|
||||||
import {
|
import {
|
||||||
Restaurant,
|
Restaurant,
|
||||||
Hotel,
|
Hotel,
|
||||||
@@ -147,12 +148,11 @@ export default function HomePage() {
|
|||||||
isolate
|
isolate
|
||||||
fallback={<DataErrorFallback error={new Error('Failed to load summary')} />}
|
fallback={<DataErrorFallback error={new Error('Failed to load summary')} />}
|
||||||
>
|
>
|
||||||
<Paper sx={{ p: 3 }}>
|
{loading ? (
|
||||||
{loading ? (
|
<StatGridSkeleton count={3} />
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
) : (
|
||||||
<CircularProgress />
|
<Paper sx={{ p: 3 }}>
|
||||||
</Box>
|
{!dailySummary ? (
|
||||||
) : !dailySummary ? (
|
|
||||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
{children.length === 0
|
{children.length === 0
|
||||||
@@ -198,8 +198,9 @@ export default function HomePage() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
|
)}
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|
||||||
{/* Next Predicted Activity */}
|
{/* Next Predicted Activity */}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
ToggleButton,
|
ToggleButton,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
import { FormSkeleton, ActivityListSkeleton } from '@/components/common/LoadingSkeletons';
|
||||||
import {
|
import {
|
||||||
ArrowBack,
|
ArrowBack,
|
||||||
Refresh,
|
Refresh,
|
||||||
@@ -305,8 +306,17 @@ export default function DiaperTrackPage() {
|
|||||||
return (
|
return (
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
<Box>
|
||||||
<CircularProgress />
|
<Typography variant="h4" fontWeight="600" sx={{ mb: 3 }}>
|
||||||
|
Track Diaper Change
|
||||||
|
</Typography>
|
||||||
|
<Paper sx={{ p: 3, mb: 3 }}>
|
||||||
|
<FormSkeleton />
|
||||||
|
</Paper>
|
||||||
|
<Typography variant="h6" fontWeight="600" sx={{ mb: 2 }}>
|
||||||
|
Recent Diaper Changes
|
||||||
|
</Typography>
|
||||||
|
<ActivityListSkeleton count={3} />
|
||||||
</Box>
|
</Box>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
@@ -545,9 +555,7 @@ export default function DiaperTrackPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{diapersLoading ? (
|
{diapersLoading ? (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
<ActivityListSkeleton count={3} />
|
||||||
<CircularProgress size={30} />
|
|
||||||
</Box>
|
|
||||||
) : recentDiapers.length === 0 ? (
|
) : recentDiapers.length === 0 ? (
|
||||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import { useAuth } from '@/lib/auth/AuthContext';
|
|||||||
import { trackingApi, Activity } from '@/lib/api/tracking';
|
import { trackingApi, Activity } from '@/lib/api/tracking';
|
||||||
import { childrenApi, Child } from '@/lib/api/children';
|
import { childrenApi, Child } from '@/lib/api/children';
|
||||||
import { VoiceInputButton } from '@/components/voice/VoiceInputButton';
|
import { VoiceInputButton } from '@/components/voice/VoiceInputButton';
|
||||||
|
import { FormSkeleton, ActivityListSkeleton } from '@/components/common/LoadingSkeletons';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
|
||||||
@@ -308,8 +309,17 @@ function FeedingTrackPage() {
|
|||||||
return (
|
return (
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
<Box>
|
||||||
<CircularProgress />
|
<Typography variant="h4" fontWeight="600" sx={{ mb: 3 }}>
|
||||||
|
Track Feeding
|
||||||
|
</Typography>
|
||||||
|
<Paper sx={{ p: 3, mb: 3 }}>
|
||||||
|
<FormSkeleton />
|
||||||
|
</Paper>
|
||||||
|
<Typography variant="h6" fontWeight="600" sx={{ mb: 2 }}>
|
||||||
|
Recent Feedings
|
||||||
|
</Typography>
|
||||||
|
<ActivityListSkeleton count={3} />
|
||||||
</Box>
|
</Box>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
Chip,
|
Chip,
|
||||||
Snackbar,
|
Snackbar,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
import { FormSkeleton, ActivityListSkeleton } from '@/components/common/LoadingSkeletons';
|
||||||
import {
|
import {
|
||||||
ArrowBack,
|
ArrowBack,
|
||||||
Refresh,
|
Refresh,
|
||||||
@@ -320,8 +321,17 @@ export default function SleepTrackPage() {
|
|||||||
return (
|
return (
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
<Box>
|
||||||
<CircularProgress />
|
<Typography variant="h4" fontWeight="600" sx={{ mb: 3 }}>
|
||||||
|
Track Sleep
|
||||||
|
</Typography>
|
||||||
|
<Paper sx={{ p: 3, mb: 3 }}>
|
||||||
|
<FormSkeleton />
|
||||||
|
</Paper>
|
||||||
|
<Typography variant="h6" fontWeight="600" sx={{ mb: 2 }}>
|
||||||
|
Recent Sleep Activities
|
||||||
|
</Typography>
|
||||||
|
<ActivityListSkeleton count={3} />
|
||||||
</Box>
|
</Box>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
@@ -536,9 +546,7 @@ export default function SleepTrackPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{sleepsLoading ? (
|
{sleepsLoading ? (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
<ActivityListSkeleton count={3} />
|
||||||
<CircularProgress size={30} />
|
|
||||||
</Box>
|
|
||||||
) : recentSleeps.length === 0 ? (
|
) : recentSleeps.length === 0 ? (
|
||||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
|||||||
316
maternal-web/components/common/LoadingSkeletons.tsx
Normal file
316
maternal-web/components/common/LoadingSkeletons.tsx
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Card, CardContent, Skeleton, Stack, Paper } from '@mui/material';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton loader for activity cards
|
||||||
|
*/
|
||||||
|
export function ActivityCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<Card sx={{ mb: 2 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Skeleton variant="circular" width={40} height={40} sx={{ mr: 2 }} />
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Skeleton variant="text" width="60%" height={24} />
|
||||||
|
<Skeleton variant="text" width="40%" height={20} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Skeleton variant="text" width="80%" />
|
||||||
|
<Skeleton variant="text" width="50%" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton loader for activity list
|
||||||
|
*/
|
||||||
|
export function ActivityListSkeleton({ count = 3 }: { count?: number }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Array.from({ length: count }).map((_, index) => (
|
||||||
|
<ActivityCardSkeleton key={index} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton loader for stat cards
|
||||||
|
*/
|
||||||
|
export function StatCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<Paper sx={{ p: 3 }}>
|
||||||
|
<Skeleton variant="text" width="40%" height={20} sx={{ mb: 1 }} />
|
||||||
|
<Skeleton variant="text" width="60%" height={32} sx={{ mb: 1 }} />
|
||||||
|
<Skeleton variant="text" width="50%" height={16} />
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton loader for stat grid
|
||||||
|
*/
|
||||||
|
export function StatGridSkeleton({ count = 4 }: { count?: number }) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: {
|
||||||
|
xs: '1fr',
|
||||||
|
sm: 'repeat(2, 1fr)',
|
||||||
|
md: 'repeat(4, 1fr)',
|
||||||
|
},
|
||||||
|
gap: 2,
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Array.from({ length: count }).map((_, index) => (
|
||||||
|
<StatCardSkeleton key={index} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton loader for charts
|
||||||
|
*/
|
||||||
|
export function ChartSkeleton({ height = 300 }: { height?: number }) {
|
||||||
|
return (
|
||||||
|
<Paper sx={{ p: 3 }}>
|
||||||
|
<Skeleton variant="text" width="40%" height={24} sx={{ mb: 3 }} />
|
||||||
|
<Skeleton variant="rectangular" width="100%" height={height} />
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-around', mt: 2 }}>
|
||||||
|
<Skeleton variant="text" width={60} />
|
||||||
|
<Skeleton variant="text" width={60} />
|
||||||
|
<Skeleton variant="text" width={60} />
|
||||||
|
<Skeleton variant="text" width={60} />
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton loader for child profile card
|
||||||
|
*/
|
||||||
|
export function ChildProfileSkeleton() {
|
||||||
|
return (
|
||||||
|
<Card sx={{ mb: 2 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Skeleton variant="circular" width={60} height={60} sx={{ mr: 2 }} />
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Skeleton variant="text" width="50%" height={28} />
|
||||||
|
<Skeleton variant="text" width="30%" height={20} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Skeleton variant="text" width="70%" />
|
||||||
|
<Skeleton variant="text" width="60%" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton loader for form fields
|
||||||
|
*/
|
||||||
|
export function FormSkeleton() {
|
||||||
|
return (
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Box>
|
||||||
|
<Skeleton variant="text" width={100} height={20} sx={{ mb: 1 }} />
|
||||||
|
<Skeleton variant="rectangular" width="100%" height={56} />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Skeleton variant="text" width={100} height={20} sx={{ mb: 1 }} />
|
||||||
|
<Skeleton variant="rectangular" width="100%" height={56} />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Skeleton variant="text" width={100} height={20} sx={{ mb: 1 }} />
|
||||||
|
<Skeleton variant="rectangular" width="100%" height={56} />
|
||||||
|
</Box>
|
||||||
|
<Skeleton variant="rectangular" width={120} height={42} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton loader for table rows
|
||||||
|
*/
|
||||||
|
export function TableRowSkeleton({ columns = 4 }: { columns?: number }) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, py: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||||
|
{Array.from({ length: columns }).map((_, index) => (
|
||||||
|
<Skeleton key={index} variant="text" width={`${100 / columns}%`} height={20} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton loader for table
|
||||||
|
*/
|
||||||
|
export function TableSkeleton({ rows = 5, columns = 4 }: { rows?: number; columns?: number }) {
|
||||||
|
return (
|
||||||
|
<Paper sx={{ p: 2 }}>
|
||||||
|
{/* Header */}
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, py: 2, borderBottom: 2, borderColor: 'divider', mb: 2 }}>
|
||||||
|
{Array.from({ length: columns }).map((_, index) => (
|
||||||
|
<Skeleton key={index} variant="text" width={`${100 / columns}%`} height={24} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
{/* Rows */}
|
||||||
|
{Array.from({ length: rows }).map((_, index) => (
|
||||||
|
<TableRowSkeleton key={index} columns={columns} />
|
||||||
|
))}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton loader for list items
|
||||||
|
*/
|
||||||
|
export function ListItemSkeleton() {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', py: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||||
|
<Skeleton variant="circular" width={40} height={40} sx={{ mr: 2 }} />
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Skeleton variant="text" width="70%" height={20} />
|
||||||
|
<Skeleton variant="text" width="40%" height={16} />
|
||||||
|
</Box>
|
||||||
|
<Skeleton variant="rectangular" width={80} height={32} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton loader for list
|
||||||
|
*/
|
||||||
|
export function ListSkeleton({ count = 5 }: { count?: number }) {
|
||||||
|
return (
|
||||||
|
<Paper sx={{ p: 2 }}>
|
||||||
|
{Array.from({ length: count }).map((_, index) => (
|
||||||
|
<ListItemSkeleton key={index} />
|
||||||
|
))}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton loader for dashboard summary
|
||||||
|
*/
|
||||||
|
export function DashboardSummarySkeleton() {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{/* Header */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||||
|
<Skeleton variant="text" width={200} height={40} />
|
||||||
|
<Skeleton variant="rectangular" width={120} height={40} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<StatGridSkeleton count={4} />
|
||||||
|
|
||||||
|
{/* Recent Activities */}
|
||||||
|
<Paper sx={{ p: 3, mb: 3 }}>
|
||||||
|
<Skeleton variant="text" width={150} height={28} sx={{ mb: 2 }} />
|
||||||
|
<ActivityListSkeleton count={3} />
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Charts */}
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: 'repeat(2, 1fr)' }, gap: 3 }}>
|
||||||
|
<ChartSkeleton height={250} />
|
||||||
|
<ChartSkeleton height={250} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton loader for chat messages
|
||||||
|
*/
|
||||||
|
export function ChatMessageSkeleton({ isUser = false }: { isUser?: boolean }) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: isUser ? 'flex-end' : 'flex-start',
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!isUser && <Skeleton variant="circular" width={40} height={40} sx={{ mr: 2 }} />}
|
||||||
|
<Box sx={{ maxWidth: '70%' }}>
|
||||||
|
<Skeleton variant="rounded" width={200} height={60} />
|
||||||
|
<Skeleton variant="text" width={100} height={16} sx={{ mt: 0.5 }} />
|
||||||
|
</Box>
|
||||||
|
{isUser && <Skeleton variant="circular" width={40} height={40} sx={{ ml: 2 }} />}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton loader for chat conversation
|
||||||
|
*/
|
||||||
|
export function ChatSkeleton({ messageCount = 4 }: { messageCount?: number }) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 2 }}>
|
||||||
|
{Array.from({ length: messageCount }).map((_, index) => (
|
||||||
|
<ChatMessageSkeleton key={index} isUser={index % 2 === 0} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton loader for page content
|
||||||
|
*/
|
||||||
|
export function PageSkeleton() {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{/* Page header */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
|
||||||
|
<Skeleton variant="circular" width={40} height={40} sx={{ mr: 2 }} />
|
||||||
|
<Skeleton variant="text" width={250} height={40} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<Paper sx={{ p: 3 }}>
|
||||||
|
<Skeleton variant="text" width="60%" height={28} sx={{ mb: 2 }} />
|
||||||
|
<Skeleton variant="text" width="100%" />
|
||||||
|
<Skeleton variant="text" width="100%" />
|
||||||
|
<Skeleton variant="text" width="80%" sx={{ mb: 3 }} />
|
||||||
|
|
||||||
|
<Skeleton variant="rectangular" width="100%" height={200} sx={{ mb: 2 }} />
|
||||||
|
|
||||||
|
<Skeleton variant="text" width="100%" />
|
||||||
|
<Skeleton variant="text" width="90%" />
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full page loading overlay with spinner
|
||||||
|
*/
|
||||||
|
export function LoadingOverlay({ message = 'Loading...' }: { message?: string }) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
bgcolor: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
zIndex: 9999,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Skeleton variant="circular" width={60} height={60} sx={{ mb: 2 }} />
|
||||||
|
<Skeleton variant="text" width={150} height={24} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user