- Fix mobile menu auto collapse on select
- Add /u/ route for public user profiles (Added private flag to db - to implement later)
- Add /user route for your own profile / edit profile
- Added handling for if API is offline/incorrect
- Add index.html loading spinner while react bundle downloads
- Change HashRouter to BrowserRouter
- Added sources column to scrobbles
This commit is contained in:
Daniel Mason 2021-04-01 23:17:46 +13:00
parent af02bd99cc
commit e570314ac2
Signed by: idanoo
GPG Key ID: 387387CDBC02F132
27 changed files with 435 additions and 89 deletions

View File

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

View File

@ -1,3 +1,12 @@
# 0.0.9
- Fix mobile menu auto collapse on select
- Add /u/ route for public user profiles (Added private flag to db - to implement later)
- Add /user route for your own profile / edit profile
- Added handling for if API is offline/incorrect
- Add index.html loading spinner while react bundle downloads
- Change HashRouter to BrowserRouter
- Added sources column to scrobbles
# 0.0.8 # 0.0.8
- Added Admin/Site config page in frontend for admin users - Added Admin/Site config page in frontend for admin users
- Added API POST/GET /config endpoint - Added API POST/GET /config endpoint

View File

@ -0,0 +1,4 @@
package goscrobble
func getImageLastFM(src string) {
}

View File

