Change prettier rules

This commit is contained in:
ErickSkrauch
2020-05-24 02:08:24 +03:00
parent 73f0c37a6a
commit f85b9d8d35
382 changed files with 24137 additions and 26046 deletions

View File

@@ -3,109 +3,109 @@
// Original: http://codepen.io/vanderlanth/pen/rxpNMY
.page {
margin: 80px auto 0;
margin: 80px auto 0;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.loading {
width: 200px;
height: 100px;
margin-bottom: 50px;
position: relative;
display: flex;
justify-content: center;
align-items: flex-end;
animation: loadStab 1s ease-out infinite;
width: 200px;
height: 100px;
margin-bottom: 50px;
position: relative;
display: flex;
justify-content: center;
align-items: flex-end;
animation: loadStab 1s ease-out infinite;
}
.cube {
width: 50px;
height: 50px;
background: white;
animation: cubeRotate 1s ease-out infinite;
width: 50px;
height: 50px;
background: white;
animation: cubeRotate 1s ease-out infinite;
}
.road {
position: absolute;
width: 100%;
height: 1px;
background: white;
left: 0;
bottom: 0;
animation: roadStab 1s ease-out infinite;
position: absolute;
width: 100%;
height: 1px;
background: white;
left: 0;
bottom: 0;
animation: roadStab 1s ease-out infinite;
}
@keyframes cubeRotate {
0% {
transform: rotate(0deg) translate3D(0, 0, 0);
}
65% {
transform: rotate(45deg) translate3D(0, -13px, 0);
}
90% {
transform: rotate(70deg) translate3D(0, -8px, 0);
}
100% {
transform: rotate(90deg) translate3D(0, 0, 0);
}
0% {
transform: rotate(0deg) translate3D(0, 0, 0);
}
65% {
transform: rotate(45deg) translate3D(0, -13px, 0);
}
90% {
transform: rotate(70deg) translate3D(0, -8px, 0);
}
100% {
transform: rotate(90deg) translate3D(0, 0, 0);
}
}
@keyframes roadStab {
0% {
transform: translate3D(0, 0, 0);
}
60% {
transform: translate3D(0, 2px, 0);
}
90% {
transform: translate3D(0, 4px, 0);
}
100% {
transform: translate3D(0, 0, 0);
}
0% {
transform: translate3D(0, 0, 0);
}
60% {
transform: translate3D(0, 2px, 0);
}
90% {
transform: translate3D(0, 4px, 0);
}
100% {
transform: translate3D(0, 0, 0);
}
}
@keyframes loadStab {
0% {
transform: translate3D(0, 0, 0);
}
60% {
transform: translate3D(0, -2px, 0);
}
95% {
transform: translate3D(0, -2px, 0);
}
100% {
transform: translate3D(0, 0, 0);
}
0% {
transform: translate3D(0, 0, 0);
}
60% {
transform: translate3D(0, -2px, 0);
}
95% {
transform: translate3D(0, -2px, 0);
}
100% {
transform: translate3D(0, 0, 0);
}
}
//------------------ MOUNTAINS ---------------------
.rocks {
width: 100%;
height: 100%;
position: absolute;
bottom: -50px;
left: 0;
overflow: hidden;
animation: roadStab 1s ease-out infinite;
width: 100%;
height: 100%;
position: absolute;
bottom: -50px;
left: 0;
overflow: hidden;
animation: roadStab 1s ease-out infinite;
}
@mixin rock($rockName, $bottom, $delay) {
.#{$rockName} {
position: absolute;
border-left: 2px solid transparent;
border-right: 2px solid transparent;
border-bottom: 4px solid white;
bottom: $bottom;
right: -2%;
animation: rockTravelling 10s $delay ease-out infinite;
}
.#{$rockName} {
position: absolute;
border-left: 2px solid transparent;
border-right: 2px solid transparent;
border-bottom: 4px solid white;
bottom: $bottom;
right: -2%;
animation: rockTravelling 10s $delay ease-out infinite;
}
}
@include rock('rockOne', 23px, 0s);
@@ -115,133 +115,133 @@
@include rock('rockFive', 18px, 8s);
@keyframes rockTravelling {
0% {
right: -2%;
}
10% {
right: 8%;
}
20% {
right: 18%;
}
30% {
right: 29%;
}
40% {
right: 40%;
}
50% {
right: 51%;
}
60% {
right: 62%;
}
70% {
right: 72%;
}
80% {
right: 82%;
}
90% {
right: 92%;
}
100% {
right: 102%;
}
0% {
right: -2%;
}
10% {
right: 8%;
}
20% {
right: 18%;
}
30% {
right: 29%;
}
40% {
right: 40%;
}
50% {
right: 51%;
}
60% {
right: 62%;
}
70% {
right: 72%;
}
80% {
right: 82%;
}
90% {
right: 92%;
}
100% {
right: 102%;
}
}
//------------------ CLOUDS ---------------------
.clouds {
width: 200%;
height: 200%;
animation: roadStab 1s ease-out infinite, cloudStab 1s ease-out infinite;
position: absolute;
bottom: -50px;
left: -50%;
overflow: hidden;
width: 200%;
height: 200%;
animation: roadStab 1s ease-out infinite, cloudStab 1s ease-out infinite;
position: absolute;
bottom: -50px;
left: -50%;
overflow: hidden;
}
.cloud {
position: absolute;
will-change: animation;
position: absolute;
will-change: animation;
background-image: url('./cloud.svg');
background-size: cover;
background-image: url('./cloud.svg');
background-size: cover;
}
.cloudOne {
composes: cloud;
composes: cloud;
top: 5px;
width: 100px;
height: 32px;
animation: cloudTravelling 16s linear infinite;
top: 5px;
width: 100px;
height: 32px;
animation: cloudTravelling 16s linear infinite;
}
.cloudTwo {
composes: cloud;
composes: cloud;
top: 65px;
right: -30%;
width: 50px;
height: 16px;
animation: cloudTravelling 21s 5s linear infinite;
top: 65px;
right: -30%;
width: 50px;
height: 16px;
animation: cloudTravelling 21s 5s linear infinite;
}
.cloudThree {
composes: cloud;
composes: cloud;
top: 40px;
right: -30%;
width: 70px;
height: 22px;
animation: cloudTravelling 26s 11s linear infinite;
top: 40px;
right: -30%;
width: 70px;
height: 22px;
animation: cloudTravelling 26s 11s linear infinite;
}
@keyframes cloudTravelling {
0% {
right: -30%;
}
100% {
right: 110%;
}
0% {
right: -30%;
}
100% {
right: 110%;
}
}
@keyframes cloudStab {
0% {
transform: translate3D(0, 0, 0);
}
60% {
transform: translate3D(0, 2px, 0);
}
85% {
transform: translate3D(0, 2px, 0);
}
100% {
transform: translate3D(0, 0, 0);
}
0% {
transform: translate3D(0, 0, 0);
}
60% {
transform: translate3D(0, 2px, 0);
}
85% {
transform: translate3D(0, 2px, 0);
}
100% {
transform: translate3D(0, 0, 0);
}
}
%text {
font-family: $font-family-title;
text-align: center;
padding: 0 10px;
line-height: 1.2;
font-family: $font-family-title;
text-align: center;
padding: 0 10px;
line-height: 1.2;
}
.text {
@extend %text;
@extend %text;
font-size: 24px;
margin-top: 25px;
color: #666;
font-size: 24px;
margin-top: 25px;
color: #666;
}
.subText {
@extend %text;
@extend %text;
font-size: 16px;
margin-top: 5px;
color: #9a9a9a;
font-size: 16px;
margin-top: 5px;
color: #9a9a9a;
}

View File

@@ -1,6 +1,6 @@
{
"title": "Page not found",
"nothingHere": "This is not a place that you are looking for",
"returnToTheHomePage": "Try to go back to the {link}",
"homePage": "main page"
"title": "Page not found",
"nothingHere": "This is not a place that you are looking for",
"returnToTheHomePage": "Try to go back to the {link}",
"homePage": "main page"
}

View File

@@ -9,47 +9,45 @@ import messages from './PageNotFound.intl.json';
import profileStyles from '../profile/profile.scss';
const PageNotFound: ComponentType = () => (
<div className={styles.page}>
<Message {...messages.title}>
{(pageTitle) => <Helmet title={pageTitle as string} />}
</Message>
<div className={styles.page}>
<Message {...messages.title}>{(pageTitle) => <Helmet title={pageTitle as string} />}</Message>
<div className={styles.loading}>
<div className={styles.cube} />
<div className={styles.road} />
<div className={styles.rocks}>
<span className={styles.rockOne} />
<span className={styles.rockTwo} />
<span className={styles.rockThree} />
<span className={styles.rockFour} />
<span className={styles.rockFive} />
</div>
<div className={styles.clouds}>
<span className={styles.cloudOne} />
<span className={styles.cloudTwo} />
<span className={styles.cloudThree} />
</div>
</div>
<p className={styles.text}>
<Message {...messages.nothingHere} />
</p>
<p className={styles.subText}>
<Message
{...messages.returnToTheHomePage}
values={{
link: (
<Link to="/">
<Message {...messages.homePage} />
</Link>
),
}}
/>
</p>
<div className={styles.loading}>
<div className={styles.cube} />
<div className={styles.road} />
<div className={styles.rocks}>
<span className={styles.rockOne} />
<span className={styles.rockTwo} />
<span className={styles.rockThree} />
<span className={styles.rockFour} />
<span className={styles.rockFive} />
</div>
<div className={styles.clouds}>
<span className={styles.cloudOne} />
<span className={styles.cloudTwo} />
<span className={styles.cloudThree} />
</div>
</div>
<p className={styles.text}>
<Message {...messages.nothingHere} />
</p>
<p className={styles.subText}>
<Message
{...messages.returnToTheHomePage}
values={{
link: (
<Link to="/">
<Message {...messages.homePage} />
</Link>
),
}}
/>
</p>
<div className={profileStyles.footer}>
<FooterMenu />
<div className={profileStyles.footer}>
<FooterMenu />
</div>
</div>
</div>
);
export default PageNotFound;

