mirror of
https://github.com/idanoo/GoScrobble
synced 2025-07-01 05:32:18 +00:00
- Login flow working..
- Jellyfin scrobble working - Returns scrobbles via API for authed users /api/v1/user/{uuid}/scrobble - Add redis handler + funcs - Move middleware to pass in uuid as needed
This commit is contained in:
parent
c83c086cdd
commit
5fd9d41069
54 changed files with 1093 additions and 386 deletions
|
@ -1,2 +0,0 @@
|
|||
REACT_APP_API_URL=http://127.0.0.1:42069
|
||||
REACT_APP_REGISTRATION_DISABLED=false
|
|
@ -1,2 +0,0 @@
|
|||
REACT_APP_API_URL=https://goscrobble.com
|
||||
REACT_APP_REGISTRATION_DISABLED=true
|
81
web/package-lock.json
generated
81
web/package-lock.json
generated
|
@ -12,8 +12,10 @@
|
|||
"@testing-library/jest-dom": "^5.11.9",
|
||||
"@testing-library/react": "^11.2.5",
|
||||
"@testing-library/user-event": "^12.8.3",
|
||||
"axios": "^0.21.1",
|
||||
"bootstrap": "^4.6.0",
|
||||
"formik": "^2.2.6",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"react": "^17.0.2",
|
||||
"react-bootstrap": "^1.5.2",
|
||||
"react-cookie": "^4.0.3",
|
||||
|
@ -24,11 +26,15 @@
|
|||
"react-spinners": "^0.10.6",
|
||||
"react-toast": "^1.0.1",
|
||||
"react-toast-notifications": "^2.4.3",
|
||||
"react-toastify": "^7.0.3",
|
||||
"reactstrap": "^8.9.0",
|
||||
"redux": "^4.0.5",
|
||||
"redux-persist": "^6.0.0",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"web-vitals": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"redux-devtools-extension": "^2.13.9",
|
||||
"webpack-dev-server": "^3.11.1"
|
||||
}
|
||||
},
|
||||
|
@ -4175,6 +4181,14 @@
|
|||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
|
||||
"integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axobject-query": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz",
|
||||
|
@ -5560,6 +5574,14 @@
|
|||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz",
|
||||
"integrity": "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/co": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
||||
|
@ -12841,6 +12863,11 @@
|
|||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jwt-decode": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
|
||||
"integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="
|
||||
},
|
||||
"node_modules/killable": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz",
|
||||
|
@ -16713,6 +16740,18 @@
|
|||
"react-dom": "^16.8.0 || ^17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-toastify": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-7.0.3.tgz",
|
||||
"integrity": "sha512-cxZ5rfurC8LzcZQMTYc8RHIkQTs+BFur18Pzk6Loz6uS8OXUWm6nXVlH/wqglz4Z7UAE8xxcF5mRjfE13487uQ==",
|
||||
"dependencies": {
|
||||
"clsx": "^1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16",
|
||||
"react-dom": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/react-transition-group": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.1.tgz",
|
||||
|
@ -16937,6 +16976,15 @@
|
|||
"symbol-observable": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redux-devtools-extension": {
|
||||
"version": "2.13.9",
|
||||
"resolved": "https://registry.npmjs.org/redux-devtools-extension/-/redux-devtools-extension-2.13.9.tgz",
|
||||
"integrity": "sha512-cNJ8Q/EtjhQaZ71c8I9+BPySIBVEKssbPpskBfsXqb8HJ002A3KRVHfeRzwRo6mGPqsm7XuHTqNSNeS1Khig0A==",
|
||||
"dev": true,
|
||||
"peerDependencies": {
|
||||
"redux": "^3.1.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redux-persist": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-persist/-/redux-persist-6.0.0.tgz",
|
||||
|
@ -25420,6 +25468,14 @@
|
|||
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.1.3.tgz",
|
||||
"integrity": "sha512-vwPpH4Aj4122EW38mxO/fxhGKtwWTMLDIJfZ1He0Edbtjcfna/R3YB67yVhezUMzqc3Jr3+Ii50KRntlENL4xQ=="
|
||||
},
|
||||
"axios": {
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
|
||||
"integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==",
|
||||
"requires": {
|
||||
"follow-redirects": "^1.10.0"
|
||||
}
|
||||
},
|
||||
"axobject-query": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz",
|
||||
|
@ -26548,6 +26604,11 @@
|
|||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"clsx": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz",
|
||||
"integrity": "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA=="
|
||||
},
|
||||
"co": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
||||
|
@ -32139,6 +32200,11 @@
|
|||
"object.assign": "^4.1.2"
|
||||
}
|
||||
},
|
||||
"jwt-decode": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
|
||||
"integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="
|
||||
},
|
||||
"killable": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz",
|
||||
|
@ -35246,6 +35312,14 @@
|
|||
"react-transition-group": "^4.4.1"
|
||||
}
|
||||
},
|
||||
"react-toastify": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-7.0.3.tgz",
|
||||
"integrity": "sha512-cxZ5rfurC8LzcZQMTYc8RHIkQTs+BFur18Pzk6Loz6uS8OXUWm6nXVlH/wqglz4Z7UAE8xxcF5mRjfE13487uQ==",
|
||||
"requires": {
|
||||
"clsx": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"react-transition-group": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.1.tgz",
|
||||
|
@ -35422,6 +35496,13 @@
|
|||
"symbol-observable": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"redux-devtools-extension": {
|
||||
"version": "2.13.9",
|
||||
"resolved": "https://registry.npmjs.org/redux-devtools-extension/-/redux-devtools-extension-2.13.9.tgz",
|
||||
"integrity": "sha512-cNJ8Q/EtjhQaZ71c8I9+BPySIBVEKssbPpskBfsXqb8HJ002A3KRVHfeRzwRo6mGPqsm7XuHTqNSNeS1Khig0A==",
|
||||
"dev": true,
|
||||
"requires": {}
|
||||
},
|
||||
"redux-persist": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-persist/-/redux-persist-6.0.0.tgz",
|
||||
|
|
|
@ -7,8 +7,10 @@
|
|||
"@testing-library/jest-dom": "^5.11.9",
|
||||
"@testing-library/react": "^11.2.5",
|
||||
"@testing-library/user-event": "^12.8.3",
|
||||
"axios": "^0.21.1",
|
||||
"bootstrap": "^4.6.0",
|
||||
"formik": "^2.2.6",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"react": "^17.0.2",
|
||||
"react-bootstrap": "^1.5.2",
|
||||
"react-cookie": "^4.0.3",
|
||||
|
@ -19,8 +21,11 @@
|
|||
"react-spinners": "^0.10.6",
|
||||
"react-toast": "^1.0.1",
|
||||
"react-toast-notifications": "^2.4.3",
|
||||
"react-toastify": "^7.0.3",
|
||||
"reactstrap": "^8.9.0",
|
||||
"redux": "^4.0.5",
|
||||
"redux-persist": "^6.0.0",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"web-vitals": "^1.1.1"
|
||||
},
|
||||
"scripts": {
|
||||
|
@ -48,6 +53,7 @@
|
|||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"redux-devtools-extension": "^2.13.9",
|
||||
"webpack-dev-server": "^3.11.1"
|
||||
}
|
||||
}
|
||||
|
|
87
web/src/Actions/auth.js
Normal file
87
web/src/Actions/auth.js
Normal file
|
@ -0,0 +1,87 @@
|
|||
import {
|
||||
REGISTER_SUCCESS,
|
||||
REGISTER_FAIL,
|
||||
LOGIN_SUCCESS,
|
||||
LOGIN_FAIL,
|
||||
SET_MESSAGE,
|
||||
} from "./types";
|
||||
import { toast } from 'react-toastify'
|
||||
|
||||
import AuthService from "../Services/auth.service";
|
||||
|
||||
export const register = (username, email, password) => (dispatch) => {
|
||||
return AuthService.register(username, email, password).then(
|
||||
(response) => {
|
||||
dispatch({
|
||||
type: REGISTER_SUCCESS,
|
||||
});
|
||||
|
||||
return Promise.resolve();
|
||||
},
|
||||
(error) => {
|
||||
const message =
|
||||
(error.response &&
|
||||
error.response.data &&
|
||||
error.response.data.message) ||
|
||||
error.message ||
|
||||
error.toString();
|
||||
|
||||
dispatch({
|
||||
type: REGISTER_FAIL,
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: SET_MESSAGE,
|
||||
payload: message,
|
||||
});
|
||||
|
||||
return Promise.reject();
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const login = (username, password) => (dispatch) => {
|
||||
return AuthService.login(username, password).then(
|
||||
(data) => {
|
||||
if (data.token) {
|
||||
toast.success('Login Success');
|
||||
dispatch({
|
||||
type: LOGIN_SUCCESS,
|
||||
payload: { user: data },
|
||||
});
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
toast.error(data.error ? data.error: 'An Unknown Error has occurred')
|
||||
dispatch({
|
||||
type: LOGIN_FAIL,
|
||||
});
|
||||
return Promise.reject();
|
||||
},
|
||||
(error) => {
|
||||
const message =
|
||||
(error.response &&
|
||||
error.response.data &&
|
||||
error.response.data.error) ||
|
||||
error.message ||
|
||||
error.toString();
|
||||
|
||||
toast.error('Error: ' + message)
|
||||
dispatch({
|
||||
type: LOGIN_FAIL,
|
||||
});
|
||||
|
||||
// dispatch({
|
||||
// type: SET_MESSAGE,
|
||||
// payload: message,
|
||||
// });
|
||||
|
||||
return Promise.reject();
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const logout = () => () => {
|
||||
AuthService.logout();
|
||||
window.location.reload();
|
||||
};
|
10
web/src/Actions/message.js
Normal file
10
web/src/Actions/message.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { SET_MESSAGE, CLEAR_MESSAGE } from "./types";
|
||||
|
||||
export const setMessage = (message) => ({
|
||||
type: SET_MESSAGE,
|
||||
payload: message,
|
||||
});
|
||||
|
||||
export const clearMessage = () => ({
|
||||
type: CLEAR_MESSAGE,
|
||||
});
|
8
web/src/Actions/types.js
Normal file
8
web/src/Actions/types.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
export const REGISTER_SUCCESS = "REGISTER_SUCCESS";
|
||||
export const REGISTER_FAIL = "REGISTER_FAIL";
|
||||
export const LOGIN_SUCCESS = "LOGIN_SUCCESS";
|
||||
export const LOGIN_FAIL = "LOGIN_FAIL";
|
||||
export const LOGOUT = "LOGOUT";
|
||||
|
||||
export const SET_MESSAGE = "SET_MESSAGE";
|
||||
export const CLEAR_MESSAGE = "CLEAR_MESSAGE";
|
102
web/src/App.js
102
web/src/App.js
|
@ -1,44 +1,82 @@
|
|||
import './App.css';
|
||||
import Home from './Components/Pages/Home';
|
||||
import About from './Components/Pages/About';
|
||||
import Help from './Components/Pages/Help';
|
||||
import Login from './Components/Pages/Login';
|
||||
import Settings from './Components/Pages/Settings';
|
||||
import Register from './Components/Pages/Register';
|
||||
import Home from './Pages/Home';
|
||||
import About from './Pages/About';
|
||||
|
||||
import Dashboard from './Pages/Dashboard';
|
||||
import Admin from './Pages/Admin';
|
||||
import Profile from './Pages/Profile';
|
||||
import Login from './Pages/Login';
|
||||
import Settings from './Pages/Settings';
|
||||
import Register from './Pages/Register';
|
||||
import Navigation from './Components/Navigation';
|
||||
|
||||
import { logout } from './Actions/auth';
|
||||
import { clearMessage } from './Actions/message';
|
||||
import { history } from './Helpers/history';
|
||||
import { Route, Switch, withRouter } from 'react-router-dom';
|
||||
import { connect } from "react-redux";
|
||||
import '../node_modules/bootstrap/dist/css/bootstrap.min.css';
|
||||
import { connect } from 'react-redux';
|
||||
import { Component } from 'react';
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
|
||||
function mapStateToProps(state) {
|
||||
const { user } = state.auth;
|
||||
return {
|
||||
isLoggedIn: state
|
||||
user,
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return {
|
||||
logIn: () => dispatch({type: true}),
|
||||
logOut: () => dispatch({type: false})
|
||||
};
|
||||
}
|
||||
class App extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.logOut = this.logOut.bind(this);
|
||||
|
||||
const App = () => {
|
||||
let exact = true
|
||||
return (
|
||||
<div>
|
||||
<Navigation />
|
||||
<Switch>
|
||||
<Route exact={exact} path="/" component={Home} />
|
||||
<Route path="/about" component={About} />
|
||||
<Route path="/settings" component={Settings} />
|
||||
<Route path="/help" component={Help} />
|
||||
<Route path="/login" component={Login} />
|
||||
<Route path="/register" component={Register} />
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
this.state = {
|
||||
// showAdminBoard: false,
|
||||
currentUser: undefined,
|
||||
// Don't even ask.. apparently you can't pass
|
||||
// exact="true".. it has to be a bool :|
|
||||
true: true,
|
||||
};
|
||||
|
||||
export default withRouter(connect(mapStateToProps, mapDispatchToProps)(App));
|
||||
history.listen((location) => {
|
||||
props.dispatch(clearMessage()); // clear message when changing location
|
||||
});
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const user = this.props.user;
|
||||
|
||||
if (user) {
|
||||
this.setState({
|
||||
currentUser: user,
|
||||
// showAdminBoard: user.roles.includes("ROLE_ADMIN"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logOut() {
|
||||
this.props.dispatch(logout());
|
||||
}
|
||||
|
||||
render() {
|
||||
// const { currentUser, showAdminBoard } = this.state;
|
||||
return (
|
||||
<div>
|
||||
<Navigation />
|
||||
<Switch>
|
||||
<Route exact={this.state.true} path="/" component={Home} />
|
||||
<Route path="/about" component={About} />
|
||||
|
||||
<Route path="/dashboard" component={Dashboard} />
|
||||
<Route path="/profile" component={Profile} />
|
||||
<Route path="/admin" component={Admin} />
|
||||
|
||||
<Route path="/settings" component={Settings} />
|
||||
<Route path="/login" component={Login} />
|
||||
<Route path="/register" component={Register} />
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
export default withRouter(connect(mapStateToProps)(App));
|
|
@ -1 +0,0 @@
|
|||
// https://stackoverflow.com/questions/38397653/redux-what-is-the-correct-place-to-save-cookie-after-login-request
|
|
@ -1,45 +0,0 @@
|
|||
import React, { Component, PropTypes } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { persistStore } from 'redux-persist';
|
||||
|
||||
class AppProvider extends Component {
|
||||
static propTypes = {
|
||||
store: PropTypes.object.isRequired,
|
||||
children: PropTypes.node
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = { rehydrated: false };
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
const opts = {
|
||||
whitelist: ['user'] // <-- Your auth/user reducer storing the cookie
|
||||
};
|
||||
|
||||
persistStore(this.props.store, opts, () => {
|
||||
this.setState({ rehydrated: true });
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.rehydrated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Provider store={this.props.store}>
|
||||
{this.props.children}
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AppProvider.propTypes = {
|
||||
store: PropTypes.object.isRequired,
|
||||
children: PropTypes.node
|
||||
}
|
||||
|
||||
export default AppProvider;
|
|
@ -3,70 +3,106 @@ import { Navbar, NavbarBrand } from 'reactstrap';
|
|||
import { Link } from 'react-router-dom';
|
||||
import logo from '../logo.png';
|
||||
import './Navigation.css';
|
||||
import { connect } from 'react-redux';
|
||||
import { logout } from '../Actions/auth';
|
||||
|
||||
const menuItems = [
|
||||
'Home',
|
||||
'Help',
|
||||
'About',
|
||||
];
|
||||
|
||||
const loggedInMenuItems = [
|
||||
'Dashboard',
|
||||
'About',
|
||||
]
|
||||
class Navigation extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
// Yeah I know you might not hit home.. but I can't get the
|
||||
// path based finder thing working on initial load :sweatsmile:
|
||||
console.log(this.props.initLocation)
|
||||
this.state = { isLoggedIn: false, active: "Home" };
|
||||
}
|
||||
constructor(props) {
|
||||
super(props);
|
||||
// Yeah I know you might not hit home.. but I can't get the
|
||||
// path based finder thing working on initial load :sweatsmile:
|
||||
this.state = { active: "Home" };
|
||||
}
|
||||
|
||||
toggleLogin() {
|
||||
this.setState({ isLoggedIn: !this.state.isLoggedIn })
|
||||
}
|
||||
componentDidMount() {
|
||||
const isLoggedIn = this.props.isLoggedIn;
|
||||
|
||||
_handleClick(menuItem) {
|
||||
this.setState({ active: menuItem });
|
||||
}
|
||||
|
||||
render() {
|
||||
const activeStyle = { color: '#FFFFFF' };
|
||||
|
||||
const renderAuthButtons = () => {
|
||||
if (this.state.isLoggedIn) {
|
||||
return <div className="navLinkLogin">
|
||||
<Link to="/profile" className="navLink">Profile</Link>
|
||||
<Link to="/" className="navLink" onClick={this.toggleLogin.bind(this)}>Logout</Link>
|
||||
</div>;
|
||||
} else {
|
||||
return <div className="navLinkLogin">
|
||||
<Link to="/login" className="navLink">Login</Link>
|
||||
<Link to="/register" className="navLink" history={this.props.history}>Register</Link>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Navbar color="dark" dark fixed="top">
|
||||
<NavbarBrand href="/" className="mr-auto"><img src={logo} className="nav-logo" alt="logo" /> GoScrobble</NavbarBrand>
|
||||
{menuItems.map(menuItem =>
|
||||
<Link
|
||||
key={menuItem}
|
||||
className="navLink"
|
||||
style={this.state.active === menuItem ? activeStyle : {}}
|
||||
onClick={this._handleClick.bind(this, menuItem)}
|
||||
to={menuItem === "Home" ? "/" : menuItem}
|
||||
>
|
||||
{menuItem}
|
||||
</Link>
|
||||
)}
|
||||
{renderAuthButtons()}
|
||||
</Navbar>
|
||||
</div>
|
||||
);
|
||||
if (isLoggedIn) {
|
||||
this.setState({
|
||||
isLoggedIn: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default Navigation;
|
||||
_handleClick(menuItem) {
|
||||
this.setState({ active: menuItem });
|
||||
}
|
||||
|
||||
render() {
|
||||
const activeStyle = { color: '#FFFFFF' };
|
||||
|
||||
const renderAuthButtons = () => {
|
||||
if (this.state.isLoggedIn) {
|
||||
return <div className="navLinkLogin">
|
||||
<Link to="/profile" className="navLink">Profile</Link>
|
||||
<Link to="/" className="navLink" onClick={logout()}>Logout</Link>
|
||||
</div>;
|
||||
} else {
|
||||
return <div className="navLinkLogin">
|
||||
<Link to="/login" className="navLink">Login</Link>
|
||||
<Link to="/register" className="navLink" history={this.props.history}>Register</Link>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
const renderMenuButtons = () => {
|
||||
if (this.state.isLoggedIn) {
|
||||
return <div>
|
||||
{loggedInMenuItems.map(menuItem =>
|
||||
<Link
|
||||
key={menuItem}
|
||||
className="navLink"
|
||||
style={this.state.active === menuItem ? activeStyle : {}}
|
||||
onClick={this._handleClick.bind(this, menuItem)}
|
||||
to={menuItem}
|
||||
>
|
||||
{menuItem}
|
||||
</Link>
|
||||
)}
|
||||
</div>;
|
||||
} else {
|
||||
return <div>
|
||||
{menuItems.map(menuItem =>
|
||||
<Link
|
||||
key={menuItem}
|
||||
className="navLink"
|
||||
style={this.state.active === menuItem ? activeStyle : {}}
|
||||
onClick={this._handleClick.bind(this, menuItem)}
|
||||
to={menuItem === "Home" ? "/" : menuItem}
|
||||
>
|
||||
{menuItem}
|
||||
</Link>
|
||||
)}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Navbar color="dark" dark fixed="top">
|
||||
<NavbarBrand href="/" className="mr-auto"><img src={logo} className="nav-logo" alt="logo" /> GoScrobble</NavbarBrand>
|
||||
{renderMenuButtons()}
|
||||
{renderAuthButtons()}
|
||||
</Navbar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state) {
|
||||
const { isLoggedIn } = state.auth;
|
||||
return {
|
||||
isLoggedIn,
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(Navigation);
|
|
@ -1,4 +0,0 @@
|
|||
.helpBody {
|
||||
padding: 20px 5px 5px 5px;
|
||||
font-size: 16pt;
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
import '../../App.css';
|
||||
import './Help.css';
|
||||
|
||||
function Help() {
|
||||
return (
|
||||
<div className="pageWrapper">
|
||||
<h1>
|
||||
Help Docs
|
||||
</h1>
|
||||
<p className="helpBody">
|
||||
Jellyfin Configuration<br/>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Help;
|
|
@ -1,115 +0,0 @@
|
|||
import React from 'react';
|
||||
import '../../App.css';
|
||||
import './Login.css';
|
||||
import { Button } from 'reactstrap';
|
||||
import { Formik, Form, Field } from 'formik';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import ScaleLoader from "react-spinners/ScaleLoader";
|
||||
|
||||
function withToast(Component) {
|
||||
return function WrappedComponent(props) {
|
||||
const toastFuncs = useToasts()
|
||||
return <Component {...props} {...toastFuncs} />;
|
||||
}
|
||||
}
|
||||
|
||||
class Login extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {username: '', password: '', loading: false};
|
||||
this.handleUsernameChange = this.handleUsernameChange.bind(this);
|
||||
this.handlePasswordChange = this.handlePasswordChange.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
}
|
||||
|
||||
handleUsernameChange(event) {
|
||||
this.setState({username: event.target.value});
|
||||
}
|
||||
|
||||
handlePasswordChange(event) {
|
||||
this.setState({password: event.target.value});
|
||||
}
|
||||
|
||||
handleSubmit(values) {
|
||||
this.setState({loading: true});
|
||||
const requestOptions = {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
timeout: 5000,
|
||||
body: JSON.stringify({
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
})
|
||||
};
|
||||
const apiUrl = process.env.REACT_APP_API_URL + '/api/v1/login';
|
||||
fetch(apiUrl, requestOptions)
|
||||
.then((response) => {
|
||||
if (response.status === 429) {
|
||||
this.props.addToast("Rate limited. Please try again soon", { appearance: 'error' });
|
||||
return "{}"
|
||||
} else {
|
||||
return response.json()
|
||||
}
|
||||
})
|
||||
.then((function(data) {
|
||||
if (data.error) {
|
||||
this.props.addToast(data.error, { appearance: 'error' });
|
||||
} else if (data.token) {
|
||||
this.props.addToast(data.token, { appearance: 'success' });
|
||||
}
|
||||
this.setState({loading: false});
|
||||
}).bind(this))
|
||||
.catch(() => {
|
||||
this.props.addToast('Error submitting form. Please try again', { appearance: 'error' });
|
||||
this.setState({loading: false});
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
let trueBool = true;
|
||||
return (
|
||||
<div className="pageWrapper">
|
||||
<h1>
|
||||
Login
|
||||
</h1>
|
||||
<div className="loginBody">
|
||||
<Formik
|
||||
initialValues={{ username: '', password: '' }}
|
||||
onSubmit={async values => this.handleSubmit(values)}
|
||||
>
|
||||
<Form>
|
||||
<label>
|
||||
Email / Username<br/>
|
||||
<Field
|
||||
name="username"
|
||||
type="text"
|
||||
required={trueBool}
|
||||
className="loginFields"
|
||||
/>
|
||||
</label>
|
||||
<br/>
|
||||
<label>
|
||||
Password<br/>
|
||||
<Field
|
||||
name="password"
|
||||
type="password"
|
||||
required={trueBool}
|
||||
className="loginFields"
|
||||
/>
|
||||
</label>
|
||||
<br/><br/>
|
||||
<Button
|
||||
color="primary"
|
||||
type="submit"
|
||||
className="loginButton"
|
||||
disabled={this.state.loading}
|
||||
>{this.state.loading ? <ScaleLoader color="#FFF" size={35} /> : "Login"}</Button>
|
||||
</Form>
|
||||
</Formik>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withToast(Login);
|
3
web/src/Helpers/history.js
Normal file
3
web/src/Helpers/history.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
import { createBrowserHistory } from "history";
|
||||
|
||||
export const history = createBrowserHistory();
|
|
@ -1,4 +1,4 @@
|
|||
import '../../App.css';
|
||||
import '../App.css';
|
||||
import './About.css';
|
||||
|
||||
function About() {
|
||||
|
@ -8,7 +8,7 @@ function About() {
|
|||
About GoScrobble.com
|
||||
</h1>
|
||||
<p className="aboutBody">
|
||||
Go-Scrobble is an open source music scorbbling service written in Go and React.<br/>
|
||||
Go-Scrobble is an open source music scorbbling service written in Go and React.
|
||||
</p>
|
||||
</div>
|
||||
);
|
45
web/src/Pages/Admin.js
Normal file
45
web/src/Pages/Admin.js
Normal file
|
@ -0,0 +1,45 @@
|
|||
import React, { Component } from "react";
|
||||
|
||||
import UserService from "../Services/user.service";
|
||||
|
||||
class Admin extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
content: ""
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
UserService.getAdminBoard().then(
|
||||
response => {
|
||||
this.setState({
|
||||
content: response.data
|
||||
});
|
||||
},
|
||||
error => {
|
||||
this.setState({
|
||||
content:
|
||||
(error.response &&
|
||||
error.response.data &&
|
||||
error.response.data.message) ||
|
||||
error.message ||
|
||||
error.toString()
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="container">
|
||||
<header className="jumbotron">
|
||||
<h3>{this.state.content}</h3>
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Admin;
|
4
web/src/Pages/Dashboard.css
Normal file
4
web/src/Pages/Dashboard.css
Normal file
|
@ -0,0 +1,4 @@
|
|||
.dashboardBody {
|
||||
padding: 20px 5px 5px 5px;
|
||||
font-size: 16pt;
|
||||
}
|
35
web/src/Pages/Dashboard.js
Normal file
35
web/src/Pages/Dashboard.js
Normal file
|
@ -0,0 +1,35 @@
|
|||
import React from 'react';
|
||||
import '../App.css';
|
||||
import './Dashboard.css';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
class Dashboard extends React.Component {
|
||||
componentDidMount() {
|
||||
const { history } = this.props;
|
||||
const isLoggedIn = this.props.isLoggedIn;
|
||||
|
||||
if (!isLoggedIn) {
|
||||
history.push("/login")
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="pageWrapper">
|
||||
<h1>
|
||||
Hai Dashboard!
|
||||
</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state) {
|
||||
const { isLoggedIn } = state.auth;
|
||||
return {
|
||||
isLoggedIn,
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(Dashboard);
|
|
@ -1,5 +1,5 @@
|
|||
import logo from '../../logo.png';
|
||||
import '../../App.css';
|
||||
import logo from '../logo.png';
|
||||
import '../App.css';
|
||||
|
||||
function Home() {
|
||||
return (
|
92
web/src/Pages/Login.js
Normal file
92
web/src/Pages/Login.js
Normal file
|
@ -0,0 +1,92 @@
|
|||
import React from 'react';
|
||||
import '../App.css';
|
||||
import './Login.css';
|
||||
import { Button } from 'reactstrap';
|
||||
import { Formik, Form, Field } from 'formik';
|
||||
import ScaleLoader from 'react-spinners/ScaleLoader';
|
||||
import { connect } from 'react-redux';
|
||||
import { login } from '../Actions/auth';
|
||||
|
||||
class Login extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {username: '', password: '', loading: false};
|
||||
}
|
||||
|
||||
handleLogin(values) {
|
||||
this.setState({loading: true});
|
||||
|
||||
const { dispatch, history } = this.props;
|
||||
|
||||
dispatch(login(values.username, values.password))
|
||||
.then(() => {
|
||||
this.setState({
|
||||
loading: false,
|
||||
});
|
||||
history.push("/dashboard");
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(() => {
|
||||
this.setState({
|
||||
loading: false
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
let trueBool = true;
|
||||
return (
|
||||
<div className="pageWrapper">
|
||||
<h1>
|
||||
Login
|
||||
</h1>
|
||||
<div className="loginBody">
|
||||
<Formik
|
||||
initialValues={{ username: '', password: '' }}
|
||||
onSubmit={async values => this.handleLogin(values)}
|
||||
>
|
||||
<Form>
|
||||
<label>
|
||||
Email / Username<br/>
|
||||
<Field
|
||||
name="username"
|
||||
type="text"
|
||||
required={trueBool}
|
||||
className="loginFields"
|
||||
/>
|
||||
</label>
|
||||
<br/>
|
||||
<label>
|
||||
Password<br/>
|
||||
<Field
|
||||
name="password"
|
||||
type="password"
|
||||
required={trueBool}
|
||||
className="loginFields"
|
||||
/>
|
||||
</label>
|
||||
<br/><br/>
|
||||
<Button
|
||||
color="primary"
|
||||
type="submit"
|
||||
className="loginButton"
|
||||
disabled={this.state.loading}
|
||||
>{this.state.loading ? <ScaleLoader color="#FFF" size={35} /> : "Login"}</Button>
|
||||
</Form>
|
||||
</Formik>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state) {
|
||||
const { isLoggedIn } = state.auth;
|
||||
const { message } = state.message;
|
||||
return {
|
||||
isLoggedIn,
|
||||
message
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(Login);
|
4
web/src/Pages/Profile.css
Normal file
4
web/src/Pages/Profile.css
Normal file
|
@ -0,0 +1,4 @@
|
|||
.profileBody {
|
||||
padding: 20px 5px 5px 5px;
|
||||
font-size: 16pt;
|
||||
}
|
35
web/src/Pages/Profile.js
Normal file
35
web/src/Pages/Profile.js
Normal file
|
@ -0,0 +1,35 @@
|
|||
import React from 'react';
|
||||
import '../App.css';
|
||||
import './Dashboard.css';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
class Profile extends React.Component {
|
||||
componentDidMount() {
|
||||
const { history } = this.props;
|
||||
const isLoggedIn = this.props.isLoggedIn;
|
||||
|
||||
if (!isLoggedIn) {
|
||||
history.push("/login")
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="pageWrapper">
|
||||
<h1>
|
||||
Hai User
|
||||
</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state) {
|
||||
const { isLoggedIn } = state.auth;
|
||||
return {
|
||||
isLoggedIn,
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(Profile);
|
|
@ -1,18 +1,10 @@
|
|||
import React from 'react';
|
||||
import '../../App.css';
|
||||
import '../App.css';
|
||||
import './Login.css';
|
||||
import { Button } from 'reactstrap';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import ScaleLoader from "react-spinners/ScaleLoader";
|
||||
import { withRouter } from 'react-router-dom'
|
||||
|
||||
function withToast(Component) {
|
||||
return function WrappedComponent(props) {
|
||||
const toastFuncs = useToasts()
|
||||
return <Component {...props} {...toastFuncs} />;
|
||||
}
|
||||
}
|
||||
|
||||
class Register extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
@ -165,4 +157,4 @@ class Register extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
export default withRouter(withToast(Register));
|
||||
export default withRouter(Register);
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import '../../App.css';
|
||||
import '../App.css';
|
||||
import './Settings.css';
|
||||
|
||||
import { useToasts } from 'react-toast-notifications';
|
50
web/src/Reducers/auth.js
Normal file
50
web/src/Reducers/auth.js
Normal file
|
@ -0,0 +1,50 @@
|
|||
import {
|
||||
REGISTER_SUCCESS,
|
||||
REGISTER_FAIL,
|
||||
LOGIN_SUCCESS,
|
||||
LOGIN_FAIL,
|
||||
LOGOUT,
|
||||
} from "../Actions/types";
|
||||
|
||||
const jwt = localStorage.getItem("jwt");
|
||||
|
||||
const initialState = jwt
|
||||
? { isLoggedIn: true, jwt }
|
||||
: { isLoggedIn: false, jwt };
|
||||
|
||||
export default function authReducer(state = initialState, action) {
|
||||
const { type, payload } = action;
|
||||
|
||||
switch (type) {
|
||||
case REGISTER_SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
isLoggedIn: false,
|
||||
};
|
||||
case REGISTER_FAIL:
|
||||
return {
|
||||
...state,
|
||||
isLoggedIn: false,
|
||||
};
|
||||
case LOGIN_SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
isLoggedIn: true,
|
||||
user: payload.user,
|
||||
};
|
||||
case LOGIN_FAIL:
|
||||
return {
|
||||
...state,
|
||||
isLoggedIn: false,
|
||||
user: null,
|
||||
};
|
||||
case LOGOUT:
|
||||
return {
|
||||
...state,
|
||||
isLoggedIn: false,
|
||||
user: null,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
8
web/src/Reducers/index.js
Normal file
8
web/src/Reducers/index.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { combineReducers } from "redux";
|
||||
import auth from "./auth";
|
||||
import message from "./message";
|
||||
|
||||
export default combineReducers({
|
||||
auth,
|
||||
message,
|
||||
});
|
18
web/src/Reducers/message.js
Normal file
18
web/src/Reducers/message.js
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { SET_MESSAGE, CLEAR_MESSAGE } from "../Actions/types";
|
||||
|
||||
const initialState = {};
|
||||
|
||||
export default function message(state = initialState, action) {
|
||||
const { type, payload } = action;
|
||||
|
||||
switch (type) {
|
||||
case SET_MESSAGE:
|
||||
return { message: payload };
|
||||
|
||||
case CLEAR_MESSAGE:
|
||||
return { message: "" };
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
0
web/src/Services/Actions.js
Normal file
0
web/src/Services/Actions.js
Normal file
9
web/src/Services/auth-header.js
Normal file
9
web/src/Services/auth-header.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
export default function authHeader() {
|
||||
const token = JSON.parse(localStorage.getItem('jwt'));
|
||||
|
||||
if (token) {
|
||||
return { Authorization: 'Bearer ' + token };
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
}
|
35
web/src/Services/auth.service.js
Normal file
35
web/src/Services/auth.service.js
Normal file
|
@ -0,0 +1,35 @@
|
|||
import axios from "axios";
|
||||
import jwt from 'jwt-decode' // import dependency
|
||||
|
||||
class AuthService {
|
||||
login(username, password) {
|
||||
return axios
|
||||
.post(process.env.REACT_APP_API_URL + "login", { username, password })
|
||||
.then((response) => {
|
||||
if (response.data.token) {
|
||||
let user = jwt(response.data.token)
|
||||
localStorage.setItem("jwt", response.data.token);
|
||||
localStorage.setItem("uuid", user.sub);
|
||||
localStorage.setItem("exp", user.exp);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
});
|
||||
}
|
||||
|
||||
logout() {
|
||||
localStorage.removeItem("jwt");
|
||||
localStorage.removeItem("uuid");
|
||||
localStorage.removeItem("exp");
|
||||
}
|
||||
|
||||
register(username, email, password) {
|
||||
return axios.post(process.env.REACT_APP_API_URL + "register", {
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new AuthService();
|
18
web/src/Services/user.service.js
Normal file
18
web/src/Services/user.service.js
Normal file
|
@ -0,0 +1,18 @@
|
|||
import axios from 'axios';
|
||||
import authHeader from './auth-header';
|
||||
|
||||
class UserService {
|
||||
getPublicContent() {
|
||||
return axios.get(process.env.REACT_APP_API_URL + 'all');
|
||||
}
|
||||
|
||||
getUserBoard() {
|
||||
return axios.get(process.env.REACT_APP_API_URL + 'user', { headers: authHeader() });
|
||||
}
|
||||
|
||||
getAdminBoard() {
|
||||
return axios.get(process.env.REACT_APP_API_URL + 'admin', { headers: authHeader() });
|
||||
}
|
||||
}
|
||||
|
||||
export default new UserService();
|
|
@ -2,25 +2,29 @@ import React from 'react';
|
|||
import ReactDOM from 'react-dom';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import { HashRouter } from 'react-router-dom'
|
||||
import { ToastProvider } from 'react-toast-notifications';
|
||||
import { HashRouter } from 'react-router-dom';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.min.css'
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { Provider } from 'react-redux'
|
||||
import { createStore } from 'redux'
|
||||
|
||||
const goScorbbleStore = (state = false, logIn) => {
|
||||
return state = logIn
|
||||
};
|
||||
|
||||
const store = createStore(goScorbbleStore);
|
||||
import store from "./store";
|
||||
|
||||
ReactDOM.render(
|
||||
<HashRouter>
|
||||
<ToastProvider autoDismiss="true" autoDismissTimeout="6000" placement="bottom-right">
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
</Provider>
|
||||
</ToastProvider>
|
||||
</HashRouter>,
|
||||
<Provider store={store}>
|
||||
<HashRouter>
|
||||
<ToastContainer
|
||||
position="bottom-right"
|
||||
autoClose={5000}
|
||||
hideProgressBar={false}
|
||||
newestOnTop={true}
|
||||
closeOnClick
|
||||
rtl={false}
|
||||
pauseOnFocusLoss={false}
|
||||
draggable
|
||||
pauseOnHover
|
||||
/>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</Provider>,
|
||||
document.getElementById('root')
|
||||
);
|
||||
|
|
|
@ -1 +1,13 @@
|
|||
import { createStore, applyMiddleware } from "redux";
|
||||
import { composeWithDevTools } from "redux-devtools-extension";
|
||||
import thunk from "redux-thunk";
|
||||
import rootReducer from "./Reducers";
|
||||
|
||||
const middleware = [thunk];
|
||||
|
||||
const store = createStore(
|
||||
rootReducer,
|
||||
composeWithDevTools(applyMiddleware(...middleware))
|
||||
);
|
||||
|
||||
export default store;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue