- Only allow ItemType:Audio from Jellyfin
- Fix NavBar for Mobile (Ugly hack but.. TO REWORK)
- Fixed registration page issues
- Add functionality to pull recent scrobbles to Dashboard
- Add MX record lookup validation for emails
- Add username validation for a-Z 0-9 _ and .
- Dashboard shows basic table of last 500 scrobbles.
This commit is contained in:
Daniel Mason 2021-03-30 21:36:28 +13:00
parent 7ae9a0cd66
commit 2f8aa2e502
Signed by: idanoo
GPG Key ID: 387387CDBC02F132
31 changed files with 425 additions and 171 deletions

View File

@ -3,7 +3,7 @@ stages:
- bundle - bundle
variables: variables:
VERSION: 0.0.4 VERSION: 0.0.5
build-go: build-go:
image: golang:1.16.2 image: golang:1.16.2

View File

@ -1,3 +1,12 @@
# 0.0.5
- Only allow ItemType:Audio from Jellyfin
- Fix NavBar for Mobile (Ugly hack but.. TO REWORK)
- Fixed registration page issues
- Add functionality to pull recent scrobbles to Dashboard
- Add MX record lookup validation for emails
- Add username validation for a-Z 0-9 _ and .
- Dashboard shows basic table of last 500 scrobbles.
# 0.0.4 # 0.0.4
- Display stats on homepage - Display stats on homepage

13
docs/removing_bad_data.md Normal file
View File

@ -0,0 +1,13 @@
## Removing bad data
This is by no means recommended.. But during testing I somehow scrobbled movies.
SET FOREIGN_KEY_CHECKS=0;
DELETE FROM artists WHERE `name` = "%!s(<nil>)";
DELETE FROM albums WHERE `name` = "%!s(<nil>)";
DELETE album_artist FROM album_artist LEFT JOIN artists ON artists.uuid = album_artist.artist WHERE artists.uuid is null;
DELETE album_artist FROM album_artist LEFT JOIN albums ON albums.uuid = album_artist.album WHERE albums.uuid is null;
DELETE track_artist FROM track_artist LEFT JOIN artists ON artists.uuid = track_artist.artist WHERE artists.uuid is null;
DELETE tracks FROM tracks LEFT JOIN track_artist ON track_artist.track = tracks.uuid WHERE track_artist.track IS NULL;
DELETE scrobbles FROM scrobbles LEFT JOIN tracks ON tracks.uuid = scrobbles.track WHERE tracks.uuid is null;
SET FOREIGN_KEY_CHECKS=1;

View File

