feat: Ghost error page for improved error display
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user