@ -50,7 +50,7 @@ func ParseJellyfinInput(userUUID string, data map[string]interface{}, ip net.IP,
} }
// Insert album if not exist // Insert album if not exist
err = insertScrobble(userUUID, track.Uuid, ip, tx) err = insertScrobble(userUUID, track.Uuid, "jellyfin", ip, tx)
if err != nil { if err != nil {
log.Printf("%+v", err) log.Printf("%+v", err)
return errors.New("Failed to map track") return errors.New("Failed to map track")

View File

@ -0,0 +1,21 @@
package goscrobble
type ProfileResponse struct {
UUID string `json:"uuid"`
Username string `json:"username"`
Scrobbles []ScrobbleRequestItem `json:"scrobbles"`
}
func getProfile(user User) (ProfileResponse, error) {
resp := ProfileResponse{
UUID: user.UUID,
Username: user.Username,
}
scrobbleReq, err := fetchScrobblesForUser(user.UUID, 10, 1)
if err != nil {
return resp, err
}
resp.Scrobbles = scrobbleReq.Items
return resp, nil
}

View File

@ -36,8 +36,8 @@ type ScrobbleRequestItem struct {
} }
// insertScrobble - This will return if it exists or create it based on MBID > Name // insertScrobble - This will return if it exists or create it based on MBID > Name
func insertScrobble(user string, track string, ip net.IP, tx *sql.Tx) error { func insertScrobble(user string, track string, source string, ip net.IP, tx *sql.Tx) error {
err := insertNewScrobble(user, track, ip, tx) err := insertNewScrobble(user, track, source, ip, tx)
if err != nil { if err != nil {
log.Printf("Error inserting scrobble %s %+v", user, err) log.Printf("Error inserting scrobble %s %+v", user, err)
return errors.New("Failed to insert scrobble!") return errors.New("Failed to insert scrobble!")
@ -46,7 +46,7 @@ func insertScrobble(user string, track string, ip net.IP, tx *sql.Tx) error {
return nil return nil
} }
func fetchScrobblesForUser(userUuid string, page int) (ScrobbleRequest, error) { func fetchScrobblesForUser(userUuid string, limit int, page int) (ScrobbleRequest, error) {
scrobbleReq := ScrobbleRequest{} scrobbleReq := ScrobbleRequest{}
var count int var count int
@ -76,8 +76,8 @@ func fetchScrobblesForUser(userUuid string, page int) (ScrobbleRequest, error) {
"JOIN albums ON track_album.album = albums.uuid "+ "JOIN albums ON track_album.album = albums.uuid "+
"JOIN users ON scrobbles.user = users.uuid "+ "JOIN users ON scrobbles.user = users.uuid "+
"WHERE user = UUID_TO_BIN(?, true) "+ "WHERE user = UUID_TO_BIN(?, true) "+
"ORDER BY scrobbles.created_at DESC LIMIT 500", "ORDER BY scrobbles.created_at DESC LIMIT ?",
userUuid) userUuid, limit)
if err != nil { if err != nil {
log.Printf("Failed to fetch scrobbles: %+v", err) log.Printf("Failed to fetch scrobbles: %+v", err)
return scrobbleReq, errors.New("Failed to fetch scrobbles") return scrobbleReq, errors.New("Failed to fetch scrobbles")
@ -108,9 +108,9 @@ func fetchScrobblesForUser(userUuid string, page int) (ScrobbleRequest, error) {
return scrobbleReq, nil return scrobbleReq, nil
} }
func insertNewScrobble(user string, track string, ip net.IP, tx *sql.Tx) error { func insertNewScrobble(user string, track string, source string, ip net.IP, tx *sql.Tx) error {
_, err := tx.Exec("INSERT INTO `scrobbles` (`uuid`, `created_at`, `created_ip`, `user`, `track`) "+ _, err := tx.Exec("INSERT INTO `scrobbles` (`uuid`, `created_at`, `created_ip`, `user`, `track`, `source`) "+
"VALUES (UUID_TO_BIN(UUID(), true), NOW(), ?, UUID_TO_BIN(?, true),UUID_TO_BIN(?, true))", ip, user, track) "VALUES (UUID_TO_BIN(UUID(), true), NOW(), ?, UUID_TO_BIN(?, true),UUID_TO_BIN(?, true), ?)", ip, user, track, source)
return err return err
} }

View File

@ -25,11 +25,14 @@ type jsonResponse struct {
Msg string `json:"message,omitempty"` Msg string `json:"message,omitempty"`
} }
// Limits to 1 req / 10 sec // Limits to 1 req / 4 sec
var heavyLimiter = NewIPRateLimiter(0.25, 2) var heavyLimiter = NewIPRateLimiter(0.25, 2)
// Limits to 5 req / sec // Limits to 5 req / sec
var standardLimiter = NewIPRateLimiter(1, 5) var standardLimiter = NewIPRateLimiter(5, 5)
// Limits to 10 req / sec
var lightLimiter = NewIPRateLimiter(10, 10)
// List of Reverse proxies // List of Reverse proxies
var ReverseProxies []string var ReverseProxies []string
@ -48,17 +51,21 @@ func HandleRequests(port string) {
// Static Token for /ingress // Static Token for /ingress
v1.HandleFunc("/ingress/jellyfin", tokenMiddleware(handleIngress)).Methods("POST") v1.HandleFunc("/ingress/jellyfin", tokenMiddleware(handleIngress)).Methods("POST")
// JWT Auth // JWT Auth - PWN PROFILE ONLY.
v1.HandleFunc("/user/{id}/scrobbles", jwtMiddleware(fetchScrobbleResponse)).Methods("GET") v1.HandleFunc("/user", jwtMiddleware(fetchUser)).Methods("GET")
// v1.HandleFunc("/user", jwtMiddleware(fetchScrobbleResponse)).Methods("PATCH")
v1.HandleFunc("/user/{uuid}/scrobbles", jwtMiddleware(fetchScrobbleResponse)).Methods("GET")
// Config auth // Config auth
v1.HandleFunc("/config", adminMiddleware(fetchConfig)).Methods("GET") v1.HandleFunc("/config", adminMiddleware(fetchConfig)).Methods("GET")
v1.HandleFunc("/config", adminMiddleware(postConfig)).Methods("POST") v1.HandleFunc("/config", adminMiddleware(postConfig)).Methods("POST")
// No Auth // No Auth
v1.HandleFunc("/stats", handleStats).Methods("GET")
v1.HandleFunc("/profile/{username}", limitMiddleware(fetchProfile, lightLimiter)).Methods("GET")
v1.HandleFunc("/register", limitMiddleware(handleRegister, heavyLimiter)).Methods("POST") v1.HandleFunc("/register", limitMiddleware(handleRegister, heavyLimiter)).Methods("POST")
v1.HandleFunc("/login", limitMiddleware(handleLogin, standardLimiter)).Methods("POST") v1.HandleFunc("/login", limitMiddleware(handleLogin, standardLimiter)).Methods("POST")
v1.HandleFunc("/stats", handleStats).Methods("GET")
// This just prevents it serving frontend stuff over /api // This just prevents it serving frontend stuff over /api
r.PathPrefix("/api") r.PathPrefix("/api")
@ -80,7 +87,7 @@ func HandleRequests(port string) {
log.Fatal(http.ListenAndServe(":"+port, handler)) log.Fatal(http.ListenAndServe(":"+port, handler))
} }
// MIDDLEWARE // MIDDLEWARE RESPONSES
// throwUnauthorized - Throws a 403 // throwUnauthorized - Throws a 403
func throwUnauthorized(w http.ResponseWriter, m string) { func throwUnauthorized(w http.ResponseWriter, m string) {
jr := jsonResponse{ jr := jsonResponse{
@ -149,6 +156,7 @@ func generateJsonError(m string) []byte {
return js return js
} }
// MIDDLEWARE ACTIONS
// tokenMiddleware - Validates token to a user // tokenMiddleware - Validates token to a user
func tokenMiddleware(next func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc { func tokenMiddleware(next func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
@ -182,15 +190,11 @@ func jwtMiddleware(next func(http.ResponseWriter, *http.Request, string, string)
var reqUuid string var reqUuid string
for k, v := range mux.Vars(r) { for k, v := range mux.Vars(r) {
if k == "id" { if k == "uuid" {
reqUuid = v reqUuid = v
} }
} }
if reqUuid == "" {
throwBadReq(w, "Invalid Request")
}
next(w, r, claims.Subject, reqUuid) next(w, r, claims.Subject, reqUuid)
} }
} }
@ -311,13 +315,13 @@ func handleIngress(w http.ResponseWriter, r *http.Request, userUuid string) {
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()) throwOkError(w, err.Error())
return return
} }
err = tx.Commit() err = tx.Commit()
if err != nil { if err != nil {
throwBadReq(w, err.Error()) throwOkError(w, err.Error())
return return
} }
@ -328,11 +332,35 @@ func handleIngress(w http.ResponseWriter, r *http.Request, userUuid string) {
throwBadReq(w, "Unknown ingress type") throwBadReq(w, "Unknown ingress type")
} }
// fetchUser - Return personal userprofile
func fetchUser(w http.ResponseWriter, r *http.Request, jwtUser string, reqUser string) {
// We don't this var most of the time
userFull, err := getUser(jwtUser)
if err != nil {
throwOkError(w, "Failed to fetch user information")
return
}
jsonFull, _ := json.Marshal(&userFull)
// Lets strip out vars we don't want to send.
user := UserResponse{}
err = json.Unmarshal(jsonFull, &user)
if err != nil {
throwOkError(w, "Failed to fetch user information")
return
}
json, _ := json.Marshal(&user)
w.WriteHeader(http.StatusOK)
w.Write(json)
}
// fetchScrobbles - Return an array of scrobbles // fetchScrobbles - Return an array of scrobbles
func fetchScrobbleResponse(w http.ResponseWriter, r *http.Request, jwtUser string, reqUser string) { func fetchScrobbleResponse(w http.ResponseWriter, r *http.Request, jwtUser string, reqUser string) {
resp, err := fetchScrobblesForUser(reqUser, 1) resp, err := fetchScrobblesForUser(reqUser, 100, 1)
if err != nil { if err != nil {
throwBadReq(w, "Failed to fetch scrobbles") throwOkError(w, "Failed to fetch scrobbles")
return return
} }
@ -374,6 +402,37 @@ func postConfig(w http.ResponseWriter, r *http.Request, jwtUser string) {
throwOkMessage(w, "Config updated successfully") throwOkMessage(w, "Config updated successfully")
} }
// fetchProfile - Returns public user profile data
func fetchProfile(w http.ResponseWriter, r *http.Request) {
var username string
for k, v := range mux.Vars(r) {
if k == "username" {
username = v
}
}
if username == "" {
throwOkError(w, "Invalid Username")
return
}
user, err := getUserByUsername(username)
if err != nil {
throwOkError(w, err.Error())
return
}
resp, err := getProfile(user)
if err != nil {
throwOkError(w, err.Error())
return
}
json, _ := json.Marshal(&resp)
w.WriteHeader(http.StatusOK)
w.Write(json)
}
// FRONTEND HANDLING // FRONTEND HANDLING
// ServerHTTP - Frontend server // ServerHTTP - Frontend server

View File

@ -3,11 +3,14 @@ package goscrobble
import ( import (
"errors" "errors"
"math/rand" "math/rand"
"time"
) )
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
// generateToken - Generates a unique token for user input
func generateToken(n int) string { func generateToken(n int) string {
rand.Seed(time.Now().UnixNano())
b := make([]byte, n) b := make([]byte, n)
for i := range b { for i := range b {
b[i] = letterBytes[rand.Int63()%int64(len(letterBytes))] b[i] = letterBytes[rand.Int63()%int64(len(letterBytes))]

View File

@ -29,6 +29,17 @@ type User struct {
Admin bool `json:"admin"` Admin bool `json:"admin"`
} }
type UserResponse struct {
UUID string `json:"uuid"`
CreatedAt time.Time `json:"created_at"`
CreatedIp net.IP `json:"created_ip"`
ModifiedAt time.Time `json:"modified_at"`
ModifiedIP net.IP `jsos:"modified_ip"`
Username string `json:"username"`
Email string `json:"email"`
Verified bool `json:"verified"`
}
// RegisterRequest - Incoming JSON // RegisterRequest - Incoming JSON
type RegisterRequest struct { type RegisterRequest struct {
Username string `json:"username"` Username string `json:"username"`
@ -205,3 +216,15 @@ func getUser(uuid string) (User, error) {
return user, nil return user, nil
} }
func getUserByUsername(username string) (User, error) {
var user User
err := db.QueryRow("SELECT BIN_TO_UUID(`uuid`, true), `created_at`, `created_ip`, `modified_at`, `modified_ip`, `username`, `email`, `password`, `verified`, `admin` FROM `users` WHERE `username` = ? AND `active` = 1",
username).Scan(&user.UUID, &user.CreatedAt, &user.CreatedIp, &user.ModifiedAt, &user.ModifiedIP, &user.Username, &user.Email, &user.Password, &user.Verified, &user.Admin)
if err == sql.ErrNoRows {
return user, errors.New("Invalid Username")
}
return user, nil
}

View File

@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS `users` (
`verified` TINYINT(1) NOT NULL DEFAULT 0, `verified` TINYINT(1) NOT NULL DEFAULT 0,
`active` TINYINT(1) NOT NULL DEFAULT 1, `active` TINYINT(1) NOT NULL DEFAULT 1,
`admin` TINYINT(1) NOT NULL DEFAULT 0, `admin` TINYINT(1) NOT NULL DEFAULT 0,
`private` TINYINT(1) NOT NULL DEFAULT 0,
KEY `usernameLookup` (`username`, `active`), KEY `usernameLookup` (`username`, `active`),
KEY `emailLookup` (`email`, `active`) KEY `emailLookup` (`email`, `active`)
) DEFAULT CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci; ) DEFAULT CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci;

View File

@ -34,8 +34,10 @@ CREATE TABLE IF NOT EXISTS `scrobbles` (
`created_ip` VARBINARY(16) NULL DEFAULT NULL, `created_ip` VARBINARY(16) NULL DEFAULT NULL,
`user` BINARY(16) NOT NULL, `user` BINARY(16) NOT NULL,
`track` BINARY(16) NOT NULL, `track` BINARY(16) NOT NULL,
`source` VARCHAR(100) NOT NULL DEFAULT '',
KEY `userLookup` (`user`), KEY `userLookup` (`user`),
KEY `dateLookup` (`created_at`), KEY `dateLookup` (`created_at`),
KEY `sourceLookup` (`source`),
FOREIGN KEY (track) REFERENCES tracks(uuid), FOREIGN KEY (track) REFERENCES tracks(uuid),
FOREIGN KEY (user) REFERENCES users(uuid) FOREIGN KEY (user) REFERENCES users(uuid)
) DEFAULT CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci; ) DEFAULT CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci;

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS `files`;

View File

@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS `files` (
`uuid` BINARY(16) PRIMARY KEY,
`path` VARCHAR(255) NOT NULL,
`filesize` INT NULL DEFAULT NULL,
`dimension` VARCHAR(4) NOT NULL,
KEY `dimensionLookup` (`uuid`, `dimension`)
) DEFAULT CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci;

View File

@ -24,20 +24,42 @@
work correctly both with client-side routing and a non-root public URL. work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`. Learn how to configure a non-root public URL by running `npm run build`.
--> -->
<style>
html, body {
width: 100%;
height: 100%;
margin: 0;
background-color: #282C34;
}
.loader-container {
position: absolute;
left: 50%;
top: 25%;
transform: translate(-50%, -25%);
}
.loader {
border: 16px solid #282C34;
border-top: 16px solid #3498db;
border-radius: 50%;
width: 130px;
height: 130px;
animation: spin 2s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
<title>GoScrobble</title> <title>GoScrobble</title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div> <div id="root"></div>
<!-- <div class="loader-container">
This HTML file is a template. <div class="loader"></div>
If you open it directly in the browser, you will see an empty page. </div>
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body> </body>
</html> </html>

52
web/public/loader.svg Normal file
View File

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="margin: auto; background: rgb(40, 44, 52) none repeat scroll 0% 0%; display: block; shape-rendering: auto;" width="200px" height="200px" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid">
<g transform="rotate(0 50 50)">
<rect x="47" y="6.5" rx="3" ry="4.41" width="6" height="21" fill="#6ad7e5">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="1s" begin="-0.9166666666666666s" repeatCount="indefinite"></animate>
</rect>
</g><g transform="rotate(30 50 50)">
<rect x="47" y="6.5" rx="3" ry="4.41" width="6" height="21" fill="#6ad7e5">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="1s" begin="-0.8333333333333334s" repeatCount="indefinite"></animate>
</rect>
</g><g transform="rotate(60 50 50)">
<rect x="47" y="6.5" rx="3" ry="4.41" width="6" height="21" fill="#6ad7e5">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="1s" begin="-0.75s" repeatCount="indefinite"></animate>
</rect>
</g><g transform="rotate(90 50 50)">
<rect x="47" y="6.5" rx="3" ry="4.41" width="6" height="21" fill="#6ad7e5">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="1s" begin="-0.6666666666666666s" repeatCount="indefinite"></animate>
</rect>
</g><g transform="rotate(120 50 50)">
<rect x="47" y="6.5" rx="3" ry="4.41" width="6" height="21" fill="#6ad7e5">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="1s" begin="-0.5833333333333334s" repeatCount="indefinite"></animate>
</rect>
</g><g transform="rotate(150 50 50)">
<rect x="47" y="6.5" rx="3" ry="4.41" width="6" height="21" fill="#6ad7e5">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="1s" begin="-0.5s" repeatCount="indefinite"></animate>
</rect>
</g><g transform="rotate(180 50 50)">
<rect x="47" y="6.5" rx="3" ry="4.41" width="6" height="21" fill="#6ad7e5">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="1s" begin="-0.4166666666666667s" repeatCount="indefinite"></animate>
</rect>
</g><g transform="rotate(210 50 50)">
<rect x="47" y="6.5" rx="3" ry="4.41" width="6" height="21" fill="#6ad7e5">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="1s" begin="-0.3333333333333333s" repeatCount="indefinite"></animate>
</rect>
</g><g transform="rotate(240 50 50)">
<rect x="47" y="6.5" rx="3" ry="4.41" width="6" height="21" fill="#6ad7e5">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="1s" begin="-0.25s" repeatCount="indefinite"></animate>
</rect>
</g><g transform="rotate(270 50 50)">
<rect x="47" y="6.5" rx="3" ry="4.41" width="6" height="21" fill="#6ad7e5">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="1s" begin="-0.16666666666666666s" repeatCount="indefinite"></animate>
</rect>
</g><g transform="rotate(300 50 50)">
<rect x="47" y="6.5" rx="3" ry="4.41" width="6" height="21" fill="#6ad7e5">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="1s" begin="-0.08333333333333333s" repeatCount="indefinite"></animate>
</rect>
</g><g transform="rotate(330 50 50)">
<rect x="47" y="6.5" rx="3" ry="4.41" width="6" height="21" fill="#6ad7e5">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="1s" begin="0s" repeatCount="indefinite"></animate>
</rect>
</g>
<!-- [ldio] generated by https://loading.io/ --></svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -14,8 +14,6 @@ function getHeaders() {
} }
export const PostLogin = (formValues) => { export const PostLogin = (formValues) => {
// const { setLoading, setUser } = useContext(AuthContext);
// setLoading(true)
return axios.post(process.env.REACT_APP_API_URL + "login", formValues) return axios.post(process.env.REACT_APP_API_URL + "login", formValues)
.then((response) => { .then((response) => {
if (response.data.token) { if (response.data.token) {
@ -36,6 +34,7 @@ export const PostLogin = (formValues) => {
} }
}) })
.catch(() => { .catch(() => {
toast.error('Failed to connect');
return Promise.resolve(); return Promise.resolve();
}); });
}; };
@ -54,6 +53,7 @@ export const PostRegister = (formValues) => {
} }
}) })
.catch(() => { .catch(() => {
toast.error('Failed to connect');
return Promise.resolve(); return Promise.resolve();
}); });
}; };
@ -61,16 +61,21 @@ export const PostRegister = (formValues) => {
export const getStats = () => { export const getStats = () => {
return axios.get(process.env.REACT_APP_API_URL + "stats").then( return axios.get(process.env.REACT_APP_API_URL + "stats").then(
(data) => { (data) => {
data.isLoading = false;
return data.data; return data.data;
} }
); ).catch(() => {
toast.error('Failed to connect');
return {};
});
}; };
export const getRecentScrobbles = (id) => { export const getRecentScrobbles = (id) => {
return axios.get(process.env.REACT_APP_API_URL + "user/" + id + "/scrobbles", { headers: getHeaders() }) return axios.get(process.env.REACT_APP_API_URL + "user/" + id + "/scrobbles", { headers: getHeaders() })
.then((data) => { .then((data) => {
return data.data; return data.data;
}).catch(() => {
toast.error('Failed to connect');
return {};
}); });
}; };
@ -78,6 +83,9 @@ export const getConfigs = () => {
return axios.get(process.env.REACT_APP_API_URL + "config", { headers: getHeaders() }) return axios.get(process.env.REACT_APP_API_URL + "config", { headers: getHeaders() })
.then((data) => { .then((data) => {
return data.data; return data.data;
}).catch(() => {
toast.error('Failed to connect');
return {};
}); });
}; };
@ -101,3 +109,22 @@ export const postConfigs = (values, toggle) => {
}); });
}; };
export const getProfile = (userName) => {
return axios.get(process.env.REACT_APP_API_URL + "profile/" + userName, { headers: getHeaders() })
.then((data) => {
return data.data;
}).catch(() => {
toast.error('Failed to connect');
return {};
});
};
export const getUser = () => {
return axios.get(process.env.REACT_APP_API_URL + "user", { headers: getHeaders() })
.then((data) => {
return data.data;
}).catch(() => {
toast.error('Failed to connect');
return {};
});
};

View File

@ -1,3 +1,7 @@
html, body {
background-color: #282c34;
}
.App { .App {
text-align: center; text-align: center;
} }

View File

@ -1,9 +1,9 @@
import { Route, Switch, withRouter } from 'react-router-dom'; import { Route, Switch, withRouter } from 'react-router-dom';
import Home from './Pages/Home'; import Home from './Pages/Home';
import About from './Pages/About'; import About from './Pages/About';
import Dashboard from './Pages/Dashboard'; import Dashboard from './Pages/Dashboard';
import Profile from './Pages/Profile'; import Profile from './Pages/Profile';
import User from './Pages/User';
import Admin from './Pages/Admin'; import Admin from './Pages/Admin';
import Login from './Pages/Login'; import Login from './Pages/Login';
import Register from './Pages/Register'; import Register from './Pages/Register';
@ -14,7 +14,13 @@ import 'bootstrap/dist/css/bootstrap.min.css';
import './App.css'; import './App.css';
const App = () => { const App = () => {
let boolTrue = true let boolTrue = true;
// Remove loading spinner on load
const el = document.querySelector(".loader-container");
if (el) {
el.remove();
}
return ( return (
<div> <div>
@ -24,7 +30,8 @@ const App = () => {
<Route path="/about" component={About} /> <Route path="/about" component={About} />
<Route path="/dashboard" component={Dashboard} /> <Route path="/dashboard" component={Dashboard} />
<Route path="/profile" component={Profile} /> <Route path="/user" component={User} />
<Route path="/u/:uuid" component={Profile} />
<Route path="/admin" component={Admin} /> <Route path="/admin" component={Admin} />

View File

@ -11,8 +11,10 @@ const HomeBanner = () => {
useEffect(() => { useEffect(() => {
getStats() getStats()
.then(data => { .then(data => {
if (data.users) {
setBannerData(data); setBannerData(data);
setIsLoading(false); setIsLoading(false);
}
}) })
}, []) }, [])

View File

@ -45,38 +45,41 @@ const Navigation = () => {
{user ? {user ?
<Nav className="navLinkLoginMobile" navbar> <Nav className="navLinkLoginMobile" navbar>
{loggedInMenuItems.map(menuItem => {loggedInMenuItems.map(menuItem =>
<NavItem> <NavItem key={menuItem}>
<Link <Link
key={menuItem} key={menuItem}
className="navLinkMobile" className="navLinkMobile"
style={active === menuItem ? activeStyle : {}} style={active === menuItem ? activeStyle : {}}
to={menuItem} to={menuItem}
onClick={toggleCollapsed}
>{menuItem}</Link> >{menuItem}</Link>
</NavItem> </NavItem>
)} )}
<Link <Link
to="/profile" to="/user"
style={active === "profile" ? activeStyle : {}} style={active === "user" ? activeStyle : {}}
className="navLinkMobile" className="navLinkMobile"
>Profile</Link> onClick={toggleCollapsed}
>{user.username}</Link>
{user.admin && {user.admin &&
<Link <Link
to="/admin" to="/admin"
style={active === "admin" ? activeStyle : {}} style={active === "admin" ? activeStyle : {}}
className="navLink" className="navLink"
onClick={toggleCollapsed}
>Admin</Link>} >Admin</Link>}
<Link to="/" className="navLink" onClick={Logout}>Logout</Link> <Link to="/" className="navLink" onClick={Logout}>Logout</Link>
</Nav> </Nav>
: <Nav className="navLinkLoginMobile" navbar> : <Nav className="navLinkLoginMobile" navbar>
{menuItems.map(menuItem => {menuItems.map(menuItem =>
<NavItem> <NavItem key={menuItem}>
<Link <Link
key={menuItem} key={menuItem}
className="navLinkMobile" className="navLinkMobile"
style={active === menuItem ? activeStyle : {}} style={active === menuItem ? activeStyle : {}}
to={menuItem === "Home" ? "/" : menuItem} to={menuItem === "Home" ? "/" : menuItem}
> onClick={toggleCollapsed}
{menuItem} >{menuItem}
</Link> </Link>
</NavItem> </NavItem>
)} )}
@ -85,6 +88,7 @@ const Navigation = () => {
to="/Login" to="/Login"
style={active === "Login" ? activeStyle : {}} style={active === "Login" ? activeStyle : {}}
className="navLinkMobile" className="navLinkMobile"
onClick={toggleCollapsed}
>Login</Link> >Login</Link>
</NavItem> </NavItem>
<NavItem> <NavItem>
@ -92,6 +96,7 @@ const Navigation = () => {
to="/Register" to="/Register"
className="navLinkMobile" className="navLinkMobile"
style={active === "Register" ? activeStyle : {}} style={active === "Register" ? activeStyle : {}}
onClick={toggleCollapsed}
>Register</Link> >Register</Link>
</NavItem> </NavItem>
</Nav> </Nav>
@ -132,8 +137,8 @@ const Navigation = () => {
{user ? {user ?
<div className="navLinkLogin"> <div className="navLinkLogin">
<Link <Link
to="/profile" to="/user"
style={active === "profile" ? activeStyle : {}} style={active === "user" ? activeStyle : {}}
className="navLink" className="navLink"
>{user.username}</Link> >{user.username}</Link>
{user.admin && {user.admin &&

View File

@ -14,9 +14,9 @@ const ScrobbleTable = (props) => {
</thead> </thead>
<tbody> <tbody>
{ {
props.data && props.data.items && props.data &&
props.data.items.map(function (element) { props.data.map(function (element) {
return <tr> return <tr key={element.uuid}>
<td>{element.time}</td> <td>{element.time}</td>
<td>{element.track}</td> <td>{element.track}</td>
<td>{element.artist}</td> <td>{element.artist}</td>

View File

@ -17,19 +17,17 @@ const Admin = () => {
useEffect(() => { useEffect(() => {
getConfigs() getConfigs()
.then(data => { .then(data => {
if (data.configs) {
setConfigs(data.configs); setConfigs(data.configs);
setToggle(data.configs.REGISTRATION_ENABLED === "1") setToggle(data.configs.REGISTRATION_ENABLED === "1")
}
setLoading(false); setLoading(false);
}) })
}, []) }, [])
if (!user || !user.admin) { const handleToggle = () => {
return ( setToggle(!toggle);
<div className="pageWrapper"> };
<h1>Unauthorized</h1>
</div>
)
}
if (loading) { if (loading) {
return ( return (
@ -39,9 +37,13 @@ const Admin = () => {
) )
} }
const handleToggle = () => { if (!user || !user.admin) {
setToggle(!toggle); return (
}; <div className="pageWrapper">
<h1>Unauthorized</h1>
</div>
)
}
return ( return (
<div className="pageWrapper"> <div className="pageWrapper">

View File

@ -13,10 +13,6 @@ const Dashboard = () => {
let [loading, setLoading] = useState(true); let [loading, setLoading] = useState(true);
let [dashboardData, setDashboardData] = useState({}); let [dashboardData, setDashboardData] = useState({});
if (!user) {
history.push("/login");
}
useEffect(() => { useEffect(() => {
if (!user) { if (!user) {
return return
@ -28,14 +24,22 @@ const Dashboard = () => {
}) })
}, [user]) }, [user])
if (loading) {
return (
<div className="pageWrapper">
<ScaleLoader color="#6AD7E5" />
</div>
)
}
return ( return (
<div className="pageWrapper"> <div className="pageWrapper">
<h1> <h1>
Dashboard! {user.username}'s Dashboard!
</h1> </h1>
{loading {loading
? <ScaleLoader color="#6AD7E5" size={60} /> ? <ScaleLoader color="#6AD7E5" size={60} />
: <ScrobbleTable data={dashboardData} /> : <ScrobbleTable data={dashboardData.items} />
} }
</div> </div>
); );

View File

@ -1,25 +1,59 @@
import React, { useContext } from 'react'; import React, { useState, useEffect } from 'react';
import '../App.css'; import '../App.css';
import './Dashboard.css'; import './Profile.css';
import { useHistory } from "react-router"; import ScaleLoader from 'react-spinners/ScaleLoader';
import AuthContext from '../Contexts/AuthContext'; import { getProfile } from '../Api/index'
import ScrobbleTable from '../Components/ScrobbleTable'
const Profile = () => { const Profile = (route) => {
const history = useHistory(); const [loading, setLoading] = useState(true);
const { user } = useContext(AuthContext); const [profile, setProfile] = useState({});
if (!user) { let username = false;
history.push("/login"); if (route && route.match && route.match.params && route.match.params.uuid) {
username = route.match.params.uuid
}
useEffect(() => {
if (!username) {
return false;
}
getProfile(username)
.then(data => {
setProfile(data);
console.log(data)
setLoading(false);
})
}, [username])
if (loading) {
return (
<div className="pageWrapper">
<ScaleLoader color="#6AD7E5" />
</div>
)
}
if (!username || Object.keys(profile).length === 0) {
return (
<div className="pageWrapper">
Unable to fetch user
</div>
)
} }
return ( return (
<div className="pageWrapper"> <div className="pageWrapper">
<h1> <h1>
Welcome {user.username}! {profile.username}'s Profile
</h1> </h1>
<div className="profileBody">
Last 10 scrobbles...<br/>
<ScrobbleTable data={profile.scrobbles}/>
</div>
</div> </div>
); );
} }
export default Profile; export default Profile;

4
web/src/Pages/User.css Normal file
View File

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

53
web/src/Pages/User.js Normal file
View File

@ -0,0 +1,53 @@
import React, { useContext, useState, useEffect } from 'react';
import '../App.css';
import './User.css';
import { useHistory } from "react-router";
import AuthContext from '../Contexts/AuthContext';
import ScaleLoader from 'react-spinners/ScaleLoader';
import { getUser } from '../Api/index'
const User = () => {
const history = useHistory();
const { user } = useContext(AuthContext);
const [loading, setLoading] = useState(true);
const [userdata, setUserdata] = useState({});
useEffect(() => {
if (!user) {
return
}
getUser()
.then(data => {
setUserdata(data);
setLoading(false);
})
}, [user])
if (loading) {
return (
<div className="pageWrapper">
<ScaleLoader color="#6AD7E5" />
</div>
)
}
if (!user) {
history.push("/login")
}
return (
<div className="pageWrapper">
<h1>
Welcome {userdata.username}
</h1>
<p className="userBody">
Created At: {userdata.created_at}<br/>
Email: {userdata.email}<br/>
Verified: {userdata.verified ? '✓' : '✖'}
</p>
</div>
);
}
export default User;

View File

@ -2,7 +2,7 @@ import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import './index.css'; import './index.css';
import App from './App'; import App from './App';
import { HashRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import { ToastContainer } from 'react-toastify'; import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.min.css'; import 'react-toastify/dist/ReactToastify.min.css';
@ -11,7 +11,7 @@ import AuthContextProvider from './Contexts/AuthContextProvider';
ReactDOM.render( ReactDOM.render(
<AuthContextProvider> <AuthContextProvider>
<HashRouter> <BrowserRouter>
<ToastContainer <ToastContainer
position="bottom-right" position="bottom-right"
autoClose={5000} autoClose={5000}
@ -24,7 +24,7 @@ ReactDOM.render(
pauseOnHover pauseOnHover
/> />
<App /> <App />
</HashRouter> </BrowserRouter>
</AuthContextProvider>, </AuthContextProvider>,
document.getElementById('root') document.getElementById('root')
); );