@ -10,6 +10,23 @@ import (
// ParseJellyfinInput - Transform API data into a common struct // ParseJellyfinInput - Transform API data into a common struct
func ParseJellyfinInput(userUUID string, data map[string]interface{}, ip net.IP, tx *sql.Tx) error { func ParseJellyfinInput(userUUID string, data map[string]interface{}, ip net.IP, tx *sql.Tx) error {
if data["ItemType"] != "Audio" {
return errors.New("Media type not audio")
}
// Safety Checks
if data["Artist"] == nil {
return errors.New("Missing artist data")
}
if data["Album"] == nil {
return errors.New("Missing album data")
}
if data["Name"] == nil {
return errors.New("Missing track data")
}
// Insert artist if not exist // Insert artist if not exist
artist, err := insertArtist(fmt.Sprintf("%s", data["Artist"]), fmt.Sprintf("%s", data["Provider_musicbrainzartist"]), tx) artist, err := insertArtist(fmt.Sprintf("%s", data["Artist"]), fmt.Sprintf("%s", data["Provider_musicbrainzartist"]), tx)
if err != nil { if err != nil {

View File

@ -165,14 +165,18 @@ func jwtMiddleware(next func(http.ResponseWriter, *http.Request, string, string)
return return
} }
var v string var reqUuid string
for k, v := range mux.Vars(r) { for k, v := range mux.Vars(r) {
if k == "id" { if k == "id" {
log.Printf("key=%v, value=%v", k, v) reqUuid = v
} }
} }
next(w, r, claims.Subject, v) if reqUuid == "" {
throwBadReq(w, "Invalid Request")
}
next(w, r, claims.Subject, reqUuid)
} }
} }
@ -206,7 +210,7 @@ func handleRegister(w http.ResponseWriter, r *http.Request) {
ip := getUserIp(r) ip := getUserIp(r)
err = createUser(&regReq, ip) err = createUser(&regReq, ip)
if err != nil { if err != nil {
throwOkMessage(w, err.Error()) throwOkError(w, err.Error())
return return
} }
@ -265,7 +269,7 @@ func handleIngress(w http.ResponseWriter, r *http.Request, userUuid string) {
ip := getUserIp(r) ip := getUserIp(r)
err := ParseJellyfinInput(userUuid, bodyJson, ip, tx) err := ParseJellyfinInput(userUuid, bodyJson, ip, tx)
if err != nil { if err != nil {
log.Printf("Error inserting track: %+v", err) // log.Printf("Error inserting track: %+v", err)
tx.Rollback() tx.Rollback()
throwBadReq(w, err.Error()) throwBadReq(w, err.Error())
return return

View File

@ -59,15 +59,12 @@ func createUser(req *RegisterRequest, ip net.IP) error {
return errors.New("A username is required") return errors.New("A username is required")
} }
// Check max length for Username // Check username is valid
if len(req.Username) > 64 { if !isUsernameValid(req.Username) {
return errors.New("Username cannot be longer than 64 characters") log.Println("user is invalid")
}
// Check username doesn't contain @
if strings.Contains(req.Username, "@") {
return errors.New("Username contains invalid characters") return errors.New("Username contains invalid characters")
} }
log.Println("user is valid")
// If set an email.. validate it! // If set an email.. validate it!
if req.Email != "" { if req.Email != "" {

View File

@ -9,9 +9,11 @@ import (
"net" "net"
"net/http" "net/http"
"regexp" "regexp"
"strings"
) )
var emailRegex = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") var emailRegex = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
var usernameRegex = regexp.MustCompile("^[a-zA-Z0-9_\\.]+$")
// decodeJson - Returns a map[string]interface{} // decodeJson - Returns a map[string]interface{}
func decodeJson(body io.ReadCloser) (map[string]interface{}, error) { func decodeJson(body io.ReadCloser) (map[string]interface{}, error) {
@ -24,10 +26,31 @@ func decodeJson(body io.ReadCloser) (map[string]interface{}, error) {
// isEmailValid - checks if the email provided passes the required structure and length. // isEmailValid - checks if the email provided passes the required structure and length.
func isEmailValid(e string) bool { func isEmailValid(e string) bool {
if len(e) < 3 && len(e) > 254 { if len(e) < 5 && len(e) > 254 {
return false return false
} }
return emailRegex.MatchString(e)
if !emailRegex.MatchString(e) {
return false
}
// Do MX lookup
parts := strings.Split(e, "@")
mx, err := net.LookupMX(parts[1])
if err != nil || len(mx) == 0 {
return false
}
return true
}
// isUsernameValid - Checks if username is alphanumeric+underscores+dots
func isUsernameValid(e string) bool {
if len(e) > 64 {
return false
}
return usernameRegex.MatchString(e)
} }
// contains - Check if string is in list // contains - Check if string is in list

40
web/package-lock.json generated
View File

@ -19,11 +19,13 @@
"react": "^17.0.2", "react": "^17.0.2",
"react-bootstrap": "^1.5.2", "react-bootstrap": "^1.5.2",
"react-cookie": "^4.0.3", "react-cookie": "^4.0.3",
"react-data-grid": "*",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-redux": "^7.2.3", "react-redux": "^7.2.3",
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",
"react-scripts": "4.0.3", "react-scripts": "4.0.3",
"react-spinners": "^0.10.6", "react-spinners": "^0.10.6",
"react-table": "^7.6.3",
"react-toast": "^1.0.1", "react-toast": "^1.0.1",
"react-toast-notifications": "^2.4.3", "react-toast-notifications": "^2.4.3",
"react-toastify": "^7.0.3", "react-toastify": "^7.0.3",
@ -16326,6 +16328,18 @@
"react": ">= 16.3.0" "react": ">= 16.3.0"
} }
}, },
"node_modules/react-data-grid": {
"version": "7.0.0-canary.38",
"resolved": "https://registry.npmjs.org/react-data-grid/-/react-data-grid-7.0.0-canary.38.tgz",
"integrity": "sha512-JjMyChuh9KxOtYmpxrOuPBI6EYIbNLn/+pjwoQYeD7d5vkWMURWWhyLX1NJkT5bt5LF2qxOSQiFf3G6YndxlAg==",
"dependencies": {
"clsx": "^1.1.1"
},
"peerDependencies": {
"react": "^16.14 || ^17.0",
"react-dom": "^16.14 || ^17.0"
}
},
"node_modules/react-dev-utils": { "node_modules/react-dev-utils": {
"version": "11.0.4", "version": "11.0.4",
"resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-11.0.4.tgz", "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-11.0.4.tgz",
@ -16719,6 +16733,18 @@
"react-dom": "^16.0.0 || ^17.0.0" "react-dom": "^16.0.0 || ^17.0.0"
} }
}, },
"node_modules/react-table": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/react-table/-/react-table-7.6.3.tgz",
"integrity": "sha512-hfPF13zDLxPMpLKzIKCE8RZud9T/XrRTsaCIf8zXpWZIZ2juCl7qrGpo3AQw9eAetXV5DP7s2GDm+hht7qq5Dw==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.3 || ^17.0.0-0"
}
},
"node_modules/react-toast": { "node_modules/react-toast": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/react-toast/-/react-toast-1.0.1.tgz", "resolved": "https://registry.npmjs.org/react-toast/-/react-toast-1.0.1.tgz",
@ -34981,6 +35007,14 @@
"universal-cookie": "^4.0.0" "universal-cookie": "^4.0.0"
} }
}, },
"react-data-grid": {
"version": "7.0.0-canary.38",
"resolved": "https://registry.npmjs.org/react-data-grid/-/react-data-grid-7.0.0-canary.38.tgz",
"integrity": "sha512-JjMyChuh9KxOtYmpxrOuPBI6EYIbNLn/+pjwoQYeD7d5vkWMURWWhyLX1NJkT5bt5LF2qxOSQiFf3G6YndxlAg==",
"requires": {
"clsx": "^1.1.1"
}
},
"react-dev-utils": { "react-dev-utils": {
"version": "11.0.4", "version": "11.0.4",
"resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-11.0.4.tgz", "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-11.0.4.tgz",
@ -35297,6 +35331,12 @@
"@emotion/core": "^10.0.35" "@emotion/core": "^10.0.35"
} }
}, },
"react-table": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/react-table/-/react-table-7.6.3.tgz",
"integrity": "sha512-hfPF13zDLxPMpLKzIKCE8RZud9T/XrRTsaCIf8zXpWZIZ2juCl7qrGpo3AQw9eAetXV5DP7s2GDm+hht7qq5Dw==",
"requires": {}
},
"react-toast": { "react-toast": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/react-toast/-/react-toast-1.0.1.tgz", "resolved": "https://registry.npmjs.org/react-toast/-/react-toast-1.0.1.tgz",

View File

@ -14,11 +14,13 @@
"react": "^17.0.2", "react": "^17.0.2",
"react-bootstrap": "^1.5.2", "react-bootstrap": "^1.5.2",
"react-cookie": "^4.0.3", "react-cookie": "^4.0.3",
"react-data-grid": "*",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-redux": "^7.2.3", "react-redux": "^7.2.3",
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",
"react-scripts": "4.0.3", "react-scripts": "4.0.3",
"react-spinners": "^0.10.6", "react-spinners": "^0.10.6",
"react-table": "^7.6.3",
"react-toast": "^1.0.1", "react-toast": "^1.0.1",
"react-toast-notifications": "^2.4.3", "react-toast-notifications": "^2.4.3",
"react-toastify": "^7.0.3", "react-toastify": "^7.0.3",

View File

@ -8,3 +8,11 @@ export const getStats = () => {
); );
}; };
export const getRecentScrobbles = (id) => {
return ApiService.getRecentScrobbles(id).then(
(data) => {
return data.data;
}
);
};

