Add error boundary around routes in web UI (#19412)
* Add error boundary around routes in web UI * Update app/javascript/mastodon/features/ui/util/react_router_helpers.js Co-authored-by: Yamagishi Kazutoshi <ykzts@desire.sh> * Update app/javascript/mastodon/features/ui/util/react_router_helpers.js Co-authored-by: Yamagishi Kazutoshi <ykzts@desire.sh> * Update app/javascript/mastodon/features/ui/components/bundle_column_error.js Co-authored-by: Yamagishi Kazutoshi <ykzts@desire.sh> Co-authored-by: Yamagishi Kazutoshi <ykzts@desire.sh>
This commit is contained in:
		
							parent
							
								
									56efa8d22f
								
							
						
					
					
						commit
						a43a823768
					
				@ -1,44 +1,155 @@
 | 
				
			|||||||
import React from 'react';
 | 
					import React from 'react';
 | 
				
			||||||
import PropTypes from 'prop-types';
 | 
					import PropTypes from 'prop-types';
 | 
				
			||||||
import { defineMessages, injectIntl } from 'react-intl';
 | 
					import { injectIntl, FormattedMessage } from 'react-intl';
 | 
				
			||||||
import Column from 'mastodon/components/column';
 | 
					import Column from 'mastodon/components/column';
 | 
				
			||||||
import ColumnHeader from 'mastodon/components/column_header';
 | 
					import Button from 'mastodon/components/button';
 | 
				
			||||||
import IconButton from 'mastodon/components/icon_button';
 | 
					 | 
				
			||||||
import { Helmet } from 'react-helmet';
 | 
					import { Helmet } from 'react-helmet';
 | 
				
			||||||
 | 
					import { Link } from 'react-router-dom';
 | 
				
			||||||
 | 
					import classNames from 'classnames';
 | 
				
			||||||
 | 
					import { autoPlayGif } from 'mastodon/initial_state';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const messages = defineMessages({
 | 
					class GIF extends React.PureComponent {
 | 
				
			||||||
  title: { id: 'bundle_column_error.title', defaultMessage: 'Network error' },
 | 
					 | 
				
			||||||
  body: { id: 'bundle_column_error.body', defaultMessage: 'Something went wrong while loading this component.' },
 | 
					 | 
				
			||||||
  retry: { id: 'bundle_column_error.retry', defaultMessage: 'Try again' },
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class BundleColumnError extends React.PureComponent {
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static propTypes = {
 | 
					  static propTypes = {
 | 
				
			||||||
    onRetry: PropTypes.func.isRequired,
 | 
					    src: PropTypes.string.isRequired,
 | 
				
			||||||
    intl: PropTypes.object.isRequired,
 | 
					    staticSrc: PropTypes.string.isRequired,
 | 
				
			||||||
    multiColumn: PropTypes.bool,
 | 
					    className: PropTypes.string,
 | 
				
			||||||
 | 
					    animate: PropTypes.bool,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static defaultProps = {
 | 
				
			||||||
 | 
					    animate: autoPlayGif,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  state = {
 | 
				
			||||||
 | 
					    hovering: false,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleMouseEnter = () => {
 | 
				
			||||||
 | 
					    const { animate } = this.props;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!animate) {
 | 
				
			||||||
 | 
					      this.setState({ hovering: true });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleRetry = () => {
 | 
					  handleMouseLeave = () => {
 | 
				
			||||||
    this.props.onRetry();
 | 
					    const { animate } = this.props;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!animate) {
 | 
				
			||||||
 | 
					      this.setState({ hovering: false });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  render () {
 | 
					  render () {
 | 
				
			||||||
    const { multiColumn, intl: { formatMessage } } = this.props;
 | 
					    const { src, staticSrc, className, animate } = this.props;
 | 
				
			||||||
 | 
					    const { hovering } = this.state;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <Column bindToDocument={!multiColumn} label={formatMessage(messages.title)}>
 | 
					      <img
 | 
				
			||||||
        <ColumnHeader
 | 
					        className={className}
 | 
				
			||||||
          icon='exclamation-circle'
 | 
					        src={(hovering || animate) ? src : staticSrc}
 | 
				
			||||||
          title={formatMessage(messages.title)}
 | 
					        alt=''
 | 
				
			||||||
          showBackButton
 | 
					        role='presentation'
 | 
				
			||||||
          multiColumn={multiColumn}
 | 
					        onMouseEnter={this.handleMouseEnter}
 | 
				
			||||||
 | 
					        onMouseLeave={this.handleMouseLeave}
 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CopyButton extends React.PureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static propTypes = {
 | 
				
			||||||
 | 
					    children: PropTypes.node.isRequired,
 | 
				
			||||||
 | 
					    value: PropTypes.string.isRequired,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  state = {
 | 
				
			||||||
 | 
					    copied: false,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleClick = () => {
 | 
				
			||||||
 | 
					    const { value } = this.props;
 | 
				
			||||||
 | 
					    navigator.clipboard.writeText(value);
 | 
				
			||||||
 | 
					    this.setState({ copied: true });
 | 
				
			||||||
 | 
					    this.timeout = setTimeout(() => this.setState({ copied: false }), 700);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  componentWillUnmount () {
 | 
				
			||||||
 | 
					    if (this.timeout) clearTimeout(this.timeout);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  render () {
 | 
				
			||||||
 | 
					    const { children } = this.props;
 | 
				
			||||||
 | 
					    const { copied } = this.state;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					      <Button onClick={this.handleClick} className={copied ? 'copied' : 'copyable'}>{copied ? <FormattedMessage id='copypaste.copied' defaultMessage='Copied' /> : children}</Button>
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default @injectIntl
 | 
				
			||||||
 | 
					class BundleColumnError extends React.PureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static propTypes = {
 | 
				
			||||||
 | 
					    errorType: PropTypes.oneOf(['routing', 'network', 'error']),
 | 
				
			||||||
 | 
					    onRetry: PropTypes.func,
 | 
				
			||||||
 | 
					    intl: PropTypes.object.isRequired,
 | 
				
			||||||
 | 
					    multiColumn: PropTypes.bool,
 | 
				
			||||||
 | 
					    stacktrace: PropTypes.string,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static defaultProps = {
 | 
				
			||||||
 | 
					    errorType: 'routing',
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleRetry = () => {
 | 
				
			||||||
 | 
					    const { onRetry } = this.props;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (onRetry) {
 | 
				
			||||||
 | 
					      onRetry();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  render () {
 | 
				
			||||||
 | 
					    const { errorType, multiColumn, stacktrace } = this.props;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let title, body;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    switch(errorType) {
 | 
				
			||||||
 | 
					    case 'routing':
 | 
				
			||||||
 | 
					      title = <FormattedMessage id='bundle_column_error.routing.title' defaultMessage='404' />;
 | 
				
			||||||
 | 
					      body = <FormattedMessage id='bundle_column_error.routing.body' defaultMessage='The requested page could not be found. Are you sure the URL in the address bar is correct?' />;
 | 
				
			||||||
 | 
					      break;
 | 
				
			||||||
 | 
					    case 'network':
 | 
				
			||||||
 | 
					      title = <FormattedMessage id='bundle_column_error.network.title' defaultMessage='Network error' />;
 | 
				
			||||||
 | 
					      body = <FormattedMessage id='bundle_column_error.network.body' defaultMessage='There was an error when trying to load this page. This could be due to a temporary problem with your internet connection or this server.' />;
 | 
				
			||||||
 | 
					      break;
 | 
				
			||||||
 | 
					    case 'error':
 | 
				
			||||||
 | 
					      title = <FormattedMessage id='bundle_column_error.error.title' defaultMessage='Oh, no!' />;
 | 
				
			||||||
 | 
					      body = <FormattedMessage id='bundle_column_error.error.body' defaultMessage='The requested page could not be rendered. It could be due to a bug in our code, or a browser compatibility issue.' />;
 | 
				
			||||||
 | 
					      break;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					      <Column bindToDocument={!multiColumn}>
 | 
				
			||||||
        <div className='error-column'>
 | 
					        <div className='error-column'>
 | 
				
			||||||
          <IconButton title={formatMessage(messages.retry)} icon='refresh' onClick={this.handleRetry} size={64} />
 | 
					          <GIF src='/oops.gif' staticSrc='/oops.png' className='error-column__image' />
 | 
				
			||||||
          {formatMessage(messages.body)}
 | 
					
 | 
				
			||||||
 | 
					          <div className='error-column__message'>
 | 
				
			||||||
 | 
					            <h1>{title}</h1>
 | 
				
			||||||
 | 
					            <p>{body}</p>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            <div className='error-column__message__actions'>
 | 
				
			||||||
 | 
					              {errorType === 'network' && <Button onClick={this.handleRetry}><FormattedMessage id='bundle_column_error.retry' defaultMessage='Try again' /></Button>}
 | 
				
			||||||
 | 
					              {errorType === 'error' && <CopyButton value={stacktrace}><FormattedMessage id='bundle_column_error.copy_stacktrace' defaultMessage='Copy error report' /></CopyButton>}
 | 
				
			||||||
 | 
					              <Link to='/' className={classNames('button', { 'button-tertiary': errorType !== 'routing' })}><FormattedMessage id='bundle_column_error.return' defaultMessage='Go back home' /></Link>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <Helmet>
 | 
					        <Helmet>
 | 
				
			||||||
@ -49,5 +160,3 @@ class BundleColumnError extends React.PureComponent {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					 | 
				
			||||||
export default injectIntl(BundleColumnError);
 | 
					 | 
				
			||||||
 | 
				
			|||||||
@ -3,7 +3,7 @@ import React from 'react';
 | 
				
			|||||||
import { HotKeys } from 'react-hotkeys';
 | 
					import { HotKeys } from 'react-hotkeys';
 | 
				
			||||||
import { defineMessages, injectIntl } from 'react-intl';
 | 
					import { defineMessages, injectIntl } from 'react-intl';
 | 
				
			||||||
import { connect } from 'react-redux';
 | 
					import { connect } from 'react-redux';
 | 
				
			||||||
import { Redirect, withRouter } from 'react-router-dom';
 | 
					import { Redirect, Route, withRouter } from 'react-router-dom';
 | 
				
			||||||
import PropTypes from 'prop-types';
 | 
					import PropTypes from 'prop-types';
 | 
				
			||||||
import NotificationsContainer from './containers/notifications_container';
 | 
					import NotificationsContainer from './containers/notifications_container';
 | 
				
			||||||
import LoadingBarContainer from './containers/loading_bar_container';
 | 
					import LoadingBarContainer from './containers/loading_bar_container';
 | 
				
			||||||
@ -18,6 +18,7 @@ import { clearHeight } from '../../actions/height_cache';
 | 
				
			|||||||
import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app';
 | 
					import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app';
 | 
				
			||||||
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers';
 | 
					import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers';
 | 
				
			||||||
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
 | 
					import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
 | 
				
			||||||
 | 
					import BundleColumnError from './components/bundle_column_error';
 | 
				
			||||||
import UploadArea from './components/upload_area';
 | 
					import UploadArea from './components/upload_area';
 | 
				
			||||||
import ColumnsAreaContainer from './containers/columns_area_container';
 | 
					import ColumnsAreaContainer from './containers/columns_area_container';
 | 
				
			||||||
import PictureInPicture from 'mastodon/features/picture_in_picture';
 | 
					import PictureInPicture from 'mastodon/features/picture_in_picture';
 | 
				
			||||||
@ -39,7 +40,6 @@ import {
 | 
				
			|||||||
  HashtagTimeline,
 | 
					  HashtagTimeline,
 | 
				
			||||||
  Notifications,
 | 
					  Notifications,
 | 
				
			||||||
  FollowRequests,
 | 
					  FollowRequests,
 | 
				
			||||||
  GenericNotFound,
 | 
					 | 
				
			||||||
  FavouritedStatuses,
 | 
					  FavouritedStatuses,
 | 
				
			||||||
  BookmarkedStatuses,
 | 
					  BookmarkedStatuses,
 | 
				
			||||||
  ListTimeline,
 | 
					  ListTimeline,
 | 
				
			||||||
@ -219,7 +219,7 @@ class SwitchingColumnsArea extends React.PureComponent {
 | 
				
			|||||||
          <WrappedRoute path='/mutes' component={Mutes} content={children} />
 | 
					          <WrappedRoute path='/mutes' component={Mutes} content={children} />
 | 
				
			||||||
          <WrappedRoute path='/lists' component={Lists} content={children} />
 | 
					          <WrappedRoute path='/lists' component={Lists} content={children} />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          <WrappedRoute component={GenericNotFound} content={children} />
 | 
					          <Route component={BundleColumnError} />
 | 
				
			||||||
        </WrappedSwitch>
 | 
					        </WrappedSwitch>
 | 
				
			||||||
      </ColumnsAreaContainer>
 | 
					      </ColumnsAreaContainer>
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
				
			|||||||
@ -1,7 +1,7 @@
 | 
				
			|||||||
import React from 'react';
 | 
					import React from 'react';
 | 
				
			||||||
import PropTypes from 'prop-types';
 | 
					import PropTypes from 'prop-types';
 | 
				
			||||||
import { Switch, Route } from 'react-router-dom';
 | 
					import { Switch, Route } from 'react-router-dom';
 | 
				
			||||||
 | 
					import StackTrace from 'stacktrace-js';
 | 
				
			||||||
import ColumnLoading from '../components/column_loading';
 | 
					import ColumnLoading from '../components/column_loading';
 | 
				
			||||||
import BundleColumnError from '../components/bundle_column_error';
 | 
					import BundleColumnError from '../components/bundle_column_error';
 | 
				
			||||||
import BundleContainer from '../containers/bundle_container';
 | 
					import BundleContainer from '../containers/bundle_container';
 | 
				
			||||||
@ -42,8 +42,38 @@ export class WrappedRoute extends React.Component {
 | 
				
			|||||||
    componentParams: {},
 | 
					    componentParams: {},
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static getDerivedStateFromError () {
 | 
				
			||||||
 | 
					    return {
 | 
				
			||||||
 | 
					      hasError: true,
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  state = {
 | 
				
			||||||
 | 
					    hasError: false,
 | 
				
			||||||
 | 
					    stacktrace: '',
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  componentDidCatch (error) {
 | 
				
			||||||
 | 
					    StackTrace.fromError(error).then(stackframes => {
 | 
				
			||||||
 | 
					      this.setState({ stacktrace: error.toString() + '\n' + stackframes.map(frame => frame.toString()).join('\n') });
 | 
				
			||||||
 | 
					    }).catch(err => {
 | 
				
			||||||
 | 
					      console.error(err);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  renderComponent = ({ match }) => {
 | 
					  renderComponent = ({ match }) => {
 | 
				
			||||||
    const { component, content, multiColumn, componentParams } = this.props;
 | 
					    const { component, content, multiColumn, componentParams } = this.props;
 | 
				
			||||||
 | 
					    const { hasError, stacktrace } = this.state;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (hasError) {
 | 
				
			||||||
 | 
					      return (
 | 
				
			||||||
 | 
					        <BundleColumnError
 | 
				
			||||||
 | 
					          stacktrace={stacktrace}
 | 
				
			||||||
 | 
					          multiColumn={multiColumn}
 | 
				
			||||||
 | 
					          errorType='error'
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <BundleContainer fetchComponent={component} loading={this.renderLoading} error={this.renderError}>
 | 
					      <BundleContainer fetchComponent={component} loading={this.renderLoading} error={this.renderError}>
 | 
				
			||||||
@ -59,7 +89,7 @@ export class WrappedRoute extends React.Component {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  renderError = (props) => {
 | 
					  renderError = (props) => {
 | 
				
			||||||
    return <BundleColumnError {...props} />;
 | 
					    return <BundleColumnError {...props} errorType='network' />;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  render () {
 | 
					  render () {
 | 
				
			||||||
 | 
				
			|||||||
@ -89,6 +89,15 @@
 | 
				
			|||||||
    cursor: default;
 | 
					    cursor: default;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &.copyable {
 | 
				
			||||||
 | 
					    transition: background 300ms linear;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &.copied {
 | 
				
			||||||
 | 
					    background: $valid-value-color;
 | 
				
			||||||
 | 
					    transition: none;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  &::-moz-focus-inner {
 | 
					  &::-moz-focus-inner {
 | 
				
			||||||
    border: 0;
 | 
					    border: 0;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
@ -2656,7 +2665,8 @@ $ui-header-height: 55px;
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  .column-header,
 | 
					  .column-header,
 | 
				
			||||||
  .column-back-button,
 | 
					  .column-back-button,
 | 
				
			||||||
  .scrollable {
 | 
					  .scrollable,
 | 
				
			||||||
 | 
					  .error-column {
 | 
				
			||||||
    border-radius: 0 !important;
 | 
					    border-radius: 0 !important;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@ -4292,7 +4302,6 @@ a.status-card.compact:hover {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.empty-column-indicator,
 | 
					.empty-column-indicator,
 | 
				
			||||||
.error-column,
 | 
					 | 
				
			||||||
.follow_requests-unlocked_explanation {
 | 
					.follow_requests-unlocked_explanation {
 | 
				
			||||||
  color: $dark-text-color;
 | 
					  color: $dark-text-color;
 | 
				
			||||||
  background: $ui-base-color;
 | 
					  background: $ui-base-color;
 | 
				
			||||||
@ -4330,7 +4339,47 @@ a.status-card.compact:hover {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.error-column {
 | 
					.error-column {
 | 
				
			||||||
 | 
					  padding: 20px;
 | 
				
			||||||
 | 
					  background: $ui-base-color;
 | 
				
			||||||
 | 
					  border-radius: 4px;
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex: 1 1 auto;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  justify-content: center;
 | 
				
			||||||
  flex-direction: column;
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  cursor: default;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &__image {
 | 
				
			||||||
 | 
					    max-width: 350px;
 | 
				
			||||||
 | 
					    margin-top: -50px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &__message {
 | 
				
			||||||
 | 
					    text-align: center;
 | 
				
			||||||
 | 
					    color: $darker-text-color;
 | 
				
			||||||
 | 
					    font-size: 15px;
 | 
				
			||||||
 | 
					    line-height: 22px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    h1 {
 | 
				
			||||||
 | 
					      font-size: 28px;
 | 
				
			||||||
 | 
					      line-height: 33px;
 | 
				
			||||||
 | 
					      font-weight: 700;
 | 
				
			||||||
 | 
					      margin-bottom: 15px;
 | 
				
			||||||
 | 
					      color: $primary-text-color;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    p {
 | 
				
			||||||
 | 
					      max-width: 48ch;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    &__actions {
 | 
				
			||||||
 | 
					      margin-top: 30px;
 | 
				
			||||||
 | 
					      display: flex;
 | 
				
			||||||
 | 
					      gap: 10px;
 | 
				
			||||||
 | 
					      align-items: center;
 | 
				
			||||||
 | 
					      justify-content: center;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@keyframes heartbeat {
 | 
					@keyframes heartbeat {
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user