View File

@@ -29,82 +29,54 @@ import styles from './auth.scss';
let isSidebarHiddenCache = false;
const AuthPage: ComponentType = () => {
const [isSidebarHidden, setIsSidebarHidden] = useState<boolean>(
isSidebarHiddenCache,
);
const client = useSelector((state: RootState) => state.auth.client);
const [isSidebarHidden, setIsSidebarHidden] = useState<boolean>(isSidebarHiddenCache);
const client = useSelector((state: RootState) => state.auth.client);
const goToAuth = useCallback(() => {
isSidebarHiddenCache = true;
setIsSidebarHidden(true);
}, []);
const goToAuth = useCallback(() => {
isSidebarHiddenCache = true;
setIsSidebarHidden(true);
}, []);
return (
<div>
<div className={isSidebarHidden ? styles.hiddenSidebar : styles.sidebar}>
<AppInfo {...client} onGoToAuth={goToAuth} />
</div>
return (
<div>
<div className={isSidebarHidden ? styles.hiddenSidebar : styles.sidebar}>
<AppInfo {...client} onGoToAuth={goToAuth} />
</div>
<div className={styles.content} data-e2e-content>
<Switch>
<Route path="/login" render={renderPanelTransition(Login)} />
<Route path="/mfa" render={renderPanelTransition(Mfa)} />
<Route path="/password" render={renderPanelTransition(Password)} />
<Route path="/register" render={renderPanelTransition(Register)} />
<Route
path="/activation/:key?"
render={renderPanelTransition(Activation)}
/>
<Route
path="/resend-activation"
render={renderPanelTransition(ResendActivation)}
/>
<Route
path="/oauth/permissions"
render={renderPanelTransition(Permissions)}
/>
<Route
path="/choose-account"
render={renderPanelTransition(ChooseAccount)}
/>
<Route
path="/oauth/choose-account"
render={renderPanelTransition(ChooseAccount)}
/>
<Route path="/oauth/finish" component={Finish} />
<Route
path="/accept-rules"
render={renderPanelTransition(AcceptRules)}
/>
<Route
path="/forgot-password"
render={renderPanelTransition(ForgotPassword)}
/>
<Route
path="/recover-password/:key?"
render={renderPanelTransition(RecoverPassword)}
/>
<Redirect to="/404" />
</Switch>
</div>
</div>
);
<div className={styles.content} data-e2e-content>
<Switch>
<Route path="/login" render={renderPanelTransition(Login)} />
<Route path="/mfa" render={renderPanelTransition(Mfa)} />
<Route path="/password" render={renderPanelTransition(Password)} />
<Route path="/register" render={renderPanelTransition(Register)} />
<Route path="/activation/:key?" render={renderPanelTransition(Activation)} />
<Route path="/resend-activation" render={renderPanelTransition(ResendActivation)} />
<Route path="/oauth/permissions" render={renderPanelTransition(Permissions)} />
<Route path="/choose-account" render={renderPanelTransition(ChooseAccount)} />
<Route path="/oauth/choose-account" render={renderPanelTransition(ChooseAccount)} />
<Route path="/oauth/finish" component={Finish} />
<Route path="/accept-rules" render={renderPanelTransition(AcceptRules)} />
<Route path="/forgot-password" render={renderPanelTransition(ForgotPassword)} />
<Route path="/recover-password/:key?" render={renderPanelTransition(RecoverPassword)} />
<Redirect to="/404" />
</Switch>
</div>
</div>
);
};
function renderPanelTransition(
factory: Factory,
): (props: RouteComponentProps<any>) => ReactNode {
const { Title, Body, Footer, Links } = factory();
function renderPanelTransition(factory: Factory): (props: RouteComponentProps<any>) => ReactNode {
const { Title, Body, Footer, Links } = factory();
return (props) => (
<PanelTransition
key="panel-transition"
Title={<Title />}
Body={<Body {...props} />}
Footer={<Footer />}
Links={<Links />}
/>
);
return (props) => (
<PanelTransition
key="panel-transition"
Title={<Title />}
Body={<Body {...props} />}
Footer={<Footer />}
Links={<Links />}
/>
);
}
export default AuthPage;

View File

@@ -1,7 +1,7 @@
{
"title": "Authorization successful",
"applicationAuth": "Application authorization",
"authorizationSuccessful": "Authorization has been successfully completed.",
"authorizationForAppSuccessful": "Authorization for {appName} has been successfully completed.",
"youCanCloseThisPage": "You can close this window and return to your application."
"title": "Authorization successful",
"applicationAuth": "Application authorization",
"authorizationSuccessful": "Authorization has been successfully completed.",
"authorizationForAppSuccessful": "Authorization for {appName} has been successfully completed.",
"youCanCloseThisPage": "You can close this window and return to your application."
}

View File

@@ -10,68 +10,66 @@ import styles from './success-oauth.scss';
import messages from './SuccessOauthPage.intl.json';
export default class SuccessOauthPage extends React.Component<{
location: {
query: Query<'appName'>;
};
location: {
query: Query<'appName'>;
};
}> {
componentDidMount() {
this.onPageUpdate();
componentDidMount() {
this.onPageUpdate();
setTimeout(() => {
try {
// try to close window if possible
// @ts-ignore
window.open('', '_self').close();
} catch (err) {
// don't care
}
}, 8000);
}
setTimeout(() => {
try {
// try to close window if possible
// @ts-ignore
window.open('', '_self').close();
} catch (err) {
// don't care
}
}, 8000);
}
componentDidUpdate() {
this.onPageUpdate();
}
componentDidUpdate() {
this.onPageUpdate();
}
onPageUpdate() {
loader.hide();
}
onPageUpdate() {
loader.hide();
}
render() {
const appName = this.props.location.query.get('appName');
render() {
const appName = this.props.location.query.get('appName');
return (
<div className={styles.page}>
<Message {...messages.title}>
{(pageTitle) => <Helmet title={pageTitle as string} />}
</Message>
return (
<div className={styles.page}>
<Message {...messages.title}>{(pageTitle) => <Helmet title={pageTitle as string} />}</Message>
<div className={styles.wrapper}>
<Link to="/" className={styles.logo}>
<Message {...rootMessages.siteName} />
</Link>
<div className={styles.wrapper}>
<Link to="/" className={styles.logo}>
<Message {...rootMessages.siteName} />
</Link>
<div className={styles.title}>
<Message {...messages.applicationAuth} />
</div>
<div className={styles.title}>
<Message {...messages.applicationAuth} />
</div>
<div className={styles.checkmark} />
<div className={styles.checkmark} />
<div className={styles.description}>
{appName ? (
<Message
{...messages.authorizationForAppSuccessful}
values={{
appName: <b>{appName}</b>,
}}
/>
) : (
<Message {...messages.authorizationSuccessful} />
)}
&nbsp;
<Message {...messages.youCanCloseThisPage} />
</div>
</div>
</div>
);
}
<div className={styles.description}>
{appName ? (
<Message
{...messages.authorizationForAppSuccessful}
values={{
appName: <b>{appName}</b>,
}}
/>
) : (
<Message {...messages.authorizationSuccessful} />
)}
&nbsp;
<Message {...messages.youCanCloseThisPage} />
</div>
</div>
</div>
);
}
}

View File

@@ -3,47 +3,47 @@
$sidebar-width: 320px;
.sidebar {
position: absolute;
bottom: 0;
right: 0;
left: 0;
top: 50px;
z-index: 10;
position: absolute;
bottom: 0;
right: 0;
left: 0;
top: 50px;
z-index: 10;
background: $black;
background: $black;
}
.hiddenSidebar {
composes: sidebar;
composes: sidebar;
display: none;
display: none;
}
.content {
text-align: center;
max-width: 340px;
margin: 0 auto;
text-align: center;
max-width: 340px;
margin: 0 auto;
}
@media (min-width: 350px) {
.content {
padding: 55px 0;
}
.content {
padding: 55px 0;
}
}
@media (min-width: 720px) {
.content {
padding: 55px 50px;
margin-left: $sidebar-width;
}
.content {
padding: 55px 50px;
margin-left: $sidebar-width;
}
.sidebar {
right: auto;
.sidebar {
right: auto;
width: $sidebar-width;
}
width: $sidebar-width;
}
.hiddenSidebar {
display: block;
}
.hiddenSidebar {
display: block;
}
}

View File

@@ -2,69 +2,69 @@
@import '~app/components/ui/colors.scss';
.page {
border-top: 50px solid #ddd8ce;
border-top: 50px solid #ddd8ce;
padding: 85px 10px;
padding: 85px 10px;
}
.wrapper {
position: relative;
position: relative;
margin: 0 auto;
padding: 55px 25px;
max-width: 330px;
box-sizing: border-box;
margin: 0 auto;
padding: 55px 25px;
max-width: 330px;
box-sizing: border-box;
background: #fff;
border: 3px solid #ddd8ce;
background: #fff;
border: 3px solid #ddd8ce;
text-align: center;
text-align: center;
}
.logo {
$borderWidth: 3px;
$borderWidth: 3px;
position: absolute;
top: -28px;
left: 50%;
transform: translate(-50%, 0);
position: absolute;
top: -28px;
left: 50%;
transform: translate(-50%, 0);
padding: 0 20px;
padding: 0 20px;
font-family: $font-family-title;
font-size: 33px;
line-height: 50px - $borderWidth * 2;
color: #fff;
background: $green;
border: 3px solid darker($green);
&:hover {
font-family: $font-family-title;
font-size: 33px;
line-height: 50px - $borderWidth * 2;
color: #fff;
background: $green;
border: 3px solid darker($green);
}
&:hover {
color: #fff;
background: $green;
border: 3px solid darker($green);
}
}
.title {
font-family: $font-family-title;
font-size: 20px;
margin-bottom: 20px;
font-family: $font-family-title;
font-size: 20px;
margin-bottom: 20px;
}
.checkmark {
composes: checkmark from '~app/components/ui/icons.scss';
composes: checkmark from '~app/components/ui/icons.scss';
color: lighter($green);
font-size: 66px;
margin-bottom: 28px;
color: lighter($green);
font-size: 66px;
margin-bottom: 28px;
}
.description {
font-size: 13px;
color: #9a9a9a;
line-height: 1.4;
font-size: 13px;
color: #9a9a9a;
line-height: 1.4;
b {
color: #666;
}
b {
color: #666;
}
}

View File

@@ -1,91 +1,87 @@
import React from 'react';
import { RouteComponentProps } from 'react-router-dom';
import { connect } from 'react-redux';
import {
fetchAvailableApps,
resetApp,
deleteApp,
} from 'app/components/dev/apps/actions';
import { fetchAvailableApps, resetApp, deleteApp } from 'app/components/dev/apps/actions';
import ApplicationsIndex from 'app/components/dev/apps/ApplicationsIndex';
import { User } from 'app/components/user';
import { OauthAppResponse } from 'app/services/api/oauth';
import { RootState } from 'app/reducers';
interface Props extends RouteComponentProps {
user: User;
apps: OauthAppResponse[];
fetchAvailableApps: () => Promise<void>;
deleteApp: (clientId: string) => Promise<void>;
resetApp: (clientId: string, resetClientSecret: boolean) => Promise<void>;
user: User;
apps: OauthAppResponse[];
fetchAvailableApps: () => Promise<void>;
deleteApp: (clientId: string) => Promise<void>;
resetApp: (clientId: string, resetClientSecret: boolean) => Promise<void>;
}
type State = {
isLoading: boolean;
forceUpdate: boolean;
isLoading: boolean;
forceUpdate: boolean;
};
class ApplicationsListPage extends React.Component<Props, State> {
state = {
isLoading: false,
forceUpdate: false,
};
state = {
isLoading: false,
forceUpdate: false,
};
componentDidMount() {
!this.props.user.isGuest && this.loadApplicationsList();
}
componentDidUpdate({ user }: Props) {
if (this.props.user !== user) {
// eslint-disable-next-line react/no-did-update-set-state
this.setState({ forceUpdate: true });
this.loadApplicationsList();
componentDidMount() {
!this.props.user.isGuest && this.loadApplicationsList();
}
}
render() {
const { user, apps, resetApp, deleteApp, location } = this.props;
const { isLoading, forceUpdate } = this.state;
const clientId = location.hash.substr(1) || null;
return (
<ApplicationsIndex
displayForGuest={user.isGuest}
applications={forceUpdate ? [] : apps}
isLoading={isLoading}
deleteApp={deleteApp}
resetApp={resetApp}
clientId={clientId}
resetClientId={this.resetClientId}
/>
);
}
loadApplicationsList = async () => {
this.setState({ isLoading: true });
await this.props.fetchAvailableApps();
this.setState({
isLoading: false,
forceUpdate: false,
});
};
resetClientId = () => {
const { history, location } = this.props;
if (location.hash) {
history.push({ ...location, hash: '' });
componentDidUpdate({ user }: Props) {
if (this.props.user !== user) {
// eslint-disable-next-line react/no-did-update-set-state
this.setState({ forceUpdate: true });
this.loadApplicationsList();
}
}
};
render() {
const { user, apps, resetApp, deleteApp, location } = this.props;
const { isLoading, forceUpdate } = this.state;
const clientId = location.hash.substr(1) || null;
return (
<ApplicationsIndex
displayForGuest={user.isGuest}
applications={forceUpdate ? [] : apps}
isLoading={isLoading}
deleteApp={deleteApp}
resetApp={resetApp}
clientId={clientId}
resetClientId={this.resetClientId}
/>
);
}
loadApplicationsList = async () => {
this.setState({ isLoading: true });
await this.props.fetchAvailableApps();
this.setState({
isLoading: false,
forceUpdate: false,
});
};
resetClientId = () => {
const { history, location } = this.props;
if (location.hash) {
history.push({ ...location, hash: '' });
}
};
}
export default connect(
(state: RootState) => ({
user: state.user,
apps: state.apps.available,
}),
{
fetchAvailableApps,
resetApp,
deleteApp,
},
(state: RootState) => ({
user: state.user,
apps: state.apps.available,
}),
{
fetchAvailableApps,
resetApp,
deleteApp,
},
)(ApplicationsListPage);

View File

@@ -7,63 +7,62 @@ import { OauthAppResponse } from 'app/services/api/oauth';
import { ApplicationType } from 'app/components/dev/apps';
const app: OauthAppResponse = {
clientId: '',
clientSecret: '',
countUsers: 0,
createdAt: 0,
type: 'application',
name: '',
description: '',
websiteUrl: '',
redirectUri: '',
minecraftServerIp: '',
clientId: '',
clientSecret: '',
countUsers: 0,
createdAt: 0,
type: 'application',
name: '',
description: '',
websiteUrl: '',
redirectUri: '',
minecraftServerIp: '',
};
interface State {
type: ApplicationType | null;
type: ApplicationType | null;
}
export default class CreateNewApplicationPage extends Component<{}, State> {
state: State = {
type: null,
};
state: State = {
type: null,
};
form: FormModel = new FormModel();
form: FormModel = new FormModel();
render() {
return (
<ApplicationForm
form={this.form}
displayTypeSwitcher
onSubmit={this.onSubmit}
type={this.state.type}
setType={this.setType}
app={app}
/>
);
}
onSubmit = async () => {
const { form } = this;
const { type } = this.state;
if (!type) {
throw new Error('Form was submitted without specified type');
render() {
return (
<ApplicationForm
form={this.form}
displayTypeSwitcher
onSubmit={this.onSubmit}
type={this.state.type}
setType={this.setType}
app={app}
/>
);
}
form.beginLoading();
const result = await oauth.create(type, form.serialize());
form.endLoading();
onSubmit = async () => {
const { form } = this;
const { type } = this.state;
this.goToMainPage(result.data.clientId);
};
if (!type) {
throw new Error('Form was submitted without specified type');
}
setType = (type: ApplicationType) => {
this.setState({
type,
});
};
form.beginLoading();
const result = await oauth.create(type, form.serialize());
form.endLoading();
goToMainPage = (hash?: string) =>
browserHistory.push(`/dev/applications${hash ? `#${hash}` : ''}`);
this.goToMainPage(result.data.clientId);
};
setType = (type: ApplicationType) => {
this.setState({
type,
});
};
goToMainPage = (hash?: string) => browserHistory.push(`/dev/applications${hash ? `#${hash}` : ''}`);
}

View File

@@ -9,31 +9,20 @@ import CreateNewApplicationPage from './CreateNewApplicationPage';
import UpdateApplicationPage from './UpdateApplicationPage';
const DevPage: ComponentType = () => (
<div className={styles.container}>
<div data-e2e-content>
<Switch>
<Route
path="/dev/applications"
exact
component={ApplicationsListPage}
/>
<PrivateRoute
path="/dev/applications/new"
exact
component={CreateNewApplicationPage}
/>
<PrivateRoute
path="/dev/applications/:clientId"
component={UpdateApplicationPage}
/>
<Redirect to="/dev/applications" />
</Switch>
</div>
<div className={styles.container}>
<div data-e2e-content>
<Switch>
<Route path="/dev/applications" exact component={ApplicationsListPage} />
<PrivateRoute path="/dev/applications/new" exact component={CreateNewApplicationPage} />
<PrivateRoute path="/dev/applications/:clientId" component={UpdateApplicationPage} />
<Redirect to="/dev/applications" />
</Switch>
</div>
<div className={styles.footer}>
<FooterMenu />
<div className={styles.footer}>
<FooterMenu />
</div>
</div>
</div>
);
export default DevPage;

View File

@@ -7,114 +7,103 @@ import { browserHistory } from 'app/services/history';
import oauth from 'app/services/api/oauth';
import * as loader from 'app/services/loader';
import PageNotFound from 'app/pages/404/PageNotFound';
import {
getApp,
fetchApp as fetchAppAction,
} from 'app/components/dev/apps/actions';
import { getApp, fetchApp as fetchAppAction } from 'app/components/dev/apps/actions';
import ApplicationForm from 'app/components/dev/apps/applicationForm/ApplicationForm';
import { OauthAppResponse } from 'app/services/api/oauth';
import { RootState } from 'app/reducers';
type OwnProps = RouteComponentProps<{
clientId: string;
clientId: string;
}>;
interface Props extends OwnProps {
app: OauthAppResponse | null;
fetchApp: (app: string) => Promise<void>;
app: OauthAppResponse | null;
fetchApp: (app: string) => Promise<void>;
}
class UpdateApplicationPage extends React.Component<
Props,
{
isNotFound: boolean;
}
Props,
{
isNotFound: boolean;
}
> {
form: FormModel = new FormModel();
form: FormModel = new FormModel();
state = {
isNotFound: false,
};
state = {
isNotFound: false,
};
componentDidMount() {
this.props.app === null && this.fetchApp();
}
render() {
const { app } = this.props;
if (this.state.isNotFound) {
return <PageNotFound />;
componentDidMount() {
this.props.app === null && this.fetchApp();
}
if (!app) {
// we are loading
return null;
render() {
const { app } = this.props;
if (this.state.isNotFound) {
return <PageNotFound />;
}
if (!app) {
// we are loading
return null;
}
return <ApplicationForm form={this.form} onSubmit={this.onSubmit} app={app} type={app.type} />;
}
return (
<ApplicationForm
form={this.form}
onSubmit={this.onSubmit}
app={app}
type={app.type}
/>
);
}
async fetchApp() {
const { fetchApp, match } = this.props;
async fetchApp() {
const { fetchApp, match } = this.props;
try {
loader.show();
await fetchApp(match.params.clientId);
} catch (resp) {
const { status } = resp.originalResponse;
try {
loader.show();
await fetchApp(match.params.clientId);
} catch (resp) {
const { status } = resp.originalResponse;
if (status === 403) {
this.goToMainPage();
if (status === 403) {
this.goToMainPage();
return;
}
return;
}
if (status === 404) {
this.setState({
isNotFound: true,
});
if (status === 404) {
this.setState({
isNotFound: true,
});
return;
}
return;
}
logger.unexpected('Error fetching app', resp);
} finally {
loader.hide();
}
}
onSubmit = async () => {
const { form } = this;
const { app } = this.props;
if (!app || !app.clientId) {
throw new Error('Form has an invalid state');
logger.unexpected('Error fetching app', resp);
} finally {
loader.hide();
}
}
form.beginLoading();
const result = await oauth.update(app.clientId, form.serialize());
form.endLoading();
onSubmit = async () => {
const { form } = this;
const { app } = this.props;
this.goToMainPage(result.data.clientId);
};
if (!app || !app.clientId) {
throw new Error('Form has an invalid state');
}
goToMainPage = (hash?: string) =>
browserHistory.push(`/dev/applications${hash ? `#${hash}` : ''}`);
form.beginLoading();
const result = await oauth.update(app.clientId, form.serialize());
form.endLoading();
this.goToMainPage(result.data.clientId);
};
goToMainPage = (hash?: string) => browserHistory.push(`/dev/applications${hash ? `#${hash}` : ''}`);
}
export default connect(
(state: RootState, props: OwnProps) => ({
app: getApp(state, props.match.params.clientId),
}),
{
fetchApp: fetchAppAction,
},
(state: RootState, props: OwnProps) => ({
app: getApp(state, props.match.params.clientId),
}),
{
fetchApp: fetchAppAction,
},
)(UpdateApplicationPage);

View File

@@ -1,18 +1,18 @@
.container {
padding: 55px 0 65px;
padding: 55px 0 65px;
}
.footer {
width: 100%;
position: absolute;
bottom: 10px;
left: 0;
width: 100%;
position: absolute;
bottom: 10px;
left: 0;
text-align: center;
text-align: center;
}
@media (max-width: 720px) {
.container {
padding-top: 20px;
}
.container {
padding-top: 20px;
}
}

View File

@@ -2,116 +2,99 @@ import React from 'react';
import { connect } from 'react-redux';
import { RouteComponentProps, Redirect } from 'react-router-dom';
import FormModel from 'app/components/ui/form/FormModel';
import ChangeEmail, {
ChangeEmailStep,
} from 'app/components/profile/changeEmail/ChangeEmail';
import {
requestEmailChange,
setNewEmail,
confirmNewEmail,
} from 'app/services/api/accounts';
import ChangeEmail, { ChangeEmailStep } from 'app/components/profile/changeEmail/ChangeEmail';
import { requestEmailChange, setNewEmail, confirmNewEmail } from 'app/services/api/accounts';
import { RootState } from 'app/reducers';
import Context from 'app/components/profile/Context';
interface RouteParams {
step: 'step1' | 'step2' | 'step3';
code: string;
step: 'step1' | 'step2' | 'step3';
code: string;
}
interface Props extends RouteComponentProps<RouteParams> {
lang: string;
email: string;
lang: string;
email: string;
}
class ChangeEmailPage extends React.Component<Props> {
static contextType = Context;
/* TODO: use declare */ context: React.ContextType<typeof Context>;
static contextType = Context;
/* TODO: use declare */ context: React.ContextType<typeof Context>;
render() {
const { step = 'step1', code } = this.props.match.params;
render() {
const { step = 'step1', code } = this.props.match.params;
if (step && !/^step[123]$/.test(step)) {
// wrong param value
return <Redirect to="/404" />;
if (step && !/^step[123]$/.test(step)) {
// wrong param value
return <Redirect to="/404" />;
}
return (
<ChangeEmail
onSubmit={this.onSubmit}
email={this.props.email}
lang={this.props.lang}
step={(Number(step.slice(-1)) - 1) as ChangeEmailStep}
onChangeStep={this.onChangeStep}
code={code}
/>
);
}
return (
<ChangeEmail
onSubmit={this.onSubmit}
email={this.props.email}
lang={this.props.lang}
step={(Number(step.slice(-1)) - 1) as ChangeEmailStep}
onChangeStep={this.onChangeStep}
code={code}
/>
);
}
onChangeStep = (step: number) => {
this.props.history.push(`/profile/change-email/step${++step}`);
};
onChangeStep = (step: number) => {
this.props.history.push(`/profile/change-email/step${++step}`);
};
onSubmit = (step: number, form: FormModel): Promise<void> => {
return this.context
.onSubmit({
form,
sendData: () => {
const { userId } = this.context;
const data = form.serialize();
onSubmit = (step: number, form: FormModel): Promise<void> => {
return this.context
.onSubmit({
form,
sendData: () => {
const { userId } = this.context;
const data = form.serialize();
switch (step) {
case 0:
return requestEmailChange(userId, data.password).catch(
handleErrors(),
);
case 1:
return setNewEmail(userId, data.email, data.key).catch(
handleErrors('/profile/change-email'),
);
case 2:
return confirmNewEmail(userId, data.key).catch(
handleErrors('/profile/change-email'),
);
default:
throw new Error(`Unsupported step ${step}`);
}
},
})
.then(() => {
step > 1 && this.context.goToProfile();
});
};
switch (step) {
case 0:
return requestEmailChange(userId, data.password).catch(handleErrors());
case 1:
return setNewEmail(userId, data.email, data.key).catch(
handleErrors('/profile/change-email'),
);
case 2:
return confirmNewEmail(userId, data.key).catch(handleErrors('/profile/change-email'));
default:
throw new Error(`Unsupported step ${step}`);
}
},
})
.then(() => {
step > 1 && this.context.goToProfile();
});
};
}
function handleErrors(
repeatUrl?: string,
): <T extends { errors: Record<string, any> }>(resp: T) => Promise<T> {
return (resp) => {
if (resp.errors) {
if (resp.errors.key) {
resp.errors.key = {
type: resp.errors.key,
payload: {},
};
function handleErrors(repeatUrl?: string): <T extends { errors: Record<string, any> }>(resp: T) => Promise<T> {
return (resp) => {
if (resp.errors) {
if (resp.errors.key) {
resp.errors.key = {
type: resp.errors.key,
payload: {},
};
if (
['error.key_not_exists', 'error.key_expire'].includes(
resp.errors.key.type,
) &&
repeatUrl
) {
Object.assign(resp.errors.key.payload, {
repeatUrl,
});
if (['error.key_not_exists', 'error.key_expire'].includes(resp.errors.key.type) && repeatUrl) {
Object.assign(resp.errors.key.payload, {
repeatUrl,
});
}
}
}
}
}
return Promise.reject(resp);
};
return Promise.reject(resp);
};
}
export default connect((state: RootState) => ({
email: state.user.email,
lang: state.user.lang,
email: state.user.email,
lang: state.user.lang,
}))(ChangeEmailPage);

View File

@@ -8,36 +8,36 @@ import { updateUser } from 'app/components/user/actions';
import Context from 'app/components/profile/Context';
interface Props {
updateUser: (fields: Partial<User>) => void;
updateUser: (fields: Partial<User>) => void;
}
class ChangePasswordPage extends React.Component<Props> {
static contextType = Context;
/* TODO: use declare */ context: React.ContextType<typeof Context>;
static contextType = Context;
/* TODO: use declare */ context: React.ContextType<typeof Context>;
form = new FormModel();
form = new FormModel();
render() {
return <ChangePassword onSubmit={this.onSubmit} form={this.form} />;
}
render() {
return <ChangePassword onSubmit={this.onSubmit} form={this.form} />;
}
onSubmit = () => {
const { form } = this;
onSubmit = () => {
const { form } = this;
return this.context
.onSubmit({
form,
sendData: () => changePassword(this.context.userId, form.serialize()),
})
.then(() => {
this.props.updateUser({
passwordChangedAt: Date.now() / 1000,
});
this.context.goToProfile();
});
};
return this.context
.onSubmit({
form,
sendData: () => changePassword(this.context.userId, form.serialize()),
})
.then(() => {
this.props.updateUser({
passwordChangedAt: Date.now() / 1000,
});
this.context.goToProfile();
});
};
}
export default connect(null, {
updateUser,
updateUser,
})(ChangePasswordPage);

View File

@@ -8,68 +8,68 @@ import ChangeUsername from 'app/components/profile/changeUsername/ChangeUsername
import Context from 'app/components/profile/Context';
type Props = {
username: string;
updateUsername: (username: string) => void;
username: string;
updateUsername: (username: string) => void;
};
class ChangeUsernamePage extends React.Component<Props> {
static contextType = Context;
/* TODO: use declare */ context: React.ContextType<typeof Context>;
static contextType = Context;
/* TODO: use declare */ context: React.ContextType<typeof Context>;
form = new FormModel();
form = new FormModel();
actualUsername: string = this.props.username;
actualUsername: string = this.props.username;
componentWillUnmount() {
this.props.updateUsername(this.actualUsername);
}
render() {
return (
<ChangeUsername
form={this.form}
onSubmit={this.onSubmit}
onChange={this.onUsernameChange}
username={this.props.username}
/>
);
}
onUsernameChange = (username: string) => {
this.props.updateUsername(username);
};
onSubmit = () => {
const { form } = this;
if (this.actualUsername === this.props.username) {
this.context.goToProfile();
return Promise.resolve();
componentWillUnmount() {
this.props.updateUsername(this.actualUsername);
}
return this.context
.onSubmit({
form,
sendData: () => {
const { username, password } = form.serialize();
render() {
return (
<ChangeUsername
form={this.form}
onSubmit={this.onSubmit}
onChange={this.onUsernameChange}
username={this.props.username}
/>
);
}
return changeUsername(this.context.userId, username, password);
},
})
.then(() => {
this.actualUsername = form.value('username');
onUsernameChange = (username: string) => {
this.props.updateUsername(username);
};
this.context.goToProfile();
});
};
onSubmit = () => {
const { form } = this;
if (this.actualUsername === this.props.username) {
this.context.goToProfile();
return Promise.resolve();
}
return this.context
.onSubmit({
form,
sendData: () => {
const { username, password } = form.serialize();
return changeUsername(this.context.userId, username, password);
},
})
.then(() => {
this.actualUsername = form.value('username');
this.context.goToProfile();
});
};
}
export default connect(
(state: RootState) => ({
username: state.user.username,
}),
{
updateUsername: (username: string) => updateUser({ username }),
},
(state: RootState) => ({
username: state.user.username,
}),
{
updateUsername: (username: string) => updateUser({ username }),
},
)(ChangeUsernamePage);

View File

@@ -1,81 +1,77 @@
import React from 'react';
import { RouteComponentProps, Redirect } from 'react-router-dom';
import { connect } from 'react-redux';
import MultiFactorAuth, {
MfaStep,
} from 'app/components/profile/multiFactorAuth';
import MultiFactorAuth, { MfaStep } from 'app/components/profile/multiFactorAuth';
import { FormModel } from 'app/components/ui/form';
import { User } from 'app/components/user';
import { RootState } from 'app/reducers';
import Context from 'app/components/profile/Context';
interface Props
extends RouteComponentProps<{
step?: '1' | '2' | '3';
}> {
user: User;
extends RouteComponentProps<{
step?: '1' | '2' | '3';
}> {
user: User;
}
class MultiFactorAuthPage extends React.Component<Props> {
static contextType = Context;
/* TODO: use declare */ context: React.ContextType<typeof Context>;
static contextType = Context;
/* TODO: use declare */ context: React.ContextType<typeof Context>;
render() {
const {
user,
match: {
params: { step },
},
} = this.props;
render() {
const {
user,
match: {
params: { step },
},
} = this.props;
if (step) {
if (!/^[1-3]$/.test(step)) {
// wrong param value
return <Redirect to="/404" />;
}
if (step) {
if (!/^[1-3]$/.test(step)) {
// wrong param value
return <Redirect to="/404" />;
}
if (user.isOtpEnabled) {
return <Redirect to="/mfa" />;
}
if (user.isOtpEnabled) {
return <Redirect to="/mfa" />;
}
}
return (
<MultiFactorAuth
isMfaEnabled={user.isOtpEnabled}
onSubmit={this.onSubmit}
step={this.getStep()}
onChangeStep={this.onChangeStep}
onComplete={this.onComplete}
/>
);
}
return (
<MultiFactorAuth
isMfaEnabled={user.isOtpEnabled}
onSubmit={this.onSubmit}
step={this.getStep()}
onChangeStep={this.onChangeStep}
onComplete={this.onComplete}
/>
);
}
getStep(): MfaStep {
const step = Number(this.props.match.params.step) - 1;
getStep(): MfaStep {
const step = Number(this.props.match.params.step) - 1;
if (step !== 0 && step !== 1 && step !== 2) {
return 0;
}
if (step !== 0 && step !== 1 && step !== 2) {
return 0;
return step;
}
return step;
}
onChangeStep = (step: number) => {
this.props.history.push(`/profile/mfa/step${step + 1}`);
};
onChangeStep = (step: number) => {
this.props.history.push(`/profile/mfa/step${step + 1}`);
};
onSubmit = (form: FormModel, sendData: () => Promise<void>) => {
return this.context.onSubmit({
form,
sendData,
});
};
onSubmit = (form: FormModel, sendData: () => Promise<void>) => {
return this.context.onSubmit({
form,
sendData,
});
};
onComplete = () => {
this.context.goToProfile();
};
onComplete = () => {
this.context.goToProfile();
};
}
export default connect(({ user }: RootState) => ({ user }))(
MultiFactorAuthPage,
);
export default connect(({ user }: RootState) => ({ user }))(MultiFactorAuthPage);

View File

@@ -14,200 +14,160 @@ import { ComponentLoader } from 'app/components/ui/loader';
import styles from './profile.scss';
const Profile = React.lazy(() =>
import(
/* webpackChunkName: "page-profile-index" */ 'app/components/profile/Profile'
),
);
const Profile = React.lazy(() => import(/* webpackChunkName: "page-profile-index" */ 'app/components/profile/Profile'));
const ChangePasswordPage = React.lazy(() =>
import(
/* webpackChunkName: "page-profile-change-password" */ 'app/pages/profile/ChangePasswordPage'
),
import(/* webpackChunkName: "page-profile-change-password" */ 'app/pages/profile/ChangePasswordPage'),
);
const ChangeUsernamePage = React.lazy(() =>
import(
/* webpackChunkName: "page-profile-change-username" */ 'app/pages/profile/ChangeUsernamePage'
),
import(/* webpackChunkName: "page-profile-change-username" */ 'app/pages/profile/ChangeUsernamePage'),
);
const ChangeEmailPage = React.lazy(() =>
import(
/* webpackChunkName: "page-profile-change-email" */ 'app/pages/profile/ChangeEmailPage'
),
import(/* webpackChunkName: "page-profile-change-email" */ 'app/pages/profile/ChangeEmailPage'),
);
const MultiFactorAuthPage = React.lazy(() =>
import(
/* webpackChunkName: "page-profile-mfa" */ 'app/pages/profile/MultiFactorAuthPage'
),
import(/* webpackChunkName: "page-profile-mfa" */ 'app/pages/profile/MultiFactorAuthPage'),
);
interface Props {
userId: number;
onSubmit: (options: {
form: FormModel;
sendData: () => Promise<any>;
}) => Promise<void>;
refreshUserData: () => Promise<any>;
userId: number;
onSubmit: (options: { form: FormModel; sendData: () => Promise<any> }) => Promise<void>;
refreshUserData: () => Promise<any>;
}
class ProfilePage extends React.Component<Props> {
render() {
const { userId, onSubmit } = this.props;
render() {
const { userId, onSubmit } = this.props;
return (
<div className={styles.container}>
<Provider
value={{
userId,
onSubmit,
goToProfile: this.goToProfile,
}}
>
<React.Suspense fallback={<ComponentLoader />}>
<Switch>
<Route
path="/profile/mfa/step:step([1-3])"
component={MultiFactorAuthPage}
/>
<Route
path="/profile/mfa"
exact
component={MultiFactorAuthPage}
/>
<Route
path="/profile/change-password"
exact
component={ChangePasswordPage}
/>
<Route
path="/profile/change-username"
exact
component={ChangeUsernamePage}
/>
<Route
path="/profile/change-email/:step?/:code?"
component={ChangeEmailPage}
/>
<Route path="/profile" exact component={Profile} />
<Route path="/" exact component={Profile} />
<Redirect to="/404" />
</Switch>
</React.Suspense>
return (
<div className={styles.container}>
<Provider
value={{
userId,
onSubmit,
goToProfile: this.goToProfile,
}}
>
<React.Suspense fallback={<ComponentLoader />}>
<Switch>
<Route path="/profile/mfa/step:step([1-3])" component={MultiFactorAuthPage} />
<Route path="/profile/mfa" exact component={MultiFactorAuthPage} />
<Route path="/profile/change-password" exact component={ChangePasswordPage} />
<Route path="/profile/change-username" exact component={ChangeUsernamePage} />
<Route path="/profile/change-email/:step?/:code?" component={ChangeEmailPage} />
<Route path="/profile" exact component={Profile} />
<Route path="/" exact component={Profile} />
<Redirect to="/404" />
</Switch>
</React.Suspense>
<div className={styles.footer}>
<FooterMenu />
</div>
</Provider>
</div>
);
}
<div className={styles.footer}>
<FooterMenu />
</div>
</Provider>
</div>
);
}
goToProfile = async () => {
await this.props.refreshUserData();
goToProfile = async () => {
await this.props.refreshUserData();
browserHistory.push('/');
};
browserHistory.push('/');
};
}
export default connect(
(state: RootState) => ({
userId: state.user.id!,
}),
{
refreshUserData,
onSubmit: ({
form,
sendData,
}: {
form: FormModel;
sendData: () => Promise<any>;
}) => (dispatch: Dispatch) => {
form.beginLoading();
(state: RootState) => ({
userId: state.user.id!,
}),
{
refreshUserData,
onSubmit: ({ form, sendData }: { form: FormModel; sendData: () => Promise<any> }) => (dispatch: Dispatch) => {
form.beginLoading();
return sendData()
.catch((resp) => {
const requirePassword = resp.errors && !!resp.errors.password;
return sendData()
.catch((resp) => {
const requirePassword = resp.errors && !!resp.errors.password;
// prevalidate user input, because requestPassword popup will block the
// entire form from input, so it must be valid
if (resp.errors) {
delete resp.errors.password;
// prevalidate user input, because requestPassword popup will block the
// entire form from input, so it must be valid
if (resp.errors) {
delete resp.errors.password;
if (resp.errors.email && resp.data && resp.data.canRepeatIn) {
resp.errors.email = {
type: resp.errors.email,
payload: {
msLeft: resp.data.canRepeatIn * 1000,
},
};
}
if (Object.keys(resp.errors).length) {
form.setErrors(resp.errors);
return Promise.reject(resp);
}
if (requirePassword) {
return requestPassword(form);
}
}
return Promise.reject(resp);
})
.catch((resp) => {
if (!resp || !resp.errors) {
logger.warn('Unexpected profile editing error', {
resp,
});
} else {
return Promise.reject(resp);
}
})
.finally(() => form.endLoading());
function requestPassword(form: FormModel) {
return new Promise((resolve, reject) => {
dispatch(
createPopup({
Popup(props: { onClose: () => Promise<any> }) {
const onSubmit = () => {
form.beginLoading();
sendData()
.then(resolve)
.then(props.onClose)
.catch((resp) => {
if (resp.errors) {
form.setErrors(resp.errors);
const parentFormHasErrors =
Object.keys(resp.errors).filter(
(name) => name !== 'password',
).length > 0;
if (parentFormHasErrors) {
// something wrong with parent form, hiding popup and show that form
props.onClose();
reject(resp);
logger.warn(
'Profile: can not submit password popup due to errors in source form',
{ resp },
);
if (resp.errors.email && resp.data && resp.data.canRepeatIn) {
resp.errors.email = {
type: resp.errors.email,
payload: {
msLeft: resp.data.canRepeatIn * 1000,
},
};
}
} else {
return Promise.reject(resp);
}
})
.finally(() => form.endLoading());
};
return <PasswordRequestForm form={form} onSubmit={onSubmit} />;
},
disableOverlayClose: true,
}),
);
});
}
if (Object.keys(resp.errors).length) {
form.setErrors(resp.errors);
return Promise.reject(resp);
}
if (requirePassword) {
return requestPassword(form);
}
}
return Promise.reject(resp);
})
.catch((resp) => {
if (!resp || !resp.errors) {
logger.warn('Unexpected profile editing error', {
resp,
});
} else {
return Promise.reject(resp);
}
})
.finally(() => form.endLoading());
function requestPassword(form: FormModel) {
return new Promise((resolve, reject) => {
dispatch(
createPopup({
Popup(props: { onClose: () => Promise<any> }) {
const onSubmit = () => {
form.beginLoading();
sendData()
.then(resolve)
.then(props.onClose)
.catch((resp) => {
if (resp.errors) {
form.setErrors(resp.errors);
const parentFormHasErrors =
Object.keys(resp.errors).filter((name) => name !== 'password')
.length > 0;
if (parentFormHasErrors) {
// something wrong with parent form, hiding popup and show that form
props.onClose();
reject(resp);
logger.warn(
'Profile: can not submit password popup due to errors in source form',
{ resp },
);
}
} else {
return Promise.reject(resp);
}
})
.finally(() => form.endLoading());
};
return <PasswordRequestForm form={form} onSubmit={onSubmit} />;
},
disableOverlayClose: true,
}),
);
});
}
},
},
},
)(ProfilePage);

View File

@@ -1,18 +1,18 @@
.container {
padding: 55px 10px 65px; // 65px for footer
padding: 55px 10px 65px; // 65px for footer
}
.footer {
width: 100%;
position: absolute;
bottom: 10px;
left: 0;
width: 100%;
position: absolute;
bottom: 10px;
left: 0;
text-align: center;
text-align: center;
}
@media (max-width: 720px) {
.container {
padding-top: 20px;
}
.container {
padding-top: 20px;
}
}

View File

@@ -1,3 +1,3 @@
{
"siteName": "Ely.by"
"siteName": "Ely.by"
}

View File

@@ -22,123 +22,103 @@ import styles from './root.scss';
import messages from './RootPage.intl.json';
const ProfilePage = React.lazy(() =>
import(
/* webpackChunkName: "page-profile-all" */ 'app/pages/profile/ProfilePage'
),
);
const PageNotFound = React.lazy(() =>
import(/* webpackChunkName: "page-not-found" */ 'app/pages/404/PageNotFound'),
);
const RulesPage = React.lazy(() =>
import(/* webpackChunkName: "page-rules" */ 'app/pages/rules/RulesPage'),
);
const DevPage = React.lazy(() =>
import(
/* webpackChunkName: "page-dev-applications" */ 'app/pages/dev/DevPage'
),
);
const AuthPage = React.lazy(() =>
import(/* webpackChunkName: "page-auth" */ 'app/pages/auth/AuthPage'),
import(/* webpackChunkName: "page-profile-all" */ 'app/pages/profile/ProfilePage'),
);
const PageNotFound = React.lazy(() => import(/* webpackChunkName: "page-not-found" */ 'app/pages/404/PageNotFound'));
const RulesPage = React.lazy(() => import(/* webpackChunkName: "page-rules" */ 'app/pages/rules/RulesPage'));
const DevPage = React.lazy(() => import(/* webpackChunkName: "page-dev-applications" */ 'app/pages/dev/DevPage'));
const AuthPage = React.lazy(() => import(/* webpackChunkName: "page-auth" */ 'app/pages/auth/AuthPage'));
class RootPage extends React.PureComponent<{
account: Account | null;
user: User;
isPopupActive: boolean;
onLogoClick: (event: React.MouseEvent<HTMLAnchorElement>) => void;
location: {
pathname: string;
};
account: Account | null;
user: User;
isPopupActive: boolean;
onLogoClick: (event: React.MouseEvent<HTMLAnchorElement>) => void;
location: {
pathname: string;
};
}> {
componentDidMount() {
this.onPageUpdate();
}
componentDidUpdate() {
this.onPageUpdate();
}
onPageUpdate() {
loader.hide();
}
render() {
const { props } = this;
const { user, account, isPopupActive, onLogoClick } = this.props;
const isRegisterPage = props.location.pathname === '/register';
if (document && document.body) {
document.body.style.overflow = isPopupActive ? 'hidden' : '';
componentDidMount() {
this.onPageUpdate();
}
return (
<div className={styles.root}>
<Helmet>
<html lang={user.lang} />
</Helmet>
componentDidUpdate() {
this.onPageUpdate();
}
<ScrollIntoView top />
onPageUpdate() {
loader.hide();
}
<div
id="view-port"
className={clsx(styles.viewPort, {
[styles.isPopupActive]: isPopupActive,
})}
>
<div className={styles.header} data-testid="toolbar">
<div className={styles.headerContent}>
<Link
to="/"
className={styles.logo}
onClick={onLogoClick}
data-testid="home-page"
>
<Message {...messages.siteName} />
</Link>
<div className={styles.userbar}>
<Userbar
account={account}
guestAction={isRegisterPage ? 'login' : 'register'}
/>
</div>
render() {
const { props } = this;
const { user, account, isPopupActive, onLogoClick } = this.props;
const isRegisterPage = props.location.pathname === '/register';
if (document && document.body) {
document.body.style.overflow = isPopupActive ? 'hidden' : '';
}
return (
<div className={styles.root}>
<Helmet>
<html lang={user.lang} />
</Helmet>
<ScrollIntoView top />
<div
id="view-port"
className={clsx(styles.viewPort, {
[styles.isPopupActive]: isPopupActive,
})}
>
<div className={styles.header} data-testid="toolbar">
<div className={styles.headerContent}>
<Link to="/" className={styles.logo} onClick={onLogoClick} data-testid="home-page">
<Message {...messages.siteName} />
</Link>
<div className={styles.userbar}>
<Userbar account={account} guestAction={isRegisterPage ? 'login' : 'register'} />
</div>
</div>
</div>
<div className={styles.body}>
<React.Suspense fallback={<ComponentLoader />}>
<Switch>
<PrivateRoute path="/profile" component={ProfilePage} />
<Route path="/404" component={PageNotFound} />
<Route path="/rules" component={RulesPage} />
<Route path="/dev" component={DevPage} />
<AuthFlowRoute
exact
path="/"
key="indexPage"
component={user.isGuest ? AuthPage : ProfilePage}
/>
<AuthFlowRoute path="/" component={AuthPage} />
<Route component={PageNotFound} />
</Switch>
</React.Suspense>
</div>
</div>
<PopupStack />
</div>
</div>
<div className={styles.body}>
<React.Suspense fallback={<ComponentLoader />}>
<Switch>
<PrivateRoute path="/profile" component={ProfilePage} />
<Route path="/404" component={PageNotFound} />
<Route path="/rules" component={RulesPage} />
<Route path="/dev" component={DevPage} />
<AuthFlowRoute
exact
path="/"
key="indexPage"
component={user.isGuest ? AuthPage : ProfilePage}
/>
<AuthFlowRoute path="/" component={AuthPage} />
<Route component={PageNotFound} />
</Switch>
</React.Suspense>
</div>
</div>
<PopupStack />
</div>
);
}
);
}
}
export default withRouter(
connect(
(state: RootState) => ({
user: state.user,
account: getActiveAccount(state),
isPopupActive: state.popup.popups.length > 0,
}),
{
onLogoClick: resetAuth,
},
)(RootPage),
connect(
(state: RootState) => ({
user: state.user,
account: getActiveAccount(state),
isPopupActive: state.popup.popups.length > 0,
}),
{
onLogoClick: resetAuth,
},
)(RootPage),
);

View File

@@ -4,63 +4,63 @@
$userBarHeight: 50px;
.root {
height: 100%;
height: 100%;
}
.viewPort {
height: 100%;
height: 100%;
}
.isPopupActive {
filter: blur(5px);
transition: filter 0.4s 0.1s ease;
filter: blur(5px);
transition: filter 0.4s 0.1s ease;
}
.wrapper {
max-width: 756px;
margin: 0 auto;
max-width: 756px;
margin: 0 auto;
}
.header {
position: fixed;
top: 0;
z-index: 100;
height: $userBarHeight;
width: 100%;
background: $green;
position: fixed;
top: 0;
z-index: 100;
height: $userBarHeight;
width: 100%;
background: $green;
}
.headerContent {
composes: wrapper;
position: relative;
composes: wrapper;
position: relative;
}
.logo {
line-height: 50px;
padding: 0 20px;
display: inline-block;
background: darker($green);
border-bottom: none;
line-height: 50px;
padding: 0 20px;
display: inline-block;
background: darker($green);
border-bottom: none;
font-family: $font-family-title;
font-size: 33px;
color: #fff !important;
font-family: $font-family-title;
font-size: 33px;
color: #fff !important;
}
.body {
// TODO: должны ли мы здесь описать базовый шрифт, его размер и базовую линию?
composes: wrapper;
position: relative;
// TODO: должны ли мы здесь описать базовый шрифт, его размер и базовую линию?
composes: wrapper;
position: relative;
min-height: 100%;
box-sizing: border-box;
min-height: 100%;
box-sizing: border-box;
padding-top: $userBarHeight; // place for header
padding-top: $userBarHeight; // place for header
}
.userbar {
position: absolute;
right: 0;
left: 115px;
top: 0;
position: absolute;
right: 0;
left: 115px;
top: 0;
}

View File

@@ -1,24 +1,24 @@
{
"title": "Site rules",
"title": "Site rules",
"mainProvisions": "Main provisions",
"mainProvision1": "{name} service was created for the organization of safety access to Ely.by's users accounts, his partners and any side project that wish to use one of the our's services.",
"mainProvision2": "We (here and in the next points) — Ely.by project developers team that make creating qualitative services for Minecraft community.",
"mainProvision3": "Ely.by is side project, that has nothing to do with Mojang and Microsoft companies. We don't provide support to Minecraft premium accounts, and we have nothing to do with servers that use or don't use our services.",
"mainProvision4": "The registration of the users account at server is free. Account creation Ely.by is only possible at that page {link}.",
"mainProvisions": "Main provisions",
"mainProvision1": "{name} service was created for the organization of safety access to Ely.by's users accounts, his partners and any side project that wish to use one of the our's services.",
"mainProvision2": "We (here and in the next points) — Ely.by project developers team that make creating qualitative services for Minecraft community.",
"mainProvision3": "Ely.by is side project, that has nothing to do with Mojang and Microsoft companies. We don't provide support to Minecraft premium accounts, and we have nothing to do with servers that use or don't use our services.",
"mainProvision4": "The registration of the users account at server is free. Account creation Ely.by is only possible at that page {link}.",
"emailAndNickname": "Email and nickname",
"emailAndNickname1": "Account registration with usage of temporary mail services is prohibited. We speak about services that gives random Email in any quantity.",
"emailAndNickname2": "We try to counteract it, but if you succesed in registration of account with usage of temporary mail services, there wont be any technical support for it and later, during of update of ours filters, account will be blocked with your nickname.",
"emailAndNickname3": "There are no any moral restrictions for users nickname that will be used in game.",
"emailAndNickname4": "Nicknames, belonging to famous persons, can be released at their favor for requirement and proves of that persons.",
"emailAndNickname5": "Minecraft premium account owner has right to require a control restore of his nickname an if it happened you have to change your nickname in 3 days or it will be done automatically.",
"emailAndNickname6": "If there is no any activity at your account during last 3 month, your nickname can be occupied by any user.",
"emailAndNickname7": "We aren't responsible for losing your game progress at servers if it was result of nickname changing, including changes on our demand.",
"emailAndNickname": "Email and nickname",
"emailAndNickname1": "Account registration with usage of temporary mail services is prohibited. We speak about services that gives random Email in any quantity.",
"emailAndNickname2": "We try to counteract it, but if you succesed in registration of account with usage of temporary mail services, there wont be any technical support for it and later, during of update of ours filters, account will be blocked with your nickname.",
"emailAndNickname3": "There are no any moral restrictions for users nickname that will be used in game.",
"emailAndNickname4": "Nicknames, belonging to famous persons, can be released at their favor for requirement and proves of that persons.",
"emailAndNickname5": "Minecraft premium account owner has right to require a control restore of his nickname an if it happened you have to change your nickname in 3 days or it will be done automatically.",
"emailAndNickname6": "If there is no any activity at your account during last 3 month, your nickname can be occupied by any user.",
"emailAndNickname7": "We aren't responsible for losing your game progress at servers if it was result of nickname changing, including changes on our demand.",
"elyAccountsAsService": "{name} as service",
"elyAccountsAsServiceDesc1": "{name} has free providing to any project, that interested in it usage for Minecraft.",
"elyAccountsAsServiceDesc2": "Despite we do our utmost to provide fast and stable work of service, we are not saved from DDOS-attack, hosters links work interruptions, electricity disorders or any cases, that impossible to be predicted. For avoiding possible incomprehension, we obliged to discuss next agreements, that will work in case of situations mentioned before:",
"elyAccountsAsService1": "We don't have any guarantee about fault free work time of this service.",
"elyAccountsAsService2": "We are not responsible for delays and lost income as the result of ours service inoperability."
"elyAccountsAsService": "{name} as service",
"elyAccountsAsServiceDesc1": "{name} has free providing to any project, that interested in it usage for Minecraft.",
"elyAccountsAsServiceDesc2": "Despite we do our utmost to provide fast and stable work of service, we are not saved from DDOS-attack, hosters links work interruptions, electricity disorders or any cases, that impossible to be predicted. For avoiding possible incomprehension, we obliged to discuss next agreements, that will work in case of situations mentioned before:",
"elyAccountsAsService1": "We don't have any guarantee about fault free work time of this service.",
"elyAccountsAsService2": "We are not responsible for delays and lost income as the result of ours service inoperability."
}

View File

@@ -7,59 +7,56 @@ import { TestContextProvider } from 'app/shell';
import RulesPage from './RulesPage';
describe('RulesPage', () => {
describe('#onRuleClick()', () => {
const id = 'rule-1-2';
const pathname = '/foo';
const search = '?bar';
let page: HTMLElement;
let replace: Function;
describe('#onRuleClick()', () => {
const id = 'rule-1-2';
const pathname = '/foo';
const search = '?bar';
let page: HTMLElement;
let replace: Function;
beforeEach(() => {
replace = sinon.stub().named('history.replace');
beforeEach(() => {
replace = sinon.stub().named('history.replace');
({ container: page } = render(
<TestContextProvider>
<RulesPage
location={{ pathname, search } as any}
history={{ replace }}
/>
</TestContextProvider>,
));
({ container: page } = render(
<TestContextProvider>
<RulesPage location={{ pathname, search } as any} history={{ replace }} />
</TestContextProvider>,
));
});
it('should update location on rule click', () => {
const expectedUrl = `/foo?bar#${id}`;
fireEvent.click(page.querySelector(`#${id}`) as HTMLElement);
expect(replace, 'to have a call satisfying', [expectedUrl]);
});
it('should not update location if link was clicked', () => {
fireEvent.click(screen.getByText('/register', { exact: false }));
expect(replace, 'was not called');
});
it('should not update location if defaultPrevented', () => {
const el = page.querySelector(`#${id}`) as HTMLElement;
const event = createEvent.click(el);
event.preventDefault();
fireEvent(el, event);
expect(replace, 'was not called');
});
it('should not update location if no id', () => {
const el = page.querySelector(`#${id}`) as HTMLElement;
el.id = '';
fireEvent.click(el);
expect(replace, 'was not called');
});
});
it('should update location on rule click', () => {
const expectedUrl = `/foo?bar#${id}`;
fireEvent.click(page.querySelector(`#${id}`) as HTMLElement);
expect(replace, 'to have a call satisfying', [expectedUrl]);
});
it('should not update location if link was clicked', () => {
fireEvent.click(screen.getByText('/register', { exact: false }));
expect(replace, 'was not called');
});
it('should not update location if defaultPrevented', () => {
const el = page.querySelector(`#${id}`) as HTMLElement;
const event = createEvent.click(el);
event.preventDefault();
fireEvent(el, event);
expect(replace, 'was not called');
});
it('should not update location if no id', () => {
const el = page.querySelector(`#${id}`) as HTMLElement;
el.id = '';
fireEvent.click(el);
expect(replace, 'was not called');
});
});
});

View File

@@ -13,163 +13,153 @@ const projectName = <Message {...appInfo.appName} />;
import clsx from 'clsx';
const rules = [
{
title: <Message {...messages.mainProvisions} />,
items: [
<Message
key="0"
{...messages.mainProvision1}
values={{
name: <b>{projectName}</b>,
}}
/>,
<Message key="1" {...messages.mainProvision2} />,
<Message key="2" {...messages.mainProvision3} />,
<Message
key="3"
{...messages.mainProvision4}
values={{
link: <Link to="/register">https://account.ely.by/register</Link>,
}}
/>,
],
},
{
title: <Message {...messages.emailAndNickname} />,
items: [
<Message key="0" {...messages.emailAndNickname1} />,
<Message key="1" {...messages.emailAndNickname2} />,
<Message key="2" {...messages.emailAndNickname3} />,
<Message key="3" {...messages.emailAndNickname4} />,
<Message key="4" {...messages.emailAndNickname5} />,
<Message key="5" {...messages.emailAndNickname6} />,
<Message key="6" {...messages.emailAndNickname7} />,
],
},
{
title: (
<Message
{...messages.elyAccountsAsService}
values={{
name: projectName,
}}
/>
),
description: (
<div>
<p>
<Message
{...messages.elyAccountsAsServiceDesc1}
values={{
name: <b>{projectName}</b>,
}}
/>
</p>
<p>
<Message {...messages.elyAccountsAsServiceDesc2} />
</p>
</div>
),
items: [
<Message key="0" {...messages.elyAccountsAsService1} />,
<Message key="1" {...messages.elyAccountsAsService2} />,
],
},
{
title: <Message {...messages.mainProvisions} />,
items: [
<Message
key="0"
{...messages.mainProvision1}
values={{
name: <b>{projectName}</b>,
}}
/>,
<Message key="1" {...messages.mainProvision2} />,
<Message key="2" {...messages.mainProvision3} />,
<Message
key="3"
{...messages.mainProvision4}
values={{
link: <Link to="/register">https://account.ely.by/register</Link>,
}}
/>,
],
},
{
title: <Message {...messages.emailAndNickname} />,
items: [
<Message key="0" {...messages.emailAndNickname1} />,
<Message key="1" {...messages.emailAndNickname2} />,
<Message key="2" {...messages.emailAndNickname3} />,
<Message key="3" {...messages.emailAndNickname4} />,
<Message key="4" {...messages.emailAndNickname5} />,
<Message key="5" {...messages.emailAndNickname6} />,
<Message key="6" {...messages.emailAndNickname7} />,
],
},
{
title: (
<Message
{...messages.elyAccountsAsService}
values={{
name: projectName,
}}
/>
),
description: (
<div>
<p>
<Message
{...messages.elyAccountsAsServiceDesc1}
values={{
name: <b>{projectName}</b>,
}}
/>
</p>
<p>
<Message {...messages.elyAccountsAsServiceDesc2} />
</p>
</div>
),
items: [
<Message key="0" {...messages.elyAccountsAsService1} />,
<Message key="1" {...messages.elyAccountsAsService2} />,
],
},
];
export default class RulesPage extends Component<{
location: {
pathname: string;
search: string;
hash: string;
};
location: {
pathname: string;
search: string;
hash: string;
};
history: {
replace: Function;
};
history: {
replace: Function;
};
}> {
render() {
let { hash } = this.props.location;
render() {
let { hash } = this.props.location;
if (hash) {
hash = hash.substring(1);
}
if (hash) {
hash = hash.substring(1);
}
return (
<div>
<Message {...messages.title}>
{(pageTitle) => <Helmet title={pageTitle as string} />}
</Message>
return (
<div>
<Message {...messages.title}>{(pageTitle) => <Helmet title={pageTitle as string} />}</Message>
<div className={styles.rules}>
{rules.map((block, sectionIndex) => (
<div className={styles.rulesSection} key={sectionIndex}>
<h2
className={clsx(styles.rulesSectionTitle, {
[styles.target]:
RulesPage.getTitleHash(sectionIndex) === hash,
})}
id={RulesPage.getTitleHash(sectionIndex)}
>
{block.title}
</h2>
<div className={styles.rules}>
{rules.map((block, sectionIndex) => (
<div className={styles.rulesSection} key={sectionIndex}>
<h2
className={clsx(styles.rulesSectionTitle, {
[styles.target]: RulesPage.getTitleHash(sectionIndex) === hash,
})}
id={RulesPage.getTitleHash(sectionIndex)}
>
{block.title}
</h2>
<div className={styles.rulesBody}>
{block.description ? (
<div className={styles.blockDescription}>
{block.description}
</div>
) : (
''
)}
<ol className={styles.rulesList}>
{block.items.map((item, ruleIndex) => (
<li
className={clsx(styles.rulesItem, {
[styles.target]:
RulesPage.getRuleHash(sectionIndex, ruleIndex) ===
hash,
})}
key={ruleIndex}
id={RulesPage.getRuleHash(sectionIndex, ruleIndex)}
onClick={this.onRuleClick.bind(this)}
>
{item}
</li>
))}
</ol>
</div>
<div className={styles.rulesBody}>
{block.description ? (
<div className={styles.blockDescription}>{block.description}</div>
) : (
''
)}
<ol className={styles.rulesList}>
{block.items.map((item, ruleIndex) => (
<li
className={clsx(styles.rulesItem, {
[styles.target]:
RulesPage.getRuleHash(sectionIndex, ruleIndex) === hash,
})}
key={ruleIndex}
id={RulesPage.getRuleHash(sectionIndex, ruleIndex)}
onClick={this.onRuleClick.bind(this)}
>
{item}
</li>
))}
</ol>
</div>
</div>
))}
</div>
<div className={styles.footer}>
<FooterMenu />
</div>
</div>
))}
</div>
<div className={styles.footer}>
<FooterMenu />
</div>
</div>
);
}
onRuleClick(event: React.SyntheticEvent<HTMLElement>) {
if (
event.defaultPrevented ||
!event.currentTarget.id ||
event.target instanceof HTMLAnchorElement
) {
// some-one have already processed this event or it is a link
return;
);
}
const { id } = event.currentTarget;
const newPath = `${this.props.location.pathname}${this.props.location.search}#${id}`;
onRuleClick(event: React.SyntheticEvent<HTMLElement>) {
if (event.defaultPrevented || !event.currentTarget.id || event.target instanceof HTMLAnchorElement) {
// some-one have already processed this event or it is a link
return;
}
this.props.history.replace(newPath);
}
const { id } = event.currentTarget;
const newPath = `${this.props.location.pathname}${this.props.location.search}#${id}`;
static getTitleHash(sectionIndex: number) {
return `rule-${sectionIndex + 1}`;
}
this.props.history.replace(newPath);
}
static getRuleHash(sectionIndex: number, ruleIndex: number) {
return `${RulesPage.getTitleHash(sectionIndex)}-${ruleIndex + 1}`;
}
static getTitleHash(sectionIndex: number) {
return `rule-${sectionIndex + 1}`;
}
static getRuleHash(sectionIndex: number, ruleIndex: number) {
return `${RulesPage.getTitleHash(sectionIndex)}-${ruleIndex + 1}`;
}
}

View File

@@ -2,97 +2,97 @@
@import '~app/components/ui/fonts.scss';
.rules {
max-width: 500px;
margin: 30px auto 0;
padding: 0 20px;
max-width: 500px;
margin: 30px auto 0;
padding: 0 20px;
}
.rulesSection {
margin-bottom: 30px;
margin-bottom: 30px;
}
.rulesSectionTitle {
line-height: 50px;
line-height: 50px;
font-family: $font-family-title;
font-size: 20px;
color: #fff;
padding: 0;
margin: 0;
text-align: center;
font-family: $font-family-title;
font-size: 20px;
color: #fff;
padding: 0;
margin: 0;
text-align: center;
background: $blue;
background: $blue;
}
.rulesBody {
position: relative;
// z-index, чтобы положить :before ниже текста, но выше фона блока
z-index: 0;
position: relative;
// z-index, чтобы положить :before ниже текста, но выше фона блока
z-index: 0;
padding: 20px;
background: #fff;
font-size: 14px;
padding: 20px;
background: #fff;
font-size: 14px;
}
%rulesTextFormat {
line-height: 1.4;
margin-bottom: 10px;
line-height: 1.4;
margin-bottom: 10px;
}
.blockDescription {
@extend %rulesTextFormat;
p {
@extend %rulesTextFormat;
}
p {
@extend %rulesTextFormat;
}
}
.rulesList {
padding: 0;
margin: 0;
padding-left: 20px;
padding: 0;
margin: 0;
padding-left: 20px;
}
.rulesItem {
@extend %rulesTextFormat;
@extend %rulesTextFormat;
list-style: decimal;
position: relative;
cursor: pointer;
list-style: decimal;
position: relative;
cursor: pointer;
&:last-of-type {
margin-bottom: 0;
}
&.target {
&:before {
cursor: default;
$border: 8px solid #ddd8ce;
content: '';
position: absolute;
top: -10px;
left: -40px;
width: calc(100% + 60px);
height: calc(100% + 20px);
background: $white;
border-left: $border;
border-right: $border;
box-sizing: border-box;
z-index: -1;
&:last-of-type {
margin-bottom: 0;
}
}
a {
color: #444;
border-bottom-color: #aaa;
&:hover {
border-bottom-color: #444;
&.target {
&:before {
cursor: default;
$border: 8px solid #ddd8ce;
content: '';
position: absolute;
top: -10px;
left: -40px;
width: calc(100% + 60px);
height: calc(100% + 20px);
background: $white;
border-left: $border;
border-right: $border;
box-sizing: border-box;
z-index: -1;
}
}
a {
color: #444;
border-bottom-color: #aaa;
&:hover {
border-bottom-color: #444;
}
}
}
}
.footer {
text-align: center;
margin-bottom: 20px;
text-align: center;
margin-bottom: 20px;
}