mirror of
https://github.com/idanoo/GoScrobble.git
synced 2024-12-25 16:19:00 +00:00
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
This commit is contained in:
parent
af02bd99cc
commit
e570314ac2
@ -3,7 +3,7 @@ stages:
|
||||
- bundle
|
||||
|
||||
variables:
|
||||
VERSION: 0.0.8
|
||||
VERSION: 0.0.9
|
||||
|
||||
build-go:
|
||||
image: golang:1.16.2
|
||||
|
@ -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
|
||||
- Added Admin/Site config page in frontend for admin users
|
||||
- Added API POST/GET /config endpoint
|
||||
|
4
internal/goscrobble/external_lastfm.go
Normal file
4
internal/goscrobble/external_lastfm.go
Normal file
@ -0,0 +1,4 @@
|
||||
package goscrobble
|
||||
|
||||
func getImageLastFM(src string) {
|
||||
}
|
@ -50,7 +50,7 @@ func ParseJellyfinInput(userUUID string, data map[string]interface{}, ip net.IP,
|
||||
}
|
||||
|
||||
// Insert album if not exist
|
||||
err = insertScrobble(userUUID, track.Uuid, ip, tx)
|
||||
err = insertScrobble(userUUID, track.Uuid, "jellyfin", ip, tx)
|
||||
if err != nil {
|
||||
log.Printf("%+v", err)
|
||||
return errors.New("Failed to map track")
|
21
internal/goscrobble/profile.go
Normal file
21
internal/goscrobble/profile.go
Normal 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
|
||||
}
|
@ -36,8 +36,8 @@ type ScrobbleRequestItem struct {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
err := insertNewScrobble(user, track, ip, tx)
|
||||
func insertScrobble(user string, track string, source string, ip net.IP, tx *sql.Tx) error {
|
||||
err := insertNewScrobble(user, track, source, ip, tx)
|
||||
if err != nil {
|
||||
log.Printf("Error inserting scrobble %s %+v", user, err)
|
||||
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
|
||||
}
|
||||
|
||||
func fetchScrobblesForUser(userUuid string, page int) (ScrobbleRequest, error) {
|
||||
func fetchScrobblesForUser(userUuid string, limit int, page int) (ScrobbleRequest, error) {
|
||||
scrobbleReq := ScrobbleRequest{}
|
||||
var count int
|
||||
|
||||
@ -76,8 +76,8 @@ func fetchScrobblesForUser(userUuid string, page int) (ScrobbleRequest, error) {
|
||||
"JOIN albums ON track_album.album = albums.uuid "+
|
||||
"JOIN users ON scrobbles.user = users.uuid "+
|
||||
"WHERE user = UUID_TO_BIN(?, true) "+
|
||||
"ORDER BY scrobbles.created_at DESC LIMIT 500",
|
||||
userUuid)
|
||||
"ORDER BY scrobbles.created_at DESC LIMIT ?",
|
||||
userUuid, limit)
|
||||
if err != nil {
|
||||
log.Printf("Failed to fetch scrobbles: %+v", err)
|
||||
return scrobbleReq, errors.New("Failed to fetch scrobbles")
|
||||
@ -108,9 +108,9 @@ func fetchScrobblesForUser(userUuid string, page int) (ScrobbleRequest, error) {
|
||||
return scrobbleReq, nil
|
||||
}
|
||||
|
||||
func insertNewScrobble(user string, track string, ip net.IP, tx *sql.Tx) error {
|
||||
_, err := tx.Exec("INSERT INTO `scrobbles` (`uuid`, `created_at`, `created_ip`, `user`, `track`) "+
|
||||
"VALUES (UUID_TO_BIN(UUID(), true), NOW(), ?, UUID_TO_BIN(?, true),UUID_TO_BIN(?, true))", ip, user, track)
|
||||
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`, `source`) "+
|
||||
"VALUES (UUID_TO_BIN(UUID(), true), NOW(), ?, UUID_TO_BIN(?, true),UUID_TO_BIN(?, true), ?)", ip, user, track, source)
|
||||
|
||||
return err
|
||||
}
|
||||
|
@ -25,11 +25,14 @@ type jsonResponse struct {
|
||||
Msg string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// Limits to 1 req / 10 sec
|
||||
// Limits to 1 req / 4 sec
|
||||
var heavyLimiter = NewIPRateLimiter(0.25, 2)
|
||||
|
||||
// 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
|
||||
var ReverseProxies []string
|
||||
@ -48,17 +51,21 @@ func HandleRequests(port string) {
|
||||
// Static Token for /ingress
|
||||
v1.HandleFunc("/ingress/jellyfin", tokenMiddleware(handleIngress)).Methods("POST")
|
||||
|
||||
// JWT Auth
|
||||
v1.HandleFunc("/user/{id}/scrobbles", jwtMiddleware(fetchScrobbleResponse)).Methods("GET")
|
||||
// JWT Auth - PWN PROFILE ONLY.
|
||||
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
|
||||
v1.HandleFunc("/config", adminMiddleware(fetchConfig)).Methods("GET")
|
||||
v1.HandleFunc("/config", adminMiddleware(postConfig)).Methods("POST")
|
||||
|
||||
// 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("/login", limitMiddleware(handleLogin, standardLimiter)).Methods("POST")
|
||||
v1.HandleFunc("/stats", handleStats).Methods("GET")
|
||||
|
||||
// This just prevents it serving frontend stuff over /api
|
||||
r.PathPrefix("/api")
|
||||
@ -80,7 +87,7 @@ func HandleRequests(port string) {
|
||||
log.Fatal(http.ListenAndServe(":"+port, handler))
|
||||
}
|
||||
|
||||
// MIDDLEWARE
|
||||
// MIDDLEWARE RESPONSES
|
||||
// throwUnauthorized - Throws a 403
|
||||
func throwUnauthorized(w http.ResponseWriter, m string) {
|
||||
jr := jsonResponse{
|
||||
@ -149,6 +156,7 @@ func generateJsonError(m string) []byte {
|
||||
return js
|
||||
}
|
||||
|
||||
// MIDDLEWARE ACTIONS
|
||||
// tokenMiddleware - Validates token to a user
|
||||
func tokenMiddleware(next func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc {
|
||||
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
|
||||
for k, v := range mux.Vars(r) {
|
||||
if k == "id" {
|
||||
if k == "uuid" {
|
||||
reqUuid = v
|
||||
}
|
||||
}
|
||||
|
||||
if reqUuid == "" {
|
||||
throwBadReq(w, "Invalid Request")
|
||||
}
|
||||
|
||||
next(w, r, claims.Subject, reqUuid)
|
||||
}
|
||||
}
|
||||
@ -311,13 +315,13 @@ func handleIngress(w http.ResponseWriter, r *http.Request, userUuid string) {
|
||||
if err != nil {
|
||||
// log.Printf("Error inserting track: %+v", err)
|
||||
tx.Rollback()
|
||||
throwBadReq(w, err.Error())
|
||||
throwOkError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
throwBadReq(w, err.Error())
|
||||
throwOkError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@ -328,11 +332,35 @@ func handleIngress(w http.ResponseWriter, r *http.Request, userUuid string) {
|
||||
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
|
||||
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 {
|
||||
throwBadReq(w, "Failed to fetch scrobbles")
|
||||
throwOkError(w, "Failed to fetch scrobbles")
|
||||
return
|
||||
}
|
||||
|
||||
@ -374,6 +402,37 @@ func postConfig(w http.ResponseWriter, r *http.Request, jwtUser string) {
|
||||
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
|
||||
|
||||
// ServerHTTP - Frontend server
|
||||
|
@ -3,11 +3,14 @@ package goscrobble
|
||||
import (
|
||||
"errors"
|
||||
"math/rand"
|
||||
"time"
|
||||
)
|
||||
|
||||
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
|
||||
// generateToken - Generates a unique token for user input
|
||||
func generateToken(n int) string {
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = letterBytes[rand.Int63()%int64(len(letterBytes))]
|
||||
|
@ -29,6 +29,17 @@ type User struct {
|
||||
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
|
||||
type RegisterRequest struct {
|
||||
Username string `json:"username"`
|
||||
@ -205,3 +216,15 @@ func getUser(uuid string) (User, error) {
|
||||
|
||||
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
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS `users` (
|
||||
`verified` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`admin` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`private` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
KEY `usernameLookup` (`username`, `active`),
|
||||
KEY `emailLookup` (`email`, `active`)
|
||||
) DEFAULT CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci;
|
@ -34,8 +34,10 @@ CREATE TABLE IF NOT EXISTS `scrobbles` (
|
||||
`created_ip` VARBINARY(16) NULL DEFAULT NULL,
|
||||
`user` BINARY(16) NOT NULL,
|
||||
`track` BINARY(16) NOT NULL,
|
||||
`source` VARCHAR(100) NOT NULL DEFAULT '',
|
||||
KEY `userLookup` (`user`),
|
||||
KEY `dateLookup` (`created_at`),
|
||||
KEY `sourceLookup` (`source`),
|
||||
FOREIGN KEY (track) REFERENCES tracks(uuid),
|
||||
FOREIGN KEY (user) REFERENCES users(uuid)
|
||||
) DEFAULT CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci;
|
||||
|
1
migrations/temp/7_files.down.sql
Normal file
1
migrations/temp/7_files.down.sql
Normal file
@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS `files`;
|
7
migrations/temp/7_files.up.sql
Normal file
7
migrations/temp/7_files.up.sql
Normal 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;
|
@ -24,20 +24,42 @@
|
||||
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`.
|
||||
-->
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
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`.
|
||||
-->
|
||||
<div class="loader-container">
|
||||
<div class="loader"></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
52
web/public/loader.svg
Normal file
52
web/public/loader.svg
Normal 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 |
@ -14,8 +14,6 @@ function getHeaders() {
|
||||
}
|
||||
|
||||
export const PostLogin = (formValues) => {
|
||||
// const { setLoading, setUser } = useContext(AuthContext);
|
||||
// setLoading(true)
|
||||
return axios.post(process.env.REACT_APP_API_URL + "login", formValues)
|
||||
.then((response) => {
|
||||
if (response.data.token) {
|
||||
@ -36,6 +34,7 @@ export const PostLogin = (formValues) => {
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('Failed to connect');
|
||||
return Promise.resolve();
|
||||
});
|
||||
};
|
||||
@ -54,6 +53,7 @@ export const PostRegister = (formValues) => {
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('Failed to connect');
|
||||
return Promise.resolve();
|
||||
});
|
||||
};
|
||||
@ -61,16 +61,21 @@ export const PostRegister = (formValues) => {
|
||||
export const getStats = () => {
|
||||
return axios.get(process.env.REACT_APP_API_URL + "stats").then(
|
||||
(data) => {
|
||||
data.isLoading = false;
|
||||
return data.data;
|
||||
}
|
||||
);
|
||||
).catch(() => {
|
||||
toast.error('Failed to connect');
|
||||
return {};
|
||||
});
|
||||
};
|
||||
|
||||
export const getRecentScrobbles = (id) => {
|
||||
return axios.get(process.env.REACT_APP_API_URL + "user/" + id + "/scrobbles", { headers: getHeaders() })
|
||||
.then((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() })
|
||||
.then((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 {};
|
||||
});
|
||||
};
|
||||
|
@ -1,3 +1,7 @@
|
||||
html, body {
|
||||
background-color: #282c34;
|
||||
}
|
||||
|
||||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { Route, Switch, withRouter } from 'react-router-dom';
|
||||
|
||||
import Home from './Pages/Home';
|
||||
import About from './Pages/About';
|
||||
import Dashboard from './Pages/Dashboard';
|
||||
import Profile from './Pages/Profile';
|
||||
import User from './Pages/User';
|
||||
import Admin from './Pages/Admin';
|
||||
import Login from './Pages/Login';
|
||||
import Register from './Pages/Register';
|
||||
@ -14,7 +14,13 @@ import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import './App.css';
|
||||
|
||||
const App = () => {
|
||||
let boolTrue = true
|
||||
let boolTrue = true;
|
||||
|
||||
// Remove loading spinner on load
|
||||
const el = document.querySelector(".loader-container");
|
||||
if (el) {
|
||||
el.remove();
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
@ -24,7 +30,8 @@ const App = () => {
|
||||
<Route path="/about" component={About} />
|
||||
|
||||
<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} />
|
||||
|
||||
|
@ -11,8 +11,10 @@ const HomeBanner = () => {
|
||||
useEffect(() => {
|
||||
getStats()
|
||||
.then(data => {
|
||||
setBannerData(data);
|
||||
setIsLoading(false);
|
||||
if (data.users) {
|
||||
setBannerData(data);
|
||||
setIsLoading(false);
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
|
@ -45,38 +45,41 @@ const Navigation = () => {
|
||||
{user ?
|
||||
<Nav className="navLinkLoginMobile" navbar>
|
||||
{loggedInMenuItems.map(menuItem =>
|
||||
<NavItem>
|
||||
<Link
|
||||
<NavItem key={menuItem}>
|
||||
<Link
|
||||
key={menuItem}
|
||||
className="navLinkMobile"
|
||||
style={active === menuItem ? activeStyle : {}}
|
||||
to={menuItem}
|
||||
onClick={toggleCollapsed}
|
||||
>{menuItem}</Link>
|
||||
</NavItem>
|
||||
)}
|
||||
<Link
|
||||
to="/profile"
|
||||
style={active === "profile" ? activeStyle : {}}
|
||||
to="/user"
|
||||
style={active === "user" ? activeStyle : {}}
|
||||
className="navLinkMobile"
|
||||
>Profile</Link>
|
||||
onClick={toggleCollapsed}
|
||||
>{user.username}</Link>
|
||||
{user.admin &&
|
||||
<Link
|
||||
to="/admin"
|
||||
style={active === "admin" ? activeStyle : {}}
|
||||
className="navLink"
|
||||
onClick={toggleCollapsed}
|
||||
>Admin</Link>}
|
||||
<Link to="/" className="navLink" onClick={Logout}>Logout</Link>
|
||||
</Nav>
|
||||
: <Nav className="navLinkLoginMobile" navbar>
|
||||
{menuItems.map(menuItem =>
|
||||
<NavItem>
|
||||
<NavItem key={menuItem}>
|
||||
<Link
|
||||
key={menuItem}
|
||||
className="navLinkMobile"
|
||||
style={active === menuItem ? activeStyle : {}}
|
||||
to={menuItem === "Home" ? "/" : menuItem}
|
||||
>
|
||||
{menuItem}
|
||||
onClick={toggleCollapsed}
|
||||
>{menuItem}
|
||||
</Link>
|
||||
</NavItem>
|
||||
)}
|
||||
@ -85,6 +88,7 @@ const Navigation = () => {
|
||||
to="/Login"
|
||||
style={active === "Login" ? activeStyle : {}}
|
||||
className="navLinkMobile"
|
||||
onClick={toggleCollapsed}
|
||||
>Login</Link>
|
||||
</NavItem>
|
||||
<NavItem>
|
||||
@ -92,6 +96,7 @@ const Navigation = () => {
|
||||
to="/Register"
|
||||
className="navLinkMobile"
|
||||
style={active === "Register" ? activeStyle : {}}
|
||||
onClick={toggleCollapsed}
|
||||
>Register</Link>
|
||||
</NavItem>
|
||||
</Nav>
|
||||
@ -132,8 +137,8 @@ const Navigation = () => {
|
||||
{user ?
|
||||
<div className="navLinkLogin">
|
||||
<Link
|
||||
to="/profile"
|
||||
style={active === "profile" ? activeStyle : {}}
|
||||
to="/user"
|
||||
style={active === "user" ? activeStyle : {}}
|
||||
className="navLink"
|
||||
>{user.username}</Link>
|
||||
{user.admin &&
|
||||
|
@ -14,9 +14,9 @@ const ScrobbleTable = (props) => {
|
||||
</thead>
|
||||
<tbody>
|
||||
{
|
||||
props.data && props.data.items &&
|
||||
props.data.items.map(function (element) {
|
||||
return <tr>
|
||||
props.data &&
|
||||
props.data.map(function (element) {
|
||||
return <tr key={element.uuid}>
|
||||
<td>{element.time}</td>
|
||||
<td>{element.track}</td>
|
||||
<td>{element.artist}</td>
|
||||
|
@ -17,19 +17,17 @@ const Admin = () => {
|
||||
useEffect(() => {
|
||||
getConfigs()
|
||||
.then(data => {
|
||||
setConfigs(data.configs);
|
||||
setToggle(data.configs.REGISTRATION_ENABLED === "1")
|
||||
if (data.configs) {
|
||||
setConfigs(data.configs);
|
||||
setToggle(data.configs.REGISTRATION_ENABLED === "1")
|
||||
}
|
||||
setLoading(false);
|
||||
})
|
||||
}, [])
|
||||
|
||||
if (!user || !user.admin) {
|
||||
return (
|
||||
<div className="pageWrapper">
|
||||
<h1>Unauthorized</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const handleToggle = () => {
|
||||
setToggle(!toggle);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@ -39,9 +37,13 @@ const Admin = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const handleToggle = () => {
|
||||
setToggle(!toggle);
|
||||
};
|
||||
if (!user || !user.admin) {
|
||||
return (
|
||||
<div className="pageWrapper">
|
||||
<h1>Unauthorized</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pageWrapper">
|
||||
|
@ -13,10 +13,6 @@ const Dashboard = () => {
|
||||
let [loading, setLoading] = useState(true);
|
||||
let [dashboardData, setDashboardData] = useState({});
|
||||
|
||||
if (!user) {
|
||||
history.push("/login");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
return
|
||||
@ -28,14 +24,22 @@ const Dashboard = () => {
|
||||
})
|
||||
}, [user])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="pageWrapper">
|
||||
<ScaleLoader color="#6AD7E5" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pageWrapper">
|
||||
<h1>
|
||||
Dashboard!
|
||||
{user.username}'s Dashboard!
|
||||
</h1>
|
||||
{loading
|
||||
? <ScaleLoader color="#6AD7E5" size={60} />
|
||||
: <ScrobbleTable data={dashboardData} />
|
||||
: <ScrobbleTable data={dashboardData.items} />
|
||||
}
|
||||
</div>
|
||||
);
|
||||
|
@ -1,25 +1,59 @@
|
||||
import React, { useContext } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import '../App.css';
|
||||
import './Dashboard.css';
|
||||
import { useHistory } from "react-router";
|
||||
import AuthContext from '../Contexts/AuthContext';
|
||||
import './Profile.css';
|
||||
import ScaleLoader from 'react-spinners/ScaleLoader';
|
||||
import { getProfile } from '../Api/index'
|
||||
import ScrobbleTable from '../Components/ScrobbleTable'
|
||||
|
||||
const Profile = () => {
|
||||
const history = useHistory();
|
||||
const { user } = useContext(AuthContext);
|
||||
const Profile = (route) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [profile, setProfile] = useState({});
|
||||
|
||||
if (!user) {
|
||||
history.push("/login");
|
||||
let username = false;
|
||||
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 (
|
||||
<div className="pageWrapper">
|
||||
<h1>
|
||||
Welcome {user.username}!
|
||||
{profile.username}'s Profile
|
||||
</h1>
|
||||
<div className="profileBody">
|
||||
Last 10 scrobbles...<br/>
|
||||
<ScrobbleTable data={profile.scrobbles}/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
export default Profile;
|
4
web/src/Pages/User.css
Normal file
4
web/src/Pages/User.css
Normal file
@ -0,0 +1,4 @@
|
||||
.userBody {
|
||||
padding: 20px 5px 5px 5px;
|
||||
font-size: 16pt;
|
||||
}
|
53
web/src/Pages/User.js
Normal file
53
web/src/Pages/User.js
Normal 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;
|
@ -2,7 +2,7 @@ import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import { HashRouter } from 'react-router-dom';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.min.css';
|
||||
@ -11,7 +11,7 @@ import AuthContextProvider from './Contexts/AuthContextProvider';
|
||||
|
||||
ReactDOM.render(
|
||||
<AuthContextProvider>
|
||||
<HashRouter>
|
||||
<BrowserRouter>
|
||||
<ToastContainer
|
||||
position="bottom-right"
|
||||
autoClose={5000}
|
||||
@ -24,7 +24,7 @@ ReactDOM.render(
|
||||
pauseOnHover
|
||||
/>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</BrowserRouter>
|
||||
</AuthContextProvider>,
|
||||
document.getElementById('root')
|
||||
);
|
||||
|
Loading…
Reference in New Issue
Block a user