feat: Ghost error page for improved error display

This commit is contained in:
Maksim Eltyshev
2025-11-06 23:11:06 +01:00
parent 33a7a6ccb1
commit 22baf5ab12
42 changed files with 553 additions and 55 deletions
@@ -1,3 +1,8 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
@@ -0,0 +1,134 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Icon } from 'semantic-ui-react';
import history from '../../../history';
import Paths from '../../../constants/Paths';
import styles from './GhostError.module.scss';
const GhostError = React.memo(({ message }) => {
const [t] = useTranslation();
const eyesRef = useRef(null);
const handleBackClick = useCallback(() => {
history.back();
}, []);
const handleHomeClick = useCallback(() => {
history.push(Paths.ROOT);
}, []);
useEffect(() => {
let pageX = document.documentElement.clientWidth;
let pageY = document.documentElement.clientHeight;
const handleMouseMove = (event) => {
if (!eyesRef.current) {
return;
}
// Vertical axis
const mouseY = event.pageY;
const yAxis = ((pageY / 2 - mouseY) / pageY) * -300;
// Horizontal axis
const mouseX = event.pageX / -pageX;
const xAxis = -mouseX * 100 - 100;
// Apply transform to eyes
eyesRef.current.style.transform = `translate(${xAxis}%, ${yAxis}%)`;
};
const handleResize = () => {
pageX = document.documentElement.clientWidth;
pageY = document.documentElement.clientHeight;
};
document.addEventListener('mousemove', handleMouseMove);
window.addEventListener('resize', handleResize);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('resize', handleResize);
};
}, []);
return (
<div className={styles.wrapper}>
<div className={styles.symbols}>
<div className={styles.symbol} />
<div className={styles.symbol} />
<div className={styles.symbol} />
<div className={styles.symbol} />
<div className={styles.symbol} />
<div className={styles.symbol} />
</div>
<div className={styles.ghost}>
<div ref={eyesRef} className={styles.eyes}>
<div className={styles.eyeLeft} />
<div className={styles.eyeRight} />
</div>
<div className={styles.bottom}>
<div />
<div />
<div />
<div />
<div />
</div>
</div>
<div className={styles.shadow} />
<div className={styles.message}>
<h1 className={styles.title}>
{t('common.whoops', {
context: 'title',
})}
</h1>
<div className={styles.text}>
{t(message, {
context: 'title',
})}
</div>
{window.history.length > 1 && (
<Button
basic
color="yellow"
size="large"
className={styles.button}
onClick={handleBackClick}
>
<Icon name="arrow left" />
{t('action.goBack')}
</Button>
)}
<Button
basic
color="olive"
size="large"
className={styles.button}
onClick={handleHomeClick}
>
<Icon name="home" />
{t('action.goHome')}
</Button>
</div>
</div>
);
});
GhostError.propTypes = {
message: PropTypes.string,
};
GhostError.defaultProps = {
message: 'common.pageNotFound',
};
export default GhostError;
@@ -0,0 +1,298 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.bottom {
display: flex;
left: 0;
position: absolute;
right: 0;
top: 100%;
div {
background-color: #fff;
border-radius: 100%;
flex-grow: 1;
height: 20px;
position: relative;
top: -10px;
&:nth-child(2n) {
background: transparent;
border-top: 15px solid #22252a;
top: -12px;
}
}
}
.button {
padding: 0.78571429em 1.5em 0.78571429em;
}
.eyeLeft {
background: #22252a;
border-radius: 50%;
height: 12px;
left: 10px;
position: absolute;
width: 12px;
}
.eyeRight {
background: #22252a;
border-radius: 50%;
height: 12px;
position: absolute;
right: 10px;
width: 12px;
}
.eyes {
height: 12px;
left: 50%;
position: absolute;
top: 45%;
transform: translateX(-50%);
width: 70px;
}
.ghost {
animation: upndown 3s ease-in-out infinite;
background: #fff;
border-radius: 100px 100px 0 0;
height: 100px;
margin: 0 auto 20px;
position: relative;
width: 100px;
z-index: 1;
}
.message {
max-width: 400px;
text-align: center;
z-index: 1;
}
.shadow {
animation: smallnbig 3s ease-in-out infinite;
background: #000;
border-radius: 50%;
height: 20px;
margin: 0 auto 30px;
width: 80px;
}
.symbol {
&:nth-child(1) {
animation: shine 4s ease-in-out 3s infinite;
left: 20px;
opacity: 0.3;
position: absolute;
top: 20px;
&:before,
&:after {
background: #6b808c;
border-radius: 3px;
content: "";
height: 3px;
position: absolute;
width: 8px;
}
&:before {
transform: rotate(45deg);
}
&:after {
transform: rotate(-45deg);
}
}
&:nth-child(2) {
animation: shine 4s ease-in-out 1.3s infinite;
border: 2px solid;
border-color: #6b808c;
border-radius: 50%;
height: 12px;
opacity: 0.3;
position: absolute;
right: 30px;
top: 40px;
width: 12px;
}
&:nth-child(3) {
animation: shine 3s ease-in-out 0.5s infinite;
bottom: 30px;
left: 30px;
opacity: 0.3;
position: absolute;
&:before,
&:after {
background: #6b808c;
border-radius: 3px;
content: "";
height: 3px;
position: absolute;
width: 8px;
}
&:before {
transform: rotate(90deg);
}
&:after {
transform: rotate(180deg);
}
}
&:nth-child(4) {
animation: shine 6s ease-in-out 1.6s infinite;
opacity: 0.3;
position: absolute;
right: 30px;
top: 10px;
&:before,
&:after {
background: #6b808c;
border-radius: 3px;
content: "";
height: 3px;
position: absolute;
width: 10px;
}
&:before {
transform: rotate(45deg);
}
&:after {
transform: rotate(-45deg);
}
}
&:nth-child(5) {
animation: shine 1.7s ease-in-out 7s infinite;
border: 2px solid;
border-color: #6b808c;
border-radius: 50%;
height: 8px;
opacity: 0.3;
position: absolute;
right: 5px;
top: 60px;
width: 8px;
}
&:nth-child(6) {
animation: shine 2s ease-in-out 6s infinite;
bottom: 10px;
opacity: 0.3;
position: absolute;
right: 10px;
&:before,
&:after {
background: #6b808c;
border-radius: 3px;
content: "";
height: 3px;
position: absolute;
width: 10px;
}
&:before {
transform: rotate(90deg);
}
&:after {
transform: rotate(180deg);
}
}
}
.symbols {
height: 200px;
left: 50%;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 200px;
}
.text {
color: #6b808c;
font-size: 18px;
line-height: 1.4;
margin-bottom: 30px;
}
.title {
font-size: 32px;
letter-spacing: 0.5px;
}
.wrapper {
align-items: center;
color: #fff;
display: flex;
flex-direction: column;
height: 100%;
justify-content: center;
min-height: 60vh;
padding: 40px 20px;
position: relative;
width: 100%;
}
@keyframes shine {
0% {
opacity: 0.3;
}
25% {
opacity: 0.1;
}
50% {
opacity: 0.3;
}
100% {
opacity: 0.3;
}
}
@keyframes smallnbig {
0% {
width: 80px;
}
50% {
width: 90px;
}
100% {
width: 80px;
}
}
@keyframes upndown {
0% {
transform: translateY(5px);
}
50% {
transform: translateY(15px);
}
100% {
transform: translateY(5px);
}
}
}
@@ -0,0 +1,8 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import GhostError from './GhostError';
export default GhostError;
-21
View File
@@ -1,21 +0,0 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
const NotFound = React.memo(() => {
const [t] = useTranslation();
return (
<h1>
{t('common.pageNotFound', {
context: 'title',
})}
</h1>
);
});
export default NotFound;
+2 -2
View File
@@ -15,7 +15,7 @@ import { ReduxRouter } from '../../lib/redux-router';
import Paths from '../../constants/Paths';
import Login from './Login';
import Core from './Core';
import NotFound from './NotFound';
import GhostError from './GhostError';
import 'react-datepicker/dist/react-datepicker.css';
import 'photoswipe/dist/photoswipe.css';
@@ -37,7 +37,7 @@ function Root({ store, history }) {
<Route path={Paths.PROJECTS} element={<Core />} />
<Route path={Paths.BOARDS} element={<Core />} />
<Route path={Paths.CARDS} element={<Core />} />
<Route path="*" element={<NotFound />} />
<Route path="*" element={<GhostError />} />
</Routes>
</ToasterProvider>
</ThemeProvider>
+4 -30
View File
@@ -13,6 +13,7 @@ import { useTransitioning } from '../../../lib/hooks';
import selectors from '../../../selectors';
import { BoardViews } from '../../../constants/Enums';
import Home from '../Home';
import GhostError from '../GhostError';
import Board from '../../boards/Board';
import styles from './Static.module.scss';
@@ -42,40 +43,13 @@ const Static = React.memo(() => {
contentNode = <Home />;
} else if (cardId === null) {
wrapperClassNames = [isFavoritesActive && styles.wrapperWithFavorites, styles.wrapperFlex];
contentNode = (
<div className={styles.message}>
<h1>
{t('common.cardNotFound', {
context: 'title',
})}
</h1>
</div>
);
contentNode = <GhostError message="common.cardNotFound" />;
} else if (board === null) {
wrapperClassNames = [isFavoritesActive && styles.wrapperWithFavorites, styles.wrapperFlex];
contentNode = (
<div className={styles.message}>
<h1>
{t('common.boardNotFound', {
context: 'title',
})}
</h1>
</div>
);
contentNode = <GhostError message="common.boardNotFound" />;
} else if (projectId === null) {
wrapperClassNames = [isFavoritesActive && styles.wrapperWithFavorites, styles.wrapperFlex];
contentNode = (
<div className={styles.message}>
<h1>
{t('common.projectNotFound', {
context: 'title',
})}
</h1>
</div>
);
contentNode = <GhostError message="common.projectNotFound" />;
} else if (board === undefined) {
wrapperClassNames = [
isFavoritesActive ? styles.wrapperProjectWithFavorites : styles.wrapperProject,