feat: enhance error handling UI with detailed messages and copy functionality

This commit is contained in:
Mo Tarbin
2026-07-01 22:43:58 -04:00
parent 0da9c7f965
commit 892754bbae

View File

@@ -1,52 +1,226 @@
import { HomeRounded, Login } from '@mui/icons-material'
import { Box, Button, CircularProgress, Container, Typography } from '@mui/joy'
import { Link } from 'react-router-dom'
import Logo from '../Logo' // Adjust the import path as necessary
import {
BugReportRounded,
CloudOffRounded,
ContentCopyRounded,
ErrorRounded,
ExpandMoreRounded,
HomeRounded,
LockRounded,
RefreshRounded,
SearchOffRounded,
} from '@mui/icons-material'
import { Box, Button, IconButton, Snackbar, Typography } from '@mui/joy'
import { useState } from 'react'
import { Link, useRouteError } from 'react-router-dom'
const getErrorKind = error => {
if (!error)
return { label: 'Unknown Error', color: 'danger', Icon: ErrorRounded }
const status = error?.status ?? error?.response?.status
if (status === 404)
return {
label: '404 · Not Found',
color: 'warning',
Icon: SearchOffRounded,
}
if (status === 401 || status === 403)
return {
label: `${status} · Unauthorized`,
color: 'warning',
Icon: LockRounded,
}
if (status >= 500)
return {
label: `${status} · Server Error`,
color: 'danger',
Icon: CloudOffRounded,
}
if (error?.name === 'TypeError')
return { label: 'Runtime Error', color: 'danger', Icon: BugReportRounded }
if (error?.name === 'SyntaxError')
return { label: 'Syntax Error', color: 'danger', Icon: BugReportRounded }
return { label: 'Unexpected Error', color: 'danger', Icon: ErrorRounded }
}
const safeMessage = error => {
const msg = error?.message ?? error?.statusText
if (!msg || msg === '[object Object]') return null
return msg
}
const buildErrorText = (error, url) => {
const lines = [
`URL: ${url}`,
`Time: ${new Date().toISOString()}`,
`Error: ${safeMessage(error) ?? String(error)}`,
]
if (error?.stack) lines.push(`\nStack:\n${error.stack}`)
return lines.join('\n')
}
const Error = () => {
const error = useRouteError()
const [showDetails, setShowDetails] = useState(false)
const [copied, setCopied] = useState(false)
const { color, Icon } = getErrorKind(error)
const message = safeMessage(error)
const url = window.location.href
const handleCopy = () => {
navigator.clipboard.writeText(buildErrorText(error, url)).then(() => {
setCopied(true)
})
}
return (
<Container className='flex h-full items-center justify-center'>
<Box
sx={{
position: 'relative',
minHeight: '100dvh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
px: 3,
py: 6,
maxWidth: 440,
mx: 'auto',
overflow: 'hidden',
}}
>
{/* Decorative dots */}
<Box
className='flex flex-col items-center justify-center'
sx={{
minHeight: '80vh',
position: 'absolute',
top: '14%',
left: '6%',
width: 14,
height: 14,
borderRadius: '50%',
bgcolor: `${color}.100`,
opacity: 0.7,
}}
/>
<Box
sx={{
position: 'absolute',
top: '22%',
right: '8%',
width: 9,
height: 9,
borderRadius: '50%',
bgcolor: `${color}.200`,
}}
/>
<Box
sx={{
position: 'absolute',
bottom: '28%',
right: '6%',
width: 7,
height: 7,
borderRadius: '50%',
bgcolor: `${color}.100`,
}}
/>
{/* Icon with concentric rings */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
mb: 4,
}}
>
<CircularProgress
value={100}
color='danger' // Set the color to 'error' for danger color
sx={{ '--CircularProgress-size': '200px' }}
>
<Logo />
</CircularProgress>
<Box
className='flex items-center gap-2'
sx={{
fontWeight: 700,
fontSize: 24,
mt: 2,
width: 172,
height: 172,
borderRadius: '50%',
border: '1.5px solid',
borderColor: `${color}.100`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
Ops, something went wrong
</Box>
<Typography level='body-md' fontWeight={500} textAlign={'center'}>
if you think this is a mistake, please contact us or{' '}
<a
href='https://github.com/donetick/donetick/issues/new'
style={{
textDecoration: 'underline',
<Box
sx={{
width: 128,
height: 128,
borderRadius: '50%',
border: '1.5px solid',
borderColor: `${color}.200`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
open issue here
</a>{' '}
</Typography>
<Box
sx={{
width: 84,
height: 84,
borderRadius: '50%',
bgcolor: `${color}.50`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Icon sx={{ fontSize: 42, color: `${color}.500` }} />
</Box>
</Box>
</Box>
</Box>
{/* Title */}
<Typography
level='h3'
fontWeight={700}
textAlign='center'
sx={{ mb: 1.5 }}
>
Something went wrong
</Typography>
{/* Error message */}
<Typography
level='body-sm'
textAlign='center'
sx={{
color: 'text.secondary',
mb: 4,
maxWidth: 320,
minHeight: '2.5em',
wordBreak: 'break-word',
}}
>
{message ??
'An unexpected error occurred. Try reloading — it usually fixes it.'}
</Typography>
{/* Primary CTA */}
<Button
variant='solid'
color='primary'
size='lg'
startDecorator={<RefreshRounded />}
onClick={() => window.location.reload()}
sx={{ width: '100%', mb: 2 }}
>
Try again
</Button>
{/* Secondary actions */}
<Box sx={{ display: 'flex', gap: 3, mb: 5 }}>
<Button
component={Link}
to='/chores'
variant='outlined'
color='primary'
sx={{ mt: 4 }}
variant='plain'
color='neutral'
size='lg'
startDecorator={<HomeRounded />}
>
@@ -55,16 +229,108 @@ const Error = () => {
<Button
component={Link}
to='/login'
variant='outlined'
color='primary'
sx={{ mt: 1 }}
variant='plain'
color='neutral'
size='lg'
startDecorator={<Login />}
>
Login
</Button>
</Box>
</Container>
{/* Report hint + collapsible details */}
<Box
sx={{
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Typography
level='body-xs'
textAlign='center'
sx={{ color: 'text.tertiary', mb: 1.5 }}
>
If this keeps happening,{' '}
<a
href='https://github.com/donetick/donetick/issues/new'
target='_blank'
rel='noopener noreferrer'
style={{ textDecoration: 'underline' }}
>
open an issue
</a>{' '}
and include the error details below.
</Typography>
{(error?.stack || message) && (
<>
<Button
variant='plain'
color='neutral'
size='sm'
onClick={() => setShowDetails(v => !v)}
endDecorator={
<ExpandMoreRounded
sx={{
transition: 'transform 0.2s',
transform: showDetails ? 'rotate(180deg)' : 'rotate(0deg)',
}}
/>
}
sx={{ mb: 1 }}
>
{showDetails ? 'Hide' : 'Show'} error details
</Button>
{showDetails && (
<Box
sx={{
position: 'relative',
bgcolor: 'background.level2',
borderRadius: 'sm',
p: 2,
width: '100%',
}}
>
<IconButton
size='sm'
variant='plain'
color='neutral'
onClick={handleCopy}
sx={{ position: 'absolute', top: 8, right: 8 }}
title='Copy to clipboard'
>
<ContentCopyRounded fontSize='small' />
</IconButton>
<Typography
level='body-xs'
sx={{
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
pr: 4,
color: 'text.secondary',
}}
>
{buildErrorText(error, url)}
</Typography>
</Box>
)}
</>
)}
</Box>
<Snackbar
open={copied}
autoHideDuration={2500}
onClose={() => setCopied(false)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
size='sm'
>
Error details copied to clipboard
</Snackbar>
</Box>
)
}