View File

@ -1,10 +0,0 @@
import { SET_MESSAGE, CLEAR_MESSAGE } from "./types";
export const setMessage = (message) => ({
type: SET_MESSAGE,
payload: message,
});
export const clearMessage = () => ({
type: CLEAR_MESSAGE,
});

View File

@ -3,6 +3,3 @@ export const REGISTER_FAIL = "REGISTER_FAIL";
export const LOGIN_SUCCESS = "LOGIN_SUCCESS"; export const LOGIN_SUCCESS = "LOGIN_SUCCESS";
export const LOGIN_FAIL = "LOGIN_FAIL"; export const LOGIN_FAIL = "LOGIN_FAIL";
export const LOGOUT = "LOGOUT"; export const LOGOUT = "LOGOUT";
export const SET_MESSAGE = "SET_MESSAGE";
export const CLEAR_MESSAGE = "CLEAR_MESSAGE";

View File

@ -31,7 +31,7 @@
.pageWrapper { .pageWrapper {
background-color: #282c34; background-color: #282c34;
padding-top: 100px; padding: 100px 15px 0 15px;
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -10,7 +10,7 @@ import Settings from './Pages/Settings';
import Register from './Pages/Register'; import Register from './Pages/Register';
import Navigation from './Components/Navigation'; import Navigation from './Components/Navigation';
import { logout } from './Actions/auth'; // import { logout } from './Actions/auth';
import { Route, Switch, withRouter } from 'react-router-dom'; import { Route, Switch, withRouter } from 'react-router-dom';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { Component } from 'react'; import { Component } from 'react';
@ -26,34 +26,33 @@ function mapStateToProps(state) {
class App extends Component { class App extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.logOut = this.logOut.bind(this); // this.logOut = this.logOut.bind(this);
this.state = { this.state = {
// showAdminBoard: false, // showAdminBoard: false,
currentUser: undefined, // currentUser: undefined,
// Don't even ask.. apparently you can't pass // Don't even ask.. apparently you can't pass
// exact="true".. it has to be a bool :| // exact="true".. it has to be a bool :|
true: true, true: true,
}; };
} }
componentDidMount() {
const user = this.props.user;
if (user) { // componentDidMount() {
this.setState({ // const user = this.props.user;
currentUser: user,
// showAdminBoard: user.roles.includes("ROLE_ADMIN"),
});
}
}
logOut() { // if (user) {
this.props.dispatch(logout()); // this.setState({
} // currentUser: user,
// });
// }
// }
// logOut() {
// this.props.dispatch(logout());
// }
render() { render() {
// const { currentUser, showAdminBoard } = this.state;
return ( return (
<div> <div>
<Navigation /> <Navigation />
@ -63,9 +62,10 @@ class App extends Component {
<Route path="/dashboard" component={Dashboard} /> <Route path="/dashboard" component={Dashboard} />
<Route path="/profile" component={Profile} /> <Route path="/profile" component={Profile} />
<Route path="/settings" component={Settings} />
<Route path="/admin" component={Admin} /> <Route path="/admin" component={Admin} />
<Route path="/settings" component={Settings} />
<Route path="/login" component={Login} /> <Route path="/login" component={Login} />
<Route path="/register" component={Register} /> <Route path="/register" component={Register} />
</Switch> </Switch>

View File

@ -1,6 +1,7 @@
.homeBanner { .homeBanner {
margin-top: 30px; margin-top: 30px;
width: 100%; width: 100%;
max-width: 1100px;
} }
.homeBannerItem { .homeBannerItem {

View File

@ -39,22 +39,22 @@ class HomeBanner extends React.Component {
<div className="homeBanner"> <div className="homeBanner">
<div className="homeBannerItem"> <div className="homeBannerItem">
{this.state.isLoading {this.state.isLoading
? <ClipLoader color="#6AD7E5" size="36" /> ? <ClipLoader color="#6AD7E5" size={36} />
: <span className="homeBannerItemCount">{this.state.scrobbleCount}</span>}<br/>Scrobbles : <span className="homeBannerItemCount">{this.state.scrobbleCount}</span>}<br/>Scrobbles
</div> </div>
<div className="homeBannerItem"> <div className="homeBannerItem">
{this.state.isLoading {this.state.isLoading
? <ClipLoader color="#6AD7E5" size="36" /> ? <ClipLoader color="#6AD7E5" size={36} />
: <span className="homeBannerItemCount">{this.state.userCount}</span>}<br/>Users : <span className="homeBannerItemCount">{this.state.userCount}</span>}<br/>Users
</div> </div>
<div className="homeBannerItem"> <div className="homeBannerItem">
{this.state.isLoading {this.state.isLoading
? <ClipLoader color="#6AD7E5" size="36" /> ? <ClipLoader color="#6AD7E5" size={36} />
: <span className="homeBannerItemCount">{this.state.trackCount}</span>}<br/>Tracks : <span className="homeBannerItemCount">{this.state.trackCount}</span>}<br/>Tracks
</div> </div>
<div className="homeBannerItem"> <div className="homeBannerItem">
{this.state.isLoading {this.state.isLoading
? <ClipLoader color="#6AD7E5" size="36" /> ? <ClipLoader color="#6AD7E5" size={36} />
: <span className="homeBannerItemCount">{this.state.artistCount}</span>}<br/>Artists : <span className="homeBannerItemCount">{this.state.artistCount}</span>}<br/>Artists
</div> </div>
</div> </div>

View File

@ -3,19 +3,33 @@
color: #CCCCCC; color: #CCCCCC;
} }
.navLinkMobile {
color: #CCCCCC;
}
.navLink:hover { .navLink:hover {
color: #666666; color: #666666;
text-decoration: none; text-decoration: none;
} }
.navLinkMobile:hover {
color: #666666;
text-decoration: none;
}
.navLinkLogin { .navLinkLogin {
margin-left: 15px; margin-left: 15px;
padding-left: 15px; padding-left: 15px;
border-left: 1px solid #282c34; border-left: 1px solid #282c34;
} }
.navLinkLoginMobile {
margin: 0 auto 0 auto;
padding: 10px;
text-align: center;
}
.nav-logo { .nav-logo {
height: 50px; height: 50px;
margin: -15px 5px -15px -5px; margin: -15px 5px -15px -5px;
} }

View File

@ -1,5 +1,5 @@
import { React, Component } from 'react'; import { React, Component } from 'react';
import { Navbar, NavbarBrand } from 'reactstrap'; import { Navbar, NavbarBrand, Collapse, Nav, NavbarToggler, NavItem } from 'reactstrap';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import logo from '../logo.png'; import logo from '../logo.png';
import './Navigation.css'; import './Navigation.css';
@ -21,12 +21,19 @@ const loggedInMenuItems = [
'Dashboard', 'Dashboard',
'About', 'About',
] ]
const isMobile = () => {
return (/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent))
};
class Navigation extends Component { class Navigation extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.toggleNavbar = this.toggleNavbar.bind(this);
// Yeah I know you might not hit home.. but I can't get the // Yeah I know you might not hit home.. but I can't get the
// path based finder thing working on initial load :sweatsmile: // path based finder thing working on initial load :sweatsmile:
this.state = { active: "Home" }; this.state = { active: "Home", collapsed: true};
} }
componentDidMount() { componentDidMount() {
@ -51,18 +58,114 @@ class Navigation extends Component {
eventBus.remove(LOGOUT); eventBus.remove(LOGOUT);
} }
_handleClick(menuItem) { _handleClick(menuItem) {
this.setState({ active: menuItem }); this.setState({ active: menuItem, collapsed: !this.state.collapsed });
} }
toggleNavbar() {
this.setState({ collapsed: !this.state.collapsed });
}
// This is a real mess. TO CLEAN UP.
render() { render() {
const activeStyle = { color: '#FFFFFF' }; const activeStyle = { color: '#FFFFFF' };
const renderAuthButtons = () => { const renderMobileNav = () => {
if (this.state.isLoggedIn) { return <Navbar color="dark" dark fixed="top">
return <div className="navLinkLogin"> <NavbarBrand className="mr-auto"><img src={logo} className="nav-logo" alt="logo" /> GoScrobble</NavbarBrand>
<NavbarToggler onClick={this.toggleNavbar} className="mr-2" />
<Collapse isOpen={!this.state.collapsed} navbar>
{this.state.isLoggedIn ?
<Nav className="navLinkLoginMobile" navbar>
{loggedInMenuItems.map(menuItem =>
<NavItem>
<Link
key={menuItem}
className="navLinkMobile"
style={this.state.active === menuItem ? activeStyle : {}}
onClick={this._handleClick.bind(this, menuItem)}
to={menuItem}
>{menuItem}</Link>
</NavItem>
)}
<Link
to="/profile"
style={this.state.active === "profile" ? activeStyle : {}}
onClick={this._handleClick.bind(this, "profile")}
className="navLinkMobile"
>Profile</Link>
<Link to="/" className="navLink" onClick={logout()}>Logout</Link>
</Nav>
: <Nav className="navLinkLoginMobile" navbar>
{menuItems.map(menuItem =>
<NavItem>
<Link
key={menuItem}
className="navLinkMobile"
style={this.state.active === menuItem ? activeStyle : {}}
onClick={this._handleClick.bind(this, menuItem)}
to={menuItem === "Home" ? "/" : menuItem}
>
{menuItem}
</Link>
</NavItem>
)}
<NavItem>
<Link
to="/login"
style={this.state.active === "login" ? activeStyle : {}}
onClick={this._handleClick.bind(this, "login")}
className="navLinkMobile"
>Login</Link>
</NavItem>
<NavItem>
<Link
to="/register"
className="navLinkMobile"
style={this.state.active === "register" ? activeStyle : {}}
onClick={this._handleClick.bind(this, "register")}
history={this.props.history}
>Register</Link>
</NavItem>
</Nav>
}
</Collapse>
</Navbar>
}
const renderDesktopNav = () => {
return <Navbar color="dark" dark fixed="top">
<NavbarBrand className="mr-auto"><img src={logo} className="nav-logo" alt="logo" /> GoScrobble</NavbarBrand>
{this.state.isLoggedIn ?
<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>
: <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>
}
{this.state.isLoggedIn ?
<div className="navLinkLogin">
<Link <Link
to="/profile" to="/profile"
style={this.state.active === "profile" ? activeStyle : {}} style={this.state.active === "profile" ? activeStyle : {}}
@ -70,9 +173,9 @@ class Navigation extends Component {
className="navLink" className="navLink"
>Profile</Link> >Profile</Link>
<Link to="/" className="navLink" onClick={logout()}>Logout</Link> <Link to="/" className="navLink" onClick={logout()}>Logout</Link>
</div>; </div>
} else { :
return <div className="navLinkLogin"> <div className="navLinkLogin">
<Link <Link
to="/login" to="/login"
style={this.state.active === "login" ? activeStyle : {}} style={this.state.active === "login" ? activeStyle : {}}
@ -86,49 +189,19 @@ class Navigation extends Component {
onClick={this._handleClick.bind(this, "register")} onClick={this._handleClick.bind(this, "register")}
history={this.props.history} history={this.props.history}
>Register</Link> >Register</Link>
</div>; </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>;
} }
</Navbar>
} }
return ( return (
<div> <div>
<Navbar color="dark" dark fixed="top"> {
<NavbarBrand className="mr-auto"><img src={logo} className="nav-logo" alt="logo" /> GoScrobble</NavbarBrand> isMobile()
{renderMenuButtons()} ? renderMobileNav()
{renderAuthButtons()} : renderDesktopNav()
</Navbar> }
</div> </div>
); );
} }

View File

@ -0,0 +1,43 @@
import React from "react";
class ScrobbleTable extends React.Component {
constructor(props) {
super(props);
this.state = {
data: this.props.data,
};
}
render() {
return (
<div>
<table border={1} cellPadding={5}>
<thead>
<tr>
<td>Timestamp</td>
<td>Track</td>
<td>Artist</td>
<td>Album</td>
</tr>
</thead>
<tbody>
{
this.state.data && this.state.data.items &&
this.state.data.items.map(function (element) {
return <tr>
<td>{element.time}</td>
<td>{element.track}</td>
<td>{element.artist}</td>
<td>{element.album}</td>
</tr>;
})
}
</tbody>
</table>
</div>
);
}
}
export default ScrobbleTable;

View File

@ -1,4 +1,4 @@
.aboutBody { .aboutBody {
padding: 20px 5px 5px 5px; padding: 20px 0px 0px 0px;
font-size: 16pt; font-size: 16pt;
} }

View File

@ -8,8 +8,16 @@ function About() {
About GoScrobble.com About GoScrobble.com
</h1> </h1>
<p className="aboutBody"> <p className="aboutBody">
Go-Scrobble is an open source music scorbbling service written in Go and React. Go-Scrobble is an open source music scorbbling service written in Go and React.<br/>
Used to track your listening history and build a profile to discover new music.
</p> </p>
<a
className="aboutBodyw"
href="https://gitlab.com/idanoo/go-scrobble"
target="_blank"
rel="noopener noreferrer"
>gitlab.com/idanoo/go-scrobble
</a>
</div> </div>
); );
} }

View File

@ -2,15 +2,40 @@ import React from 'react';
import '../App.css'; import '../App.css';
import './Dashboard.css'; import './Dashboard.css';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { getRecentScrobbles } from '../Actions/api';
import ScaleLoader from 'react-spinners/ScaleLoader';
import ScrobbleTable from "../Components/ScrobbleTable";
class Dashboard extends React.Component { class Dashboard extends React.Component {
constructor(props) {
super(props);
this.state = {
isLoading: true,
scrobbleData: [],
uuid: null,
};
}
componentDidMount() { componentDidMount() {
const { history } = this.props; const { history, uuid } = this.props;
const isLoggedIn = this.props.isLoggedIn; const isLoggedIn = this.props.isLoggedIn;
if (!isLoggedIn) { if (!isLoggedIn) {
history.push("/login") history.push("/login")
} }
getRecentScrobbles(uuid)
.then((data) => {
this.setState({
isLoading: false,
data: data
});
})
.catch(() => {
this.setState({
isLoading: false
});
});
} }
render() { render() {
@ -19,6 +44,10 @@ class Dashboard extends React.Component {
<h1> <h1>
Dashboard! Dashboard!
</h1> </h1>
{this.state.isLoading
? <ScaleLoader color="#FFF" size={60} />
: <ScrobbleTable data={this.state.data} />
}
</div> </div>
); );
} }
@ -26,8 +55,11 @@ class Dashboard extends React.Component {
function mapStateToProps(state) { function mapStateToProps(state) {
const { isLoggedIn } = state.auth; const { isLoggedIn } = state.auth;
const { uuid } = state.auth.user;
return { return {
isLoggedIn, isLoggedIn,
uuid,
}; };
} }

0
web/src/Pages/Home.css Normal file
View File

View File

@ -1,24 +1,15 @@
import logo from '../logo.png'; import logo from '../logo.png';
import '../App.css'; import '../App.css';
import './Home.css';
import HomeBanner from '../Components/HomeBanner'; import HomeBanner from '../Components/HomeBanner';
import React from 'react'; import React from 'react';
class Home extends React.Component { class Home extends React.Component {
render() { render() {
return ( return (
<div className="App-header"> <div className="pageWrapper">
<img src={logo} className="App-logo" alt="logo" /> <img src={logo} className="App-logo" alt="logo" />
<p> <p className="homeText">Go-Scrobble is an open source music scrobbling service written in Go and React.</p>
goscrobble.com
</p>
<a
className="App-link"
href="https://gitlab.com/idanoo/go-scrobble"
target="_blank"
rel="noopener noreferrer"
>
gitlab.com/idanoo/go-scrobble
</a>
<HomeBanner /> <HomeBanner />
</div> </div>
); );

View File

@ -1,14 +1,14 @@
.loginBody { .registerBody {
padding: 20px 5px 5px 5px; padding: 20px 5px 5px 5px;
font-size: 16pt; font-size: 16pt;
width: 300px; width: 300px;
} }
.loginFields { .registerFields {
width: 100%; width: 100%;
} }
.loginButton { .registerButton {
height: 50px; height: 50px;
width: 100%; width: 100%;
margin-top:-5px; margin-top:-5px;

View File

@ -1,10 +1,10 @@
import React from 'react'; import React from 'react';
import '../App.css'; import '../App.css';
import './Login.css'; import './Register.css';
import { Button, Form } from 'reactstrap'; import { Button } from 'reactstrap';
import ScaleLoader from "react-spinners/ScaleLoader"; import ScaleLoader from "react-spinners/ScaleLoader";
import { register } from '../Actions/auth'; import { register } from '../Actions/auth';
import { Formik, Field } from 'formik'; import { Formik, Field, Form } from 'formik';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
class Register extends React.Component { class Register extends React.Component {
@ -22,6 +22,7 @@ class Register extends React.Component {
} }
handleRegister(values) { handleRegister(values) {
console.log(values)
this.setState({loading: true}); this.setState({loading: true});
const { dispatch, history } = this.props; const { dispatch, history } = this.props;
@ -53,10 +54,11 @@ class Register extends React.Component {
<h1> <h1>
Register Register
</h1> </h1>
<div className="loginBody"> <div className="registerBody">
<Formik <Formik
initialValues={{ username: '', email: '', password: '', passwordconfirm: '' }} initialValues={{ username: '', email: '', password: '', passwordconfirm: '' }}
onSubmit={async values => this.handleRegister(values)}> onSubmit={async values => this.handleRegister(values)}
>
<Form> <Form>
<label> <label>
Username*<br/> Username*<br/>
@ -64,7 +66,7 @@ class Register extends React.Component {
name="username" name="username"
type="text" type="text"
required={trueBool} required={trueBool}
className="loginFields" className="registerFields"
/> />
</label> </label>
<br/> <br/>
@ -73,7 +75,7 @@ class Register extends React.Component {
<Field <Field
name="email" name="email"
type="email" type="email"
className="loginFields" className="registerFields"
/> />
</label> </label>
<br/> <br/>
@ -83,7 +85,7 @@ class Register extends React.Component {
name="password" name="password"
type="password" type="password"
required={trueBool} required={trueBool}
className="loginFields" className="registerFields"
/> />
</label> </label>
<br/> <br/>
@ -93,14 +95,14 @@ class Register extends React.Component {
name="passwordconfirm" name="passwordconfirm"
type="password" type="password"
required={trueBool} required={trueBool}
className="loginFields" className="registerFields"
/> />
</label> </label>
<br/><br/> <br/><br/>
<Button <Button
color="primary" color="primary"
type="submit" type="submit"
className="loginButton" className="registerButton"
disabled={this.state.loading} disabled={this.state.loading}
>{this.state.loading ? <ScaleLoader color="#FFF" size={35} /> : "Register"}</Button> >{this.state.loading ? <ScaleLoader color="#FFF" size={35} /> : "Register"}</Button>
</Form> </Form>

View File

@ -1,8 +1,6 @@
import { combineReducers } from "redux"; import { combineReducers } from "redux";
import auth from "./auth"; import auth from "./auth";
import message from "./message";
export default combineReducers({ export default combineReducers({
auth, auth,
message,
}); });

View File

@ -1,18 +0,0 @@
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;
}
}

View File

@ -1,9 +1,14 @@
import axios from "axios"; import axios from "axios";
import authHeader from '../Services/auth-header';
class ApiService { class ApiService {
async getStats() { async getStats() {
return axios.get(process.env.REACT_APP_API_URL + "stats"); return axios.get(process.env.REACT_APP_API_URL + "stats");
} }
async getRecentScrobbles(id) {
return axios.get(process.env.REACT_APP_API_URL + "user/" + id + "/scrobbles", { headers: authHeader() });
}
} }
export default new ApiService(); export default new ApiService();

View File

@ -1,8 +1,8 @@
export default function authHeader() { export default function authHeader() {
const auth = localStorage.getItem('user'); const user = JSON.parse(localStorage.getItem('user'));
if (auth && auth.jwt) { if (user && user.jwt) {
return { Authorization: 'Bearer ' + auth.jwt }; return { Authorization: 'Bearer ' + user.jwt };
} else { } else {
return {}; return {};
} }

View File

@ -2,7 +2,7 @@ import axios from "axios";
import jwt from 'jwt-decode' // import dependency import jwt from 'jwt-decode' // import dependency
class AuthService { class AuthService {
login(username, password) { async login(username, password) {
return axios return axios
.post(process.env.REACT_APP_API_URL + "login", { username, password }) .post(process.env.REACT_APP_API_URL + "login", { username, password })
.then((response) => { .then((response) => {
@ -24,11 +24,16 @@ class AuthService {
localStorage.removeItem("user"); localStorage.removeItem("user");
} }
register(username, email, password) { async register(username, email, password) {
return axios.post(process.env.REACT_APP_API_URL + "register", { return axios
.post(process.env.REACT_APP_API_URL + "register", {
username, username,
email, email,
password, password,
})
.then((response) => {
console.log(response)
return response.data;
}); });
} }
} }