mirror of
https://github.com/usatiuk/photos.git
synced 2025-10-28 15:27:49 +01:00
transplant frontend auth from writer app
This commit is contained in:
4258
frontend/package-lock.json
generated
4258
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,8 +12,10 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@blueprintjs/core": "^3.33.0",
|
||||
"@typescript-eslint/eslint-plugin": "^4.4.0",
|
||||
"@typescript-eslint/parser": "^4.4.0",
|
||||
"autoprefixer": "^9.8.6",
|
||||
"enzyme": "^3.11.0",
|
||||
"enzyme-adapter-react-16": "^1.15.5",
|
||||
"eslint": "^7.10.0",
|
||||
@@ -27,21 +29,36 @@
|
||||
"eslint-plugin-react": "^7.21.3",
|
||||
"eslint-plugin-react-hooks": "^4.1.2",
|
||||
"jest": "^26.5.2",
|
||||
"parcel": "^2.0.0-nightly.419",
|
||||
"parcel": "^1.12.4",
|
||||
"postcss": "^8.1.1",
|
||||
"prettier": "^2.1.2",
|
||||
"prettier-eslint": "^11.0.0",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-redux": "^7.2.1",
|
||||
"react-router": "^5.2.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-spring": "^8.0.27",
|
||||
"redux": "^4.0.5",
|
||||
"redux-devtools-extension": "^2.13.8",
|
||||
"redux-persist": "^6.0.0",
|
||||
"redux-saga": "^1.1.3",
|
||||
"sass": "^1.27.0",
|
||||
"ts-jest": "^26.4.1",
|
||||
"typescript": "^4.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/autoprefixer": "^9.7.2",
|
||||
"@types/enzyme": "^3.10.7",
|
||||
"@types/enzyme-adapter-react-16": "^1.0.6",
|
||||
"@types/eslint": "^7.2.4",
|
||||
"@types/eslint-plugin-prettier": "^3.1.0",
|
||||
"@types/prettier": "^2.1.1",
|
||||
"@types/react": "^16.9.51",
|
||||
"@types/react-dom": "^16.9.8"
|
||||
"@types/react-dom": "^16.9.8",
|
||||
"@types/react-redux": "^7.1.9",
|
||||
"@types/react-router": "^5.1.8",
|
||||
"@types/react-router-dom": "^5.1.6",
|
||||
"@types/sass": "^1.16.0"
|
||||
}
|
||||
}
|
||||
|
||||
0
frontend/src/Account/Account.scss
Normal file
0
frontend/src/Account/Account.scss
Normal file
76
frontend/src/Account/Account.tsx
Normal file
76
frontend/src/Account/Account.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Button, Card, FormGroup, H2, InputGroup } from "@blueprintjs/core";
|
||||
import * as React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { Dispatch } from "redux";
|
||||
import { IAppState } from "~redux/reducers";
|
||||
import { userPassChange } from "~redux/user/actions";
|
||||
|
||||
export interface IAccountComponentProps {
|
||||
username: string | undefined;
|
||||
changePass: (password: string) => void;
|
||||
}
|
||||
|
||||
export function AccountComponent(props: IAccountComponentProps) {
|
||||
const [pass, setPass] = React.useState("");
|
||||
|
||||
return (
|
||||
<Card className="AuthForm" elevation={2}>
|
||||
<form
|
||||
onSubmit={(e: React.FormEvent<any>) => {
|
||||
e.preventDefault();
|
||||
if (pass.trim()) {
|
||||
props.changePass(pass);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="header">
|
||||
<H2>Account</H2>
|
||||
</div>
|
||||
<FormGroup label="Username">
|
||||
<InputGroup
|
||||
name="username"
|
||||
leftIcon="person"
|
||||
disabled={true}
|
||||
value={props.username}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup label="Password">
|
||||
<InputGroup
|
||||
name="password"
|
||||
type="password"
|
||||
leftIcon="key"
|
||||
value={pass}
|
||||
onChange={(e: React.FormEvent<HTMLInputElement>) =>
|
||||
setPass(e.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
</FormGroup>
|
||||
<div className="footer">
|
||||
<Button
|
||||
className="submit"
|
||||
intent="primary"
|
||||
icon="floppy-disk"
|
||||
type="submit"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function mapStateToProps(state: IAppState) {
|
||||
return { username: state?.user?.user?.username };
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch: Dispatch) {
|
||||
return {
|
||||
changePass: (password: string) => dispatch(userPassChange(password)),
|
||||
};
|
||||
}
|
||||
|
||||
export const Account = connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
)(AccountComponent);
|
||||
17
frontend/src/Account/tests/Account.test.tsx
Normal file
17
frontend/src/Account/tests/Account.test.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { shallow } from "enzyme";
|
||||
import { AccountComponent } from "../Account";
|
||||
|
||||
describe("<Account />", () => {
|
||||
it("should not crash", () => {
|
||||
const wrapper = shallow(
|
||||
<AccountComponent
|
||||
username="user"
|
||||
changePass={(pass: string) => {
|
||||
return null;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
});
|
||||
32
frontend/src/App.scss
Normal file
32
frontend/src/App.scss
Normal file
@@ -0,0 +1,32 @@
|
||||
@import "~@blueprintjs/core/lib/scss/variables";
|
||||
|
||||
.animationWrapper {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.loadingWrapper {
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
height: 75%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bp3-navbar {
|
||||
.bp3-button {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "PT Sans", "emoji", sans-serif;
|
||||
|
||||
background: $light-gray5;
|
||||
}
|
||||
42
frontend/src/App.tsx
Normal file
42
frontend/src/App.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import {
|
||||
Redirect,
|
||||
Route,
|
||||
RouteComponentProps,
|
||||
Switch,
|
||||
withRouter,
|
||||
} from "react-router";
|
||||
import { AuthScreen } from "~Auth/AuthScreen";
|
||||
import { Home } from "~Home/Home";
|
||||
import { Landing } from "~Landing/Landing";
|
||||
import { IAppState } from "~redux/reducers";
|
||||
|
||||
interface IAppComponentProps extends RouteComponentProps {
|
||||
loggedIn: boolean;
|
||||
}
|
||||
|
||||
export function AppComponent(props: IAppComponentProps) {
|
||||
const { loggedIn } = props;
|
||||
return loggedIn ? (
|
||||
<Switch>
|
||||
<Route path="/signup" component={AuthScreen} />,
|
||||
<Route path="/login" component={AuthScreen} />,
|
||||
<Route path="/docs/:id" component={Home} />,
|
||||
<Route path="/" component={Home} />,
|
||||
</Switch>
|
||||
) : (
|
||||
<Switch>
|
||||
<Route path="/signup" component={AuthScreen} />
|
||||
<Route path="/login" component={AuthScreen} />
|
||||
<Route exact={true} path="/" component={Landing} />
|
||||
<Route path="/" component={() => <Redirect to="/login" />} />
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
function mapStateToProps(state: IAppState) {
|
||||
return { loggedIn: state.auth.jwt !== null };
|
||||
}
|
||||
|
||||
export const App = withRouter(connect(mapStateToProps)(AppComponent));
|
||||
22
frontend/src/AppToaster.tsx
Normal file
22
frontend/src/AppToaster.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Position, Toaster } from "@blueprintjs/core";
|
||||
|
||||
export const AppToaster = Toaster.create({
|
||||
className: "recipe-toaster",
|
||||
position: Position.TOP,
|
||||
});
|
||||
|
||||
export function showPasswordSavedToast() {
|
||||
AppToaster.show({
|
||||
message: "Password saved!",
|
||||
intent: "success",
|
||||
timeout: 2000,
|
||||
});
|
||||
}
|
||||
|
||||
export function showPasswordNotSavedToast(error: string) {
|
||||
AppToaster.show({
|
||||
message: "Password not saved! " + error,
|
||||
intent: "danger",
|
||||
timeout: 2000,
|
||||
});
|
||||
}
|
||||
41
frontend/src/Auth/Auth.scss
Normal file
41
frontend/src/Auth/Auth.scss
Normal file
@@ -0,0 +1,41 @@
|
||||
@import "~@blueprintjs/core/lib/scss/variables";
|
||||
|
||||
.AuthForm {
|
||||
margin: auto;
|
||||
margin-top: 10rem;
|
||||
width: 20rem;
|
||||
left: 0;
|
||||
right: 0;
|
||||
position: absolute;
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
h2 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.header,
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
|
||||
button.change {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
#error {
|
||||
height: 1rem;
|
||||
color: $pt-intent-danger;
|
||||
}
|
||||
|
||||
button.submit {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
69
frontend/src/Auth/AuthScreen.tsx
Normal file
69
frontend/src/Auth/AuthScreen.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import * as React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import {
|
||||
Redirect,
|
||||
Route,
|
||||
RouteComponentProps,
|
||||
Switch,
|
||||
withRouter,
|
||||
} from "react-router";
|
||||
import { animated, Transition } from "react-spring/renderprops";
|
||||
import { IAppState } from "~redux/reducers";
|
||||
|
||||
import { Login } from "./Login";
|
||||
import { Signup } from "./Signup";
|
||||
|
||||
interface IAuthScreenProps extends RouteComponentProps {
|
||||
loggedIn: boolean;
|
||||
}
|
||||
|
||||
export class AuthScreenComponent extends React.PureComponent<IAuthScreenProps> {
|
||||
constructor(props: IAuthScreenProps) {
|
||||
super(props);
|
||||
}
|
||||
public render() {
|
||||
const { location } = this.props.history;
|
||||
const { from } = this.props.location.state || { from: "/" };
|
||||
const { loggedIn } = this.props;
|
||||
return loggedIn ? (
|
||||
<Redirect to={from} />
|
||||
) : (
|
||||
<div className="animationWrapper">
|
||||
<Transition
|
||||
native={true}
|
||||
items={location}
|
||||
keys={location.pathname}
|
||||
from={{
|
||||
opacity: 0,
|
||||
transform: "translate3d(-400px,0,0)",
|
||||
}}
|
||||
enter={{
|
||||
opacity: 1,
|
||||
transform: "translate3d(0,0,0)",
|
||||
}}
|
||||
leave={{
|
||||
opacity: 0,
|
||||
transform: "translate3d(400px,0,0)",
|
||||
}}
|
||||
>
|
||||
{(_location: any) => (style: any) => (
|
||||
<animated.div style={style}>
|
||||
<Switch location={_location}>
|
||||
<Route path="/login" component={Login} />
|
||||
<Route path="/signup" component={Signup} />
|
||||
</Switch>
|
||||
</animated.div>
|
||||
)}
|
||||
</Transition>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state: IAppState) {
|
||||
return { loggedIn: !(state.auth.jwt === null) };
|
||||
}
|
||||
|
||||
export const AuthScreen = withRouter(
|
||||
connect(mapStateToProps)(AuthScreenComponent),
|
||||
);
|
||||
123
frontend/src/Auth/Login.tsx
Normal file
123
frontend/src/Auth/Login.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import "./Auth.scss";
|
||||
|
||||
import { Button, Card, FormGroup, H2, InputGroup } from "@blueprintjs/core";
|
||||
import * as React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { RouteComponentProps, withRouter } from "react-router-dom";
|
||||
import { Dispatch } from "redux";
|
||||
import { authStart } from "~redux/auth/actions";
|
||||
import { IAppState } from "~redux/reducers";
|
||||
|
||||
interface ILoginComponentProps extends RouteComponentProps {
|
||||
inProgress: boolean;
|
||||
error: string;
|
||||
spinner: boolean;
|
||||
login: (username: string, password: string) => void;
|
||||
}
|
||||
|
||||
interface ILoginComponentState {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class LoginComponent extends React.PureComponent<
|
||||
ILoginComponentProps,
|
||||
ILoginComponentState
|
||||
> {
|
||||
constructor(props: ILoginComponentProps) {
|
||||
super(props);
|
||||
this.submit = this.submit.bind(this);
|
||||
this.change = this.change.bind(this);
|
||||
this.updateFields = this.updateFields.bind(this);
|
||||
this.state = { username: "", password: "" };
|
||||
}
|
||||
|
||||
public change() {
|
||||
this.props.history.push("/signup");
|
||||
}
|
||||
|
||||
public submit(e: React.FormEvent<any>) {
|
||||
e.preventDefault();
|
||||
const { username, password } = this.state;
|
||||
if (!this.props.inProgress) {
|
||||
this.props.login(username, password);
|
||||
}
|
||||
}
|
||||
|
||||
public updateFields(e: React.FormEvent<HTMLInputElement>) {
|
||||
const { value, name } = e.currentTarget;
|
||||
this.setState({ ...this.state, [name]: value });
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<>
|
||||
<Card className="AuthForm" elevation={2}>
|
||||
<form onSubmit={this.submit}>
|
||||
<div className="header">
|
||||
<H2>Login</H2>
|
||||
<Button
|
||||
icon="new-person"
|
||||
minimal={true}
|
||||
onClick={this.change}
|
||||
className="change"
|
||||
>
|
||||
Signup
|
||||
</Button>
|
||||
</div>
|
||||
<FormGroup label="Username">
|
||||
<InputGroup
|
||||
name="username"
|
||||
value={this.state.username}
|
||||
onChange={this.updateFields}
|
||||
leftIcon="person"
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup label="Password">
|
||||
<InputGroup
|
||||
name="password"
|
||||
value={this.state.password}
|
||||
onChange={this.updateFields}
|
||||
type="password"
|
||||
leftIcon="key"
|
||||
/>
|
||||
</FormGroup>
|
||||
<div className="footer">
|
||||
<div id="error">{this.props.error}</div>
|
||||
<Button
|
||||
loading={this.props.spinner}
|
||||
className="submit"
|
||||
intent="primary"
|
||||
icon="log-in"
|
||||
type="submit"
|
||||
onClick={this.submit}
|
||||
disabled={this.props.spinner}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state: IAppState) {
|
||||
return {
|
||||
inProgress: state.auth.inProgress,
|
||||
error: state.auth.formError,
|
||||
spinner: state.auth.formSpinner,
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch: Dispatch) {
|
||||
return {
|
||||
login: (username: string, password: string) =>
|
||||
dispatch(authStart(username, password)),
|
||||
};
|
||||
}
|
||||
|
||||
export const Login = withRouter(
|
||||
connect(mapStateToProps, mapDispatchToProps)(LoginComponent),
|
||||
);
|
||||
134
frontend/src/Auth/Signup.tsx
Normal file
134
frontend/src/Auth/Signup.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import "./Auth.scss";
|
||||
|
||||
import { Button, Card, FormGroup, H2, InputGroup } from "@blueprintjs/core";
|
||||
import * as React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { RouteComponentProps, withRouter } from "react-router";
|
||||
import { Dispatch } from "redux";
|
||||
import { signupStart } from "~redux/auth/actions";
|
||||
import { IAppState } from "~redux/reducers";
|
||||
|
||||
interface ISignupComponentProps extends RouteComponentProps {
|
||||
inProgress: boolean;
|
||||
error: string;
|
||||
spinner: boolean;
|
||||
signup: (username: string, password: string, email: string) => void;
|
||||
}
|
||||
|
||||
interface ISignupComponentState {
|
||||
username: string;
|
||||
password: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export class SignupComponent extends React.PureComponent<
|
||||
ISignupComponentProps,
|
||||
ISignupComponentState
|
||||
> {
|
||||
constructor(props: ISignupComponentProps) {
|
||||
super(props);
|
||||
this.submit = this.submit.bind(this);
|
||||
this.change = this.change.bind(this);
|
||||
this.updateFields = this.updateFields.bind(this);
|
||||
this.state = { username: "", password: "", email: "" };
|
||||
}
|
||||
|
||||
public change() {
|
||||
this.props.history.push("/login");
|
||||
}
|
||||
|
||||
public submit(e: React.FormEvent<any>) {
|
||||
e.preventDefault();
|
||||
const { username, password, email } = this.state;
|
||||
if (!this.props.inProgress) {
|
||||
this.props.signup(username, password, email);
|
||||
}
|
||||
}
|
||||
|
||||
public updateFields(e: React.FormEvent<HTMLInputElement>) {
|
||||
const { value, name } = e.currentTarget;
|
||||
this.setState({ ...this.state, [name]: value });
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<>
|
||||
<Card className="AuthForm" elevation={2}>
|
||||
<form onSubmit={this.submit}>
|
||||
<div className="header">
|
||||
<H2>Signup</H2>
|
||||
<Button
|
||||
icon="log-in"
|
||||
minimal={true}
|
||||
onClick={this.change}
|
||||
className="change"
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</div>
|
||||
<FormGroup label="Username">
|
||||
<InputGroup
|
||||
name="username"
|
||||
value={this.state.username}
|
||||
onChange={this.updateFields}
|
||||
leftIcon="person"
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup label="Email">
|
||||
<InputGroup
|
||||
name="email"
|
||||
value={this.state.email}
|
||||
onChange={this.updateFields}
|
||||
type="email"
|
||||
leftIcon="envelope"
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup label="Password">
|
||||
<InputGroup
|
||||
name="password"
|
||||
value={this.state.password}
|
||||
onChange={this.updateFields}
|
||||
type="password"
|
||||
leftIcon="key"
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<div className="footer">
|
||||
<div id="error">{this.props.error}</div>
|
||||
<Button
|
||||
loading={this.props.spinner}
|
||||
icon="new-person"
|
||||
className="submit"
|
||||
intent="primary"
|
||||
type="submit"
|
||||
onClick={this.submit}
|
||||
disabled={this.props.spinner}
|
||||
>
|
||||
Signup
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state: IAppState) {
|
||||
return {
|
||||
inProgress: state.auth.inProgress,
|
||||
error: state.auth.formError,
|
||||
spinner: state.auth.formSpinner,
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch: Dispatch) {
|
||||
return {
|
||||
signup: (username: string, password: string, email: string) =>
|
||||
dispatch(signupStart(username, password, email)),
|
||||
};
|
||||
}
|
||||
|
||||
export const Signup = withRouter(
|
||||
connect(mapStateToProps, mapDispatchToProps)(SignupComponent),
|
||||
);
|
||||
53
frontend/src/Home/Home.scss
Normal file
53
frontend/src/Home/Home.scss
Normal file
@@ -0,0 +1,53 @@
|
||||
@import "~@blueprintjs/core/lib/scss/variables";
|
||||
|
||||
.viewComponent {
|
||||
position: absolute;
|
||||
max-width: 100%;
|
||||
width: 50rem;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: -$pt-navbar-height;
|
||||
bottom: 0;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding-top: 2 * $pt-navbar-height + 20px;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
#mainContainer {
|
||||
transition: 0.3s;
|
||||
|
||||
#MainScreen {
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
.bp3-navbar {
|
||||
transition: 0.3s;
|
||||
|
||||
> * {
|
||||
// keeps the breadcrumbs from taking all the space
|
||||
max-width: 65%;
|
||||
}
|
||||
|
||||
* {
|
||||
transition: 0.3s;
|
||||
}
|
||||
}
|
||||
|
||||
#uploadingStatusButton {
|
||||
width: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
#mainContainer.bp3-dark {
|
||||
transition: 0.3s;
|
||||
|
||||
#MainScreen {
|
||||
transition: 0.3s;
|
||||
background: $dark-gray3;
|
||||
}
|
||||
|
||||
.bp3-navbar {
|
||||
transition: 0.3s;
|
||||
}
|
||||
}
|
||||
161
frontend/src/Home/Home.tsx
Normal file
161
frontend/src/Home/Home.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import "./Home.scss";
|
||||
|
||||
import {
|
||||
Alignment,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Classes,
|
||||
IBreadcrumbProps,
|
||||
Icon,
|
||||
Menu,
|
||||
Navbar,
|
||||
Popover,
|
||||
Spinner,
|
||||
} from "@blueprintjs/core";
|
||||
import * as React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { Route, RouteComponentProps, Switch, withRouter } from "react-router";
|
||||
import { animated, config, Transition } from "react-spring/renderprops";
|
||||
import { Dispatch } from "redux";
|
||||
import { IUserJSON } from "~../../src/entity/User";
|
||||
import { Account } from "~Account/Account";
|
||||
import { Overview } from "~Photos/Overview";
|
||||
import { toggleDarkMode } from "~redux/localSettings/actions";
|
||||
import { IAppState } from "~redux/reducers";
|
||||
import { logoutUser } from "~redux/user/actions";
|
||||
|
||||
export interface IHomeProps extends RouteComponentProps {
|
||||
user: IUserJSON | null;
|
||||
|
||||
darkMode: boolean;
|
||||
|
||||
logout: () => void;
|
||||
dispatchToggleDarkMode: () => void;
|
||||
}
|
||||
|
||||
export class HomeComponent extends React.PureComponent<IHomeProps> {
|
||||
constructor(props: IHomeProps) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { location } = this.props.history;
|
||||
return (
|
||||
this.props.user && (
|
||||
<div
|
||||
id="mainContainer"
|
||||
className={this.props.darkMode ? Classes.DARK : undefined}
|
||||
>
|
||||
<Navbar fixedToTop={true}>
|
||||
<Navbar.Group align={Alignment.LEFT}>
|
||||
<Button
|
||||
minimal={true}
|
||||
onClick={() => this.props.history.push("/")}
|
||||
>
|
||||
Writer
|
||||
</Button>
|
||||
<Navbar.Divider />
|
||||
</Navbar.Group>
|
||||
<Navbar.Group align={Alignment.RIGHT}>
|
||||
<Popover
|
||||
target={
|
||||
<Button id="userButton">
|
||||
{this.props.user.username}
|
||||
</Button>
|
||||
}
|
||||
content={this.menu()}
|
||||
/>
|
||||
</Navbar.Group>
|
||||
</Navbar>
|
||||
<div id="MainScreen" className="animationWrapper">
|
||||
<Transition
|
||||
native={true}
|
||||
config={{
|
||||
...config.default,
|
||||
clamp: true,
|
||||
precision: 0.1,
|
||||
}}
|
||||
items={location}
|
||||
keys={location.pathname}
|
||||
from={{
|
||||
opacity: 0,
|
||||
transform: "translate3d(-400px,0,0)",
|
||||
}}
|
||||
enter={{
|
||||
opacity: 1,
|
||||
transform: "translate3d(0,0,0)",
|
||||
}}
|
||||
leave={{
|
||||
opacity: 0,
|
||||
transform: "translate3d(400px,0,0)",
|
||||
}}
|
||||
>
|
||||
{(_location: any) => (style: any) => (
|
||||
<animated.div
|
||||
style={style}
|
||||
className="viewComponent"
|
||||
>
|
||||
<Switch location={_location}>
|
||||
<Route
|
||||
path="/account"
|
||||
component={Account}
|
||||
/>
|
||||
<Route path="/" component={Overview} />
|
||||
</Switch>
|
||||
</animated.div>
|
||||
)}
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private menu() {
|
||||
return (
|
||||
<Menu>
|
||||
<Menu.Item
|
||||
icon="user"
|
||||
text="Account"
|
||||
onClick={() => this.props.history.push("/account")}
|
||||
/>
|
||||
<Menu.Item
|
||||
icon="log-out"
|
||||
text="Logout"
|
||||
onClick={this.props.logout}
|
||||
/>
|
||||
{this.props.darkMode ? (
|
||||
<Menu.Item
|
||||
icon="flash"
|
||||
text="Light Mode"
|
||||
onClick={this.props.dispatchToggleDarkMode}
|
||||
/>
|
||||
) : (
|
||||
<Menu.Item
|
||||
icon="moon"
|
||||
text="Dark Mode"
|
||||
onClick={this.props.dispatchToggleDarkMode}
|
||||
/>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state: IAppState) {
|
||||
return {
|
||||
user: state.user.user,
|
||||
darkMode: state.localSettings.darkMode,
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch: Dispatch) {
|
||||
return {
|
||||
logout: () => dispatch(logoutUser()),
|
||||
dispatchToggleDarkMode: () => dispatch(toggleDarkMode()),
|
||||
};
|
||||
}
|
||||
|
||||
export const Home = withRouter(
|
||||
connect(mapStateToProps, mapDispatchToProps)(HomeComponent),
|
||||
);
|
||||
27
frontend/src/Home/tests/Home.test.tsx
Normal file
27
frontend/src/Home/tests/Home.test.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { shallow } from "enzyme";
|
||||
import * as React from "react";
|
||||
|
||||
import { HomeComponent, IHomeProps } from "../Home";
|
||||
|
||||
const defaultHomeProps: IHomeProps = {
|
||||
user: { id: 1, username: "test" },
|
||||
|
||||
darkMode: false,
|
||||
|
||||
logout: jest.fn(),
|
||||
dispatchToggleDarkMode: jest.fn(),
|
||||
|
||||
history: { location: { pathname: "/" } } as any,
|
||||
location: { pathname: "/" } as any,
|
||||
match: {
|
||||
params: {
|
||||
id: null,
|
||||
},
|
||||
} as any,
|
||||
};
|
||||
|
||||
describe("<Home />", () => {
|
||||
it("should not crash", () => {
|
||||
const wrapper = shallow(<HomeComponent {...defaultHomeProps} />);
|
||||
});
|
||||
});
|
||||
26
frontend/src/Landing/Landing.tsx
Normal file
26
frontend/src/Landing/Landing.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Alignment, Button, Navbar } from "@blueprintjs/core";
|
||||
import * as React from "react";
|
||||
import { RouteComponentProps, withRouter } from "react-router";
|
||||
|
||||
export function LandingComponent(props: RouteComponentProps) {
|
||||
function login() {
|
||||
props.history.push("/login");
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Navbar>
|
||||
<Navbar.Group align={Alignment.LEFT}>
|
||||
<Navbar.Heading>Writer</Navbar.Heading>
|
||||
<Navbar.Divider />
|
||||
</Navbar.Group>
|
||||
<Navbar.Group align={Alignment.RIGHT}>
|
||||
<Button icon="log-in" minimal={true} onClick={login}>
|
||||
Login
|
||||
</Button>
|
||||
</Navbar.Group>
|
||||
</Navbar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const Landing = withRouter(LandingComponent);
|
||||
10
frontend/src/LoadingStub.tsx
Normal file
10
frontend/src/LoadingStub.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Spinner } from "@blueprintjs/core";
|
||||
import * as React from "react";
|
||||
|
||||
export function LoadingStub() {
|
||||
return (
|
||||
<div className="loadingWrapper">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
frontend/src/NotFound.tsx
Normal file
5
frontend/src/NotFound.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import * as React from "react";
|
||||
|
||||
export function NotFound() {
|
||||
return <div>404</div>;
|
||||
}
|
||||
29
frontend/src/Photos/Overview.tsx
Normal file
29
frontend/src/Photos/Overview.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import "./Photos.scss";
|
||||
import * as React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { Dispatch } from "redux";
|
||||
import { IAppState } from "~redux/reducers";
|
||||
|
||||
export interface IOverviewComponentProps {
|
||||
fetching: boolean;
|
||||
spinner: boolean;
|
||||
|
||||
fetchDocs: () => void;
|
||||
}
|
||||
|
||||
export function OverviewComponent() {
|
||||
return <div id="overview">Overview!</div>;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: IAppState) {
|
||||
return {};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch: Dispatch) {
|
||||
return {};
|
||||
}
|
||||
|
||||
export const Overview = connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
)(OverviewComponent);
|
||||
10
frontend/src/Photos/Photos.scss
Normal file
10
frontend/src/Photos/Photos.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
@import "~@blueprintjs/core/lib/scss/variables";
|
||||
|
||||
#overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bp3-dark {
|
||||
#overview {}
|
||||
}
|
||||
15
frontend/src/Photos/tests/Overview.test.tsx
Normal file
15
frontend/src/Photos/tests/Overview.test.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { shallow } from "enzyme";
|
||||
import { OverviewComponent } from "../Overview";
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("<Overview />", () => {
|
||||
it("should not crash", () => {
|
||||
const wrapper = shallow(<OverviewComponent />);
|
||||
expect(wrapper.contains("Overview!")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { shallow } from "enzyme";
|
||||
import { TestTest } from "~TestTest";
|
||||
|
||||
describe("<TestTest />", () => {
|
||||
it("should not crash", () => {
|
||||
const wrapper = shallow(<TestTest />);
|
||||
expect(wrapper.contains(<div>Hello!</div>)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
export function TestTest() {
|
||||
return <div>Hello!</div>;
|
||||
}
|
||||
2
frontend/src/env.ts
Normal file
2
frontend/src/env.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const apiRoot = process.env.API_ROOT || "http://localhost:3000";
|
||||
export const webRoot = process.env.WEB_ROOT || "http://localhost:1234";
|
||||
1
frontend/src/fileMock.ts
Normal file
1
frontend/src/fileMock.ts
Normal file
@@ -0,0 +1 @@
|
||||
module.exports = "test-file-stub";
|
||||
@@ -1,14 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>My Parcel Project</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
||||
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css?family=PT+Sans:400,400i,700,700i&display=swap&subset=cyrillic,cyrillic-ext,latin-ext"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<title>Writer</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="body"></div>
|
||||
<script src="./index.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,4 +1,23 @@
|
||||
import "@blueprintjs/core/lib/css/blueprint.css";
|
||||
import "@blueprintjs/icons/lib/css/blueprint-icons.css";
|
||||
import "normalize.css/normalize.css";
|
||||
import "~App.scss";
|
||||
|
||||
import * as React from "react";
|
||||
import { render } from "react-dom";
|
||||
import { Provider } from "react-redux";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { PersistGate } from "redux-persist/integration/react";
|
||||
import { App } from "~App";
|
||||
import { persistor, store } from "~redux/store";
|
||||
|
||||
render(<h1>Hello World</h1>, document.getElementById("root"));
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<PersistGate loading={null} persistor={persistor}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</PersistGate>
|
||||
</Provider>,
|
||||
document.getElementById("body"),
|
||||
);
|
||||
|
||||
25
frontend/src/redux/api/auth/index.ts
Normal file
25
frontend/src/redux/api/auth/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { IUserAuthJSON } from "~../../src/entity/User";
|
||||
import { IAPIResponse } from "~../../src/types";
|
||||
import { fetchJSON } from "../utils";
|
||||
|
||||
export async function login(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<IAPIResponse<IUserAuthJSON>> {
|
||||
return (fetchJSON("/users/login", "POST", {
|
||||
username,
|
||||
password,
|
||||
}) as unknown) as Promise<IAPIResponse<IUserAuthJSON>>;
|
||||
}
|
||||
|
||||
export async function signup(
|
||||
username: string,
|
||||
password: string,
|
||||
email: string,
|
||||
): Promise<IAPIResponse<IUserAuthJSON>> {
|
||||
return (fetchJSON("/users/signup", "POST", {
|
||||
username,
|
||||
password,
|
||||
email,
|
||||
}) as unknown) as Promise<IAPIResponse<IUserAuthJSON>>;
|
||||
}
|
||||
15
frontend/src/redux/api/user/index.ts
Normal file
15
frontend/src/redux/api/user/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { fetchJSON, fetchJSONAuth } from "../utils";
|
||||
import { IAPIResponse } from "~/../../src/types";
|
||||
import { IUserAuthJSON, IUserJSON } from "~../../src/entity/User";
|
||||
|
||||
export async function fetchUser(): Promise<IAPIResponse<IUserJSON>> {
|
||||
return (fetchJSONAuth("/users/user", "GET") as unknown) as Promise<
|
||||
IAPIResponse<IUserAuthJSON>
|
||||
>;
|
||||
}
|
||||
|
||||
export async function changeUserPassword(newPassword: string) {
|
||||
return (fetchJSONAuth("/users/edit", "POST", {
|
||||
password: newPassword,
|
||||
}) as unknown) as Promise<IAPIResponse<IUserAuthJSON>>;
|
||||
}
|
||||
52
frontend/src/redux/api/utils.ts
Normal file
52
frontend/src/redux/api/utils.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { apiRoot } from "~env";
|
||||
|
||||
let token: string | null;
|
||||
|
||||
export function setToken(_token: string): void {
|
||||
token = _token;
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
return token;
|
||||
}
|
||||
|
||||
export function deleteToken(): void {
|
||||
token = null;
|
||||
}
|
||||
|
||||
export async function fetchJSON(
|
||||
path: string,
|
||||
method: string,
|
||||
body?: string | Record<string, unknown>,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (typeof body === "object") {
|
||||
body = JSON.stringify(body);
|
||||
}
|
||||
const response = await fetch(apiRoot + path, {
|
||||
method,
|
||||
body,
|
||||
headers: {
|
||||
...headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const json = (await response.json()) as Record<string, unknown>;
|
||||
return json;
|
||||
}
|
||||
|
||||
export async function fetchJSONAuth(
|
||||
path: string,
|
||||
method: string,
|
||||
body?: string | Record<string, unknown>,
|
||||
headers?: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (token) {
|
||||
return fetchJSON(path, method, body, {
|
||||
...headers,
|
||||
Authorization: `Bearer ${token}`,
|
||||
});
|
||||
} else {
|
||||
throw new Error("Not logged in");
|
||||
}
|
||||
}
|
||||
80
frontend/src/redux/auth/actions.ts
Normal file
80
frontend/src/redux/auth/actions.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Action } from "redux";
|
||||
import { IUserAuthJSON } from "~../../src/entity/User";
|
||||
|
||||
export enum AuthTypes {
|
||||
AUTH_START = "AUTH_START",
|
||||
SIGNUP_START = "SIGNUP_START",
|
||||
AUTH_SUCCESS = "AUTH_SUCCESS",
|
||||
AUTH_FAIL = "AUTH_FAIL",
|
||||
AUTH_START_FORM_SPINNER = "AUTH_START_FORM_SPINNER",
|
||||
}
|
||||
|
||||
export interface IAuthStartAction extends Action {
|
||||
type: AuthTypes.AUTH_START;
|
||||
payload: {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ISignupStartAction extends Action {
|
||||
type: AuthTypes.SIGNUP_START;
|
||||
payload: {
|
||||
username: string;
|
||||
password: string;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IAuthSuccessAction extends Action {
|
||||
type: AuthTypes.AUTH_SUCCESS;
|
||||
payload: IUserAuthJSON;
|
||||
}
|
||||
|
||||
export interface IAuthFailureAction extends Action {
|
||||
type: AuthTypes.AUTH_FAIL;
|
||||
payload: {
|
||||
error: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IAuthStartFormSpinnerAction extends Action {
|
||||
type: AuthTypes.AUTH_START_FORM_SPINNER;
|
||||
}
|
||||
|
||||
export function startFormSpinner(): IAuthStartFormSpinnerAction {
|
||||
return { type: AuthTypes.AUTH_START_FORM_SPINNER };
|
||||
}
|
||||
|
||||
export function authStart(
|
||||
username: string,
|
||||
password: string,
|
||||
): IAuthStartAction {
|
||||
return { type: AuthTypes.AUTH_START, payload: { username, password } };
|
||||
}
|
||||
|
||||
export function signupStart(
|
||||
username: string,
|
||||
password: string,
|
||||
email: string,
|
||||
): ISignupStartAction {
|
||||
return {
|
||||
type: AuthTypes.SIGNUP_START,
|
||||
payload: { username, password, email },
|
||||
};
|
||||
}
|
||||
|
||||
export function authSuccess(user: IUserAuthJSON): IAuthSuccessAction {
|
||||
return { type: AuthTypes.AUTH_SUCCESS, payload: user };
|
||||
}
|
||||
|
||||
export function authFail(error: string): IAuthFailureAction {
|
||||
return { type: AuthTypes.AUTH_FAIL, payload: { error } };
|
||||
}
|
||||
|
||||
export type AuthAction =
|
||||
| IAuthStartAction
|
||||
| IAuthSuccessAction
|
||||
| IAuthFailureAction
|
||||
| IAuthStartFormSpinnerAction
|
||||
| ISignupStartAction;
|
||||
56
frontend/src/redux/auth/reducer.ts
Normal file
56
frontend/src/redux/auth/reducer.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Reducer } from "react";
|
||||
|
||||
import { setToken } from "~redux/api/utils";
|
||||
import { UserAction, UserTypes } from "~redux/user/actions";
|
||||
import { AuthAction, AuthTypes } from "./actions";
|
||||
|
||||
export interface IAuthState {
|
||||
jwt: string | null;
|
||||
inProgress: boolean;
|
||||
formError: string | null;
|
||||
formSpinner: boolean;
|
||||
}
|
||||
|
||||
const defaultAuthState: IAuthState = {
|
||||
jwt: null,
|
||||
inProgress: false,
|
||||
formError: null,
|
||||
formSpinner: false,
|
||||
};
|
||||
|
||||
export const authReducer: Reducer<IAuthState, AuthAction> = (
|
||||
state: IAuthState = defaultAuthState,
|
||||
action: AuthAction | UserAction,
|
||||
): IAuthState => {
|
||||
switch (action.type) {
|
||||
case AuthTypes.AUTH_START:
|
||||
case AuthTypes.SIGNUP_START:
|
||||
return { ...defaultAuthState, inProgress: true };
|
||||
break;
|
||||
case AuthTypes.AUTH_SUCCESS:
|
||||
case UserTypes.USER_GET_SUCCESS:
|
||||
setToken(action.payload.jwt);
|
||||
return {
|
||||
...defaultAuthState,
|
||||
jwt: action.payload.jwt,
|
||||
};
|
||||
break;
|
||||
case UserTypes.USER_GET_FAIL:
|
||||
if (action.payload.logout) {
|
||||
return defaultAuthState;
|
||||
}
|
||||
break;
|
||||
case AuthTypes.AUTH_FAIL:
|
||||
return { ...defaultAuthState, formError: action.payload.error };
|
||||
break;
|
||||
case AuthTypes.AUTH_START_FORM_SPINNER:
|
||||
return { ...state, formSpinner: true };
|
||||
case UserTypes.USER_LOGOUT:
|
||||
return defaultAuthState;
|
||||
break;
|
||||
default:
|
||||
return state;
|
||||
break;
|
||||
}
|
||||
return state;
|
||||
};
|
||||
86
frontend/src/redux/auth/sagas.ts
Normal file
86
frontend/src/redux/auth/sagas.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
all,
|
||||
call,
|
||||
cancel,
|
||||
delay,
|
||||
fork,
|
||||
put,
|
||||
race,
|
||||
takeLatest,
|
||||
} from "redux-saga/effects";
|
||||
import { login, signup } from "~redux/api/auth";
|
||||
|
||||
import {
|
||||
authFail,
|
||||
authSuccess,
|
||||
AuthTypes,
|
||||
IAuthStartAction,
|
||||
ISignupStartAction,
|
||||
startFormSpinner,
|
||||
} from "./actions";
|
||||
|
||||
function* startSpinner() {
|
||||
yield delay(300);
|
||||
yield put(startFormSpinner());
|
||||
}
|
||||
|
||||
function* authStart(action: IAuthStartAction) {
|
||||
const { username, password } = action.payload;
|
||||
try {
|
||||
const spinner = yield fork(startSpinner);
|
||||
|
||||
const { response, timeout } = yield race({
|
||||
response: call(login, username, password),
|
||||
timeout: delay(10000),
|
||||
});
|
||||
|
||||
yield cancel(spinner);
|
||||
|
||||
if (timeout) {
|
||||
yield put(authFail("Timeout"));
|
||||
return;
|
||||
}
|
||||
if (response.data) {
|
||||
const user = response.data;
|
||||
yield put(authSuccess(user));
|
||||
} else {
|
||||
yield put(authFail(response.error));
|
||||
}
|
||||
} catch (e) {
|
||||
yield put(authFail("Internal error"));
|
||||
}
|
||||
}
|
||||
|
||||
function* signupStart(action: ISignupStartAction) {
|
||||
const { username, password, email } = action.payload;
|
||||
try {
|
||||
const spinner = yield fork(startSpinner);
|
||||
|
||||
const { response, timeout } = yield race({
|
||||
response: call(signup, username, password, email),
|
||||
timeout: delay(10000),
|
||||
});
|
||||
|
||||
yield cancel(spinner);
|
||||
|
||||
if (timeout) {
|
||||
yield put(authFail("Timeout"));
|
||||
return;
|
||||
}
|
||||
if (response.data) {
|
||||
const user = response.data;
|
||||
yield put(authSuccess(user));
|
||||
} else {
|
||||
yield put(authFail(response.error));
|
||||
}
|
||||
} catch (e) {
|
||||
yield put(authFail(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
export function* authSaga() {
|
||||
yield all([
|
||||
takeLatest(AuthTypes.AUTH_START, authStart),
|
||||
takeLatest(AuthTypes.SIGNUP_START, signupStart),
|
||||
]);
|
||||
}
|
||||
15
frontend/src/redux/localSettings/actions.ts
Normal file
15
frontend/src/redux/localSettings/actions.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Action } from "redux";
|
||||
|
||||
export enum LocalSettingsTypes {
|
||||
TOGGLE_DARK_MODE = "TOGGLE_DARK_MODE",
|
||||
}
|
||||
|
||||
export interface IToggleDarkModeAction extends Action {
|
||||
type: LocalSettingsTypes.TOGGLE_DARK_MODE;
|
||||
}
|
||||
|
||||
export function toggleDarkMode(): IToggleDarkModeAction {
|
||||
return { type: LocalSettingsTypes.TOGGLE_DARK_MODE };
|
||||
}
|
||||
|
||||
export type LocalSettingsAction = IToggleDarkModeAction;
|
||||
32
frontend/src/redux/localSettings/reducer.ts
Normal file
32
frontend/src/redux/localSettings/reducer.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Reducer } from "react";
|
||||
import { UserAction, UserTypes } from "~redux/user/actions";
|
||||
|
||||
import { LocalSettingsAction, LocalSettingsTypes } from "./actions";
|
||||
|
||||
export interface ILocalSettingsState {
|
||||
darkMode: boolean;
|
||||
}
|
||||
|
||||
const defaultLocalSettingsState: ILocalSettingsState = {
|
||||
darkMode: false,
|
||||
};
|
||||
|
||||
export const localSettingsReducer: Reducer<
|
||||
ILocalSettingsState,
|
||||
LocalSettingsAction
|
||||
> = (
|
||||
state: ILocalSettingsState = defaultLocalSettingsState,
|
||||
action: LocalSettingsAction | UserAction,
|
||||
): ILocalSettingsState => {
|
||||
const { darkMode } = state;
|
||||
switch (action.type) {
|
||||
case LocalSettingsTypes.TOGGLE_DARK_MODE:
|
||||
return { ...state, darkMode: !darkMode };
|
||||
case UserTypes.USER_LOGOUT:
|
||||
return defaultLocalSettingsState;
|
||||
default:
|
||||
return state;
|
||||
break;
|
||||
}
|
||||
return state;
|
||||
};
|
||||
36
frontend/src/redux/reducers.ts
Normal file
36
frontend/src/redux/reducers.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { combineReducers } from "redux";
|
||||
import { persistReducer } from "redux-persist";
|
||||
import { PersistPartial } from "redux-persist/es/persistReducer";
|
||||
import storage from "redux-persist/lib/storage";
|
||||
import { authReducer, IAuthState } from "~redux/auth/reducer";
|
||||
import {
|
||||
ILocalSettingsState,
|
||||
localSettingsReducer,
|
||||
} from "./localSettings/reducer";
|
||||
import { IUserState, userReducer } from "./user/reducer";
|
||||
|
||||
export interface IAppState {
|
||||
auth: IAuthState & PersistPartial;
|
||||
user: IUserState;
|
||||
localSettings: ILocalSettingsState & PersistPartial;
|
||||
}
|
||||
|
||||
const authPersistConfig = {
|
||||
key: "auth",
|
||||
storage,
|
||||
whitelist: ["jwt"],
|
||||
};
|
||||
|
||||
const localSettingsPersistConfig = {
|
||||
key: "localSettings",
|
||||
storage,
|
||||
};
|
||||
|
||||
export const rootReducer = combineReducers({
|
||||
auth: persistReducer<IAuthState>(authPersistConfig, authReducer),
|
||||
user: userReducer,
|
||||
localSettings: persistReducer(
|
||||
localSettingsPersistConfig,
|
||||
localSettingsReducer,
|
||||
),
|
||||
});
|
||||
28
frontend/src/redux/store.ts
Normal file
28
frontend/src/redux/store.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { applyMiddleware, createStore } from "redux";
|
||||
import { composeWithDevTools } from "redux-devtools-extension";
|
||||
import { persistStore } from "redux-persist";
|
||||
import createSagaMiddlware from "redux-saga";
|
||||
import { rootReducer } from "~redux/reducers";
|
||||
|
||||
import { setToken } from "./api/utils";
|
||||
import { authSaga } from "./auth/sagas";
|
||||
import { getUser } from "./user/actions";
|
||||
import { userSaga } from "./user/sagas";
|
||||
|
||||
const sagaMiddleware = createSagaMiddlware();
|
||||
|
||||
export const store = createStore(
|
||||
rootReducer,
|
||||
composeWithDevTools(applyMiddleware(sagaMiddleware)),
|
||||
);
|
||||
|
||||
export const persistor = persistStore(store, null, () => {
|
||||
const state = store.getState();
|
||||
if (state.auth.jwt) {
|
||||
setToken(state.auth.jwt);
|
||||
store.dispatch(getUser());
|
||||
}
|
||||
});
|
||||
|
||||
sagaMiddleware.run(authSaga);
|
||||
sagaMiddleware.run(userSaga);
|
||||
0
frontend/src/redux/types.ts
Normal file
0
frontend/src/redux/types.ts
Normal file
102
frontend/src/redux/user/actions.ts
Normal file
102
frontend/src/redux/user/actions.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Action } from "redux";
|
||||
import { IUserAuthJSON, IUserJSON } from "~../../src/entity/User";
|
||||
import { showPasswordNotSavedToast, showPasswordSavedToast } from "~AppToaster";
|
||||
|
||||
export enum UserTypes {
|
||||
USER_GET = "USER_GET",
|
||||
USER_GET_SUCCESS = "USER_GET_SUCCESS",
|
||||
USER_GET_FAIL = "USER_GET_FAIL",
|
||||
USER_LOGOUT = "USER_LOGOUT",
|
||||
USER_PASS_CHANGE = "USER_PASS_CHANGE",
|
||||
USER_PASS_CHANGE_SUCCESS = "USER_PASS_CHANGE_SUCCESS",
|
||||
USER_PASS_CHANGE_FAIL = "USER_PASS_CHANGE_FAIL",
|
||||
}
|
||||
|
||||
export interface IUserGetAction extends Action {
|
||||
type: UserTypes.USER_GET;
|
||||
}
|
||||
|
||||
export interface IUserLogoutAction extends Action {
|
||||
type: UserTypes.USER_LOGOUT;
|
||||
}
|
||||
|
||||
export interface IUserGetSuccessAction extends Action {
|
||||
type: UserTypes.USER_GET_SUCCESS;
|
||||
payload: IUserAuthJSON;
|
||||
}
|
||||
|
||||
export interface IUserGetFailAction extends Action {
|
||||
type: UserTypes.USER_GET_FAIL;
|
||||
payload: {
|
||||
error: string;
|
||||
logout: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IUserPassChangeAction extends Action {
|
||||
type: UserTypes.USER_PASS_CHANGE;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface IUserPassChangeSuccessAction extends Action {
|
||||
type: UserTypes.USER_PASS_CHANGE_SUCCESS;
|
||||
payload: IUserAuthJSON;
|
||||
}
|
||||
|
||||
export interface IUserPassChangeFailAction extends Action {
|
||||
type: UserTypes.USER_PASS_CHANGE_FAIL;
|
||||
payload: {
|
||||
error: string;
|
||||
logout: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function getUser(): IUserGetAction {
|
||||
return { type: UserTypes.USER_GET };
|
||||
}
|
||||
|
||||
export function logoutUser(): IUserLogoutAction {
|
||||
return { type: UserTypes.USER_LOGOUT };
|
||||
}
|
||||
|
||||
export function getUserSuccess(user: IUserAuthJSON): IUserGetSuccessAction {
|
||||
return { type: UserTypes.USER_GET_SUCCESS, payload: user };
|
||||
}
|
||||
|
||||
export function getUserFail(
|
||||
error: string,
|
||||
logout: boolean,
|
||||
): IUserGetFailAction {
|
||||
return { type: UserTypes.USER_GET_FAIL, payload: { error, logout } };
|
||||
}
|
||||
|
||||
export function userPassChange(password: string): IUserPassChangeAction {
|
||||
return { type: UserTypes.USER_PASS_CHANGE, password };
|
||||
}
|
||||
|
||||
export function userPassChangeSuccess(
|
||||
user: IUserAuthJSON,
|
||||
): IUserPassChangeSuccessAction {
|
||||
showPasswordSavedToast();
|
||||
return { type: UserTypes.USER_PASS_CHANGE_SUCCESS, payload: user };
|
||||
}
|
||||
|
||||
export function userPassChangeFail(
|
||||
error: string,
|
||||
logout: boolean,
|
||||
): IUserPassChangeFailAction {
|
||||
showPasswordNotSavedToast(error);
|
||||
return {
|
||||
type: UserTypes.USER_PASS_CHANGE_FAIL,
|
||||
payload: { error, logout },
|
||||
};
|
||||
}
|
||||
|
||||
export type UserAction =
|
||||
| IUserGetAction
|
||||
| IUserGetSuccessAction
|
||||
| IUserGetFailAction
|
||||
| IUserLogoutAction
|
||||
| IUserPassChangeAction
|
||||
| IUserPassChangeFailAction
|
||||
| IUserPassChangeSuccessAction;
|
||||
39
frontend/src/redux/user/reducer.ts
Normal file
39
frontend/src/redux/user/reducer.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Reducer } from "react";
|
||||
import { IUserJSON } from "~../../src/entity/User";
|
||||
import { AuthAction, AuthTypes } from "~redux/auth/actions";
|
||||
import { UserAction, UserTypes } from "./actions";
|
||||
|
||||
export interface IUserState {
|
||||
user: IUserJSON | null;
|
||||
}
|
||||
|
||||
const defaultUserState: IUserState = {
|
||||
user: null,
|
||||
};
|
||||
|
||||
export const userReducer: Reducer<IUserState, AuthAction> = (
|
||||
state: IUserState = defaultUserState,
|
||||
action: AuthAction | UserAction,
|
||||
): IUserState => {
|
||||
switch (action.type) {
|
||||
case AuthTypes.AUTH_SUCCESS:
|
||||
case UserTypes.USER_GET_SUCCESS:
|
||||
case UserTypes.USER_PASS_CHANGE_SUCCESS:
|
||||
return {
|
||||
...defaultUserState,
|
||||
user: action.payload,
|
||||
};
|
||||
break;
|
||||
case UserTypes.USER_GET_FAIL:
|
||||
case UserTypes.USER_PASS_CHANGE_FAIL:
|
||||
return defaultUserState;
|
||||
break;
|
||||
case UserTypes.USER_LOGOUT:
|
||||
return defaultUserState;
|
||||
break;
|
||||
default:
|
||||
return state;
|
||||
break;
|
||||
}
|
||||
return state;
|
||||
};
|
||||
62
frontend/src/redux/user/sagas.ts
Normal file
62
frontend/src/redux/user/sagas.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { all, call, delay, put, race, takeLatest } from "redux-saga/effects";
|
||||
import { changeUserPassword, fetchUser } from "~redux/api/user";
|
||||
|
||||
import {
|
||||
getUserFail,
|
||||
getUserSuccess,
|
||||
IUserPassChangeAction,
|
||||
userPassChangeFail,
|
||||
userPassChangeSuccess,
|
||||
UserTypes,
|
||||
} from "./actions";
|
||||
|
||||
function* getUser() {
|
||||
try {
|
||||
const { response, timeout } = yield race({
|
||||
response: call(fetchUser),
|
||||
timeout: delay(10000),
|
||||
});
|
||||
|
||||
if (timeout) {
|
||||
yield put(getUserFail("Timeout", false));
|
||||
return;
|
||||
}
|
||||
if (response.data) {
|
||||
const user = response.data;
|
||||
yield put(getUserSuccess(user));
|
||||
} else {
|
||||
yield put(getUserFail(response.error, true));
|
||||
}
|
||||
} catch (e) {
|
||||
yield put(getUserFail("Internal error", false));
|
||||
}
|
||||
}
|
||||
|
||||
function* userPassChange(action: IUserPassChangeAction) {
|
||||
try {
|
||||
const { response, timeout } = yield race({
|
||||
response: call(changeUserPassword, action.password),
|
||||
timeout: delay(10000),
|
||||
});
|
||||
|
||||
if (timeout) {
|
||||
yield put(userPassChangeFail("Timeout", false));
|
||||
return;
|
||||
}
|
||||
if (response.data) {
|
||||
const user = response.data;
|
||||
yield put(userPassChangeSuccess(user));
|
||||
} else {
|
||||
yield put(userPassChangeFail(response.error, true));
|
||||
}
|
||||
} catch (e) {
|
||||
yield put(userPassChangeFail("Internal error", false));
|
||||
}
|
||||
}
|
||||
|
||||
export function* userSaga() {
|
||||
yield all([
|
||||
takeLatest(UserTypes.USER_GET, getUser),
|
||||
takeLatest(UserTypes.USER_PASS_CHANGE, userPassChange),
|
||||
]);
|
||||
}
|
||||
1
frontend/src/styleMock.ts
Normal file
1
frontend/src/styleMock.ts
Normal file
@@ -0,0 +1 @@
|
||||
module.exports = {};
|
||||
3
frontend/src/tests/utils.ts
Normal file
3
frontend/src/tests/utils.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function flushPromises() {
|
||||
return new Promise(setImmediate);
|
||||
}
|
||||
Reference in New Issue
Block a user